From b1d1ddef44652f3cde8b42501b68bae16d841963 Mon Sep 17 00:00:00 2001
From: latin-panda <66472237+latin-panda@users.noreply.github.com>
Date: Tue, 7 Apr 2026 22:21:02 +0200
Subject: [PATCH 1/4] fix NaN issue in coalesce
---
packages/xpath/src/functions/xforms/string.ts | 8 +++++---
packages/xpath/test/xforms/coalesce.test.ts | 8 ++++++++
2 files changed, 13 insertions(+), 3 deletions(-)
diff --git a/packages/xpath/src/functions/xforms/string.ts b/packages/xpath/src/functions/xforms/string.ts
index 84cc75c61..f8a1d6c0b 100644
--- a/packages/xpath/src/functions/xforms/string.ts
+++ b/packages/xpath/src/functions/xforms/string.ts
@@ -33,10 +33,12 @@ export const coalesce = new StringFunction(
{ arityType: 'required', typeHint: 'string' },
],
(context, [aExpression, bExpression]): string => {
- const a = aExpression!.evaluate(context).toString();
+ const aEval = aExpression!.evaluate(context);
+ const aValue = aEval.toString();
- if (a !== '') {
- return a;
+ const isEmpty = aValue === '' || (aEval.type === 'NUMBER' && Number.isNaN(aEval.toNumber()));
+ if (!isEmpty) {
+ return aValue;
}
return bExpression!.evaluate(context).toString();
diff --git a/packages/xpath/test/xforms/coalesce.test.ts b/packages/xpath/test/xforms/coalesce.test.ts
index c355342b3..11d71b583 100644
--- a/packages/xpath/test/xforms/coalesce.test.ts
+++ b/packages/xpath/test/xforms/coalesce.test.ts
@@ -40,6 +40,14 @@ describe('#coalesce()', () => {
testContext.assertStringValue('coalesce(/simple/xpath/to/node, "SECOND")', 'SECOND');
});
+ it('should return second value if first value is NaN', () => {
+ testContext.assertStringValue('coalesce(1 * /simple/xpath/to/node, "0")', '0');
+ testContext.assertStringValue(
+ 'coalesce(/simple/xpath/to/node * /simple/xpath/to/node, "0")',
+ '0'
+ );
+ });
+
it('coalesce(self::*)', () => {
testContext = createXFormsTestContext(`
From 912e6cea7a4ea3b3e660055679bf121b847597dc Mon Sep 17 00:00:00 2001
From: latin-panda <66472237+latin-panda@users.noreply.github.com>
Date: Tue, 7 Apr 2026 22:27:28 +0200
Subject: [PATCH 2/4] changeset
---
.changeset/yummy-meals-care.md | 5 +++++
1 file changed, 5 insertions(+)
create mode 100644 .changeset/yummy-meals-care.md
diff --git a/.changeset/yummy-meals-care.md b/.changeset/yummy-meals-care.md
new file mode 100644
index 000000000..ca7f1bc40
--- /dev/null
+++ b/.changeset/yummy-meals-care.md
@@ -0,0 +1,5 @@
+---
+'@getodk/xpath': patch
+---
+
+Fixes coalesce function to handle NaN
From 7c2ab378d762057129dc5d9c12bbf7b38dee5ad0 Mon Sep 17 00:00:00 2001
From: latin-panda <66472237+latin-panda@users.noreply.github.com>
Date: Wed, 8 Apr 2026 20:24:19 +0200
Subject: [PATCH 3/4] refactor
---
packages/xpath/src/evaluations/NumberEvaluation.ts | 2 +-
packages/xpath/src/functions/xforms/string.ts | 8 +++-----
packages/xpath/test/xforms/once.test.ts | 4 ++--
packages/xpath/test/xforms/randomize.test.ts | 2 +-
4 files changed, 7 insertions(+), 9 deletions(-)
diff --git a/packages/xpath/src/evaluations/NumberEvaluation.ts b/packages/xpath/src/evaluations/NumberEvaluation.ts
index ea380951d..bc1ae43a3 100644
--- a/packages/xpath/src/evaluations/NumberEvaluation.ts
+++ b/packages/xpath/src/evaluations/NumberEvaluation.ts
@@ -18,6 +18,6 @@ export class NumberEvaluation extends ValueEvaluation {
- const aEval = aExpression!.evaluate(context);
- const aValue = aEval.toString();
+ const a = aExpression!.evaluate(context).toString();
- const isEmpty = aValue === '' || (aEval.type === 'NUMBER' && Number.isNaN(aEval.toNumber()));
- if (!isEmpty) {
- return aValue;
+ if (a !== '') {
+ return a;
}
return bExpression!.evaluate(context).toString();
diff --git a/packages/xpath/test/xforms/once.test.ts b/packages/xpath/test/xforms/once.test.ts
index 0b730561f..6308fa682 100644
--- a/packages/xpath/test/xforms/once.test.ts
+++ b/packages/xpath/test/xforms/once.test.ts
@@ -27,8 +27,8 @@ describe('once()', () => {
});
});
- it('should set value to NaN', () => {
- testContext.assertStringValue('once(. * 10)', 'NaN', {
+ it('should set value to empty string when arithmetic on empty node produces NaN', () => {
+ testContext.assertStringValue('once(. * 10)', '', {
contextNode,
});
});
diff --git a/packages/xpath/test/xforms/randomize.test.ts b/packages/xpath/test/xforms/randomize.test.ts
index a45b74877..1fe2a43e3 100644
--- a/packages/xpath/test/xforms/randomize.test.ts
+++ b/packages/xpath/test/xforms/randomize.test.ts
@@ -80,7 +80,7 @@ describe('randomize()', () => {
{ seed: 0, expected: 'CBEAFD' },
{ seed: NaN, expected: 'CBEAFD' },
{ seed: Infinity, expected: 'CBEAFD' },
- { seed: -Infinity, expected: 'CFBEAD' },
+ { seed: -Infinity, expected: 'CBEAFD' },
{ seed: 'floor(1.1)', expected: 'BFEACD' },
{ seed: '//xhtml:div[@id="testFunctionNodeset2"]/xhtml:p', expected: 'BFEACD' },
{ seed: MIRROR_HASH_VALUE, expected: MIRROR_HASH_SORT_ORDER },
From 6a77007507e9bd084e56f2387589593b8ab2e9d9 Mon Sep 17 00:00:00 2001
From: latin-panda <66472237+latin-panda@users.noreply.github.com>
Date: Wed, 8 Apr 2026 21:01:56 +0200
Subject: [PATCH 4/4] fixes tests
---
.changeset/yummy-meals-care.md | 2 +-
packages/scenario/test/instance-input.test.ts | 2 +-
packages/xforms-engine/test/instance/instance.test.ts | 6 +++---
3 files changed, 5 insertions(+), 5 deletions(-)
diff --git a/.changeset/yummy-meals-care.md b/.changeset/yummy-meals-care.md
index ca7f1bc40..9b57d66b0 100644
--- a/.changeset/yummy-meals-care.md
+++ b/.changeset/yummy-meals-care.md
@@ -2,4 +2,4 @@
'@getodk/xpath': patch
---
-Fixes coalesce function to handle NaN
+Stringify NaN as an empty string according to the XForms specs.
diff --git a/packages/scenario/test/instance-input.test.ts b/packages/scenario/test/instance-input.test.ts
index 260b313f6..dca4aa90b 100644
--- a/packages/scenario/test/instance-input.test.ts
+++ b/packages/scenario/test/instance-input.test.ts
@@ -812,7 +812,7 @@ describe.each([
readonly inner3: ComparableAnswer;
}
- const NAN_ANSWER = stringAnswer(String(NaN));
+ const NAN_ANSWER = stringAnswer('');
interface MissingRepeatInstanceInputCase {
readonly detail: string;
diff --git a/packages/xforms-engine/test/instance/instance.test.ts b/packages/xforms-engine/test/instance/instance.test.ts
index 717039949..c3f55893b 100644
--- a/packages/xforms-engine/test/instance/instance.test.ts
+++ b/packages/xforms-engine/test/instance/instance.test.ts
@@ -133,7 +133,7 @@ describe('Form instance state', () => {
it.each([
{ firstValue: '2', expected: '4' },
- { firstValue: '', expected: 'NaN' },
+ { firstValue: '', expected: '' },
])(
'updates the calculation to $expected when its dependency value is updated to $firstValue',
({ firstValue, expected }) => {
@@ -331,7 +331,7 @@ describe('Form instance state', () => {
it.each([
{ firstValue: '2', expected: '6' },
{ firstValue: '0', expected: '0' },
- { firstValue: '', expected: 'NaN' },
+ { firstValue: '', expected: '' },
])(
'updates the calculated value $expected while it is relevant',
({ firstValue, expected }) => {
@@ -350,7 +350,7 @@ describe('Form instance state', () => {
it.each([
{ firstValue: '2', expected: '6' },
{ firstValue: '0', expected: '0' },
- { firstValue: '', expected: 'NaN' },
+ { firstValue: '', expected: '' },
])(
'updates the calculated value $expected when it becomes relevant after the calculated dependency has been updated',
({ firstValue, expected }) => {