Description
Memory leak: LazilyTransformingAstService.transformations and UndoRedo.oldData grow unboundedly when undoLimit is 0
Description
When undoLimit is set to 0, two internal data structures grow without bound, causing memory leaks in long-running applications. The undoLimit configuration only controls the size of undoStack (via addUndoEntry), but does not prevent accumulation in transformations or oldData.
Root Cause
1. LazilyTransformingAstService.transformations (Critical)
transformations: FormulaTransformer[] grows without bound.
- Write path:
addTransformation() pushes to the array on every structural change (add/remove rows/columns, move cells).
- Cleanup: None. The array is only initialized to
[] in the constructor and is never truncated.
- Version coupling:
version() returns transformations.length, and both FormulaVertex.version and ColumnIndex.ValueIndex.version store indices into this array for lazy transformation application.
After each operation, recomputeIfDependencyGraphNeedsIt() triggers FormulaVertex.ensureRecentData() which consumes all pending transformations. Once consumed, old transformer objects are never needed again, but they remain in the array permanently.
2. UndoRedo.oldData (Critical)
oldData: Map<number, [SimpleCellAddress, string][]> grows without bound.
- Write path:
storeDataForVersion() is called from applyTransformations() for every irreversible transformation (RemoveRows, RemoveColumns, MoveCells, CombinedTransformer).
- Cleanup: None.
restoreOldDataFromVersion() only reads from the map — it never calls this.oldData.delete(version).
- Problem with
undoLimit=0: storeDataForVersion() has no awareness of undoLimit and continues accumulating data that can never be consumed (since undo is disabled).
3. ParserWithCaching cache (Minor)
cache: Map<string, CacheEntry> has no eviction mechanism. When formula references shift due to structural changes, new AST hashes are generated and cached indefinitely.
Reproduction
const { HyperFormula } = require('hyperformula');
const hf = HyperFormula.buildFromArray(
[
['=SUM(A2:A5)', '=SUM(B2:B5)'],
[1, 2],
[3, 4],
[5, 6],
[7, 8],
],
{ licenseKey: 'gpl-v3', undoLimit: 0 }
);
const ltas = hf._lazilyTransformingAstService;
console.log('=== Before operations ===');
console.log('transformations.length:', ltas.transformations.length);
console.log('oldData.size:', ltas.undoRedo.oldData.size);
console.log('cache.size:', ltas.parser.cache.cache.size);
const ITERATIONS = 1000;
for (let i = 0; i < ITERATIONS; i++) {
// Insert row at index 1 (INSIDE the SUM range, forces formula recalc)
hf.addRows(0, [1, 1]);
// Set values so the row is not empty
hf.setCellContents({ sheet: 0, col: 0, row: 1 }, [[10, 20]]);
// Remove the row (irreversible, formulas are dirty and get re-evaluated)
hf.removeRows(0, [1, 1]);
}
console.log('\n=== After ' + ITERATIONS + ' cycles ===');
console.log('transformations.length:', ltas.transformations.length);
console.log('oldData.size:', ltas.undoRedo.oldData.size);
console.log('cache.size:', ltas.parser.cache.cache.size);
// rebuildAndRecalculate forces all lazy transformations to be applied,
// which triggers storeDataForVersion for irreversible transformers
hf.rebuildAndRecalculate();
console.log('\n=== After rebuildAndRecalculate (old LTAS) ===');
console.log('transformations.length:', ltas.transformations.length);
console.log('oldData.size:', ltas.undoRedo.oldData.size);
console.log('cache.size:', ltas.parser.cache.cache.size);
// rebuildAndRecalculate creates a new LTAS, so the new one is clean
const newLtas = hf._lazilyTransformingAstService;
console.log('\n=== After rebuildAndRecalculate (new LTAS) ===');
console.log('transformations.length:', newLtas.transformations.length);
console.log('oldData.size:', newLtas.undoRedo.oldData.size);
console.log('cache.size:', newLtas.parser.cache.cache.size);
Output:
=== Before operations ===
transformations.length: 0
oldData.size: 0
cache.size: 1
=== After 1000 cycles ===
transformations.length: 2000
oldData.size: 0
cache.size: 1
=== After rebuildAndRecalculate (old LTAS) ===
transformations.length: 2000
oldData.size: 1000
cache.size: 2
=== After rebuildAndRecalculate (new LTAS) ===
transformations.length: 0
oldData.size: 0
cache.size: 1
Key observations:
transformations grows by 2 per iteration (one for addRows, one for removeRows) — 2000 consumed but never freed
oldData is 0 after the loop because lazy transformations on formulas outside the dirty range haven't been triggered yet. After rebuildAndRecalculate() forces evaluation, oldData jumps to 1000 (one entry per removeRows, which is irreversible)
rebuildAndRecalculate() creates a new LTAS internally, so the leaked data from the old instance lingers until GC
- The old LTAS with 2000 transformations and 1000 oldData entries is pure waste —
undoLimit=0 means this data will never be used for undo
Suggested Fix
Option 1: versionOffset + compaction for transformations
Introduce a versionOffset so the array can be truncated while maintaining correct version semantics:
private versionOffset: number = 0
public version(): number {
return this.versionOffset + this.transformations.length
}
public compact(): void {
// Call after forceApplyPostponedTransformations()
this.versionOffset += this.transformations.length
this.transformations = []
}
Option 2: Skip storeDataForVersion when undoLimit === 0
public storeDataForVersion(version: number, address: SimpleCellAddress, astHash: string) {
if (this.undoLimit === 0) return // No undo data needed
// ... existing logic
}
Option 3: Expose a public compact() / clearTransformations() API
At minimum, provide a method for users who have disabled undo to manually reclaim memory.
Environment
- HyperFormula version: 3.2.0
- Runtime: Node.js (server-side, long-running process)
- Configuration:
undoLimit: 0
Expected Behavior
When undoLimit is set to 0, consumed transformations and undo-only data structures (oldData) should not accumulate indefinitely, or there should be a public API to compact/clear them.
Video or screenshots
No response
Demo
https://stackblitz.com/edit/node-oqngupra?file=index.js
HyperFormula version
3.2.0
Your framework
NodeJS v24.12.0
Your environment
Ubuntu 22.04.4 LTS
Description
Memory leak:
LazilyTransformingAstService.transformationsandUndoRedo.oldDatagrow unboundedly whenundoLimitis 0Description
When
undoLimitis set to0, two internal data structures grow without bound, causing memory leaks in long-running applications. TheundoLimitconfiguration only controls the size ofundoStack(viaaddUndoEntry), but does not prevent accumulation intransformationsoroldData.Root Cause
1.
LazilyTransformingAstService.transformations(Critical)transformations: FormulaTransformer[]grows without bound.addTransformation()pushes to the array on every structural change (add/remove rows/columns, move cells).[]in the constructor and is never truncated.version()returnstransformations.length, and bothFormulaVertex.versionandColumnIndex.ValueIndex.versionstore indices into this array for lazy transformation application.After each operation,
recomputeIfDependencyGraphNeedsIt()triggersFormulaVertex.ensureRecentData()which consumes all pending transformations. Once consumed, old transformer objects are never needed again, but they remain in the array permanently.2.
UndoRedo.oldData(Critical)oldData: Map<number, [SimpleCellAddress, string][]>grows without bound.storeDataForVersion()is called fromapplyTransformations()for every irreversible transformation (RemoveRows,RemoveColumns,MoveCells,CombinedTransformer).restoreOldDataFromVersion()only reads from the map — it never callsthis.oldData.delete(version).undoLimit=0:storeDataForVersion()has no awareness ofundoLimitand continues accumulating data that can never be consumed (since undo is disabled).3.
ParserWithCachingcache (Minor)cache: Map<string, CacheEntry>has no eviction mechanism. When formula references shift due to structural changes, new AST hashes are generated and cached indefinitely.Reproduction
Output:
Key observations:
transformationsgrows by 2 per iteration (one foraddRows, one forremoveRows) — 2000 consumed but never freedoldDatais 0 after the loop because lazy transformations on formulas outside the dirty range haven't been triggered yet. AfterrebuildAndRecalculate()forces evaluation,oldDatajumps to 1000 (one entry perremoveRows, which is irreversible)rebuildAndRecalculate()creates a new LTAS internally, so the leaked data from the old instance lingers until GCundoLimit=0means this data will never be used for undoSuggested Fix
Option 1:
versionOffset+ compaction fortransformationsIntroduce a
versionOffsetso the array can be truncated while maintaining correct version semantics:Option 2: Skip
storeDataForVersionwhenundoLimit === 0Option 3: Expose a public
compact()/clearTransformations()APIAt minimum, provide a method for users who have disabled undo to manually reclaim memory.
Environment
undoLimit: 0Expected Behavior
When
undoLimitis set to0, consumed transformations and undo-only data structures (oldData) should not accumulate indefinitely, or there should be a public API to compact/clear them.Video or screenshots
No response
Demo
https://stackblitz.com/edit/node-oqngupra?file=index.js
HyperFormula version
3.2.0
Your framework
NodeJS v24.12.0
Your environment
Ubuntu 22.04.4 LTS