From 3a38019452ec97f40a34568ea31ecf8e92826553 Mon Sep 17 00:00:00 2001 From: marcin-kordas-hoc Date: Wed, 22 Jul 2026 10:50:57 +0000 Subject: [PATCH 1/9] feat(config): add arrayFunctionResultOverwritesData opt-in overwrite flag (HF-305) Adds a public boolean Config option `arrayFunctionResultOverwritesData` (default `false`). When `false`, behavior is unchanged: an array spill onto an occupied cell yields `#SPILL!` and leaves the occupant intact. When `true`, the spill overwrites the occupied cells (clears occupants, spills the array, reroutes dependents) instead of emitting `#SPILL!`. Implementation reuses the existing spill-placement exchange primitive: - ConfigParams/Config: new option, mirrored on `useArrayArithmetic`. - DependencyGraph gains `config` + two guarded early-returns in exchangeOrAddFormulaVertex / setAddressMappingForArrayVertex. - Evaluator: recompute no longer emits `#SPILL!` when overwrite is allowed. Array-vs-array safety: a spill colliding with ANOTHER array always keeps `#SPILL!` and leaves that array intact, even in overwrite mode (canOverwriteArrayResult / overwriteWouldHitArray). This matches Excel and avoids corrupting a pre-existing array; overwrite only clears static data. Default `false` guarantees no existing embedder loses data silently. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 1 + src/Config.ts | 5 + src/ConfigParams.ts | 19 ++ src/CrudOperations.ts | 6 +- src/DependencyGraph/DependencyGraph.ts | 53 ++- src/Evaluator.ts | 2 +- src/Operations.ts | 108 +++++- src/UndoRedo.ts | 7 + test/hf-305-overwrite.spec.ts | 445 +++++++++++++++++++++++++ 9 files changed, 634 insertions(+), 12 deletions(-) create mode 100644 test/hf-305-overwrite.spec.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 5a16d04698..646c7ae7be 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), - Added a new function: `XIRR`. [#1701](https://github.com/handsontable/hyperformula/pull/1701) - Added the UNIQUE function. [#1708](https://github.com/handsontable/hyperformula/pull/1708) - Added the SORT function. [#1707](https://github.com/handsontable/hyperformula/pull/1707) +- Added the `arrayFunctionResultOverwritesData` configuration option (default `false`). When enabled, an array function whose result spills onto occupied cells overwrites them instead of returning a `#SPILL!` error. This is an opt-in, destructive behavior; a collision with another array still yields `#SPILL!`. - Added an Indonesian (Bahasa Indonesia) language pack. [#1674](https://github.com/handsontable/hyperformula/pull/1674) - Added a `stringifyCurrency` config option that lets you plug in a custom currency formatter for the `TEXT` function. [#1145](https://github.com/handsontable/hyperformula/issues/1145) diff --git a/src/Config.ts b/src/Config.ts index 8e8a924eee..0f11c80e9f 100644 --- a/src/Config.ts +++ b/src/Config.ts @@ -69,11 +69,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 @@ -203,6 +206,7 @@ export class Config implements ConfigParams, ParserConfig { timeFormats, thousandSeparator, useArrayArithmetic, + arrayFunctionResultOverwritesData, useStats, undoLimit, maxPendingLazyTransformations, @@ -216,6 +220,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/src/ConfigParams.ts b/src/ConfigParams.ts index 71aeb0bb7d..2cd2dbcac8 100644 --- a/src/ConfigParams.ts +++ b/src/ConfigParams.ts @@ -390,6 +390,25 @@ export interface ConfigParams { * @category Engine */ useArrayArithmetic: boolean, + /** + * When set to `true`, an array function whose result spills onto already-occupied cells + * overwrites those cells (clearing their previous content, spilling the array, and rerouting + * any dependents to the spilled values) instead of returning a `#SPILL!` error. + * + * **Warning:** this is a destructive, opt-in behavior. Enabling it clears whatever data + * happens to sit in the spill range on the live sheet, so use it only when overwriting is the + * intended outcome. The cleared cells are restored by `undo()`. + * + * The overwrite is applied when the array formula is evaluated (e.g. via `setCellContents`). + * When set to `false`, an array spill onto an occupied cell yields `#SPILL!` and leaves the + * occupant intact (the default, Excel-compatible behavior). + * + * Even when set to `true`, a spill that would collide with *another array* still yields + * `#SPILL!` and leaves that array intact — overwrite mode never clobbers another array. + * @default false + * @category Engine + */ + arrayFunctionResultOverwritesData: boolean, /** * When set to `true`, switches column search strategy from binary search to column index. * diff --git a/src/CrudOperations.ts b/src/CrudOperations.ts index bf0238dbc9..b6fd98ac88 100644 --- a/src/CrudOperations.ts +++ b/src/CrudOperations.ts @@ -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/src/DependencyGraph/DependencyGraph.ts b/src/DependencyGraph/DependencyGraph.ts index 962dedff1a..535a1407dd 100644 --- a/src/DependencyGraph/DependencyGraph.ts +++ b/src/DependencyGraph/DependencyGraph.ts @@ -63,6 +63,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 +83,8 @@ export class DependencyGraph { stats, lazilyTransformingAstService, functionRegistry, - namedExpressions + namedExpressions, + config ) } @@ -480,6 +482,30 @@ 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. + */ + public canOverwriteArrayResult(arrayVertex: ArrayFormulaVertex): boolean { + return this.config.arrayFunctionResultOverwritesData && !this.overwriteWouldHitArray(arrayVertex) + } + + private overwriteWouldHitArray(arrayVertex: ArrayFormulaVertex): boolean { + const range = arrayVertex.getRangeOrUndef() + if (range === undefined) { + return false + } + 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 +1145,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,7 +1196,7 @@ export class DependencyGraph { } this.setArray(range, vertex) - if (!this.isThereSpaceForArray(vertex)) { + if (!this.isThereSpaceForArray(vertex) && !this.canOverwriteArrayResult(vertex)) { return } diff --git a/src/Evaluator.ts b/src/Evaluator.ts index f810bee363..2a132cb906 100644 --- a/src/Evaluator.ts +++ b/src/Evaluator.ts @@ -133,7 +133,7 @@ export class Evaluator { private recomputeFormulaVertexValue(vertex: FormulaVertex): InterpreterValue { const address = vertex.getAddress(this.lazilyTransformingAstService) - if (vertex instanceof ArrayFormulaVertex && (vertex.array.size.isRef || !this.dependencyGraph.isThereSpaceForArray(vertex))) { + if (vertex instanceof ArrayFormulaVertex && (vertex.array.size.isRef || (!this.dependencyGraph.isThereSpaceForArray(vertex) && !this.dependencyGraph.canOverwriteArrayResult(vertex)))) { return vertex.setNoSpace() } else { const formula = vertex.getFormula(this.lazilyTransformingAstService) diff --git a/src/Operations.ts b/src/Operations.ts index 6a0e5ccdd4..e83162793b 100644 --- a/src/Operations.ts +++ b/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 @@ -513,7 +524,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 (HF-305). 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 +611,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 +629,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 +646,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.config.arrayFunctionResultOverwritesData || size.width * size.height <= 1) { + 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 +748,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 +789,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 +801,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) } /** @@ -840,10 +923,23 @@ export class Operations { 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 (HF-305 overwrite, 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) + const changes = this.setFormulaToCellFromCache(hash, address) + const overwriteChanges = changes.getChanges() + .filter(change => oldRange === undefined || !oldRange.addressInRange(change.address)) + this.columnSearch.applyChanges(overwriteChanges) } } diff --git a/src/UndoRedo.ts b/src/UndoRedo.ts index 87ab06439a..d2700df692 100644 --- a/src/UndoRedo.ts +++ b/src/UndoRedo.ts @@ -350,6 +350,7 @@ export class SetCellContentsUndoEntry extends BaseUndoEntry { newContent: RawCellContent, oldContent: [SimpleCellAddress, ClipboardCell], }[], + public readonly overwrittenCells: [SimpleCellAddress, ClipboardCell][] = [], ) { super() } @@ -640,6 +641,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) { diff --git a/test/hf-305-overwrite.spec.ts b/test/hf-305-overwrite.spec.ts new file mode 100644 index 0000000000..576e5f6fac --- /dev/null +++ b/test/hf-305-overwrite.spec.ts @@ -0,0 +1,445 @@ +import {DetailedCellError, ErrorType, HyperFormula} from '../src' +import {SimpleCellAddress, simpleCellAddress} from '../src/Cell' + +const adr = (stringAddress: string, sheet: number = 0): SimpleCellAddress => { + const result = /^(\$([A-Za-z0-9_]+)\.)?(\$?)([A-Za-z]+)(\$?)([0-9]+)$/.exec(stringAddress)! + const row = Number(result[6]) - 1 + return simpleCellAddress(sheet, colNumber(result[4]), row) +} + +const colNumber = (input: string): number => { + if (input.length === 1) { + return input.toUpperCase().charCodeAt(0) - 65 + } else { + return input.split('').reduce((currentColumn, nextLetter) => { + return currentColumn * 26 + (nextLetter.toUpperCase().charCodeAt(0) - 64) + }, 0) - 1 + } +} + +describe('HF-305 arrayFunctionResultOverwritesData', () => { + it('OFF (flag omitted, default): array spilling onto an occupied cell yields #SPILL! and leaves the occupant intact', () => { + const data = [ + ['=TRANSPOSE(C1:E1)', null, 1, 2, 3], + ['x'], + ] + + const hf = HyperFormula.buildFromArray(data, {licenseKey: 'gpl-v3'}) + + const a1 = hf.getCellValue(adr('A1')) + expect(a1 instanceof DetailedCellError).toBe(true) + expect((a1 as DetailedCellError).type).toBe(ErrorType.SPILL) + expect(hf.getCellValue(adr('A2'))).toBe('x') + + hf.destroy() + }) + + it('OFF (explicit false): array spilling onto an occupied cell yields #SPILL! and leaves the occupant intact', () => { + const data = [ + ['=TRANSPOSE(C1:E1)', null, 1, 2, 3], + ['x'], + ] + + const hf = HyperFormula.buildFromArray(data, {licenseKey: 'gpl-v3', arrayFunctionResultOverwritesData: false}) + + const a1 = hf.getCellValue(adr('A1')) + expect((a1 as DetailedCellError).type).toBe(ErrorType.SPILL) + expect(hf.getCellValue(adr('A2'))).toBe('x') + + hf.destroy() + }) + + it('OFF: setting an array formula that collides with an occupied cell yields #SPILL! (recalc parity)', () => { + const data = [ + [null, null, 1, 2, 3], + ['x'], + ] + + const hf = HyperFormula.buildFromArray(data, {licenseKey: 'gpl-v3', arrayFunctionResultOverwritesData: false}) + + hf.setCellContents(adr('A1'), [['=TRANSPOSE(C1:E1)']]) + + const a1 = hf.getCellValue(adr('A1')) + expect((a1 as DetailedCellError).type).toBe(ErrorType.SPILL) + expect(hf.getCellValue(adr('A2'))).toBe('x') + + hf.destroy() + }) + + it('ON: setting an array formula that collides with an occupied cell overwrites it and the spill lands', () => { + const data = [ + [null, null, 1, 2, 3], + ['x'], + ] + + const hf = HyperFormula.buildFromArray(data, {licenseKey: 'gpl-v3', arrayFunctionResultOverwritesData: true}) + + hf.setCellContents(adr('A1'), [['=TRANSPOSE(C1:E1)']]) + + expect(hf.getCellValue(adr('A1'))).toBe(1) + expect(hf.getCellValue(adr('A2'))).toBe(2) + expect(hf.getCellValue(adr('A3'))).toBe(3) + + hf.destroy() + }) + + it('ON: a cell referencing the overwritten occupant reflects the new spilled value (dependent reroute)', () => { + const data = [ + [null, '=A2', 1, 2, 3], + ['x'], + ] + + const hf = HyperFormula.buildFromArray(data, {licenseKey: 'gpl-v3', arrayFunctionResultOverwritesData: true}) + + hf.setCellContents(adr('A1'), [['=TRANSPOSE(C1:E1)']]) + + expect(hf.getCellValue(adr('A2'))).toBe(2) + expect(hf.getCellValue(adr('B1'))).toBe(2) + + hf.destroy() + }) + + it('OFF: free spill into empty cells still works (no regression)', () => { + const data = [ + ['=TRANSPOSE(C1:E1)', null, 1, 2, 3], + ] + + const hf = HyperFormula.buildFromArray(data, {licenseKey: 'gpl-v3', arrayFunctionResultOverwritesData: false}) + + expect(hf.getCellValue(adr('A1'))).toBe(1) + expect(hf.getCellValue(adr('A2'))).toBe(2) + expect(hf.getCellValue(adr('A3'))).toBe(3) + + hf.destroy() + }) + + it('ON: free spill into empty cells still works (no regression when the flag is on but there is no collision)', () => { + const data = [ + ['=TRANSPOSE(C1:E1)', null, 1, 2, 3], + ] + + const hf = HyperFormula.buildFromArray(data, {licenseKey: 'gpl-v3', arrayFunctionResultOverwritesData: true}) + + expect(hf.getCellValue(adr('A1'))).toBe(1) + expect(hf.getCellValue(adr('A2'))).toBe(2) + expect(hf.getCellValue(adr('A3'))).toBe(3) + + hf.destroy() + }) + + // OUT OF SCOPE (documented current behavior): the overwrite primitive lives on the + // setFormulaToCell / setCellContents path (`exchangeOrAddFormulaVertex`). When an array + // formula AND a conflicting occupant are declared *inline in the same buildFromArray call*, + // the occupant is processed after the array and GraphBuilder.shrinkArrayIfNeeded shrinks the + // array back to its corner, so the inline occupant wins at build time even with the flag ON. + // Asserting current behavior so a future change to this edge is a conscious decision. + it('ON (out of scope): an occupant declared inline in the same buildFromArray still wins at build time', () => { + const data = [ + ['=TRANSPOSE(C1:E1)', null, 1, 2, 3], + ['x'], + ] + + const hf = HyperFormula.buildFromArray(data, {licenseKey: 'gpl-v3', arrayFunctionResultOverwritesData: true}) + + expect(hf.getCellValue(adr('A2'))).toBe('x') + expect(hf.getCellValue(adr('A3'))).toBe(null) + + hf.destroy() + }) + + // Array-vs-array collisions ALWAYS keep #SPILL!, even in overwrite mode: the flag clears + // static occupants but must never clobber another array (matches Excel, and avoids corrupting + // the pre-existing array). Guarded by DependencyGraph.canOverwriteArrayResult / overwriteWouldHitArray. + it('ON: array-vs-array collision stays #SPILL! and leaves the pre-existing array intact', () => { + // First array at B2 spills B2:B4 = 1,2,3 into free space. + const hf = HyperFormula.buildFromArray([ + [null, null, 1, 2, 3], + [null, '=TRANSPOSE(C1:E1)'], + ], {licenseKey: 'gpl-v3', arrayFunctionResultOverwritesData: true}) + expect(hf.getCellValue(adr('B2'))).toBe(1) + + // A second array at B1 would spill B1:B2, hitting the first array's anchor at B2. + hf.setCellContents(adr('B1'), [['=TRANSPOSE(C1:D1)']]) + + const b1 = hf.getCellValue(adr('B1')) + expect(b1 instanceof DetailedCellError).toBe(true) + expect((b1 as DetailedCellError).type).toBe(ErrorType.SPILL) + // the pre-existing array is left untouched (no corruption) + expect(hf.getCellValue(adr('B2'))).toBe(1) + expect(hf.getCellValue(adr('B3'))).toBe(2) + expect(hf.getCellValue(adr('B4'))).toBe(3) + + hf.destroy() + }) + + it('ON: overwrites a formula occupant (recalc path)', () => { + const hf = HyperFormula.buildFromArray([ + [null, null, 1, 2, 3], + ['=C1+100'], + ], {licenseKey: 'gpl-v3', arrayFunctionResultOverwritesData: true}) + expect(hf.getCellValue(adr('A2'))).toBe(101) + + hf.setCellContents(adr('A1'), [['=TRANSPOSE(C1:E1)']]) + + expect(hf.getCellValue(adr('A1'))).toBe(1) + expect(hf.getCellValue(adr('A2'))).toBe(2) // formula occupant overwritten + expect(hf.getCellValue(adr('A3'))).toBe(3) + + hf.destroy() + }) + + it('ON: overwrites a 2-D block of occupants (MMULT 2x2)', () => { + const hf = HyperFormula.buildFromArray([ + [null, null, null, 1, 0, null, 1, 0], + ['x', 'y', null, 0, 1, null, 0, 1], + ], {licenseKey: 'gpl-v3', arrayFunctionResultOverwritesData: true}) + + hf.setCellContents(adr('A1'), [['=MMULT(D1:E2,G1:H2)']]) // 2x2 spill A1:B2 over A2='x', B2='y' + + expect(hf.getCellValue(adr('A1'))).toBe(1) + expect(hf.getCellValue(adr('B1'))).toBe(0) + expect(hf.getCellValue(adr('A2'))).toBe(0) // occupant 'x' overwritten + expect(hf.getCellValue(adr('B2'))).toBe(1) // occupant 'y' overwritten + + hf.destroy() + }) + + // The overwrite is destructive on the live sheet, but the cleared occupants are recorded on + // the undo stack, so undo restores them. + it('ON: undo of an overwrite restores the overwritten occupant', () => { + const hf = HyperFormula.buildFromArray([ + [null, null, 1, 2, 3], + ['x'], + ], {licenseKey: 'gpl-v3', arrayFunctionResultOverwritesData: true}) + hf.setCellContents(adr('A1'), [['=TRANSPOSE(C1:E1)']]) // overwrites A2='x' + expect(hf.getCellValue(adr('A2'))).toBe(2) + + hf.undo() + + expect(hf.getCellValue(adr('A1'))).toBe(null) // formula removed + expect(hf.getCellValue(adr('A2'))).toBe('x') // overwritten occupant restored + + hf.destroy() + }) + + it('ON: redo after undo re-applies the overwrite', () => { + const hf = HyperFormula.buildFromArray([ + [null, null, 1, 2, 3], + ['x'], + ], {licenseKey: 'gpl-v3', arrayFunctionResultOverwritesData: true}) + hf.setCellContents(adr('A1'), [['=TRANSPOSE(C1:E1)']]) // overwrites A2='x' + hf.undo() + expect(hf.getCellValue(adr('A2'))).toBe('x') // restored + + hf.redo() + + expect(hf.getCellValue(adr('A1'))).toBe(1) + expect(hf.getCellValue(adr('A2'))).toBe(2) // overwrite re-applied + + hf.destroy() + }) + + it('ON: undo of a blocked array-vs-array spill leaves the pre-existing array intact', () => { + // First array at B2 spills B2:B4 = 1,2,3 into free space. + const hf = HyperFormula.buildFromArray([ + [null, null, 1, 2, 3], + [null, '=TRANSPOSE(C1:E1)'], + ], {licenseKey: 'gpl-v3', arrayFunctionResultOverwritesData: true}) + expect(hf.getCellValue(adr('B2'))).toBe(1) + + // A second array at B1 would spill B1:B2, hitting the first array's anchor at B2 -> #SPILL!. + hf.setCellContents(adr('B1'), [['=TRANSPOSE(C1:D1)']]) + expect((hf.getCellValue(adr('B1')) as DetailedCellError).type).toBe(ErrorType.SPILL) + + hf.undo() + + expect(hf.getCellValue(adr('B1'))).toBe(null) // failed spill removed + // pre-existing array is untouched by the undo (nothing was overwritten to restore) + expect(hf.getCellValue(adr('B2'))).toBe(1) + expect(hf.getCellValue(adr('B3'))).toBe(2) + expect(hf.getCellValue(adr('B4'))).toBe(3) + + hf.destroy() + }) + + // With useColumnIndex, overwriting a cell must drop its old value from the column index, + // otherwise VLOOKUP/MATCH could still match an overwritten value (stale index). + it('ON + useColumnIndex: overwrite drops the stale value from the column index', () => { + // A1:A3 = 10,20,30; G1:I1 = 100,200,300; K1 = MATCH(20, A1:A3); L1 = MATCH(200, A1:A3) + const hf = HyperFormula.buildFromArray([ + [10, null, null, null, null, null, 100, 200, 300, null, '=MATCH(20,A1:A3,0)', '=MATCH(200,A1:A3,0)'], + [20], + [30], + ], {licenseKey: 'gpl-v3', arrayFunctionResultOverwritesData: true, useColumnIndex: true}) + expect(hf.getCellValue(adr('K1'))).toBe(2) // 20 initially at A2 + + hf.setCellContents(adr('A1'), [['=TRANSPOSE(G1:I1)']]) // A1:A3 -> 100,200,300 + + expect(hf.getCellValue(adr('A2'))).toBe(200) + const match20 = hf.getCellValue(adr('K1')) + expect(match20 instanceof DetailedCellError).toBe(true) // 20 is gone -> #N/A (not stale) + expect((match20 as DetailedCellError).type).toBe(ErrorType.NA) + expect(hf.getCellValue(adr('L1'))).toBe(2) // 200 now at A2 + + hf.destroy() + }) + + // Finding #1 (Bugbot): replacing/expanding an EXISTING array so it overwrites a NEW static cell + // must snapshot that static cell for undo. Previously overwrittenOccupantAddresses bailed out on + // ANY array in the spill range — including the array being replaced at the anchor — so nothing + // was captured and undo lost the static occupant. The anchor's own array is now excluded from + // the block, so only a genuinely new static occupant (A3) is snapshotted. + it('ON: undo of an array EXPANDED over a new static cell restores that cell', () => { + // A1=TRANSPOSE(D1:E1) spills A1:A2 = 10,20 into free space; A3 = 99 (static). + const hf = HyperFormula.buildFromArray([ + ['=TRANSPOSE(D1:E1)', null, null, 10, 20, 30], + [null], + [99], + ], {licenseKey: 'gpl-v3', arrayFunctionResultOverwritesData: true}) + expect(hf.getCellValue(adr('A2'))).toBe(20) + expect(hf.getCellValue(adr('A3'))).toBe(99) + + // Expand the array to A1:A3 = 10,20,30, overwriting the static A3 = 99. + hf.setCellContents(adr('A1'), [['=TRANSPOSE(D1:F1)']]) + expect(hf.getCellValue(adr('A3'))).toBe(30) // 99 overwritten by the expanded spill + + hf.undo() + + expect(hf.getCellValue(adr('A1'))).toBe(10) // original array re-spilled + expect(hf.getCellValue(adr('A2'))).toBe(20) + expect(hf.getCellValue(adr('A3'))).toBe(99) // overwritten static cell restored (finding #1) + + hf.destroy() + }) + + // Finding #3 on the array-replace path: expanding an array over a static cell with useColumnIndex + // must drop the overwritten value from the column index (not just the plain overwrite path). + it('ON + useColumnIndex: expanding an array over a static cell drops the stale value from the column index', () => { + // A1=TRANSPOSE(D1:E1) spills A1:A2 = 10,20; A3 = 99; H1 = MATCH(99, A1:A3). + const hf = HyperFormula.buildFromArray([ + ['=TRANSPOSE(D1:E1)', null, null, 10, 20, 30, null, '=MATCH(99,A1:A3,0)'], + [null], + [99], + ], {licenseKey: 'gpl-v3', arrayFunctionResultOverwritesData: true, useColumnIndex: true}) + expect(hf.getCellValue(adr('H1'))).toBe(3) // 99 at A3 + + hf.setCellContents(adr('A1'), [['=TRANSPOSE(D1:F1)']]) // expand to A1:A3 = 10,20,30 + + expect(hf.getCellValue(adr('A3'))).toBe(30) + const match99 = hf.getCellValue(adr('H1')) + expect(match99 instanceof DetailedCellError).toBe(true) // 99 gone -> #N/A (not stale) + expect((match99 as DetailedCellError).type).toBe(ErrorType.NA) + + hf.destroy() + }) + + // Undoing an expand-overwrite must also drop the vacated spill values from the column index — + // restore goes through restoreCell -> setFormulaToCellFromCache, which now applies the shrink's + // content changes to the column search (otherwise a lookup could still match a value undo removed). + it('ON + useColumnIndex: undo of an expand-overwrite drops the vacated value from the column index', () => { + // A1=TRANSPOSE(D1:E1) spills A1:A2 = 10,20; A3 = 99; K1 = MATCH(30, A1:A3). + const hf = HyperFormula.buildFromArray([ + ['=TRANSPOSE(D1:E1)', null, null, 10, 20, 30, null, null, null, null, '=MATCH(30,A1:A3,0)'], + [null], + [99], + ], {licenseKey: 'gpl-v3', arrayFunctionResultOverwritesData: true, useColumnIndex: true}) + + hf.setCellContents(adr('A1'), [['=TRANSPOSE(D1:F1)']]) // expand to A1:A3 = 10,20,30 (overwrites 99) + expect(hf.getCellValue(adr('K1'))).toBe(3) // 30 now at A3 + + hf.undo() // back to A1:A2 = 10,20, A3 = 99 + + expect(hf.getCellValue(adr('A3'))).toBe(99) // value restored + const match30 = hf.getCellValue(adr('K1')) + expect(match30 instanceof DetailedCellError).toBe(true) // 30 vacated -> #N/A (not stale) + expect((match30 as DetailedCellError).type).toBe(ErrorType.NA) + + hf.destroy() + }) + + // Documents the copy/paste boundary relevant to Bugbot finding #2. Copying an array cell captures + // its VALUE (getClipboardCell materializes ArrayFormulaVertex cells), NOT the array formula, so a + // paste writes a plain value that never spills. There is therefore no public copy/paste path that + // spills an array over occupied cells, and the flag has no effect on paste. Pinned so a future + // change to copy semantics (preserving array formulas) is a conscious decision that would also + // need paste-undo of the overwritten occupants. + it('ON: pasting a copied array anchor writes its value and does NOT overwrite occupants (copy materializes arrays)', () => { + const hf = HyperFormula.buildFromArray([ + ['=TRANSPOSE(G1:I1)', null, null, 'x', null, null, 100, 200, 300], + [null, null, null, 'y'], + [null, null, null, 'z'], + ], {licenseKey: 'gpl-v3', arrayFunctionResultOverwritesData: true}) + + hf.copy({start: adr('A1'), end: adr('A1')}) // captures the VALUE 100, not =TRANSPOSE(...) + hf.paste(adr('D1')) + + expect(hf.getCellValue(adr('D1'))).toBe(100) // pasted value, not a spill + expect(hf.getCellValue(adr('D2'))).toBe('y') // occupant intact (no spill, no overwrite) + expect(hf.getCellValue(adr('D3'))).toBe('z') + + hf.destroy() + }) + + // Bugbot (PR #1714, round 1): overwriting an occupant recorded its previous value by calling + // `getCellValue`, which throws for a `ScalarFormulaVertex` that hasn't been computed yet -- the + // normal state of a sibling cell set earlier in the same batch()/suspendEvaluation() block, since + // evaluation only runs once the batch is committed. That throw aborted the whole operation before + // the spill landed (and could even corrupt graph state, since `batch()`'s catch handler calls + // `resumeEvaluation()` before rethrowing, and that call could itself throw against the half-applied + // exchange, masking the original error). Fixed by reading the occupant via the non-throwing + // `valueOrUndef()` accessor every `FormulaVertex` exposes, treating an uncomputed occupant as + // `EmptyValue`. + it('ON: array spilling over an occupant formula that is not yet computed (inside batch) does not throw', () => { + const hf = HyperFormula.buildFromArray([ + [0, 1, 10], + [null, null, 20], + ], {licenseKey: 'gpl-v3', arrayFunctionResultOverwritesData: true}) + + expect(() => { + hf.batch(() => { + // B1 becomes a formula whose value is not computed yet (batch suspends evaluation). + hf.setCellContents(adr('B1'), '=A1+1') + // A1's spill claims B1 while it is still uncomputed. + hf.setCellContents(adr('A1'), [['=TRANSPOSE(C1:C2)']]) + }) + }).not.toThrow() + + expect(hf.getCellValue(adr('A1'))).toBe(10) + expect(hf.getCellValue(adr('B1'))).toBe(20) + + hf.destroy() + }) + + // Bugbot (PR #1714, round 1): `setFormulaToCellFromCache` returns overwrite `ContentChanges`, but + // `rewriteAffectedArrays` (the structural caller used by doAddRows/doRemoveRows/doAddColumns/ + // doRemoveColumns to re-place arrays whose dependencies shifted) ignored that return value. When a + // structural insert/delete grows an array so it overwrites a previously-static cell under the + // overwrite flag, the stale value was never dropped from the column index, so MATCH/VLOOKUP could + // still match it. Fixed by applying the returned changes to the column index at this call site only + // (mirroring the existing `restoreCell` pattern), without threading extra undo entries through + // insert/delete -- that half is a documented, deliberately out-of-scope limitation. + it('ON + useColumnIndex: addColumns growing an array over a static cell drops the stale value from the column index', () => { + // A1 = TRANSPOSE(D1:E1) spills A1:A2 = 10,20. A3 = 99 (static, not yet overlapped). + // C1 = MATCH(99, A1:A3, 0) sits left of D1:E1 so the column insertion never shifts it. + const hf = HyperFormula.buildFromArray([ + ['=TRANSPOSE(D1:E1)', null, '=MATCH(99,A1:A3,0)', 10, 20], + [null], + [99], + ], {licenseKey: 'gpl-v3', arrayFunctionResultOverwritesData: true, useColumnIndex: true}) + + expect(hf.getCellValue(adr('A3'))).toBe(99) + expect(hf.getCellValue(adr('C1'))).toBe(3) // 99 found at row 3 + + // Insert a column inside D1:E1 (at E's position), extending the reference to D1:F1 and growing + // the array from 1x2 to 1x3 -- it now spills over A3, overwriting the static 99 there. + hf.addColumns(0, [4, 1]) + + expect(hf.getCellValue(adr('A3'))).toBe(20) // spilled value, 99 is gone + + const match99 = hf.getCellValue(adr('C1')) + expect(match99 instanceof DetailedCellError).toBe(true) // must be #N/A, not a stale match + expect((match99 as DetailedCellError).type).toBe(ErrorType.NA) + + hf.destroy() + }) +}) From e8d39821279bf7a0489ab90db6c90e8e61de8a66 Mon Sep 17 00:00:00 2001 From: Marcin Kordas Date: Fri, 28 Aug 2026 05:13:57 +0000 Subject: [PATCH 2/9] HF-305: move the overwrite-flag spec out of the public repository The 53 cases now live in hyperformula-tests on the same-name branch (feat/hf-305-overwrite-flag), which is what test/fetch-tests.sh pairs on. They arrived here as test/hf-305-overwrite.spec.ts, and #1612 took every spec out of this repository: on develop, test/ carries only the setup files, fetch-tests.sh and smoke.spec.ts. Jest's testMatch (test/**/*spec.(ts|js)) did pick the file up, so the suite was running - it was running from the wrong repository, where it is invisible to reviewers of the tests repo and ships in the public tree. Coverage is not lost by the move: measured against this commit, every executable line this PR adds to Operations.ts (29) and DependencyGraph.ts (17) is exercised by the ported spec alone, which is what the red codecov/patch was about. Co-Authored-By: Claude Opus 5 (1M context) --- test/hf-305-overwrite.spec.ts | 445 ---------------------------------- 1 file changed, 445 deletions(-) delete mode 100644 test/hf-305-overwrite.spec.ts diff --git a/test/hf-305-overwrite.spec.ts b/test/hf-305-overwrite.spec.ts deleted file mode 100644 index 576e5f6fac..0000000000 --- a/test/hf-305-overwrite.spec.ts +++ /dev/null @@ -1,445 +0,0 @@ -import {DetailedCellError, ErrorType, HyperFormula} from '../src' -import {SimpleCellAddress, simpleCellAddress} from '../src/Cell' - -const adr = (stringAddress: string, sheet: number = 0): SimpleCellAddress => { - const result = /^(\$([A-Za-z0-9_]+)\.)?(\$?)([A-Za-z]+)(\$?)([0-9]+)$/.exec(stringAddress)! - const row = Number(result[6]) - 1 - return simpleCellAddress(sheet, colNumber(result[4]), row) -} - -const colNumber = (input: string): number => { - if (input.length === 1) { - return input.toUpperCase().charCodeAt(0) - 65 - } else { - return input.split('').reduce((currentColumn, nextLetter) => { - return currentColumn * 26 + (nextLetter.toUpperCase().charCodeAt(0) - 64) - }, 0) - 1 - } -} - -describe('HF-305 arrayFunctionResultOverwritesData', () => { - it('OFF (flag omitted, default): array spilling onto an occupied cell yields #SPILL! and leaves the occupant intact', () => { - const data = [ - ['=TRANSPOSE(C1:E1)', null, 1, 2, 3], - ['x'], - ] - - const hf = HyperFormula.buildFromArray(data, {licenseKey: 'gpl-v3'}) - - const a1 = hf.getCellValue(adr('A1')) - expect(a1 instanceof DetailedCellError).toBe(true) - expect((a1 as DetailedCellError).type).toBe(ErrorType.SPILL) - expect(hf.getCellValue(adr('A2'))).toBe('x') - - hf.destroy() - }) - - it('OFF (explicit false): array spilling onto an occupied cell yields #SPILL! and leaves the occupant intact', () => { - const data = [ - ['=TRANSPOSE(C1:E1)', null, 1, 2, 3], - ['x'], - ] - - const hf = HyperFormula.buildFromArray(data, {licenseKey: 'gpl-v3', arrayFunctionResultOverwritesData: false}) - - const a1 = hf.getCellValue(adr('A1')) - expect((a1 as DetailedCellError).type).toBe(ErrorType.SPILL) - expect(hf.getCellValue(adr('A2'))).toBe('x') - - hf.destroy() - }) - - it('OFF: setting an array formula that collides with an occupied cell yields #SPILL! (recalc parity)', () => { - const data = [ - [null, null, 1, 2, 3], - ['x'], - ] - - const hf = HyperFormula.buildFromArray(data, {licenseKey: 'gpl-v3', arrayFunctionResultOverwritesData: false}) - - hf.setCellContents(adr('A1'), [['=TRANSPOSE(C1:E1)']]) - - const a1 = hf.getCellValue(adr('A1')) - expect((a1 as DetailedCellError).type).toBe(ErrorType.SPILL) - expect(hf.getCellValue(adr('A2'))).toBe('x') - - hf.destroy() - }) - - it('ON: setting an array formula that collides with an occupied cell overwrites it and the spill lands', () => { - const data = [ - [null, null, 1, 2, 3], - ['x'], - ] - - const hf = HyperFormula.buildFromArray(data, {licenseKey: 'gpl-v3', arrayFunctionResultOverwritesData: true}) - - hf.setCellContents(adr('A1'), [['=TRANSPOSE(C1:E1)']]) - - expect(hf.getCellValue(adr('A1'))).toBe(1) - expect(hf.getCellValue(adr('A2'))).toBe(2) - expect(hf.getCellValue(adr('A3'))).toBe(3) - - hf.destroy() - }) - - it('ON: a cell referencing the overwritten occupant reflects the new spilled value (dependent reroute)', () => { - const data = [ - [null, '=A2', 1, 2, 3], - ['x'], - ] - - const hf = HyperFormula.buildFromArray(data, {licenseKey: 'gpl-v3', arrayFunctionResultOverwritesData: true}) - - hf.setCellContents(adr('A1'), [['=TRANSPOSE(C1:E1)']]) - - expect(hf.getCellValue(adr('A2'))).toBe(2) - expect(hf.getCellValue(adr('B1'))).toBe(2) - - hf.destroy() - }) - - it('OFF: free spill into empty cells still works (no regression)', () => { - const data = [ - ['=TRANSPOSE(C1:E1)', null, 1, 2, 3], - ] - - const hf = HyperFormula.buildFromArray(data, {licenseKey: 'gpl-v3', arrayFunctionResultOverwritesData: false}) - - expect(hf.getCellValue(adr('A1'))).toBe(1) - expect(hf.getCellValue(adr('A2'))).toBe(2) - expect(hf.getCellValue(adr('A3'))).toBe(3) - - hf.destroy() - }) - - it('ON: free spill into empty cells still works (no regression when the flag is on but there is no collision)', () => { - const data = [ - ['=TRANSPOSE(C1:E1)', null, 1, 2, 3], - ] - - const hf = HyperFormula.buildFromArray(data, {licenseKey: 'gpl-v3', arrayFunctionResultOverwritesData: true}) - - expect(hf.getCellValue(adr('A1'))).toBe(1) - expect(hf.getCellValue(adr('A2'))).toBe(2) - expect(hf.getCellValue(adr('A3'))).toBe(3) - - hf.destroy() - }) - - // OUT OF SCOPE (documented current behavior): the overwrite primitive lives on the - // setFormulaToCell / setCellContents path (`exchangeOrAddFormulaVertex`). When an array - // formula AND a conflicting occupant are declared *inline in the same buildFromArray call*, - // the occupant is processed after the array and GraphBuilder.shrinkArrayIfNeeded shrinks the - // array back to its corner, so the inline occupant wins at build time even with the flag ON. - // Asserting current behavior so a future change to this edge is a conscious decision. - it('ON (out of scope): an occupant declared inline in the same buildFromArray still wins at build time', () => { - const data = [ - ['=TRANSPOSE(C1:E1)', null, 1, 2, 3], - ['x'], - ] - - const hf = HyperFormula.buildFromArray(data, {licenseKey: 'gpl-v3', arrayFunctionResultOverwritesData: true}) - - expect(hf.getCellValue(adr('A2'))).toBe('x') - expect(hf.getCellValue(adr('A3'))).toBe(null) - - hf.destroy() - }) - - // Array-vs-array collisions ALWAYS keep #SPILL!, even in overwrite mode: the flag clears - // static occupants but must never clobber another array (matches Excel, and avoids corrupting - // the pre-existing array). Guarded by DependencyGraph.canOverwriteArrayResult / overwriteWouldHitArray. - it('ON: array-vs-array collision stays #SPILL! and leaves the pre-existing array intact', () => { - // First array at B2 spills B2:B4 = 1,2,3 into free space. - const hf = HyperFormula.buildFromArray([ - [null, null, 1, 2, 3], - [null, '=TRANSPOSE(C1:E1)'], - ], {licenseKey: 'gpl-v3', arrayFunctionResultOverwritesData: true}) - expect(hf.getCellValue(adr('B2'))).toBe(1) - - // A second array at B1 would spill B1:B2, hitting the first array's anchor at B2. - hf.setCellContents(adr('B1'), [['=TRANSPOSE(C1:D1)']]) - - const b1 = hf.getCellValue(adr('B1')) - expect(b1 instanceof DetailedCellError).toBe(true) - expect((b1 as DetailedCellError).type).toBe(ErrorType.SPILL) - // the pre-existing array is left untouched (no corruption) - expect(hf.getCellValue(adr('B2'))).toBe(1) - expect(hf.getCellValue(adr('B3'))).toBe(2) - expect(hf.getCellValue(adr('B4'))).toBe(3) - - hf.destroy() - }) - - it('ON: overwrites a formula occupant (recalc path)', () => { - const hf = HyperFormula.buildFromArray([ - [null, null, 1, 2, 3], - ['=C1+100'], - ], {licenseKey: 'gpl-v3', arrayFunctionResultOverwritesData: true}) - expect(hf.getCellValue(adr('A2'))).toBe(101) - - hf.setCellContents(adr('A1'), [['=TRANSPOSE(C1:E1)']]) - - expect(hf.getCellValue(adr('A1'))).toBe(1) - expect(hf.getCellValue(adr('A2'))).toBe(2) // formula occupant overwritten - expect(hf.getCellValue(adr('A3'))).toBe(3) - - hf.destroy() - }) - - it('ON: overwrites a 2-D block of occupants (MMULT 2x2)', () => { - const hf = HyperFormula.buildFromArray([ - [null, null, null, 1, 0, null, 1, 0], - ['x', 'y', null, 0, 1, null, 0, 1], - ], {licenseKey: 'gpl-v3', arrayFunctionResultOverwritesData: true}) - - hf.setCellContents(adr('A1'), [['=MMULT(D1:E2,G1:H2)']]) // 2x2 spill A1:B2 over A2='x', B2='y' - - expect(hf.getCellValue(adr('A1'))).toBe(1) - expect(hf.getCellValue(adr('B1'))).toBe(0) - expect(hf.getCellValue(adr('A2'))).toBe(0) // occupant 'x' overwritten - expect(hf.getCellValue(adr('B2'))).toBe(1) // occupant 'y' overwritten - - hf.destroy() - }) - - // The overwrite is destructive on the live sheet, but the cleared occupants are recorded on - // the undo stack, so undo restores them. - it('ON: undo of an overwrite restores the overwritten occupant', () => { - const hf = HyperFormula.buildFromArray([ - [null, null, 1, 2, 3], - ['x'], - ], {licenseKey: 'gpl-v3', arrayFunctionResultOverwritesData: true}) - hf.setCellContents(adr('A1'), [['=TRANSPOSE(C1:E1)']]) // overwrites A2='x' - expect(hf.getCellValue(adr('A2'))).toBe(2) - - hf.undo() - - expect(hf.getCellValue(adr('A1'))).toBe(null) // formula removed - expect(hf.getCellValue(adr('A2'))).toBe('x') // overwritten occupant restored - - hf.destroy() - }) - - it('ON: redo after undo re-applies the overwrite', () => { - const hf = HyperFormula.buildFromArray([ - [null, null, 1, 2, 3], - ['x'], - ], {licenseKey: 'gpl-v3', arrayFunctionResultOverwritesData: true}) - hf.setCellContents(adr('A1'), [['=TRANSPOSE(C1:E1)']]) // overwrites A2='x' - hf.undo() - expect(hf.getCellValue(adr('A2'))).toBe('x') // restored - - hf.redo() - - expect(hf.getCellValue(adr('A1'))).toBe(1) - expect(hf.getCellValue(adr('A2'))).toBe(2) // overwrite re-applied - - hf.destroy() - }) - - it('ON: undo of a blocked array-vs-array spill leaves the pre-existing array intact', () => { - // First array at B2 spills B2:B4 = 1,2,3 into free space. - const hf = HyperFormula.buildFromArray([ - [null, null, 1, 2, 3], - [null, '=TRANSPOSE(C1:E1)'], - ], {licenseKey: 'gpl-v3', arrayFunctionResultOverwritesData: true}) - expect(hf.getCellValue(adr('B2'))).toBe(1) - - // A second array at B1 would spill B1:B2, hitting the first array's anchor at B2 -> #SPILL!. - hf.setCellContents(adr('B1'), [['=TRANSPOSE(C1:D1)']]) - expect((hf.getCellValue(adr('B1')) as DetailedCellError).type).toBe(ErrorType.SPILL) - - hf.undo() - - expect(hf.getCellValue(adr('B1'))).toBe(null) // failed spill removed - // pre-existing array is untouched by the undo (nothing was overwritten to restore) - expect(hf.getCellValue(adr('B2'))).toBe(1) - expect(hf.getCellValue(adr('B3'))).toBe(2) - expect(hf.getCellValue(adr('B4'))).toBe(3) - - hf.destroy() - }) - - // With useColumnIndex, overwriting a cell must drop its old value from the column index, - // otherwise VLOOKUP/MATCH could still match an overwritten value (stale index). - it('ON + useColumnIndex: overwrite drops the stale value from the column index', () => { - // A1:A3 = 10,20,30; G1:I1 = 100,200,300; K1 = MATCH(20, A1:A3); L1 = MATCH(200, A1:A3) - const hf = HyperFormula.buildFromArray([ - [10, null, null, null, null, null, 100, 200, 300, null, '=MATCH(20,A1:A3,0)', '=MATCH(200,A1:A3,0)'], - [20], - [30], - ], {licenseKey: 'gpl-v3', arrayFunctionResultOverwritesData: true, useColumnIndex: true}) - expect(hf.getCellValue(adr('K1'))).toBe(2) // 20 initially at A2 - - hf.setCellContents(adr('A1'), [['=TRANSPOSE(G1:I1)']]) // A1:A3 -> 100,200,300 - - expect(hf.getCellValue(adr('A2'))).toBe(200) - const match20 = hf.getCellValue(adr('K1')) - expect(match20 instanceof DetailedCellError).toBe(true) // 20 is gone -> #N/A (not stale) - expect((match20 as DetailedCellError).type).toBe(ErrorType.NA) - expect(hf.getCellValue(adr('L1'))).toBe(2) // 200 now at A2 - - hf.destroy() - }) - - // Finding #1 (Bugbot): replacing/expanding an EXISTING array so it overwrites a NEW static cell - // must snapshot that static cell for undo. Previously overwrittenOccupantAddresses bailed out on - // ANY array in the spill range — including the array being replaced at the anchor — so nothing - // was captured and undo lost the static occupant. The anchor's own array is now excluded from - // the block, so only a genuinely new static occupant (A3) is snapshotted. - it('ON: undo of an array EXPANDED over a new static cell restores that cell', () => { - // A1=TRANSPOSE(D1:E1) spills A1:A2 = 10,20 into free space; A3 = 99 (static). - const hf = HyperFormula.buildFromArray([ - ['=TRANSPOSE(D1:E1)', null, null, 10, 20, 30], - [null], - [99], - ], {licenseKey: 'gpl-v3', arrayFunctionResultOverwritesData: true}) - expect(hf.getCellValue(adr('A2'))).toBe(20) - expect(hf.getCellValue(adr('A3'))).toBe(99) - - // Expand the array to A1:A3 = 10,20,30, overwriting the static A3 = 99. - hf.setCellContents(adr('A1'), [['=TRANSPOSE(D1:F1)']]) - expect(hf.getCellValue(adr('A3'))).toBe(30) // 99 overwritten by the expanded spill - - hf.undo() - - expect(hf.getCellValue(adr('A1'))).toBe(10) // original array re-spilled - expect(hf.getCellValue(adr('A2'))).toBe(20) - expect(hf.getCellValue(adr('A3'))).toBe(99) // overwritten static cell restored (finding #1) - - hf.destroy() - }) - - // Finding #3 on the array-replace path: expanding an array over a static cell with useColumnIndex - // must drop the overwritten value from the column index (not just the plain overwrite path). - it('ON + useColumnIndex: expanding an array over a static cell drops the stale value from the column index', () => { - // A1=TRANSPOSE(D1:E1) spills A1:A2 = 10,20; A3 = 99; H1 = MATCH(99, A1:A3). - const hf = HyperFormula.buildFromArray([ - ['=TRANSPOSE(D1:E1)', null, null, 10, 20, 30, null, '=MATCH(99,A1:A3,0)'], - [null], - [99], - ], {licenseKey: 'gpl-v3', arrayFunctionResultOverwritesData: true, useColumnIndex: true}) - expect(hf.getCellValue(adr('H1'))).toBe(3) // 99 at A3 - - hf.setCellContents(adr('A1'), [['=TRANSPOSE(D1:F1)']]) // expand to A1:A3 = 10,20,30 - - expect(hf.getCellValue(adr('A3'))).toBe(30) - const match99 = hf.getCellValue(adr('H1')) - expect(match99 instanceof DetailedCellError).toBe(true) // 99 gone -> #N/A (not stale) - expect((match99 as DetailedCellError).type).toBe(ErrorType.NA) - - hf.destroy() - }) - - // Undoing an expand-overwrite must also drop the vacated spill values from the column index — - // restore goes through restoreCell -> setFormulaToCellFromCache, which now applies the shrink's - // content changes to the column search (otherwise a lookup could still match a value undo removed). - it('ON + useColumnIndex: undo of an expand-overwrite drops the vacated value from the column index', () => { - // A1=TRANSPOSE(D1:E1) spills A1:A2 = 10,20; A3 = 99; K1 = MATCH(30, A1:A3). - const hf = HyperFormula.buildFromArray([ - ['=TRANSPOSE(D1:E1)', null, null, 10, 20, 30, null, null, null, null, '=MATCH(30,A1:A3,0)'], - [null], - [99], - ], {licenseKey: 'gpl-v3', arrayFunctionResultOverwritesData: true, useColumnIndex: true}) - - hf.setCellContents(adr('A1'), [['=TRANSPOSE(D1:F1)']]) // expand to A1:A3 = 10,20,30 (overwrites 99) - expect(hf.getCellValue(adr('K1'))).toBe(3) // 30 now at A3 - - hf.undo() // back to A1:A2 = 10,20, A3 = 99 - - expect(hf.getCellValue(adr('A3'))).toBe(99) // value restored - const match30 = hf.getCellValue(adr('K1')) - expect(match30 instanceof DetailedCellError).toBe(true) // 30 vacated -> #N/A (not stale) - expect((match30 as DetailedCellError).type).toBe(ErrorType.NA) - - hf.destroy() - }) - - // Documents the copy/paste boundary relevant to Bugbot finding #2. Copying an array cell captures - // its VALUE (getClipboardCell materializes ArrayFormulaVertex cells), NOT the array formula, so a - // paste writes a plain value that never spills. There is therefore no public copy/paste path that - // spills an array over occupied cells, and the flag has no effect on paste. Pinned so a future - // change to copy semantics (preserving array formulas) is a conscious decision that would also - // need paste-undo of the overwritten occupants. - it('ON: pasting a copied array anchor writes its value and does NOT overwrite occupants (copy materializes arrays)', () => { - const hf = HyperFormula.buildFromArray([ - ['=TRANSPOSE(G1:I1)', null, null, 'x', null, null, 100, 200, 300], - [null, null, null, 'y'], - [null, null, null, 'z'], - ], {licenseKey: 'gpl-v3', arrayFunctionResultOverwritesData: true}) - - hf.copy({start: adr('A1'), end: adr('A1')}) // captures the VALUE 100, not =TRANSPOSE(...) - hf.paste(adr('D1')) - - expect(hf.getCellValue(adr('D1'))).toBe(100) // pasted value, not a spill - expect(hf.getCellValue(adr('D2'))).toBe('y') // occupant intact (no spill, no overwrite) - expect(hf.getCellValue(adr('D3'))).toBe('z') - - hf.destroy() - }) - - // Bugbot (PR #1714, round 1): overwriting an occupant recorded its previous value by calling - // `getCellValue`, which throws for a `ScalarFormulaVertex` that hasn't been computed yet -- the - // normal state of a sibling cell set earlier in the same batch()/suspendEvaluation() block, since - // evaluation only runs once the batch is committed. That throw aborted the whole operation before - // the spill landed (and could even corrupt graph state, since `batch()`'s catch handler calls - // `resumeEvaluation()` before rethrowing, and that call could itself throw against the half-applied - // exchange, masking the original error). Fixed by reading the occupant via the non-throwing - // `valueOrUndef()` accessor every `FormulaVertex` exposes, treating an uncomputed occupant as - // `EmptyValue`. - it('ON: array spilling over an occupant formula that is not yet computed (inside batch) does not throw', () => { - const hf = HyperFormula.buildFromArray([ - [0, 1, 10], - [null, null, 20], - ], {licenseKey: 'gpl-v3', arrayFunctionResultOverwritesData: true}) - - expect(() => { - hf.batch(() => { - // B1 becomes a formula whose value is not computed yet (batch suspends evaluation). - hf.setCellContents(adr('B1'), '=A1+1') - // A1's spill claims B1 while it is still uncomputed. - hf.setCellContents(adr('A1'), [['=TRANSPOSE(C1:C2)']]) - }) - }).not.toThrow() - - expect(hf.getCellValue(adr('A1'))).toBe(10) - expect(hf.getCellValue(adr('B1'))).toBe(20) - - hf.destroy() - }) - - // Bugbot (PR #1714, round 1): `setFormulaToCellFromCache` returns overwrite `ContentChanges`, but - // `rewriteAffectedArrays` (the structural caller used by doAddRows/doRemoveRows/doAddColumns/ - // doRemoveColumns to re-place arrays whose dependencies shifted) ignored that return value. When a - // structural insert/delete grows an array so it overwrites a previously-static cell under the - // overwrite flag, the stale value was never dropped from the column index, so MATCH/VLOOKUP could - // still match it. Fixed by applying the returned changes to the column index at this call site only - // (mirroring the existing `restoreCell` pattern), without threading extra undo entries through - // insert/delete -- that half is a documented, deliberately out-of-scope limitation. - it('ON + useColumnIndex: addColumns growing an array over a static cell drops the stale value from the column index', () => { - // A1 = TRANSPOSE(D1:E1) spills A1:A2 = 10,20. A3 = 99 (static, not yet overlapped). - // C1 = MATCH(99, A1:A3, 0) sits left of D1:E1 so the column insertion never shifts it. - const hf = HyperFormula.buildFromArray([ - ['=TRANSPOSE(D1:E1)', null, '=MATCH(99,A1:A3,0)', 10, 20], - [null], - [99], - ], {licenseKey: 'gpl-v3', arrayFunctionResultOverwritesData: true, useColumnIndex: true}) - - expect(hf.getCellValue(adr('A3'))).toBe(99) - expect(hf.getCellValue(adr('C1'))).toBe(3) // 99 found at row 3 - - // Insert a column inside D1:E1 (at E's position), extending the reference to D1:F1 and growing - // the array from 1x2 to 1x3 -- it now spills over A3, overwriting the static 99 there. - hf.addColumns(0, [4, 1]) - - expect(hf.getCellValue(adr('A3'))).toBe(20) // spilled value, 99 is gone - - const match99 = hf.getCellValue(adr('C1')) - expect(match99 instanceof DetailedCellError).toBe(true) // must be #N/A, not a stale match - expect((match99 as DetailedCellError).type).toBe(ErrorType.NA) - - hf.destroy() - }) -}) From 63e2137f2ac38a4765f717798f38a00d7f584116 Mon Sep 17 00:00:00 2001 From: Marcin Kordas Date: Fri, 28 Aug 2026 06:25:00 +0000 Subject: [PATCH 3/9] HF-305: put the changelog entry back under Unreleased The rebase onto develop surfaced this: the entry was written when the branch's base still had 3.4.0 open, so replaying it landed the line inside the released 3.4.0 section. Moved to [Unreleased], and given the PR link every neighbouring entry carries. Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 646c7ae7be..61813ca316 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), ## [Unreleased] +### Added + +- Added the `arrayFunctionResultOverwritesData` configuration option (default `false`). When enabled, an array function whose result spills onto occupied cells overwrites them instead of returning a `#SPILL!` error. This is an opt-in, destructive behavior; a collision with another array still yields `#SPILL!`. [#1714](https://github.com/handsontable/hyperformula/pull/1714) + ## [3.4.0] - 2026-08-10 ### Added @@ -16,7 +20,6 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), - Added a new function: `XIRR`. [#1701](https://github.com/handsontable/hyperformula/pull/1701) - Added the UNIQUE function. [#1708](https://github.com/handsontable/hyperformula/pull/1708) - Added the SORT function. [#1707](https://github.com/handsontable/hyperformula/pull/1707) -- Added the `arrayFunctionResultOverwritesData` configuration option (default `false`). When enabled, an array function whose result spills onto occupied cells overwrites them instead of returning a `#SPILL!` error. This is an opt-in, destructive behavior; a collision with another array still yields `#SPILL!`. - Added an Indonesian (Bahasa Indonesia) language pack. [#1674](https://github.com/handsontable/hyperformula/pull/1674) - Added a `stringifyCurrency` config option that lets you plug in a custom currency formatter for the `TEXT` function. [#1145](https://github.com/handsontable/hyperformula/issues/1145) From 3e2a099cb2d6ce55cf81c3ced9620725ae6bca2f Mon Sep 17 00:00:00 2001 From: marcin-kordas-hoc Date: Fri, 28 Aug 2026 12:34:55 +0000 Subject: [PATCH 4/9] HF-305: snapshot spill occupants only for formulas that actually spill The overwrite snapshot guarded on `width * height <= 1`, but placement (FormulaVertex.fromAst) treats every ref-flavored size as a non-spilling scalar via ArraySize.isScalar(). A bare range formula such as =C1:C3 therefore snapshotted neighbours it never overwrites, and undoing that edit spuriously restored them (re-entering formulas, re-rolling volatiles). Guard on size.isScalar() so the snapshot scope matches the placement scope, as the JSDoc already described. Co-Authored-By: Claude Fable 5 --- src/Operations.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Operations.ts b/src/Operations.ts index e83162793b..73e6df118e 100644 --- a/src/Operations.ts +++ b/src/Operations.ts @@ -673,7 +673,7 @@ export class Operations { * nor is reported as an occupant. */ private overwrittenOccupantAddresses(anchorAddress: SimpleCellAddress, size: ArraySize): SimpleCellAddress[] { - if (!this.dependencyGraph.config.arrayFunctionResultOverwritesData || size.width * size.height <= 1) { + if (!this.dependencyGraph.config.arrayFunctionResultOverwritesData || size.isScalar()) { return [] } From e410ed0f2bc715bc7e4f9088517a8a8d2ffe9bae Mon Sep 17 00:00:00 2001 From: marcin-kordas-hoc Date: Mon, 31 Aug 2026 07:29:40 +0000 Subject: [PATCH 5/9] HF-305: keep #SPILL! for build-time collisions and unrepresentable spill ranges The overwrite feature resolves a spill collision when the array formula is placed: exchangeOrAddFormulaVertex claims the whole spill range in the address mapping before evaluation, so isThereSpaceForArray is already true when the array is recomputed. The canOverwriteArrayResult bypass added to the Evaluator (and to the build-path setAddressMappingForArrayVertex) could therefore only ever fire for arrays whose overwrite never happened - an array blocked by an occupant at buildFromArray/updateConfig time, or an array whose spill range cannot be represented on the sheet (e.g. =2*(B:B) anchored outside row 1, where getRangeOrUndef() is undefined and overwriteWouldHitArray vacuously returns false). In both cases the array silently truncated to its anchor value instead of yielding #SPILL!. Remove the bypass from both sites so an array that does not own its spill range keeps #SPILL! exactly like with the flag off, and make canOverwriteArrayResult itself refuse unrepresentable ranges - overwrite mode bypasses occupancy collisions only, never a range that cannot be spanned at all. Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 2 +- src/DependencyGraph/DependencyGraph.ts | 14 +++++++++++++- src/Evaluator.ts | 8 +++++++- 3 files changed, 21 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 85fec93ed7..47a950411b 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/), ### Added -- Added the `arrayFunctionResultOverwritesData` configuration option (default `false`). When enabled, an array function whose result spills onto occupied cells overwrites them instead of returning a `#SPILL!` error. This is an opt-in, destructive behavior; a collision with another array still yields `#SPILL!`. [#1714](https://github.com/handsontable/hyperformula/pull/1714) +- Added the `arrayFunctionResultOverwritesData` configuration option (default `false`). When enabled, an array function whose result spills onto occupied cells overwrites them instead of returning a `#SPILL!` error. This is an opt-in, destructive behavior; a collision with another array, or a spill range that cannot be represented on the sheet, still yields `#SPILL!`. [#1714](https://github.com/handsontable/hyperformula/pull/1714) ### Fixed diff --git a/src/DependencyGraph/DependencyGraph.ts b/src/DependencyGraph/DependencyGraph.ts index 535a1407dd..f2b6e1c7db 100644 --- a/src/DependencyGraph/DependencyGraph.ts +++ b/src/DependencyGraph/DependencyGraph.ts @@ -487,8 +487,15 @@ export class DependencyGraph { * (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 { + if (arrayVertex.getRangeOrUndef() === undefined) { + return false + } return this.config.arrayFunctionResultOverwritesData && !this.overwriteWouldHitArray(arrayVertex) } @@ -1196,7 +1203,12 @@ export class DependencyGraph { } this.setArray(range, vertex) - if (!this.isThereSpaceForArray(vertex) && !this.canOverwriteArrayResult(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/src/Evaluator.ts b/src/Evaluator.ts index 2a132cb906..235736e828 100644 --- a/src/Evaluator.ts +++ b/src/Evaluator.ts @@ -133,7 +133,13 @@ export class Evaluator { private recomputeFormulaVertexValue(vertex: FormulaVertex): InterpreterValue { const address = vertex.getAddress(this.lazilyTransformingAstService) - if (vertex instanceof ArrayFormulaVertex && (vertex.array.size.isRef || (!this.dependencyGraph.isThereSpaceForArray(vertex) && !this.dependencyGraph.canOverwriteArrayResult(vertex)))) { + // 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 { const formula = vertex.getFormula(this.lazilyTransformingAstService) From fde93a5e7100d945172fca2f55cd089db7030620 Mon Sep 17 00:00:00 2001 From: marcin-kordas-hoc Date: Thu, 22 Oct 2026 17:35:12 +0000 Subject: [PATCH 6/9] docs: describe the overwrite bookkeeping without a ticket reference, shorten the changelog entry Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 2 +- src/Operations.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 47a950411b..5a71d8b7ad 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/), ### Added -- Added the `arrayFunctionResultOverwritesData` configuration option (default `false`). When enabled, an array function whose result spills onto occupied cells overwrites them instead of returning a `#SPILL!` error. This is an opt-in, destructive behavior; a collision with another array, or a spill range that cannot be represented on the sheet, still yields `#SPILL!`. [#1714](https://github.com/handsontable/hyperformula/pull/1714) +- 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 diff --git a/src/Operations.ts b/src/Operations.ts index 73e6df118e..7f52aa5b99 100644 --- a/src/Operations.ts +++ b/src/Operations.ts @@ -526,7 +526,7 @@ export class Operations { case ClipboardCellType.FORMULA: { // 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 (HF-305). The removeRows / version-restore + // 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()) @@ -925,7 +925,7 @@ export class Operations { } // 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 (HF-305 overwrite, outside this range) + // - 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 From 3f4d7e9afac39ca2abde833a897988a0d7cb2407 Mon Sep 17 00:00:00 2001 From: marcin-kordas-hoc Date: Thu, 22 Oct 2026 17:52:07 +0000 Subject: [PATCH 7/9] refactor: pass the spill range into overwriteWouldHitArray instead of re-checking it canOverwriteArrayResult already returns false for an unrepresentable range, so the second undefined check in overwriteWouldHitArray could never be reached. Co-Authored-By: Claude Opus 5.5 --- hyperformula/src/DependencyGraph/DependencyGraph.ts | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/hyperformula/src/DependencyGraph/DependencyGraph.ts b/hyperformula/src/DependencyGraph/DependencyGraph.ts index f2b6e1c7db..764a57c779 100644 --- a/hyperformula/src/DependencyGraph/DependencyGraph.ts +++ b/hyperformula/src/DependencyGraph/DependencyGraph.ts @@ -493,17 +493,11 @@ export class DependencyGraph { * never a range that cannot be spanned at all, so those keep `#SPILL!` too. */ public canOverwriteArrayResult(arrayVertex: ArrayFormulaVertex): boolean { - if (arrayVertex.getRangeOrUndef() === undefined) { - return false - } - return this.config.arrayFunctionResultOverwritesData && !this.overwriteWouldHitArray(arrayVertex) + const range = arrayVertex.getRangeOrUndef() + return range !== undefined && this.config.arrayFunctionResultOverwritesData && !this.overwriteWouldHitArray(arrayVertex, range) } - private overwriteWouldHitArray(arrayVertex: ArrayFormulaVertex): boolean { - const range = arrayVertex.getRangeOrUndef() - if (range === undefined) { - return false - } + 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) { From f90c5c60ba41ca640aacc6ada6d03c677c842578 Mon Sep 17 00:00:00 2001 From: marcin-kordas-hoc Date: Fri, 23 Oct 2026 00:38:42 +0000 Subject: [PATCH 8/9] HF-305: Restore the cells an array overwrote when added rows or columns are undone With arrayFunctionResultOverwritesData on, an array that grows because rows or columns were added inside the range it reads overwrites the cells it reaches. Undo now restores them: the add operation snapshots those cells, maps them back to the coordinates and references from before the added span, and undo writes them back right after removing the span. Undo of one setCellContents call now reverts its writes in reverse order, so a write into the spill range of an array entered earlier in the same call no longer brings that array formula back. This also applies with the option off. Row and column moves no longer overwrite data: a move adds the moved rows or columns before it removes them, and the array that grew for that moment cleared cells it does not cover once the move ends. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + docs/guide/arrays.md | 27 +++++ hyperformula/src/ConfigParams.ts | 28 ++--- hyperformula/src/CrudOperations.ts | 8 +- .../src/DependencyGraph/DependencyGraph.ts | 29 ++++- hyperformula/src/Operations.ts | 103 +++++++++++++++--- hyperformula/src/UndoRedo.ts | 23 +++- 7 files changed, 182 insertions(+), 37 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a2289f0adb..c3e1e7a974 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), ### 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..e5ea505d76 100644 --- a/docs/guide/arrays.md +++ b/docs/guide/arrays.md @@ -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/ConfigParams.ts b/hyperformula/src/ConfigParams.ts index 2cd2dbcac8..cd58a236ed 100644 --- a/hyperformula/src/ConfigParams.ts +++ b/hyperformula/src/ConfigParams.ts @@ -391,20 +391,20 @@ export interface ConfigParams { */ useArrayArithmetic: boolean, /** - * When set to `true`, an array function whose result spills onto already-occupied cells - * overwrites those cells (clearing their previous content, spilling the array, and rerouting - * any dependents to the spilled values) instead of returning a `#SPILL!` error. - * - * **Warning:** this is a destructive, opt-in behavior. Enabling it clears whatever data - * happens to sit in the spill range on the live sheet, so use it only when overwriting is the - * intended outcome. The cleared cells are restored by `undo()`. - * - * The overwrite is applied when the array formula is evaluated (e.g. via `setCellContents`). - * When set to `false`, an array spill onto an occupied cell yields `#SPILL!` and leaves the - * occupant intact (the default, Excel-compatible behavior). - * - * Even when set to `true`, a spill that would collide with *another array* still yields - * `#SPILL!` and leaves that array intact — overwrite mode never clobbers another array. + * 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 */ diff --git a/hyperformula/src/CrudOperations.ts b/hyperformula/src/CrudOperations.ts index b6fd98ac88..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 { diff --git a/hyperformula/src/DependencyGraph/DependencyGraph.ts b/hyperformula/src/DependencyGraph/DependencyGraph.ts index 764a57c779..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( @@ -494,7 +496,32 @@ export class DependencyGraph { */ public canOverwriteArrayResult(arrayVertex: ArrayFormulaVertex): boolean { const range = arrayVertex.getRangeOrUndef() - return range !== undefined && this.config.arrayFunctionResultOverwritesData && !this.overwriteWouldHitArray(arrayVertex, range) + 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 { diff --git a/hyperformula/src/Operations.ts b/hyperformula/src/Operations.ts index 6a92a6ea4a..bfe8dea9f5 100644 --- a/hyperformula/src/Operations.ts +++ b/hyperformula/src/Operations.ts @@ -205,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[] { @@ -313,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) @@ -332,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) @@ -673,7 +695,7 @@ export class Operations { * nor is reported as an occupant. */ private overwrittenOccupantAddresses(anchorAddress: SimpleCellAddress, size: ArraySize): SimpleCellAddress[] { - if (!this.dependencyGraph.config.arrayFunctionResultOverwritesData || size.isScalar()) { + if (!this.dependencyGraph.isOverwritingDataAllowed() || size.isScalar()) { return [] } @@ -902,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) @@ -915,10 +937,18 @@ 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 @@ -936,11 +966,36 @@ export class Operations { const ast = arrayVertex.getFormula(this.lazilyTransformingAstService) const address = arrayVertex.getAddress(this.lazilyTransformingAstService) const hash = this.parser.computeHashFromAst(ast) + 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)}] + }) } /** @@ -948,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) @@ -963,7 +1018,8 @@ export class Operations { this.lazilyTransformingAstService.addTransformation(transformation) }) - this.rewriteAffectedArrays(affectedArrays) + const overwrittenCells = this.rewriteAffectedArrays(affectedArrays) + return this.overwrittenCellsBeforeSpan(overwrittenCells, addedColumns) } /** @@ -1204,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 d2700df692..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() } @@ -621,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]) } } @@ -629,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)) { From 7aecb9fe4cd2cc66e834b62e380b36bf1a7aa175 Mon Sep 17 00:00:00 2001 From: marcin-kordas-hoc Date: Fri, 23 Oct 2026 00:55:58 +0000 Subject: [PATCH 9/9] HF-305: Point the array spill rule to the section on spilling onto occupied cells Co-Authored-By: Claude Opus 5.5 --- docs/guide/arrays.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/guide/arrays.md b/docs/guide/arrays.md index e5ea505d76..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