Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),

- Added a message after `npm i hyperformula` that points AI assistants to the SKILLS file. The message needs `--foreground-scripts` to be visible in npm, and pnpm 10 needs the `postinstall` script to be approved first. [#1829](https://github.com/handsontable/hyperformula/pull/1829)

### Changed

- **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

- Fixed the `EDATE` function returning the 28th of February instead of the 29th when the shifted date falls in February of a leap year (e.g. `=EDATE(DATE(2028, 1, 31), 1)` now returns `2028-02-29` instead of `2028-02-28`). [#1781](https://github.com/handsontable/hyperformula/pull/1781)
Expand Down
1 change: 1 addition & 0 deletions docs/.vuepress/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -387,6 +387,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'],
]
},
{
Expand Down
148 changes: 148 additions & 0 deletions docs/guide/migration-from-3.x-to-4.0.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
---
tags:
- migration
- migrate
- upgrade
- breaking changes
- v4.0
---
Comment thread
cursor[bot] marked this conversation as resolved.

# 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 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.

### 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` / `=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` 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.

### 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:

| 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!` |
| `=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:

| 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
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 same distinction applies to `buildFromArray([['']])` versus `buildFromArray([[null]])`.

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.

**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:

- **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. 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).
13 changes: 9 additions & 4 deletions hyperformula/src/interpreter/ArithmeticHelper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<ExtendedNumber> {
if (arg === EmptyValue) {
return 0
} else if (typeof arg === 'string') {
if (arg === '') {
return 0
}

const maybePercentNumber = this.coerceStringToMaybePercentNumber(arg)
if (maybePercentNumber !== undefined) {
return maybePercentNumber
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -517,6 +517,12 @@ export class NumericAggregationPlugin extends FunctionPlugin implements Function
return acc
}

// 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
}

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) {
Expand Down
Loading