diff --git a/CHANGELOG.md b/CHANGELOG.md
index 323c746b2d..c3e1e7a974 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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
diff --git a/docs/guide/arrays.md b/docs/guide/arrays.md
index a049ad2283..58860af298 100644
--- a/docs/guide/arrays.md
+++ b/docs/guide/arrays.md
@@ -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).
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)).
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
@@ -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.
diff --git a/hyperformula/src/Config.ts b/hyperformula/src/Config.ts
index fbb48d72ec..7065345699 100644
--- a/hyperformula/src/Config.ts
+++ b/hyperformula/src/Config.ts
@@ -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
@@ -216,6 +219,7 @@ export class Config implements ConfigParams, ParserConfig {
timeFormats,
thousandSeparator,
useArrayArithmetic,
+ arrayFunctionResultOverwritesData,
useStats,
undoLimit,
maxPendingLazyTransformations,
@@ -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')
diff --git a/hyperformula/src/ConfigParams.ts b/hyperformula/src/ConfigParams.ts
index 71aeb0bb7d..cd58a236ed 100644
--- a/hyperformula/src/ConfigParams.ts
+++ b/hyperformula/src/ConfigParams.ts
@@ -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.
*
diff --git a/hyperformula/src/CrudOperations.ts b/hyperformula/src/CrudOperations.ts
index bf0238dbc9..edc3c55403 100644
--- a/hyperformula/src/CrudOperations.ts
+++ b/hyperformula/src/CrudOperations.ts
@@ -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 {
@@ -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 {
@@ -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++) {
@@ -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 {
diff --git a/hyperformula/src/DependencyGraph/DependencyGraph.ts b/hyperformula/src/DependencyGraph/DependencyGraph.ts
index 962dedff1a..eb0aa352cd 100644
--- a/hyperformula/src/DependencyGraph/DependencyGraph.ts
+++ b/hyperformula/src/DependencyGraph/DependencyGraph.ts
@@ -52,6 +52,8 @@ import { findBoundaries } from '../Sheet'
export class DependencyGraph {
public readonly graph: Graph
private changes: ContentChanges = ContentChanges.empty()
+ /** Set while `withoutOverwritingData` runs. */
+ private isOverwritingDataSuspended = false
public readonly sheetReferenceRegistrar: SheetReferenceRegistrar
constructor(
@@ -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(this.dependencyQueryVertices)
this.sheetReferenceRegistrar = new SheetReferenceRegistrar(sheetMapping, addressMapping)
@@ -82,7 +85,8 @@ export class DependencyGraph {
stats,
lazilyTransformingAstService,
functionRegistry,
- namedExpressions
+ namedExpressions,
+ config
)
}
@@ -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(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)
@@ -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)
+ }
this.exchangeOrAddGraphNode(old, vertex)
}
}
@@ -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)) {
return
}
diff --git a/hyperformula/src/Evaluator.ts b/hyperformula/src/Evaluator.ts
index f810bee363..235736e828 100644
--- a/hyperformula/src/Evaluator.ts
+++ b/hyperformula/src/Evaluator.ts
@@ -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 {
diff --git a/hyperformula/src/Operations.ts b/hyperformula/src/Operations.ts
index df6d41264f..bfe8dea9f5 100644
--- a/hyperformula/src/Operations.ts
+++ b/hyperformula/src/Operations.ts
@@ -154,6 +154,17 @@ export interface MoveCellsResult {
addedGlobalNamedExpressions: string[],
}
+export interface SetCellContentResult {
+ /** Previous content of the anchor cell (the cell the new content is written to). */
+ oldContent: [SimpleCellAddress, ClipboardCell],
+ /**
+ * Content of the non-anchor cells that an array formula overwrote while spilling
+ * (only populated when `arrayFunctionResultOverwritesData` is on and the spill actually
+ * overwrites static occupants). Empty otherwise. Used to make the overwrite undoable.
+ */
+ overwrittenCells: [SimpleCellAddress, ClipboardCell][],
+}
+
export class Operations {
private changes: ContentChanges = ContentChanges.empty()
private readonly maxRows: number
@@ -194,16 +205,22 @@ export class Operations {
return rowsRemovals
}
- public addRows(cmd: AddRowsCommand) {
- for (const addedRows of cmd.rowsSpans()) {
- this.doAddRows(addedRows)
- }
+ /**
+ * Adds the rows of every span of the command.
+ *
+ * Returns, per span, the cells that arrays grown by that span overwrote in the coordinates from before that span.
+ */
+ public addRows(cmd: AddRowsCommand): [SimpleCellAddress, ClipboardCell][][] {
+ return cmd.rowsSpans().map(addedRows => this.doAddRows(addedRows))
}
- public addColumns(cmd: AddColumnsCommand) {
- for (const addedColumns of cmd.columnsSpans()) {
- this.doAddColumns(addedColumns)
- }
+ /**
+ * Adds the columns of every span of the command.
+ *
+ * Returns, per span, the cells that arrays grown by that span overwrote in the coordinates from before that span.
+ */
+ public addColumns(cmd: AddColumnsCommand): [SimpleCellAddress, ClipboardCell][][] {
+ return cmd.columnsSpans().map(addedColumns => this.doAddColumns(addedColumns))
}
public removeColumns(cmd: RemoveColumnsCommand): ColumnsRemoval[] {
@@ -302,7 +319,23 @@ export class Operations {
}
}
+ /**
+ * Moves rows. An array that reads the moved rows never overwrites data during the move, whatever
+ * `arrayFunctionResultOverwritesData` says (see `DependencyGraph.withoutOverwritingData`).
+ */
public moveRows(sheet: number, startRow: number, numberOfRows: number, targetRow: number): number {
+ return this.dependencyGraph.withoutOverwritingData(() => this.doMoveRows(sheet, startRow, numberOfRows, targetRow))
+ }
+
+ /**
+ * Moves columns. An array that reads the moved columns never overwrites data during the move, whatever
+ * `arrayFunctionResultOverwritesData` says (see `DependencyGraph.withoutOverwritingData`).
+ */
+ public moveColumns(sheet: number, startColumn: number, numberOfColumns: number, targetColumn: number): number {
+ return this.dependencyGraph.withoutOverwritingData(() => this.doMoveColumns(sheet, startColumn, numberOfColumns, targetColumn))
+ }
+
+ private doMoveRows(sheet: number, startRow: number, numberOfRows: number, targetRow: number): number {
const rowsToAdd = RowsSpan.fromNumberOfRows(sheet, targetRow, numberOfRows)
this.lazilyTransformingAstService.beginCombinedMode(sheet)
@@ -321,7 +354,7 @@ export class Operations {
return this.lazilyTransformingAstService.commitCombinedMode()
}
- public moveColumns(sheet: number, startColumn: number, numberOfColumns: number, targetColumn: number): number {
+ private doMoveColumns(sheet: number, startColumn: number, numberOfColumns: number, targetColumn: number): number {
const columnsToAdd = ColumnsSpan.fromNumberOfColumns(sheet, targetColumn, numberOfColumns)
this.lazilyTransformingAstService.beginCombinedMode(sheet)
@@ -513,7 +546,12 @@ export class Operations {
break
}
case ClipboardCellType.FORMULA: {
- this.setFormulaToCellFromCache(clipboardCell.hash, address)
+ // Apply the resulting content changes to the column index so that when an array
+ // formula shrinks on restore (e.g. undoing an overwrite-expand), the vacated spill
+ // values are dropped from the index. The removeRows / version-restore
+ // callers deliberately ignore the return value and keep their own index bookkeeping.
+ const changes = this.setFormulaToCellFromCache(clipboardCell.hash, address)
+ this.columnSearch.applyChanges(changes.getChanges())
break
}
case ClipboardCellType.EMPTY: {
@@ -595,9 +633,10 @@ export class Operations {
return result
}
- public setCellContent(address: SimpleCellAddress, newCellContent: RawCellContent): [SimpleCellAddress, ClipboardCell] {
+ public setCellContent(address: SimpleCellAddress, newCellContent: RawCellContent): SetCellContentResult {
const parsedCellContent = this.cellContentParser.parse(newCellContent)
const oldContent = this.getOldContent(address)
+ let overwrittenCells: [SimpleCellAddress, ClipboardCell][] = []
if (parsedCellContent instanceof CellContent.Formula) {
const parserResult = this.parser.parse(parsedCellContent.formula, address)
@@ -612,6 +651,7 @@ export class Operations {
throw Error('Incorrect array size')
}
+ overwrittenCells = this.snapshotOverwrittenOccupants(address, size)
this.setFormulaToCell(address, size, parserResult)
} catch (error) {
if (!(error as Error).message) {
@@ -628,7 +668,67 @@ export class Operations {
this.setValueToCell({ parsedValue: parsedCellContent.value, rawValue: newCellContent }, address)
}
- return oldContent
+ return { oldContent, overwrittenCells }
+ }
+
+ /**
+ * Snapshots the cells that an array formula is about to overwrite while spilling, so the
+ * overwrite can be undone. Returns an empty list unless `arrayFunctionResultOverwritesData`
+ * is on and the array is non-scalar. Mirrors `DependencyGraph.canOverwriteArrayResult`:
+ * if a DIFFERENT pre-existing array sits in the spill range, the spill will be blocked
+ * (`#SPILL!`) and nothing is overwritten, so nothing is captured. The array currently anchored
+ * at `anchorAddress` (the one being replaced/expanded) does not block and is not snapshotted —
+ * its cells are re-created when the old anchor formula is restored on undo. The anchor cell
+ * itself is excluded because its previous content is captured separately as `oldContent`.
+ */
+ private snapshotOverwrittenOccupants(anchorAddress: SimpleCellAddress, size: ArraySize): [SimpleCellAddress, ClipboardCell][] {
+ return this.overwrittenOccupantAddresses(anchorAddress, size)
+ .map(occupantAddress => [occupantAddress, this.getClipboardCell(occupantAddress)] as [SimpleCellAddress, ClipboardCell])
+ }
+
+ /**
+ * The occupied, non-array cells (excluding the anchor) that an array formula will overwrite while
+ * spilling. Empty unless `arrayFunctionResultOverwritesData` is on and the array is non-scalar.
+ * Mirrors `DependencyGraph.canOverwriteArrayResult`: if a DIFFERENT pre-existing array sits in
+ * the spill range, the spill is blocked (`#SPILL!`) and nothing is overwritten, so the list is
+ * empty. The array currently anchored at `anchorAddress` (being replaced/expanded) neither blocks
+ * nor is reported as an occupant.
+ */
+ private overwrittenOccupantAddresses(anchorAddress: SimpleCellAddress, size: ArraySize): SimpleCellAddress[] {
+ if (!this.dependencyGraph.isOverwritingDataAllowed() || size.isScalar()) {
+ return []
+ }
+
+ const spillRange = AbsoluteCellRange.spanFromOrUndef(anchorAddress, size.width, size.height)
+ if (spillRange === undefined) {
+ return []
+ }
+
+ const occupants: SimpleCellAddress[] = []
+ for (const occupantAddress of spillRange.addresses(this.dependencyGraph)) {
+ const vertex = this.dependencyGraph.getCell(occupantAddress)
+
+ if (vertex instanceof ArrayFormulaVertex) {
+ // The array currently anchored at `anchorAddress` is the one being replaced/expanded.
+ // Its cells (corner + internal) are re-created when the old anchor formula is restored
+ // on undo, so they must be neither snapshotted nor treated as a blocking collision.
+ // A DIFFERENT pre-existing array still blocks the overwrite (the spill stays #SPILL!
+ // and overwrites nothing), matching DependencyGraph.canOverwriteArrayResult /
+ // overwriteWouldHitArray.
+ if (!vertex.isLeftCorner(anchorAddress)) {
+ return []
+ }
+ continue
+ }
+
+ if (equalSimpleCellAddress(occupantAddress, anchorAddress) || vertex === undefined || vertex instanceof EmptyCellVertex) {
+ continue
+ }
+
+ occupants.push(occupantAddress)
+ }
+
+ return occupants
}
public setSheetContent(sheetId: number, newSheetContent: RawCellContent[][]) {
@@ -670,6 +770,11 @@ export class Operations {
const arrayChanges = this.dependencyGraph.setFormulaToCell(address, ast, absolutizeDependencies(dependencies, address), size, hasVolatileFunction, hasStructuralChangeFunction)
+ // In overwrite mode the spill clears occupied cells; their stale values are dropped from the
+ // column index here uniformly, because `exchangeOrAddFormulaVertex` records a content change
+ // (new value EmptyValue, carrying the old value) for every occupant it claims and
+ // `applyChanges` removes any change whose `oldValue` is set. Applies to the free-spill and
+ // non-overwrite paths too, where no occupants are claimed and this is a no-op.
this.columnSearch.applyChanges(arrayChanges.getChanges())
this.changes.addAll(arrayChanges)
}
@@ -706,7 +811,7 @@ export class Operations {
this.changes.addChange(EmptyValue, address)
}
- public setFormulaToCellFromCache(formulaHash: string, address: SimpleCellAddress) {
+ public setFormulaToCellFromCache(formulaHash: string, address: SimpleCellAddress): ContentChanges {
const {
ast,
hasVolatileFunction,
@@ -718,7 +823,7 @@ export class Operations {
this.parser.rememberNewAst(cleanedAst)
const cleanedDependencies = filterDependenciesOutOfScope(absoluteDependencies)
const size = this.arraySizePredictor.checkArraySize(ast, address)
- this.dependencyGraph.setFormulaToCell(address, cleanedAst, cleanedDependencies, size, hasVolatileFunction, hasStructuralChangeFunction)
+ return this.dependencyGraph.setFormulaToCell(address, cleanedAst, cleanedDependencies, size, hasVolatileFunction, hasStructuralChangeFunction)
}
/**
@@ -819,9 +924,9 @@ export class Operations {
* Does nothing if rows are outside of effective sheet size.
* @param {RowsSpan} addedRows - rows to add
*/
- private doAddRows(addedRows: RowsSpan) {
+ private doAddRows(addedRows: RowsSpan): [SimpleCellAddress, ClipboardCell][] {
if (this.rowEffectivelyNotInSheet(addedRows.rowStart, addedRows.sheet)) {
- return
+ return []
}
const { affectedArrays } = this.dependencyGraph.addRows(addedRows)
@@ -832,19 +937,65 @@ export class Operations {
this.lazilyTransformingAstService.addTransformation(transformation)
})
- this.rewriteAffectedArrays(affectedArrays)
+ const overwrittenCells = this.rewriteAffectedArrays(affectedArrays)
+ return this.overwrittenCellsBeforeSpan(overwrittenCells, addedRows)
}
- private rewriteAffectedArrays(affectedArrays: Set) {
+ /**
+ * Re-places the arrays whose source ranges a structural operation resized.
+ *
+ * Returns the cells, in the coordinates after the operation, that the re-placed arrays overwrote
+ * because they grew onto occupied cells. Empty unless `arrayFunctionResultOverwritesData` is on.
+ */
+ private rewriteAffectedArrays(affectedArrays: Set): [SimpleCellAddress, ClipboardCell][] {
+ const overwrittenCells: [SimpleCellAddress, ClipboardCell][] = []
for (const arrayVertex of affectedArrays.values()) {
if (arrayVertex.array.size.isRef) {
continue
}
+ // Capture the array's footprint BEFORE re-placing it, to tell apart two kinds of
+ // ContentChanges that setFormulaToCellFromCache returns below:
+ // - genuinely NEW occupants a grown array just claimed (overwrite mode, outside this range)
+ // - the routine shrink-then-recreate artifact of re-placing an array in the same spot
+ // (cleanAddressMappingUnderArray clears the array's OWN previous cells every time an array
+ // vertex is re-set, overwrite flag or not) -- these are already reconciled by the normal
+ // recompute cycle right after this method returns, so re-applying them to the column index
+ // here double-touches entries the row/column-shift machinery already moved and corrupts it
+ // (this is what broke the column-index removeRows canary in an earlier attempt).
+ const oldRange = arrayVertex.getRangeOrUndef()
const ast = arrayVertex.getFormula(this.lazilyTransformingAstService)
const address = arrayVertex.getAddress(this.lazilyTransformingAstService)
const hash = this.parser.computeHashFromAst(ast)
- this.setFormulaToCellFromCache(hash, address)
+ if (this.dependencyGraph.isOverwritingDataAllowed()) {
+ overwrittenCells.push(...this.snapshotOverwrittenOccupants(address, this.arraySizePredictor.checkArraySize(ast, address)))
+ }
+ const changes = this.setFormulaToCellFromCache(hash, address)
+ const overwriteChanges = changes.getChanges()
+ .filter(change => oldRange === undefined || !oldRange.addressInRange(change.address))
+ this.columnSearch.applyChanges(overwriteChanges)
}
+ return overwrittenCells
+ }
+
+ /**
+ * Expresses cells snapshotted right after a span of rows or columns was added in the coordinates
+ * from before that span, so that undo can restore them once it has removed the span again.
+ *
+ * Each cell moves back the way the removal of the span moves it. A formula also has its references
+ * rewritten the way that removal rewrites every other formula, for example `=F1` back to `=E1`
+ * after a column was inserted before column E. No snapshotted cell lies inside the added span:
+ * the cells of the span are new and empty, so no array overwrites them.
+ */
+ private overwrittenCellsBeforeSpan(overwrittenCells: [SimpleCellAddress, ClipboardCell][], addedSpan: RowsSpan | ColumnsSpan): [SimpleCellAddress, ClipboardCell][] {
+ const removal = addedSpan instanceof RowsSpan ? new RemoveRowsTransformer(addedSpan) : new RemoveColumnsTransformer(addedSpan)
+ return overwrittenCells.map(([address, clipboardCell]) => {
+ if (clipboardCell.type !== ClipboardCellType.FORMULA) {
+ return [addressBeforeSpan(address, addedSpan), clipboardCell]
+ }
+ const [ast, addressBefore] = removal.transformSingleAst(this.parser.fetchCachedResult(clipboardCell.hash).ast, address)
+ const cachedAst = this.parser.rememberNewAst(ast)
+ return [addressBefore, {type: ClipboardCellType.FORMULA, hash: this.parser.computeHashFromAst(cachedAst)}]
+ })
}
/**
@@ -852,9 +1003,9 @@ export class Operations {
* Does nothing if columns are outside of effective sheet size
* @param {ColumnsSpan} addedColumns - object containing information about columns to add
*/
- private doAddColumns(addedColumns: ColumnsSpan): void {
+ private doAddColumns(addedColumns: ColumnsSpan): [SimpleCellAddress, ClipboardCell][] {
if (this.columnEffectivelyNotInSheet(addedColumns.columnStart, addedColumns.sheet)) {
- return
+ return []
}
const { affectedArrays, contentChanges } = this.dependencyGraph.addColumns(addedColumns)
@@ -867,7 +1018,8 @@ export class Operations {
this.lazilyTransformingAstService.addTransformation(transformation)
})
- this.rewriteAffectedArrays(affectedArrays)
+ const overwrittenCells = this.rewriteAffectedArrays(affectedArrays)
+ return this.overwrittenCellsBeforeSpan(overwrittenCells, addedColumns)
}
/**
@@ -1108,3 +1260,16 @@ function isRowOrColumnRange(leftCorner: SimpleCellAddress, width: number, height
return (leftCorner.row === 0 && isPositiveInteger(width) && height === Number.POSITIVE_INFINITY)
|| (leftCorner.col === 0 && isPositiveInteger(height) && width === Number.POSITIVE_INFINITY)
}
+
+/**
+ * Where a cell that sits outside an added span of rows or columns was before that span was added.
+ */
+function addressBeforeSpan(address: SimpleCellAddress, addedSpan: RowsSpan | ColumnsSpan): SimpleCellAddress {
+ if (address.sheet !== addedSpan.sheet) {
+ return address
+ }
+ if (addedSpan instanceof RowsSpan) {
+ return address.row >= addedSpan.rowStart ? {...address, row: address.row - addedSpan.numberOfRows} : address
+ }
+ return address.col >= addedSpan.columnStart ? {...address, col: address.col - addedSpan.numberOfColumns} : address
+}
diff --git a/hyperformula/src/UndoRedo.ts b/hyperformula/src/UndoRedo.ts
index 87ab06439a..33d29a6316 100644
--- a/hyperformula/src/UndoRedo.ts
+++ b/hyperformula/src/UndoRedo.ts
@@ -94,6 +94,8 @@ export class MoveCellsUndoEntry extends BaseUndoEntry {
export class AddRowsUndoEntry extends BaseUndoEntry {
constructor(
public readonly command: AddRowsCommand,
+ /** Per added span, the cells that arrays grown by the span overwrote, in the coordinates from before the span. */
+ public readonly overwrittenCells: [SimpleCellAddress, ClipboardCell][][] = [],
) {
super()
}
@@ -222,6 +224,8 @@ export class MoveColumnsUndoEntry extends BaseUndoEntry {
export class AddColumnsUndoEntry extends BaseUndoEntry {
constructor(
public readonly command: AddColumnsCommand,
+ /** Per added span, the cells that arrays grown by the span overwrote, in the coordinates from before the span. */
+ public readonly overwrittenCells: [SimpleCellAddress, ClipboardCell][][] = [],
) {
super()
}
@@ -350,6 +354,7 @@ export class SetCellContentsUndoEntry extends BaseUndoEntry {
newContent: RawCellContent,
oldContent: [SimpleCellAddress, ClipboardCell],
}[],
+ public readonly overwrittenCells: [SimpleCellAddress, ClipboardCell][] = [],
) {
super()
}
@@ -620,6 +625,7 @@ export class UndoRedo {
for (let i = addedRowsSpans.length - 1; i >= 0; --i) {
const addedRows = addedRowsSpans[i]
this.operations.removeRows(new RemoveRowsCommand(operation.command.sheet, [[addedRows.rowStart, addedRows.numberOfRows]]))
+ this.restoreCellsOverwrittenBySpan(operation.overwrittenCells[i])
}
}
@@ -628,11 +634,27 @@ export class UndoRedo {
for (let i = addedColumnsSpans.length - 1; i >= 0; --i) {
const addedColumns = addedColumnsSpans[i]
this.operations.removeColumns(new RemoveColumnsCommand(operation.command.sheet, [[addedColumns.columnStart, addedColumns.numberOfColumns]]))
+ this.restoreCellsOverwrittenBySpan(operation.overwrittenCells[i])
+ }
+ }
+
+ /**
+ * Restores the cells that an array overwrote when an added span made it grow. Runs right after
+ * that span is removed again: the array has shrunk back by then, so the cells are free, and the
+ * coordinates are the ones the cells were snapshotted in.
+ */
+ private restoreCellsOverwrittenBySpan(overwrittenCells: [SimpleCellAddress, ClipboardCell][] = []) {
+ for (const [address, clipboardCell] of overwrittenCells) {
+ this.operations.restoreCell(address, clipboardCell)
}
}
public undoSetCellContents(operation: SetCellContentsUndoEntry) {
- for (const cellContentData of operation.cellContents) {
+ // Undo the writes in reverse order. A later write can land on a cell that an earlier write in
+ // the same call made part of an array; its old content then points at that array's anchor, and
+ // only once that write is undone may the earlier write restore the anchor's own old content.
+ for (let i = operation.cellContents.length - 1; i >= 0; --i) {
+ const cellContentData = operation.cellContents[i]
const address = cellContentData.address
const [oldContentAddress, oldContent] = cellContentData.oldContent
if (!equalSimpleCellAddress(address, oldContentAddress)) {
@@ -640,6 +662,12 @@ export class UndoRedo {
}
this.operations.restoreCell(oldContentAddress, oldContent)
}
+ // Restore any cells that an array formula overwrote while spilling. This must run after
+ // the anchor formulas above are undone, so the spill (and its array-internal cells) is
+ // gone and the overwritten addresses are free to restore.
+ for (const [address, clipboardCell] of operation.overwrittenCells) {
+ this.operations.restoreCell(address, clipboardCell)
+ }
}
public undoPaste(operation: PasteUndoEntry) {