From 2fa0930bbdd39be56d366be247d2f9a612fa5035 Mon Sep 17 00:00:00 2001 From: marcin-kordas-hoc Date: Thu, 10 Sep 2026 12:06:57 +0000 Subject: [PATCH 1/8] Fix HF-361: empty string no longer coerces to 0 `ArithmeticHelper#coerceNonDateScalarToMaybeNumber` special-cased an empty string ('') to coerce to 0, the same as a truly blank cell (EmptyValue). This is a different case from EmptyValue -- a cell or literal holding '' is text, not blank -- and it made HyperFormula diverge from Excel in two ways: - Arithmetic on '' returned 0 instead of #VALUE! (e.g. `=""+0`). - COUNT("") returned 1 instead of 0, since the literal argument was coerced to 0 (an ExtendedNumber) before COUNT's own isExtendedNumber predicate ever saw it. Root cause: - Interpreter.ts `plusOp`/`minusOp`/etc. call `ArithmeticHelper#coerceScalarToNumberOrError`, which returned 0 for '' before this fix. - NumericAggregationPlugin#reduce (used by COUNT, SUM, AVERAGE, PRODUCT, MIN, MAX) calls the same coercion for a literal/expression argument (as opposed to a direct cell reference, which uses the aggregation's own predicate without numeric coercion) -- so COUNT("") went through the same 0-producing bug before isExtendedNumber(0) counted it. This removes the `arg === ''` special case, letting '' fall through to the normal string-to-number parsing (which correctly fails for non-numeric text), matching how HyperFormula already treats any other non-numeric string literal (e.g. `SUM("abc")` already returned #VALUE!; `SUM("")` was the sole inconsistent exception). Not affected (verified with tests): blank cells (EmptyValue) still coerce to 0 in arithmetic and are still governed separately by `evaluateNullToZero`; a '' criterion in COUNTIF/SUMIF-style functions still matches only genuinely blank cells (Criterion.ts resolves that case before any numeric coercion runs). This is a breaking change. Co-Authored-By: Claude Sonnet 5 --- CHANGELOG.md | 1 + src/interpreter/ArithmeticHelper.ts | 13 +- test/hf-361-empty-string-coercion.spec.ts | 261 ++++++++++++++++++++++ 3 files changed, 271 insertions(+), 4 deletions(-) create mode 100644 test/hf-361-empty-string-coercion.spec.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index aeea8dd511..64544418d2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), ### Fixed +- **Breaking change**: Fixed an empty string (`''`) being silently coerced to `0` in arithmetic operations and in any function argument that expects a number, which made results differ from Excel (e.g. `=""+0` returned `0` instead of the `#VALUE!` error, and `COUNT("")` returned `1` instead of `0`). This does not affect blank cells, which keep coercing to `0` as before. (HF-361) - Fixed the `AVERAGEIF` function returning a division-by-zero error when the calculated average was `0`. [#1733](https://github.com/handsontable/hyperformula/pull/1733) - Fixed the localized names of `VSTACK` and `HSTACK` in 14 language packs to match Microsoft Excel. [#1748](https://github.com/handsontable/hyperformula/pull/1748) - Fixed the MAXPOOL and MEDIANPOOL functions throwing an uncaught `TypeError` instead of returning the `#VALUE!` error when the range dimensions are not a whole multiple of the window size and the stride. [#1718](https://github.com/handsontable/hyperformula/pull/1718) diff --git a/src/interpreter/ArithmeticHelper.ts b/src/interpreter/ArithmeticHelper.ts index 73719a60fb..ff12344e46 100644 --- a/src/interpreter/ArithmeticHelper.ts +++ b/src/interpreter/ArithmeticHelper.ts @@ -222,14 +222,19 @@ export class ArithmeticHelper { ) } + /** + * Coerces a scalar value to a number for use in arithmetic and numeric aggregation, without + * attempting date-string parsing (see {@link coerceToMaybeNumber} for that extra step). + * + * An empty string (`''`) is deliberately *not* coerced to `0` here: it is a text value, not a + * blank cell (that case is `EmptyValue`, handled above), so it is left for the caller to reject + * as non-numeric — matching how Excel treats `""` as text in arithmetic and numeric + * functions (`=""+0` and `=COUNT("")` both fail to find a number in Excel). + */ public coerceNonDateScalarToMaybeNumber(arg: InternalScalarValue): Maybe { if (arg === EmptyValue) { return 0 } else if (typeof arg === 'string') { - if (arg === '') { - return 0 - } - const maybePercentNumber = this.coerceStringToMaybePercentNumber(arg) if (maybePercentNumber !== undefined) { return maybePercentNumber diff --git a/test/hf-361-empty-string-coercion.spec.ts b/test/hf-361-empty-string-coercion.spec.ts new file mode 100644 index 0000000000..90404ac963 --- /dev/null +++ b/test/hf-361-empty-string-coercion.spec.ts @@ -0,0 +1,261 @@ +import {HyperFormula} from '../src' +import {ErrorType} from '../src/Cell' +import {DetailedCellError} from '../src/CellValue' + +/** + * HF-361: an empty string ('') must not be silently coerced to 0. + * + * Excel treats '' as ordinary text: it is not a valid number, so arithmetic on it fails with + * #VALUE! and COUNT (which only counts numbers) does not count it. HyperFormula used to special-case + * '' to 0 inside ArithmeticHelper#coerceNonDateScalarToMaybeNumber, which is a different code path + * from a genuinely blank cell (`EmptyValue`, governed separately by `evaluateNullToZero`). + */ +describe('HF-361: empty string is not coerced to 0', () => { + it('COUNT of a literal empty string does not count it as numeric', () => { + const hf = HyperFormula.buildFromArray([['=COUNT("")']], {licenseKey: 'gpl-v3'}) + + expect(hf.getCellValue({sheet: 0, col: 0, row: 0})).toBe(0) + + hf.destroy() + }) + + it('COUNT of a cell holding an empty string (via ="") does not count it as numeric', () => { + const hf = HyperFormula.buildFromArray([['=""', '=COUNT(A1)']], {licenseKey: 'gpl-v3'}) + + expect(hf.getCellValue({sheet: 0, col: 1, row: 0})).toBe(0) + + hf.destroy() + }) + + it('COUNT of a cell holding a raw empty string (a text cell, not a blank one) does not count it', () => { + const hf = HyperFormula.buildFromArray([['', '=COUNT(A1)']], {licenseKey: 'gpl-v3'}) + + expect(hf.getCellValue({sheet: 0, col: 1, row: 0})).toBe(0) + + hf.destroy() + }) + + it('adding a literal empty string to a number is #VALUE!, matching Excel', () => { + const hf = HyperFormula.buildFromArray([['=""+0']], {licenseKey: 'gpl-v3'}) + + const result = hf.getCellValue({sheet: 0, col: 0, row: 0}) as DetailedCellError + + expect(result.type).toBe(ErrorType.VALUE) + + hf.destroy() + }) + + it('adding a number to an empty string is #VALUE!', () => { + const hf = HyperFormula.buildFromArray([['=""+1']], {licenseKey: 'gpl-v3'}) + + const result = hf.getCellValue({sheet: 0, col: 0, row: 0}) as DetailedCellError + + expect(result.type).toBe(ErrorType.VALUE) + + hf.destroy() + }) + + it('subtracting a number from an empty string is #VALUE!', () => { + const hf = HyperFormula.buildFromArray([['=""-1']], {licenseKey: 'gpl-v3'}) + + const result = hf.getCellValue({sheet: 0, col: 0, row: 0}) as DetailedCellError + + expect(result.type).toBe(ErrorType.VALUE) + + hf.destroy() + }) + + it('multiplying an empty string is #VALUE!', () => { + const hf = HyperFormula.buildFromArray([['=""*1']], {licenseKey: 'gpl-v3'}) + + const result = hf.getCellValue({sheet: 0, col: 0, row: 0}) as DetailedCellError + + expect(result.type).toBe(ErrorType.VALUE) + + hf.destroy() + }) + + it('dividing an empty string is #VALUE!', () => { + const hf = HyperFormula.buildFromArray([['=""/1']], {licenseKey: 'gpl-v3'}) + + const result = hf.getCellValue({sheet: 0, col: 0, row: 0}) as DetailedCellError + + expect(result.type).toBe(ErrorType.VALUE) + + hf.destroy() + }) + + it('negating an empty string is #VALUE!', () => { + const hf = HyperFormula.buildFromArray([['=-""']], {licenseKey: 'gpl-v3'}) + + const result = hf.getCellValue({sheet: 0, col: 0, row: 0}) as DetailedCellError + + expect(result.type).toBe(ErrorType.VALUE) + + hf.destroy() + }) + + it('raising a number to the power of an empty string is #VALUE!', () => { + const hf = HyperFormula.buildFromArray([['=1^""']], {licenseKey: 'gpl-v3'}) + + const result = hf.getCellValue({sheet: 0, col: 0, row: 0}) as DetailedCellError + + expect(result.type).toBe(ErrorType.VALUE) + + hf.destroy() + }) + + it('a text cell holding an empty string behaves the same as the literal in arithmetic', () => { + const hf = HyperFormula.buildFromArray([['', '=A1+0']], {licenseKey: 'gpl-v3'}) + + const result = hf.getCellValue({sheet: 0, col: 1, row: 0}) as DetailedCellError + + expect(result.type).toBe(ErrorType.VALUE) + + hf.destroy() + }) + + it('SUM of a literal empty string is #VALUE!, consistent with other non-numeric text', () => { + const hf = HyperFormula.buildFromArray([['=SUM("")']], {licenseKey: 'gpl-v3'}) + + const result = hf.getCellValue({sheet: 0, col: 0, row: 0}) as DetailedCellError + + expect(result.type).toBe(ErrorType.VALUE) + + hf.destroy() + }) + + it('AVERAGE of a literal empty string is #VALUE!', () => { + const hf = HyperFormula.buildFromArray([['=AVERAGE("")']], {licenseKey: 'gpl-v3'}) + + const result = hf.getCellValue({sheet: 0, col: 0, row: 0}) as DetailedCellError + + expect(result.type).toBe(ErrorType.VALUE) + + hf.destroy() + }) + + it('PRODUCT of a literal empty string is #VALUE!', () => { + const hf = HyperFormula.buildFromArray([['=PRODUCT("")']], {licenseKey: 'gpl-v3'}) + + const result = hf.getCellValue({sheet: 0, col: 0, row: 0}) as DetailedCellError + + expect(result.type).toBe(ErrorType.VALUE) + + hf.destroy() + }) + + it('MIN of a literal empty string is #VALUE!', () => { + const hf = HyperFormula.buildFromArray([['=MIN("")']], {licenseKey: 'gpl-v3'}) + + const result = hf.getCellValue({sheet: 0, col: 0, row: 0}) as DetailedCellError + + expect(result.type).toBe(ErrorType.VALUE) + + hf.destroy() + }) + + it('MAX of a literal empty string is #VALUE!', () => { + const hf = HyperFormula.buildFromArray([['=MAX("")']], {licenseKey: 'gpl-v3'}) + + const result = hf.getCellValue({sheet: 0, col: 0, row: 0}) as DetailedCellError + + expect(result.type).toBe(ErrorType.VALUE) + + hf.destroy() + }) + + it('SUM of other non-numeric literal text is #VALUE! too (baseline, unaffected by this fix)', () => { + const hf = HyperFormula.buildFromArray([['=SUM("abc")']], {licenseKey: 'gpl-v3'}) + + const result = hf.getCellValue({sheet: 0, col: 0, row: 0}) as DetailedCellError + + expect(result.type).toBe(ErrorType.VALUE) + + hf.destroy() + }) + + it('ROUND rejects a literal empty string for its NUMBER-typed argument', () => { + const hf = HyperFormula.buildFromArray([['=ROUND("",1)']], {licenseKey: 'gpl-v3'}) + + const result = hf.getCellValue({sheet: 0, col: 0, row: 0}) as DetailedCellError + + expect(result.type).toBe(ErrorType.VALUE) + + hf.destroy() + }) + + it('POWER rejects a literal empty string for its NUMBER-typed argument', () => { + const hf = HyperFormula.buildFromArray([['=POWER("",2)']], {licenseKey: 'gpl-v3'}) + + const result = hf.getCellValue({sheet: 0, col: 0, row: 0}) as DetailedCellError + + expect(result.type).toBe(ErrorType.VALUE) + + hf.destroy() + }) +}) + +describe('HF-361: unaffected — blank cells (EmptyValue) keep their existing behavior', () => { + it('a truly blank cell still coerces to 0 in arithmetic (evaluateNullToZero: false)', () => { + const hf = HyperFormula.buildFromArray([[null, '=A1+1']], {licenseKey: 'gpl-v3', evaluateNullToZero: false}) + + expect(hf.getCellValue({sheet: 0, col: 1, row: 0})).toBe(1) + + hf.destroy() + }) + + it('a truly blank cell still coerces to 0 in arithmetic (evaluateNullToZero: true)', () => { + const hf = HyperFormula.buildFromArray([[null, '=A1+1']], {licenseKey: 'gpl-v3', evaluateNullToZero: true}) + + expect(hf.getCellValue({sheet: 0, col: 1, row: 0})).toBe(1) + + hf.destroy() + }) + + it('COUNT of a truly blank cell is still 0 (evaluateNullToZero: false)', () => { + const hf = HyperFormula.buildFromArray([[null, '=COUNT(A1)']], {licenseKey: 'gpl-v3', evaluateNullToZero: false}) + + expect(hf.getCellValue({sheet: 0, col: 1, row: 0})).toBe(0) + + hf.destroy() + }) + + it('COUNT of a truly blank cell is still 0 (evaluateNullToZero: true)', () => { + const hf = HyperFormula.buildFromArray([[null, '=COUNT(A1)']], {licenseKey: 'gpl-v3', evaluateNullToZero: true}) + + expect(hf.getCellValue({sheet: 0, col: 1, row: 0})).toBe(0) + + hf.destroy() + }) + + it('evaluateNullToZero: false leaves a formula that evaluates to EmptyValue as null', () => { + const hf = HyperFormula.buildFromArray([[null, '=A1']], {licenseKey: 'gpl-v3', evaluateNullToZero: false}) + + expect(hf.getCellValue({sheet: 0, col: 1, row: 0})).toBeNull() + + hf.destroy() + }) + + it('evaluateNullToZero: true turns a formula that evaluates to EmptyValue into 0', () => { + const hf = HyperFormula.buildFromArray([[null, '=A1']], {licenseKey: 'gpl-v3', evaluateNullToZero: true}) + + expect(hf.getCellValue({sheet: 0, col: 1, row: 0})).toBe(0) + + hf.destroy() + }) + + it('a "" COUNTIF criterion still matches only genuinely blank cells, not text cells holding ""', () => { + const hf = HyperFormula.buildFromArray([ + [1, null, ''], + ['=COUNTIF(A1:C1, "")'], + ], {licenseKey: 'gpl-v3'}) + + // Criterion.ts resolves a '' criterion to `cellValue === EmptyValue` *before* any numeric + // coercion happens, so it never went through the coerceNonDateScalarToMaybeNumber bug and + // this behavior (blank matches, a text "" cell does not) is unchanged by this fix. + expect(hf.getCellValue({sheet: 0, col: 0, row: 1})).toBe(1) + + hf.destroy() + }) +}) From 78b1c75bd354e4580437b5fe4eaf3da143d5193c Mon Sep 17 00:00:00 2001 From: marcin-kordas-hoc Date: Thu, 10 Sep 2026 13:58:26 +0000 Subject: [PATCH 2/8] HF-361: complete the Definition of Done for the empty-string change The fix itself was already in place; this adds what a breaking change owes the reader, and moves the tests where this repo keeps them. - Changelog: moved the entry from Fixed to Changed and rewrote it in the "A **breaking change**:" form the other 4.0 entries use, with a migration guide link and the PR number instead of an internal task id. - Migration guide: new docs/guide/migration-from-3.x-to-4.0.md (plus its sidebar entry) with a section on empty-string coercion. Every before/after value in it is measured, not assumed -- two rounds of probes across the unpatched and patched engine, plus the assertions the private suite already pinned. That caught two wrong claims: DATE("","","") returned #NUM! and not a computed date, and ACOT("") returned 1.5707963267949 -- a plausible number from an argument that was never numeric, which is the most useful example this change has. - Removed test/hf-361-empty-string-coercion.spec.ts. DEV_DOCS.md:129 is explicit that interpreter specs do not live in the public repo, because a copy here runs twice and leaves develop red if only one of the paired PRs lands. Those tests move to the paired hyperformula-tests branch of the same name. Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 5 +- docs/.vuepress/config.js | 1 + docs/guide/migration-from-3.x-to-4.0.md | 84 +++++++ test/hf-361-empty-string-coercion.spec.ts | 261 ---------------------- 4 files changed, 89 insertions(+), 262 deletions(-) create mode 100644 docs/guide/migration-from-3.x-to-4.0.md delete mode 100644 test/hf-361-empty-string-coercion.spec.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 64544418d2..b664217e42 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,9 +7,12 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), ## [Unreleased] +### Changed + +- A **breaking change**: an empty string (`""`) is no longer coerced to `0` in numeric contexts. As in Excel, `""` is text and is never a number, so `=""+0` now returns the `#VALUE!` error instead of `0`, and `=COUNT("")` returns `0` instead of `1`. This applies wherever a value reaches a number: the arithmetic operators, numeric aggregation (`SUM`, `AVERAGE`, `MIN`, `MAX`, `PRODUCT`, `COUNT`), and any function argument declared as a number. Blank cells are a different value and are unaffected — they still coerce to `0`, and the `evaluateNullToZero` option behaves exactly as before. See the [migration guide](https://hyperformula.handsontable.com/docs/guide/migration-from-3.x-to-4.0.html#changes-to-empty-string-coercion). [#1768](https://github.com/handsontable/hyperformula/pull/1768) + ### Fixed -- **Breaking change**: Fixed an empty string (`''`) being silently coerced to `0` in arithmetic operations and in any function argument that expects a number, which made results differ from Excel (e.g. `=""+0` returned `0` instead of the `#VALUE!` error, and `COUNT("")` returned `1` instead of `0`). This does not affect blank cells, which keep coercing to `0` as before. (HF-361) - Fixed the `AVERAGEIF` function returning a division-by-zero error when the calculated average was `0`. [#1733](https://github.com/handsontable/hyperformula/pull/1733) - Fixed the localized names of `VSTACK` and `HSTACK` in 14 language packs to match Microsoft Excel. [#1748](https://github.com/handsontable/hyperformula/pull/1748) - Fixed the MAXPOOL and MEDIANPOOL functions throwing an uncaught `TypeError` instead of returning the `#VALUE!` error when the range dimensions are not a whole multiple of the window size and the stride. [#1718](https://github.com/handsontable/hyperformula/pull/1718) diff --git a/docs/.vuepress/config.js b/docs/.vuepress/config.js index 5caadb41a7..5d21d5fcc6 100644 --- a/docs/.vuepress/config.js +++ b/docs/.vuepress/config.js @@ -372,6 +372,7 @@ module.exports = { ['/guide/migration-from-0.6-to-1.0', 'Migrating from 0.6 to 1.0'], ['/guide/migration-from-1.x-to-2.0', 'Migrating from 1.x to 2.0'], ['/guide/migration-from-2.x-to-3.0', 'Migrating from 2.x to 3.0'], + ['/guide/migration-from-3.x-to-4.0', 'Migrating from 3.x to 4.0'], ] }, { diff --git a/docs/guide/migration-from-3.x-to-4.0.md b/docs/guide/migration-from-3.x-to-4.0.md new file mode 100644 index 0000000000..b0e529fd9f --- /dev/null +++ b/docs/guide/migration-from-3.x-to-4.0.md @@ -0,0 +1,84 @@ +--- +tags: + - migration + - migrate + - upgrade + - breaking changes + - v4.0 + - empty string + - coercion +--- + +# Migrating from 3.x to 4.0 + +To upgrade your HyperFormula version from 3.x.x to 4.0.0, follow this guide. + +## Changes to empty string coercion + +An empty string (`""`) used to be coerced to `0` wherever a number was expected. It no longer is: as in Excel, `""` is text, and text that is not a number produces the `#VALUE!` error. The Excel results quoted below were verified in Excel Online, so where this guide says "as in Excel", that is a measured result rather than an intention. + +Nothing in your code needs to change for this on its own. What may change are results in your sheets, and only where an empty string reaches a numeric context — see [When a cell holds an empty string](#when-a-cell-holds-an-empty-string) for the case that is easy to hit without writing `""` anywhere. + +### Every numeric context now rejects an empty string + +| Formula | 3.x | 4.0 | +| --- | --- | --- | +| `=""+0` | `0` | `#VALUE!` | +| `=1-""` | `1` | `#VALUE!` | +| `=""*2` | `0` | `#VALUE!` | +| `=""/1` | `0` | `#VALUE!` | +| `=-""` | `0` | `#VALUE!` | +| `=2^""` | `1` | `#VALUE!` | +| `=COUNT("")` | `1` | `0` | +| `=SUM("")` | `0` | `#VALUE!` | +| `=AVERAGE("")` | `0` | `#VALUE!` | +| `=MIN("")` / `=MAX("")` / `=PRODUCT("")` | `0` | `#VALUE!` | +| `=ROUND("", 0)` | `0` | `#VALUE!` | +| `=ACOT("")` | `1.5707963267949` | `#VALUE!` | +| `=LN("")` / `=LOG10("")` / `=LOG("", 42)` | `#NUM!` | `#VALUE!` | +| `=COT("")` / `=COTH("")` | `#DIV/0!` | `#VALUE!` | +| `=DATE("", "", "")` | `#NUM!` | `#VALUE!` | + +The `ACOT` row is the one worth looking at twice. `""` became `0`, and `ACOT(0)` is a perfectly good number, so the formula returned π/2 — a plausible result computed from an argument that was never a number. The rows below it are the milder shape of the same problem: an error, but one naming a domain violation discovered after the coercion rather than the coercion itself. + +`=SUM("abc")` returned `#VALUE!` in 3.x and still does. The point of this change is that `""` is no longer the one text value treated as a number. + +### Blank cells are not affected + +A blank cell is not an empty string, and nothing about it changes: + +| Case | 3.x | 4.0 | +| --- | --- | --- | +| `=A1+0` where `A1` is empty | `0` | `0` (unchanged) | +| `=COUNT(A1)` where `A1` is empty | `0` | `0` (unchanged) | +| [`evaluateNullToZero`](../api/interfaces/configparams.md#evaluatenulltozero) | — | behaves exactly as before | +| a `""` criterion in `COUNTIF`/`SUMIF` | matches blank cells only | unchanged | + +The `""` criterion row is worth reading twice: `=COUNTIF(A1:A3, "")` matched only genuinely blank cells before this change, and still does. It never matched text cells holding `""`. + +### When a cell holds an empty string + +This is the case to check in an application, because it can arise without any formula containing `""`. + +Writing an empty string into a cell produces a **text** cell, not a blank one: + +```js +hfInstance.setCellContents({ sheet: 0, col: 0, row: 0 }, ''); // a text cell holding '' +hfInstance.setCellContents({ sheet: 0, col: 0, row: 0 }, null); // a blank cell +``` + +The first is `#VALUE!`-producing input for a numeric formula from 4.0 on; the second is not, and never was. The same distinction applies to `buildFromArray([['']])` versus `buildFromArray([[null]])`. + +**How to migrate.** If your integration clears cells, make sure it writes `null` (or `undefined`), not `''`: + +```js +hfInstance.setCellContents(address, ''); -> hfInstance.setCellContents(address, null); +``` + +Two places worth auditing in a host application: + +- **A "clear cell" action.** If it hands the engine the empty value of a text input rather than `null`, it writes `''`. +- **Paste.** Clipboard content is text, so a blank cell inside a copied range parses to `''` rather than to `null` unless something maps it back. + +To find affected formulas rather than affected code, look for numeric formulas that reference cells your application can blank out, and for `IF(..., "")` used as a "leave it empty" branch feeding another calculation — for example +`=IF(SUM(A1:A2)>0, SUM(A1:A2), "")/1000`, which returned a number in 3.x and returns `#VALUE!` in 4.0. That pattern is Excel-correct in 4.0: Excel reports `#VALUE!` for it too. Where the intent is "treat the empty branch as zero", write `0` instead of `""` in the branch, or wrap the reference in [`N()`](built-in-functions.md) / `IFERROR`. diff --git a/test/hf-361-empty-string-coercion.spec.ts b/test/hf-361-empty-string-coercion.spec.ts deleted file mode 100644 index 90404ac963..0000000000 --- a/test/hf-361-empty-string-coercion.spec.ts +++ /dev/null @@ -1,261 +0,0 @@ -import {HyperFormula} from '../src' -import {ErrorType} from '../src/Cell' -import {DetailedCellError} from '../src/CellValue' - -/** - * HF-361: an empty string ('') must not be silently coerced to 0. - * - * Excel treats '' as ordinary text: it is not a valid number, so arithmetic on it fails with - * #VALUE! and COUNT (which only counts numbers) does not count it. HyperFormula used to special-case - * '' to 0 inside ArithmeticHelper#coerceNonDateScalarToMaybeNumber, which is a different code path - * from a genuinely blank cell (`EmptyValue`, governed separately by `evaluateNullToZero`). - */ -describe('HF-361: empty string is not coerced to 0', () => { - it('COUNT of a literal empty string does not count it as numeric', () => { - const hf = HyperFormula.buildFromArray([['=COUNT("")']], {licenseKey: 'gpl-v3'}) - - expect(hf.getCellValue({sheet: 0, col: 0, row: 0})).toBe(0) - - hf.destroy() - }) - - it('COUNT of a cell holding an empty string (via ="") does not count it as numeric', () => { - const hf = HyperFormula.buildFromArray([['=""', '=COUNT(A1)']], {licenseKey: 'gpl-v3'}) - - expect(hf.getCellValue({sheet: 0, col: 1, row: 0})).toBe(0) - - hf.destroy() - }) - - it('COUNT of a cell holding a raw empty string (a text cell, not a blank one) does not count it', () => { - const hf = HyperFormula.buildFromArray([['', '=COUNT(A1)']], {licenseKey: 'gpl-v3'}) - - expect(hf.getCellValue({sheet: 0, col: 1, row: 0})).toBe(0) - - hf.destroy() - }) - - it('adding a literal empty string to a number is #VALUE!, matching Excel', () => { - const hf = HyperFormula.buildFromArray([['=""+0']], {licenseKey: 'gpl-v3'}) - - const result = hf.getCellValue({sheet: 0, col: 0, row: 0}) as DetailedCellError - - expect(result.type).toBe(ErrorType.VALUE) - - hf.destroy() - }) - - it('adding a number to an empty string is #VALUE!', () => { - const hf = HyperFormula.buildFromArray([['=""+1']], {licenseKey: 'gpl-v3'}) - - const result = hf.getCellValue({sheet: 0, col: 0, row: 0}) as DetailedCellError - - expect(result.type).toBe(ErrorType.VALUE) - - hf.destroy() - }) - - it('subtracting a number from an empty string is #VALUE!', () => { - const hf = HyperFormula.buildFromArray([['=""-1']], {licenseKey: 'gpl-v3'}) - - const result = hf.getCellValue({sheet: 0, col: 0, row: 0}) as DetailedCellError - - expect(result.type).toBe(ErrorType.VALUE) - - hf.destroy() - }) - - it('multiplying an empty string is #VALUE!', () => { - const hf = HyperFormula.buildFromArray([['=""*1']], {licenseKey: 'gpl-v3'}) - - const result = hf.getCellValue({sheet: 0, col: 0, row: 0}) as DetailedCellError - - expect(result.type).toBe(ErrorType.VALUE) - - hf.destroy() - }) - - it('dividing an empty string is #VALUE!', () => { - const hf = HyperFormula.buildFromArray([['=""/1']], {licenseKey: 'gpl-v3'}) - - const result = hf.getCellValue({sheet: 0, col: 0, row: 0}) as DetailedCellError - - expect(result.type).toBe(ErrorType.VALUE) - - hf.destroy() - }) - - it('negating an empty string is #VALUE!', () => { - const hf = HyperFormula.buildFromArray([['=-""']], {licenseKey: 'gpl-v3'}) - - const result = hf.getCellValue({sheet: 0, col: 0, row: 0}) as DetailedCellError - - expect(result.type).toBe(ErrorType.VALUE) - - hf.destroy() - }) - - it('raising a number to the power of an empty string is #VALUE!', () => { - const hf = HyperFormula.buildFromArray([['=1^""']], {licenseKey: 'gpl-v3'}) - - const result = hf.getCellValue({sheet: 0, col: 0, row: 0}) as DetailedCellError - - expect(result.type).toBe(ErrorType.VALUE) - - hf.destroy() - }) - - it('a text cell holding an empty string behaves the same as the literal in arithmetic', () => { - const hf = HyperFormula.buildFromArray([['', '=A1+0']], {licenseKey: 'gpl-v3'}) - - const result = hf.getCellValue({sheet: 0, col: 1, row: 0}) as DetailedCellError - - expect(result.type).toBe(ErrorType.VALUE) - - hf.destroy() - }) - - it('SUM of a literal empty string is #VALUE!, consistent with other non-numeric text', () => { - const hf = HyperFormula.buildFromArray([['=SUM("")']], {licenseKey: 'gpl-v3'}) - - const result = hf.getCellValue({sheet: 0, col: 0, row: 0}) as DetailedCellError - - expect(result.type).toBe(ErrorType.VALUE) - - hf.destroy() - }) - - it('AVERAGE of a literal empty string is #VALUE!', () => { - const hf = HyperFormula.buildFromArray([['=AVERAGE("")']], {licenseKey: 'gpl-v3'}) - - const result = hf.getCellValue({sheet: 0, col: 0, row: 0}) as DetailedCellError - - expect(result.type).toBe(ErrorType.VALUE) - - hf.destroy() - }) - - it('PRODUCT of a literal empty string is #VALUE!', () => { - const hf = HyperFormula.buildFromArray([['=PRODUCT("")']], {licenseKey: 'gpl-v3'}) - - const result = hf.getCellValue({sheet: 0, col: 0, row: 0}) as DetailedCellError - - expect(result.type).toBe(ErrorType.VALUE) - - hf.destroy() - }) - - it('MIN of a literal empty string is #VALUE!', () => { - const hf = HyperFormula.buildFromArray([['=MIN("")']], {licenseKey: 'gpl-v3'}) - - const result = hf.getCellValue({sheet: 0, col: 0, row: 0}) as DetailedCellError - - expect(result.type).toBe(ErrorType.VALUE) - - hf.destroy() - }) - - it('MAX of a literal empty string is #VALUE!', () => { - const hf = HyperFormula.buildFromArray([['=MAX("")']], {licenseKey: 'gpl-v3'}) - - const result = hf.getCellValue({sheet: 0, col: 0, row: 0}) as DetailedCellError - - expect(result.type).toBe(ErrorType.VALUE) - - hf.destroy() - }) - - it('SUM of other non-numeric literal text is #VALUE! too (baseline, unaffected by this fix)', () => { - const hf = HyperFormula.buildFromArray([['=SUM("abc")']], {licenseKey: 'gpl-v3'}) - - const result = hf.getCellValue({sheet: 0, col: 0, row: 0}) as DetailedCellError - - expect(result.type).toBe(ErrorType.VALUE) - - hf.destroy() - }) - - it('ROUND rejects a literal empty string for its NUMBER-typed argument', () => { - const hf = HyperFormula.buildFromArray([['=ROUND("",1)']], {licenseKey: 'gpl-v3'}) - - const result = hf.getCellValue({sheet: 0, col: 0, row: 0}) as DetailedCellError - - expect(result.type).toBe(ErrorType.VALUE) - - hf.destroy() - }) - - it('POWER rejects a literal empty string for its NUMBER-typed argument', () => { - const hf = HyperFormula.buildFromArray([['=POWER("",2)']], {licenseKey: 'gpl-v3'}) - - const result = hf.getCellValue({sheet: 0, col: 0, row: 0}) as DetailedCellError - - expect(result.type).toBe(ErrorType.VALUE) - - hf.destroy() - }) -}) - -describe('HF-361: unaffected — blank cells (EmptyValue) keep their existing behavior', () => { - it('a truly blank cell still coerces to 0 in arithmetic (evaluateNullToZero: false)', () => { - const hf = HyperFormula.buildFromArray([[null, '=A1+1']], {licenseKey: 'gpl-v3', evaluateNullToZero: false}) - - expect(hf.getCellValue({sheet: 0, col: 1, row: 0})).toBe(1) - - hf.destroy() - }) - - it('a truly blank cell still coerces to 0 in arithmetic (evaluateNullToZero: true)', () => { - const hf = HyperFormula.buildFromArray([[null, '=A1+1']], {licenseKey: 'gpl-v3', evaluateNullToZero: true}) - - expect(hf.getCellValue({sheet: 0, col: 1, row: 0})).toBe(1) - - hf.destroy() - }) - - it('COUNT of a truly blank cell is still 0 (evaluateNullToZero: false)', () => { - const hf = HyperFormula.buildFromArray([[null, '=COUNT(A1)']], {licenseKey: 'gpl-v3', evaluateNullToZero: false}) - - expect(hf.getCellValue({sheet: 0, col: 1, row: 0})).toBe(0) - - hf.destroy() - }) - - it('COUNT of a truly blank cell is still 0 (evaluateNullToZero: true)', () => { - const hf = HyperFormula.buildFromArray([[null, '=COUNT(A1)']], {licenseKey: 'gpl-v3', evaluateNullToZero: true}) - - expect(hf.getCellValue({sheet: 0, col: 1, row: 0})).toBe(0) - - hf.destroy() - }) - - it('evaluateNullToZero: false leaves a formula that evaluates to EmptyValue as null', () => { - const hf = HyperFormula.buildFromArray([[null, '=A1']], {licenseKey: 'gpl-v3', evaluateNullToZero: false}) - - expect(hf.getCellValue({sheet: 0, col: 1, row: 0})).toBeNull() - - hf.destroy() - }) - - it('evaluateNullToZero: true turns a formula that evaluates to EmptyValue into 0', () => { - const hf = HyperFormula.buildFromArray([[null, '=A1']], {licenseKey: 'gpl-v3', evaluateNullToZero: true}) - - expect(hf.getCellValue({sheet: 0, col: 1, row: 0})).toBe(0) - - hf.destroy() - }) - - it('a "" COUNTIF criterion still matches only genuinely blank cells, not text cells holding ""', () => { - const hf = HyperFormula.buildFromArray([ - [1, null, ''], - ['=COUNTIF(A1:C1, "")'], - ], {licenseKey: 'gpl-v3'}) - - // Criterion.ts resolves a '' criterion to `cellValue === EmptyValue` *before* any numeric - // coercion happens, so it never went through the coerceNonDateScalarToMaybeNumber bug and - // this behavior (blank matches, a text "" cell does not) is unchanged by this fix. - expect(hf.getCellValue({sheet: 0, col: 0, row: 1})).toBe(1) - - hf.destroy() - }) -}) From c99427b3b17c7762ec3aaf15c0324b0d34be1801 Mon Sep 17 00:00:00 2001 From: marcin-kordas-hoc Date: Thu, 10 Sep 2026 15:14:09 +0000 Subject: [PATCH 3/8] HF-361: correct the scope of the change in the changelog and migration guide Two independent reviews converged on the same defect: both documents implied that a cell holding an empty string breaks any numeric formula. It does not. Measured on both engines: SUM, AVERAGE, MIN, MAX and COUNT over a reference or a range are byte-identical before and after -- they have always ignored text found there, matching Excel -- because the aggregation path filters strings out before this coercion is reached. Only arithmetic, number-typed arguments, and an empty string written directly into an aggregation change. A reader with =SUM(A1:A100) would have been told to expect a break that cannot happen, in a document whose whole job is to predict breaks. Also corrected or added, all measured rather than read off a test: - =ACOT("") returned 1.5707963268, not 1.5707963267949. I had taken the digits from the private suite's toBeCloseTo(..., 10) assertion, which passes for both. Default config rounds to 10 significant digits. - =DATE(A1, 2, 3) over a text empty string silently returned 35 -- a date in 1900 -- which is the same "answered instead of failing" class as ACOT and a better example, because it is the reference form users actually write. - Added the contexts the table omitted while claiming to be exhaustive: =""%, =SUM(1, ""), =ABS, =INT, =SUMSQ, =MEDIAN, =STDEV. - Added the nested-expression class (=SUM(IF(TRUE,"",1)), =SUM(LEFT("abc",0))), where 3.x was wrong and 4.0 matches Excel, and the one class that moves away from Excel (an argument Excel evaluates as an array of only text), with the pre-existing non-array-aware IF that actually causes it. - Fixed the code blocks against DOCS_CONTENT_GUIDE: the mandated { sheet, row, col } key order, a snippet that actually runs as written (verified), and an untagged fence for the before/after pair that is not JavaScript. Pointed N() and IFERROR at their real category anchors. Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 2 +- docs/guide/migration-from-3.x-to-4.0.md | 98 +++++++++++++++++++------ 2 files changed, 78 insertions(+), 22 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b664217e42..869042e41d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), ### Changed -- A **breaking change**: an empty string (`""`) is no longer coerced to `0` in numeric contexts. As in Excel, `""` is text and is never a number, so `=""+0` now returns the `#VALUE!` error instead of `0`, and `=COUNT("")` returns `0` instead of `1`. This applies wherever a value reaches a number: the arithmetic operators, numeric aggregation (`SUM`, `AVERAGE`, `MIN`, `MAX`, `PRODUCT`, `COUNT`), and any function argument declared as a number. Blank cells are a different value and are unaffected — they still coerce to `0`, and the `evaluateNullToZero` option behaves exactly as before. See the [migration guide](https://hyperformula.handsontable.com/docs/guide/migration-from-3.x-to-4.0.html#changes-to-empty-string-coercion). [#1768](https://github.com/handsontable/hyperformula/pull/1768) +- A **breaking change**: an empty string (`""`) is no longer coerced to `0` in numeric contexts. As in Excel, `""` is text and is never a number, so `=""+0` and `=A1+1` (where `A1` holds an empty string) now return the `#VALUE!` error instead of `0` and `1`. This affects the arithmetic operators and any function argument declared as a number (`=ROUND(A1, 0)`, `=ACOT(A1)`, `=DATE(A1, 2, 3)`), plus an empty string written directly into an aggregation (`=COUNT("")` returns `0` instead of `1`, `=SUM(1, "")` returns `#VALUE!` instead of `1`). Aggregation over a cell reference or a range is **not** affected: `SUM`, `AVERAGE`, `MIN`, `MAX` and `COUNT` have always ignored text found there and still do. Blank cells are a different value and are unaffected — they still coerce to `0`, and the `evaluateNullToZero` option behaves exactly as before. See the [migration guide](https://hyperformula.handsontable.com/docs/guide/migration-from-3.x-to-4.0.html#changes-to-empty-string-coercion). [#1768](https://github.com/handsontable/hyperformula/pull/1768) ### Fixed diff --git a/docs/guide/migration-from-3.x-to-4.0.md b/docs/guide/migration-from-3.x-to-4.0.md index b0e529fd9f..1395a74220 100644 --- a/docs/guide/migration-from-3.x-to-4.0.md +++ b/docs/guide/migration-from-3.x-to-4.0.md @@ -19,30 +19,81 @@ An empty string (`""`) used to be coerced to `0` wherever a number was expected. Nothing in your code needs to change for this on its own. What may change are results in your sheets, and only where an empty string reaches a numeric context — see [When a cell holds an empty string](#when-a-cell-holds-an-empty-string) for the case that is easy to hit without writing `""` anywhere. -### Every numeric context now rejects an empty string +### What changes: arithmetic and number-typed arguments + +This holds whether the empty string is written in the formula or read from a cell. In the tables below, `A1` is a cell holding an empty string — see [When a cell holds an empty string](#when-a-cell-holds-an-empty-string) for how one gets there. | Formula | 3.x | 4.0 | | --- | --- | --- | -| `=""+0` | `0` | `#VALUE!` | -| `=1-""` | `1` | `#VALUE!` | -| `=""*2` | `0` | `#VALUE!` | -| `=""/1` | `0` | `#VALUE!` | -| `=-""` | `0` | `#VALUE!` | -| `=2^""` | `1` | `#VALUE!` | -| `=COUNT("")` | `1` | `0` | -| `=SUM("")` | `0` | `#VALUE!` | -| `=AVERAGE("")` | `0` | `#VALUE!` | -| `=MIN("")` / `=MAX("")` / `=PRODUCT("")` | `0` | `#VALUE!` | -| `=ROUND("", 0)` | `0` | `#VALUE!` | -| `=ACOT("")` | `1.5707963267949` | `#VALUE!` | +| `=""+0` / `=A1+1` | `0` / `1` | `#VALUE!` | +| `=1-""` / `=A1-1` | `1` / `-1` | `#VALUE!` | +| `=""*2` / `=A1*2` | `0` | `#VALUE!` | +| `=""/1` / `=A1/1` | `0` | `#VALUE!` | +| `=-""` / `=-A1` | `0` | `#VALUE!` | +| `=2^""` / `=A1^2` | `1` / `0` | `#VALUE!` | +| `=""%` / `=A1%` | `0` | `#VALUE!` | +| `=ROUND("", 0)` / `=ROUND(A1, 0)` | `0` | `#VALUE!` | +| `=ABS("")` / `=INT("")` | `0` | `#VALUE!` | +| `=ACOT("")` / `=ACOT(A1)` | `1.5707963268` | `#VALUE!` | +| `=DATE(A1, 2, 3)` | `35` | `#VALUE!` | | `=LN("")` / `=LOG10("")` / `=LOG("", 42)` | `#NUM!` | `#VALUE!` | | `=COT("")` / `=COTH("")` | `#DIV/0!` | `#VALUE!` | | `=DATE("", "", "")` | `#NUM!` | `#VALUE!` | -The `ACOT` row is the one worth looking at twice. `""` became `0`, and `ACOT(0)` is a perfectly good number, so the formula returned π/2 — a plausible result computed from an argument that was never a number. The rows below it are the milder shape of the same problem: an error, but one naming a domain violation discovered after the coercion rather than the coercion itself. +The `ACOT` and `DATE` rows are the ones worth looking at twice, because they did not fail — they answered. `""` became `0`, and both `ACOT(0)` and `DATE(0, 2, 3)` are perfectly good inputs, so the formulas returned π/2 and a date in 1900 respectively: plausible results computed from an argument that was never a number. The rows below them are the milder shape of the same problem — an error, but one naming a domain violation discovered after the coercion rather than the coercion itself. + +### What changes: an empty string written directly into an aggregation + +| Formula | 3.x | 4.0 | +| --- | --- | --- | +| `=COUNT("")` | `1` | `0` | +| `=SUM("")` / `=AVERAGE("")` / `=MIN("")` / `=MAX("")` / `=PRODUCT("")` | `0` | `#VALUE!` | +| `=SUM(1, "")` | `1` | `#VALUE!` | +| `=SUMSQ("")` / `=MEDIAN("")` | `0` | `#VALUE!` | +| `=STDEV("")` | `#DIV/0!` | `#VALUE!` | + +`=SUM(1, "")` is the likeliest of these to appear in a real sheet, and it is also the sharpest illustration: one argument is a number, the other never was, and 3.x answered `1`. `=SUM("abc")` returned `#VALUE!` in 3.x and still does. The point of this change is that `""` is no longer the one text value treated as a number. +### What does NOT change: aggregations over a cell or a range + +This is the important half, and the one most likely to be misread. The aggregation functions have always ignored text found in a **cell reference or a range**, and they still do — nothing here changes, and all of it matches Excel: + +| Formula | 3.x | 4.0 | +| --- | --- | --- | +| `=SUM(A1)` / `=MIN(A1)` / `=MAX(A1)` | `0` | `0` (unchanged) | +| `=COUNT(A1)` | `0` | `0` (unchanged) | +| `=AVERAGE(A1)` | `#DIV/0!` | `#DIV/0!` (unchanged) | +| `=COUNTA(A1)` | `1` | `1` (unchanged) | +| `=SUM(A1:C1)` where the range is `1`, `""`, `2` | `3` | `3` (unchanged) | +| `=COUNT(A1:C1)` on the same range | `2` | `2` (unchanged) | + +So a sheet whose empty strings only ever reach `SUM`, `COUNT` and their relatives sees no change at all. What breaks is arithmetic and number-typed arguments, per the first table. + +### What changes for the better: an empty string produced mid-formula + +The most common real shape of this bug was never a literal `""` — it was an expression that produces one. 3.x got all of these wrong; 4.0 matches Excel: + +| Formula | Excel | 3.x | 4.0 | +| --- | --- | --- | --- | +| `=SUM(IF(TRUE,"",1))` | `#VALUE!` | `0` | `#VALUE!` | +| `=COUNT(IF(TRUE,"",1))` | `0` | `1` | `0` | +| `=SUM(LEFT("abc",0))` | `#VALUE!` | `0` | `#VALUE!` | +| `=LEFT("abc",0)+1` | `#VALUE!` | `1` | `#VALUE!` | + +### One case that moves away from Excel + +Where Excel evaluates an argument as an **array containing only text**, it ignores the text and 3.x happened to agree, by way of coercing `""` to `0`: + +| Formula | Excel | 3.x | 4.0 | +| --- | --- | --- | --- | +| `=SUM(IF(A1:A3>0,"",A1:A3))` | `0` | `0` | `#VALUE!` | +| `=SUM({""})` | `0` | `0` | `#VALUE!` | +| `=COUNT(IF({1,0},"",5))` | `1` | `1` | `0` | + +That agreement was a coincidence rather than support for the idiom: HyperFormula's `IF` is not array-aware, so it collapses such an argument to a single value regardless. `=SUM(IF(A1:A3>5,"",A1:A3))` over `1, 2, 3` is `6` in Excel and `1` in HyperFormula — in 3.x as much as in 4.0. What changes is only that the collapsed value is no longer silently read as `0`. Ranges and array literals with more than one element are unaffected: `=SUM({1,"",2})` is `3` and `=COUNT({1,"",2})` is `2`, before and after. + ### Blank cells are not affected A blank cell is not an empty string, and nothing about it changes: @@ -63,16 +114,21 @@ This is the case to check in an application, because it can arise without any fo Writing an empty string into a cell produces a **text** cell, not a blank one: ```js -hfInstance.setCellContents({ sheet: 0, col: 0, row: 0 }, ''); // a text cell holding '' -hfInstance.setCellContents({ sheet: 0, col: 0, row: 0 }, null); // a blank cell +const hfInstance = HyperFormula.buildFromArray([[]], { licenseKey: 'gpl-v3' }); + +hfInstance.setCellContents({ sheet: 0, row: 0, col: 0 }, ''); // a text cell holding '' +hfInstance.setCellContents({ sheet: 0, row: 1, col: 0 }, null); // a blank cell ``` -The first is `#VALUE!`-producing input for a numeric formula from 4.0 on; the second is not, and never was. The same distinction applies to `buildFromArray([['']])` versus `buildFromArray([[null]])`. +The same distinction applies to `buildFromArray([['']])` versus `buildFromArray([[null]])`. -**How to migrate.** If your integration clears cells, make sure it writes `null` (or `undefined`), not `''`: +Only the first one is affected by this change, and only in the contexts of the first table above: `=A1+1` and `=ROUND(A1, 0)` change, `=SUM(A1)` and `=COUNT(A1)` do not. So an application that writes `''` where it means "blank" is not automatically broken — it is exposed only where such a cell feeds arithmetic or a number-typed argument. -```js -hfInstance.setCellContents(address, ''); -> hfInstance.setCellContents(address, null); +**How to migrate.** If your integration clears cells, have it write `null` (or `undefined`) rather than `''`: + +``` +hfInstance.setCellContents(address, ''); // before +hfInstance.setCellContents(address, null); // after ``` Two places worth auditing in a host application: @@ -81,4 +137,4 @@ Two places worth auditing in a host application: - **Paste.** Clipboard content is text, so a blank cell inside a copied range parses to `''` rather than to `null` unless something maps it back. To find affected formulas rather than affected code, look for numeric formulas that reference cells your application can blank out, and for `IF(..., "")` used as a "leave it empty" branch feeding another calculation — for example -`=IF(SUM(A1:A2)>0, SUM(A1:A2), "")/1000`, which returned a number in 3.x and returns `#VALUE!` in 4.0. That pattern is Excel-correct in 4.0: Excel reports `#VALUE!` for it too. Where the intent is "treat the empty branch as zero", write `0` instead of `""` in the branch, or wrap the reference in [`N()`](built-in-functions.md) / `IFERROR`. +`=IF(SUM(A1:A2)>0, SUM(A1:A2), "")/1000`, which returned a number in 3.x and returns `#VALUE!` in 4.0. That pattern is Excel-correct in 4.0: Excel reports `#VALUE!` for it too. Where the intent is "treat the empty branch as zero", write `0` instead of `""` in the branch, or wrap the reference in [`N()`](built-in-functions.md#information) / [`IFERROR`](built-in-functions.md#logical). From a24a523dd85055b745981597c9303902e918e536 Mon Sep 17 00:00:00 2001 From: marcin-kordas-hoc Date: Tue, 22 Sep 2026 16:35:50 +0000 Subject: [PATCH 4/8] HF-361: look through parentheses when checking for a reference in numeric aggregations Reported in review on this PR: with B1 holding ="", SUM(B1) correctly returns 0 (aggregation over a reference ignores non-numeric text, unchanged by this PR), but SUM((B1)) returned #VALUE!. Root cause: NumericAggregationPlugin#reduce decides how to treat an argument by checking arg.type against CELL_REFERENCE (and separately against CELL_RANGE/COLUMN_RANGE/ROW_RANGE) to tell "this is a reference, ignore its non-numeric text" apart from "this is a literal/computed value, coerce it to a number." A parenthesized argument reaches the reducer as a PARENTHESIS node wrapping the real expression, so neither check matched and (B1) fell through to the same coercion "" now correctly fails for as a literal. reduce() is the single shared implementation behind SUM, SUMSQ, COUNT, COUNTA, AVERAGE, AVERAGEA, MIN, MAX, MINA, MAXA, PRODUCT, VAR.S/P, VARA/VARPA and STDEV.S/P/A/PA (via doAverage/doCount/reduceAggregate(A)/doMax/doMin/ doSum/doProduct), so this was not SUM-specific: every one of those reproduced the same #VALUE! regression through a single extra pair of parentheses. Fix: unwrap PARENTHESIS nodes before either arg.type check, the same idiom already used in FunctionPlugin#runFunctionWithReferenceArgument and InformationPlugin. PARENTHESIS is a pure passthrough in Interpreter#evaluateAst, so this only changes which branch classifies the argument, not what evaluateAst(arg, state) itself returns. Verified against Excel Online via MS Graph: a parenthesized reference or range behaves identically to the bare one in every case tested (single and double parentheses, SUM/COUNT/AVERAGE/MIN/MAX/PRODUCT/SUMSQ/STDEV, a range containing an embedded empty string). Ranges wrapped in parentheses already worked before this fix -- they take a different path in reduce() that checks the evaluated value's runtime type (SimpleRangeValue) rather than the AST node type -- so this fix does not change range handling, only single-cell (and column/row) references. Paired failing-then-passing tests: handsontable/hyperformula-tests, same branch, "parentheses around a reference do not change numeric-aggregation behavior" in empty-string-coercion.spec.ts. Negative control: reverting this commit's source change while keeping those tests fails 5 of the 8 new specs (the 3 that also pass without the fix are pins on already-correct behavior: COUNT((A1)) and the two parenthesized-range cases). Co-Authored-By: Claude Opus 5 (1M context) --- src/interpreter/plugin/NumericAggregationPlugin.ts | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/src/interpreter/plugin/NumericAggregationPlugin.ts b/src/interpreter/plugin/NumericAggregationPlugin.ts index 5a675e9360..88f3958197 100644 --- a/src/interpreter/plugin/NumericAggregationPlugin.ts +++ b/src/interpreter/plugin/NumericAggregationPlugin.ts @@ -553,6 +553,20 @@ export class NumericAggregationPlugin extends FunctionPlugin implements Function return acc } + // A parenthesized argument reaches this reducer as a PARENTHESIS node wrapping the actual + // expression (e.g. `(B1)` is `{type: PARENTHESIS, expression: {type: CELL_REFERENCE, ...}}`), + // not as a CELL_REFERENCE/CELL_RANGE/COLUMN_RANGE/ROW_RANGE node itself. Both type checks below + // key off `arg.type` to decide whether this argument is a reference (whose non-numeric values + // are ignored) or a literal/computed value (which is coerced to a number, erroring on non-numeric + // text). Unwrapping first -- the same idiom used in FunctionPlugin#runFunctionWithReferenceArgument + // and InformationPlugin -- makes a parenthesized reference behave exactly like the bare one, which + // is what Excel does (measured live: SUM((A1)), COUNT((A1)), AVERAGE((A1)), MIN((A1)), MAX((A1)) + // all equal their unparenthesized form). PARENTHESIS is a pure passthrough in evaluateAst, so this + // does not change what evaluateAst(arg, state) returns below -- only which branch handles it. + while (arg.type === AstNodeType.PARENTHESIS) { + arg = arg.expression + } + if (arg.type === AstNodeType.CELL_RANGE || arg.type === AstNodeType.COLUMN_RANGE || arg.type === AstNodeType.ROW_RANGE) { const val = this.evaluateRange(arg, state, initialAccValue, functionName, reducingFunction, mapFunction, coercionFunction) if (val instanceof CellError) { From 23fb194b8f3bab17035db424568eb5ff68c3aa0a Mon Sep 17 00:00:00 2001 From: marcin-kordas-hoc Date: Thu, 22 Oct 2026 17:31:47 +0000 Subject: [PATCH 5/8] docs: shorten the changelog entry to one sentence Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 869042e41d..3ade853084 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), ### Changed -- A **breaking change**: an empty string (`""`) is no longer coerced to `0` in numeric contexts. As in Excel, `""` is text and is never a number, so `=""+0` and `=A1+1` (where `A1` holds an empty string) now return the `#VALUE!` error instead of `0` and `1`. This affects the arithmetic operators and any function argument declared as a number (`=ROUND(A1, 0)`, `=ACOT(A1)`, `=DATE(A1, 2, 3)`), plus an empty string written directly into an aggregation (`=COUNT("")` returns `0` instead of `1`, `=SUM(1, "")` returns `#VALUE!` instead of `1`). Aggregation over a cell reference or a range is **not** affected: `SUM`, `AVERAGE`, `MIN`, `MAX` and `COUNT` have always ignored text found there and still do. Blank cells are a different value and are unaffected — they still coerce to `0`, and the `evaluateNullToZero` option behaves exactly as before. See the [migration guide](https://hyperformula.handsontable.com/docs/guide/migration-from-3.x-to-4.0.html#changes-to-empty-string-coercion). [#1768](https://github.com/handsontable/hyperformula/pull/1768) +- A **breaking change**: an empty string (`""`) is no longer coerced to `0` in numeric contexts, so `=""+0` returns `#VALUE!` as in Excel; see the [migration guide](https://hyperformula.handsontable.com/docs/guide/migration-from-3.x-to-4.0.html#changes-to-empty-string-coercion). [#1768](https://github.com/handsontable/hyperformula/pull/1768) ### Fixed From 731a20f145055b14c1dccdc521282bb227137f59 Mon Sep 17 00:00:00 2001 From: marcin-kordas-hoc Date: Fri, 23 Oct 2026 00:29:36 +0000 Subject: [PATCH 6/8] HF-361: Describe the parenthesized-reference change in the changelog and the migration guide Drop two search tags that repeat the page's own heading, and shorten the comment on the parenthesis unwrap to the behaviour it implements. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 2 +- docs/guide/migration-from-3.x-to-4.0.md | 13 +++++++++++-- .../interpreter/plugin/NumericAggregationPlugin.ts | 12 ++---------- 3 files changed, 14 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2b718abca3..91540b083d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,7 +13,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), ### Changed -- A **breaking change**: an empty string (`""`) is no longer coerced to `0` in numeric contexts, so `=""+0` returns `#VALUE!` as in Excel; see the [migration guide](https://hyperformula.handsontable.com/docs/guide/migration-from-3.x-to-4.0.html#changes-to-empty-string-coercion). [#1768](https://github.com/handsontable/hyperformula/pull/1768) +- **Breaking change**: Changed numeric contexts to read `""` as text, not `0` (`=""+0` returns `#VALUE!`), and `(A1)` as a reference; see the [migration guide](https://hyperformula.handsontable.com/docs/guide/migration-from-3.x-to-4.0.html#changes-to-empty-string-coercion). [#1768](https://github.com/handsontable/hyperformula/pull/1768) ### Fixed diff --git a/docs/guide/migration-from-3.x-to-4.0.md b/docs/guide/migration-from-3.x-to-4.0.md index 1395a74220..ac6bdd705f 100644 --- a/docs/guide/migration-from-3.x-to-4.0.md +++ b/docs/guide/migration-from-3.x-to-4.0.md @@ -5,8 +5,6 @@ tags: - upgrade - breaking changes - v4.0 - - empty string - - coercion --- # Migrating from 3.x to 4.0 @@ -71,6 +69,17 @@ This is the important half, and the one most likely to be misread. The aggregati So a sheet whose empty strings only ever reach `SUM`, `COUNT` and their relatives sees no change at all. What breaks is arithmetic and number-typed arguments, per the first table. +### A reference in parentheses now counts as a reference + +In 3.x, `SUM`, `COUNT`, `AVERAGE`, `MIN`, `MAX`, `PRODUCT` and `SUMSQ` treated a reference wrapped in parentheses, such as `(A1)`, like a value typed into the formula, not like a reference. In 4.0 a parenthesized reference behaves exactly like the bare one, whatever the cell holds: + +| Formula | 3.x | 4.0 | +| --- | --- | --- | +| `=SUM((A1))` where `A1` holds `abc` | `#VALUE!` | `0`, like `=SUM(A1)` | +| `=SUM((A1))` where `A1` holds `TRUE` | `1` | `0`, like `=SUM(A1)` | +| `=COUNT((A1))` where `A1` is empty | `1` | `0`, like `=COUNT(A1)` | +| `=AVERAGE((A1))` where `A1` is empty | `0` | `#DIV/0!`, like `=AVERAGE(A1)` | + ### What changes for the better: an empty string produced mid-formula The most common real shape of this bug was never a literal `""` — it was an expression that produces one. 3.x got all of these wrong; 4.0 matches Excel: diff --git a/hyperformula/src/interpreter/plugin/NumericAggregationPlugin.ts b/hyperformula/src/interpreter/plugin/NumericAggregationPlugin.ts index 9f19070bd6..9183f80162 100644 --- a/hyperformula/src/interpreter/plugin/NumericAggregationPlugin.ts +++ b/hyperformula/src/interpreter/plugin/NumericAggregationPlugin.ts @@ -517,16 +517,8 @@ export class NumericAggregationPlugin extends FunctionPlugin implements Function return acc } - // A parenthesized argument reaches this reducer as a PARENTHESIS node wrapping the actual - // expression (e.g. `(B1)` is `{type: PARENTHESIS, expression: {type: CELL_REFERENCE, ...}}`), - // not as a CELL_REFERENCE/CELL_RANGE/COLUMN_RANGE/ROW_RANGE node itself. Both type checks below - // key off `arg.type` to decide whether this argument is a reference (whose non-numeric values - // are ignored) or a literal/computed value (which is coerced to a number, erroring on non-numeric - // text). Unwrapping first -- the same idiom used in FunctionPlugin#runFunctionWithReferenceArgument - // and InformationPlugin -- makes a parenthesized reference behave exactly like the bare one, which - // is what Excel does (measured live: SUM((A1)), COUNT((A1)), AVERAGE((A1)), MIN((A1)), MAX((A1)) - // all equal their unparenthesized form). PARENTHESIS is a pure passthrough in evaluateAst, so this - // does not change what evaluateAst(arg, state) returns below -- only which branch handles it. + // A parenthesized argument such as `(A1)` arrives as a PARENTHESIS node wrapping the reference. + // Unwrap it so that the checks below see the reference and ignore its non-numeric values. while (arg.type === AstNodeType.PARENTHESIS) { arg = arg.expression } From 761251fa07db10977a0b7fbb2c5a27b8f4f0b637 Mon Sep 17 00:00:00 2001 From: marcin-kordas-hoc Date: Fri, 23 Oct 2026 00:41:48 +0000 Subject: [PATCH 7/8] HF-361: Write TRUE() in the migration guide's IF examples, which HyperFormula needs `=SUM(IF(TRUE,"",1))` returns #NAME? in HyperFormula, because TRUE is a function there, not a literal; the table's values hold for `TRUE()`. Co-Authored-By: Claude Opus 5.5 --- docs/guide/migration-from-3.x-to-4.0.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/guide/migration-from-3.x-to-4.0.md b/docs/guide/migration-from-3.x-to-4.0.md index ac6bdd705f..7f31497913 100644 --- a/docs/guide/migration-from-3.x-to-4.0.md +++ b/docs/guide/migration-from-3.x-to-4.0.md @@ -86,8 +86,8 @@ The most common real shape of this bug was never a literal `""` — it was an ex | Formula | Excel | 3.x | 4.0 | | --- | --- | --- | --- | -| `=SUM(IF(TRUE,"",1))` | `#VALUE!` | `0` | `#VALUE!` | -| `=COUNT(IF(TRUE,"",1))` | `0` | `1` | `0` | +| `=SUM(IF(TRUE(),"",1))` | `#VALUE!` | `0` | `#VALUE!` | +| `=COUNT(IF(TRUE(),"",1))` | `0` | `1` | `0` | | `=SUM(LEFT("abc",0))` | `#VALUE!` | `0` | `#VALUE!` | | `=LEFT("abc",0)+1` | `#VALUE!` | `1` | `#VALUE!` | From cc66c2aa88017865f5553125653c16acd67c4ce8 Mon Sep 17 00:00:00 2001 From: marcin-kordas-hoc Date: Fri, 23 Oct 2026 08:21:19 +0000 Subject: [PATCH 8/8] HF-361: Keep only Excel values that were measured in the migration guide Co-Authored-By: Claude Opus 5.5 --- docs/guide/migration-from-3.x-to-4.0.md | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/docs/guide/migration-from-3.x-to-4.0.md b/docs/guide/migration-from-3.x-to-4.0.md index 7f31497913..84302e3f1a 100644 --- a/docs/guide/migration-from-3.x-to-4.0.md +++ b/docs/guide/migration-from-3.x-to-4.0.md @@ -13,7 +13,7 @@ To upgrade your HyperFormula version from 3.x.x to 4.0.0, follow this guide. ## Changes to empty string coercion -An empty string (`""`) used to be coerced to `0` wherever a number was expected. It no longer is: as in Excel, `""` is text, and text that is not a number produces the `#VALUE!` error. The Excel results quoted below were verified in Excel Online, so where this guide says "as in Excel", that is a measured result rather than an intention. +An empty string (`""`) used to be coerced to `0` wherever a number was expected. It no longer is: as in Excel, `""` is text, and text that is not a number produces the `#VALUE!` error. The Excel values in the tables below were measured in Excel Online. Nothing in your code needs to change for this on its own. What may change are results in your sheets, and only where an empty string reaches a numeric context — see [When a cell holds an empty string](#when-a-cell-holds-an-empty-string) for the case that is easy to hit without writing `""` anywhere. @@ -98,7 +98,6 @@ Where Excel evaluates an argument as an **array containing only text**, it ignor | Formula | Excel | 3.x | 4.0 | | --- | --- | --- | --- | | `=SUM(IF(A1:A3>0,"",A1:A3))` | `0` | `0` | `#VALUE!` | -| `=SUM({""})` | `0` | `0` | `#VALUE!` | | `=COUNT(IF({1,0},"",5))` | `1` | `1` | `0` | That agreement was a coincidence rather than support for the idiom: HyperFormula's `IF` is not array-aware, so it collapses such an argument to a single value regardless. `=SUM(IF(A1:A3>5,"",A1:A3))` over `1, 2, 3` is `6` in Excel and `1` in HyperFormula — in 3.x as much as in 4.0. What changes is only that the collapsed value is no longer silently read as `0`. Ranges and array literals with more than one element are unaffected: `=SUM({1,"",2})` is `3` and `=COUNT({1,"",2})` is `2`, before and after. @@ -146,4 +145,4 @@ Two places worth auditing in a host application: - **Paste.** Clipboard content is text, so a blank cell inside a copied range parses to `''` rather than to `null` unless something maps it back. To find affected formulas rather than affected code, look for numeric formulas that reference cells your application can blank out, and for `IF(..., "")` used as a "leave it empty" branch feeding another calculation — for example -`=IF(SUM(A1:A2)>0, SUM(A1:A2), "")/1000`, which returned a number in 3.x and returns `#VALUE!` in 4.0. That pattern is Excel-correct in 4.0: Excel reports `#VALUE!` for it too. Where the intent is "treat the empty branch as zero", write `0` instead of `""` in the branch, or wrap the reference in [`N()`](built-in-functions.md#information) / [`IFERROR`](built-in-functions.md#logical). +`=IF(SUM(A1:A2)>0, SUM(A1:A2), "")/1000`, which returned a number in 3.x and returns `#VALUE!` in 4.0. Where the intent is "treat the empty branch as zero", write `0` instead of `""` in the branch, or wrap the reference in [`N()`](built-in-functions.md#information) / [`IFERROR`](built-in-functions.md#logical).