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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,12 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
### Added

- 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)
- Added the `arrayFunctionResultOverwritesData` configuration option (default `false`), which lets an array function's result overwrite the occupied cells it spills onto instead of returning a `#SPILL!` error. [#1714](https://github.com/handsontable/hyperformula/pull/1714)

### 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)
- Fixed `undo()` leaving an array formula in the sheet after a single `setCellContents()` call that entered the formula and also wrote into its spill range. [#1714](https://github.com/handsontable/hyperformula/pull/1714)

## [3.5.0] - 2026-10-09

Expand Down
29 changes: 28 additions & 1 deletion docs/guide/arrays.md
Original file line number Diff line number Diff line change
Expand Up @@ -162,7 +162,7 @@ If your specified output array size is larger or equal to the input array size,
When the [array arithmetic mode](#array-arithmetic-mode) is enabled, and you pass an array to a [scalar](#about-arrays) function, the following rules apply:
* Array dimensions need to be consistent (e.g., every row needs to be of the same length).
* If an input array value is missing (due to a difference in dimensions), the corresponding output array value is `#N/A`.
* If a cell evaluates to an array, the array values are spilled into neighboring cells (unless the neighboring cells are already filled).<br>This behavior doesn't apply to ranges, which return the `#VALUE!` error in this case.
* If a cell evaluates to an array, the array values are spilled into neighboring cells (unless the neighboring cells are already filled, see [Spilling onto occupied cells](#spilling-onto-occupied-cells)).<br>This behavior doesn't apply to ranges, which return the `#VALUE!` error in this case.
* If one of input array dimensions is `1` (`1`x`n` or `n`x`1`), the array is repeated, to match the output array dimensions.

### With the array arithmetic mode disabled
Expand All @@ -175,3 +175,30 @@ When the [array arithmetic mode](#array-arithmetic-mode) is disabled, and you op
|:--------------------------------------------------|:---------------------------------------|
| In the same row as as one of the range's elements | Only that particular element is taken. |
| Any other cell | `#VALUE!` error |

## Spilling onto occupied cells

By default, an array that would spill onto cells that already hold data returns the `#SPILL!` error, and the cells keep their data.

To let the array overwrite that data instead, set the [`arrayFunctionResultOverwritesData`](../api/interfaces/configparams.md#arrayfunctionresultoverwritesdata) option to `true`:

```js
const hfInstance = HyperFormula.buildFromArray([
[1, 2],
[],
['old value'],
], {
licenseKey: 'gpl-v3',
arrayFunctionResultOverwritesData: true,
});

// the result spills into A2:A3 and overwrites 'old value' in A3
hfInstance.setCellContents({ sheet: 0, col: 0, row: 1 }, '=TRANSPOSE(A1:B1)');
hfInstance.getCellValue({ sheet: 0, col: 0, row: 2 }); // 2

// undo brings 'old value' back
hfInstance.undo();
hfInstance.getCellValue({ sheet: 0, col: 0, row: 2 }); // 'old value'
```

With the option enabled, an array overwrites cells when you enter it and when it grows because rows or columns were added inside the range it reads. It never overwrites another array: that spill still returns `#SPILL!`. Data declared in the same `buildFromArray` call as the array, and cells an array would reach while you move rows or columns, keep their content too.
5 changes: 5 additions & 0 deletions hyperformula/src/Config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,11 +82,14 @@ export class Config implements ConfigParams, ParserConfig {
useColumnIndex: false,
useStats: false,
useArrayArithmetic: false,
arrayFunctionResultOverwritesData: false,
}

/** @inheritDoc */
public readonly useArrayArithmetic: boolean
/** @inheritDoc */
public readonly arrayFunctionResultOverwritesData: boolean
/** @inheritDoc */
public readonly caseSensitive: boolean
/** @inheritDoc */
public readonly chooseAddressMappingPolicy: ChooseAddressMapping
Expand Down Expand Up @@ -216,6 +219,7 @@ export class Config implements ConfigParams, ParserConfig {
timeFormats,
thousandSeparator,
useArrayArithmetic,
arrayFunctionResultOverwritesData,
useStats,
undoLimit,
maxPendingLazyTransformations,
Expand All @@ -229,6 +233,7 @@ export class Config implements ConfigParams, ParserConfig {
}

this.useArrayArithmetic = configValueFromParam(useArrayArithmetic, 'boolean', 'useArrayArithmetic')
this.arrayFunctionResultOverwritesData = configValueFromParam(arrayFunctionResultOverwritesData, 'boolean', 'arrayFunctionResultOverwritesData')
this.accentSensitive = configValueFromParam(accentSensitive, 'boolean', 'accentSensitive')
this.caseSensitive = configValueFromParam(caseSensitive, 'boolean', 'caseSensitive')
this.caseFirst = configValueFromParam(caseFirst, ['upper', 'lower', 'false'], 'caseFirst')
Expand Down
19 changes: 19 additions & 0 deletions hyperformula/src/ConfigParams.ts
Original file line number Diff line number Diff line change
Expand Up @@ -390,6 +390,25 @@ export interface ConfigParams {
* @category Engine
*/
useArrayArithmetic: boolean,
/**
* When set to `true`, an array whose result spills onto occupied cells overwrites them: their
* previous content is cleared, the array spills, and formulas that read those cells get the
* spilled values. When set to `false`, such an array returns the `#SPILL!` error and the cells
* keep their content (the default, Excel-compatible behavior).
*
* **Warning:** overwriting is destructive. Turn it on only when clearing the data in the spill
* range is the intended outcome. `undo()` restores the cleared cells.
*
* An array overwrites cells when it is entered (for example with `setCellContents`) and when it
* grows because rows or columns were added inside the range it reads. Even when set to `true`,
* an array does not overwrite:
* - another array: that spill still returns `#SPILL!` and leaves the other array intact,
* - data declared in the same `buildFromArray` call as the array,
* - cells it would reach while rows or columns are moved with `moveRows` or `moveColumns`.
* @default false
* @category Engine
*/
arrayFunctionResultOverwritesData: boolean,
/**
* When set to `true`, switches column search strategy from binary search to column index.
*
Expand Down
14 changes: 8 additions & 6 deletions hyperformula/src/CrudOperations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,8 +94,8 @@ export class CrudOperations {
this.ensureItIsPossibleToAddRows(sheet, ...indexes)
this.undoRedo.clearRedoStack()
this.clipboardOperations.abortCut()
this.operations.addRows(addRowsCommand)
this.undoRedo.saveOperation(new AddRowsUndoEntry(addRowsCommand))
const overwrittenCells = this.operations.addRows(addRowsCommand)
this.undoRedo.saveOperation(new AddRowsUndoEntry(addRowsCommand, overwrittenCells))
}

public removeRows(sheet: number, ...indexes: ColumnRowIndex[]): void {
Expand All @@ -112,8 +112,8 @@ export class CrudOperations {
this.ensureItIsPossibleToAddColumns(sheet, ...indexes)
this.undoRedo.clearRedoStack()
this.clipboardOperations.abortCut()
this.operations.addColumns(addColumnsCommand)
this.undoRedo.saveOperation(new AddColumnsUndoEntry(addColumnsCommand))
const overwrittenCells = this.operations.addColumns(addColumnsCommand)
this.undoRedo.saveOperation(new AddColumnsUndoEntry(addColumnsCommand, overwrittenCells))
}

public removeColumns(sheet: number, ...indexes: ColumnRowIndex[]): void {
Expand Down Expand Up @@ -262,6 +262,7 @@ export class CrudOperations {
this.undoRedo.clearRedoStack()

const oldContents: { address: SimpleCellAddress, newContent: RawCellContent, oldContent: [SimpleCellAddress, ClipboardCell] }[] = []
const overwrittenCells: [SimpleCellAddress, ClipboardCell][] = []

for (let i = 0; i < cellContents.length; i++) {
for (let j = 0; j < cellContents[i].length; j++) {
Expand All @@ -272,12 +273,13 @@ export class CrudOperations {
}
const newContent = cellContents[i][j]
this.clipboardOperations.abortCut()
const oldContent = this.operations.setCellContent(address, newContent)
const {oldContent, overwrittenCells: overwritten} = this.operations.setCellContent(address, newContent)
oldContents.push({address, newContent, oldContent})
overwrittenCells.push(...overwritten)
}
}

this.undoRedo.saveOperation(new SetCellContentsUndoEntry(oldContents))
this.undoRedo.saveOperation(new SetCellContentsUndoEntry(oldContents, overwrittenCells))
}

public setSheetContent(sheetId: number, values: RawCellContent[][]): void {
Expand Down
84 changes: 82 additions & 2 deletions hyperformula/src/DependencyGraph/DependencyGraph.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,8 @@ import { findBoundaries } from '../Sheet'
export class DependencyGraph {
public readonly graph: Graph<Vertex>
private changes: ContentChanges = ContentChanges.empty()
/** Set while `withoutOverwritingData` runs. */
private isOverwritingDataSuspended = false
public readonly sheetReferenceRegistrar: SheetReferenceRegistrar

constructor(
Expand All @@ -63,6 +65,7 @@ export class DependencyGraph {
public readonly lazilyTransformingAstService: LazilyTransformingAstService,
public readonly functionRegistry: FunctionRegistry,
public readonly namedExpressions: NamedExpressions,
public readonly config: Config,
) {
this.graph = new Graph<Vertex>(this.dependencyQueryVertices)
this.sheetReferenceRegistrar = new SheetReferenceRegistrar(sheetMapping, addressMapping)
Expand All @@ -82,7 +85,8 @@ export class DependencyGraph {
stats,
lazilyTransformingAstService,
functionRegistry,
namedExpressions
namedExpressions,
config
)
}

Expand Down Expand Up @@ -480,6 +484,56 @@ export class DependencyGraph {
return true
}

/**
* True when an array spill collision may be resolved by overwriting the occupants
* (i.e. `arrayFunctionResultOverwritesData` is on) AND doing so would not clobber
* another array. Array-vs-array collisions always keep `#SPILL!` (matches Excel and
* avoids corrupting the pre-existing array), even in overwrite mode.
*
* An unrepresentable spill range (e.g. an infinite-height array anchored outside row 1)
* can never be overwrite-resolved: overwrite mode bypasses occupancy collisions only,
* never a range that cannot be spanned at all, so those keep `#SPILL!` too.
*/
public canOverwriteArrayResult(arrayVertex: ArrayFormulaVertex): boolean {
const range = arrayVertex.getRangeOrUndef()
return range !== undefined && this.isOverwritingDataAllowed() && !this.overwriteWouldHitArray(arrayVertex, range)
}

/**
* True when `arrayFunctionResultOverwritesData` is on and no row or column move is running
* (see `withoutOverwritingData`).
*/
public isOverwritingDataAllowed(): boolean {
return this.config.arrayFunctionResultOverwritesData && !this.isOverwritingDataSuspended
}

/**
* Runs `operation` with overwriting disabled, as if `arrayFunctionResultOverwritesData` were off.
*
* Used for row and column moves. A move first adds the moved rows or columns at the target and only
* then removes them at the source, so an array that reads them grows for a moment. Overwriting the
* cells it reaches in that moment would clear data that the array does not cover once the move ends.
*/
public withoutOverwritingData<T>(operation: () => T): T {
const wasSuspended = this.isOverwritingDataSuspended
this.isOverwritingDataSuspended = true
try {
return operation()
} finally {
this.isOverwritingDataSuspended = wasSuspended
}
}

private overwriteWouldHitArray(arrayVertex: ArrayFormulaVertex, range: AbsoluteCellRange): boolean {
for (const address of range.addresses(this)) {
const vertexUnderAddress = this.addressMapping.getCell(address)
if (vertexUnderAddress instanceof ArrayFormulaVertex && vertexUnderAddress !== arrayVertex) {
return true
}
}
return false
}

public moveCells(sourceRange: AbsoluteCellRange, toRight: number, toBottom: number, toSheet: number) {
for (const sourceAddress of sourceRange.addressesWithDirection(toRight, toBottom, this)) {
const targetAddress = simpleCellAddress(toSheet, sourceAddress.col + toRight, sourceAddress.row + toBottom)
Expand Down Expand Up @@ -1119,14 +1173,35 @@ export class DependencyGraph {
this.addressMapping.setCell(address, vertex)

if (vertex instanceof ArrayFormulaVertex) {
if (!this.isThereSpaceForArray(vertex)) {
const spaceForArray = this.isThereSpaceForArray(vertex)
if (!spaceForArray && !this.canOverwriteArrayResult(vertex)) {
return
}
// We reach the loop either because the array spills into free space, or because there is no
// free space but `arrayFunctionResultOverwritesData` lets it overwrite the occupants. Only
// the latter actually claims occupied cells, so only then do we record the overwrite.
const isOverwritingOccupants = !spaceForArray
for (const cellAddress of range.addresses(this)) {
if (vertex.isLeftCorner(cellAddress)) {
continue
}
const old = this.getCell(cellAddress)
// Record each overwritten occupant's previous value as a content change (new value
// `EmptyValue`, carrying the old value) BEFORE dropping the vertex, so every caller —
// `setCellContents`, array replace/expand, restore-from-cache — uniformly drops the stale
// value from the column index via `ColumnSearch.applyChanges`. Gated on overwrite mode so
// the free-spill path (empty occupants, and array re-placement during row/column ops) is
// untouched.
if (isOverwritingOccupants && old !== undefined && !(old instanceof EmptyCellVertex)) {
// Read the occupant's previous value WITHOUT going through `getCellValue`/`addressMapping`:
// a `ScalarFormulaVertex` that hasn't been computed yet (e.g. a sibling cell set earlier in
// the same `batch()`/`suspendEvaluation()` block) throws from `getCellValue()`. `valueOrUndef`
// is the non-throwing accessor every `FormulaVertex` exposes for exactly this case; an
// uncomputed occupant is treated as `EmptyValue`, matching what it would evaluate to before
// its formula runs.
const previousValue = old instanceof FormulaVertex ? (old.valueOrUndef() ?? EmptyValue) : old.getCellValue()
this.changes.addChange(EmptyValue, cellAddress, previousValue)
}
Comment thread
cursor[bot] marked this conversation as resolved.
this.exchangeOrAddGraphNode(old, vertex)
}
}
Expand All @@ -1149,6 +1224,11 @@ export class DependencyGraph {
}
this.setArray(range, vertex)

// No `canOverwriteArrayResult` bypass here: this is the build path (`GraphBuilder` via
// `addArrayVertex`), which is deliberately outside the `arrayFunctionResultOverwritesData`
// feature — an occupant declared in the same build wins, and the array keeps `#SPILL!`.
// Claiming occupied cells here would silently overwrite them with no undo snapshot and
// no column-index change records.
if (!this.isThereSpaceForArray(vertex)) {
Comment thread
cursor[bot] marked this conversation as resolved.
return
}
Expand Down
6 changes: 6 additions & 0 deletions hyperformula/src/Evaluator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,12 @@ export class Evaluator {

private recomputeFormulaVertexValue(vertex: FormulaVertex): InterpreterValue {
const address = vertex.getAddress(this.lazilyTransformingAstService)
// No `canOverwriteArrayResult` bypass here: when `arrayFunctionResultOverwritesData` resolves
// a collision, the overwrite has already claimed the whole spill range in the address mapping
// (`DependencyGraph.exchangeOrAddFormulaVertex`) BEFORE evaluation, so `isThereSpaceForArray`
// is true by the time the array is recomputed. An array that still lacks space at this point
// was never overwrite-resolved (blocked at build time, an unrepresentable spill range, another
// array in the way) and must surface `#SPILL!` instead of silently truncating to its anchor.
if (vertex instanceof ArrayFormulaVertex && (vertex.array.size.isRef || !this.dependencyGraph.isThereSpaceForArray(vertex))) {
return vertex.setNoSpace()
} else {
Expand Down
Loading
Loading