From cd94a6d7b42091158809156a7768f2cb0453f960 Mon Sep 17 00:00:00 2001 From: David Ardila Date: Wed, 14 Oct 2026 21:02:40 -0500 Subject: [PATCH] Recalculation: a spill anchor given a formula that reads its old spill area sees it empty When a dynamic array's anchor gets a new formula, the formula is evaluated while the old array is still laid out, so it can read the old spilled values. The anchor was then never evaluated again: it is left out of its own spill area's dependents, so as not to read its own array. - `A1` was `=SEQUENCE(3)` and becomes `=SUM(A2:A3)+1`: it showed 6 (2 + 3 + 1) with A2:A3 empty; recalculating from scratch gives 1. - `A1` was `=SEQUENCE(3)` and becomes `=SEQUENCE(1,A2+1)`: it read A2 as 2 and spilled over A1:C1 instead of showing 1. The anchor is now evaluated again when its old array is gone or partly freed. It is still left out when its new array covers the old one, where it would read its own array. Regression test in calc (`an_anchor_given_a_formula_that_reads_its_old_spill_area_sees_it_empty`: the new formula stops spilling, spills elsewhere, or spills over part of the old area); it fails without the fix. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 4 +++- crates/calc/src/recalc.rs | 10 +++++++++- crates/calc/src/tests.rs | 19 +++++++++++++++++++ 3 files changed, 31 insertions(+), 2 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 070552d07..931d22418 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~25 points of "ready for real work" and ~160–240 h away -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-12 · **Change:** minor (rows auto-fit oversized fonts on open and whole-row/column formatting) · **Target:** Microsoft Excel (Microsoft 365) +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-14 · **Change:** minor (a spill anchor given a formula that reads its old spill area sees it empty) · **Target:** Microsoft Excel (Microsoft 365) GridCraft aims at full Microsoft Excel parity, and at being better in places: faster, open (XLSX is the native format), fully scriptable by agents (MCP, control channel, CLI), and on the web. @@ -132,6 +132,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in | Date | What landed | |---|---| +| 2026-10-14 | Recalculation: a dynamic array's anchor given a new formula that reads its old spill area (`A1` was `=SEQUENCE(3)`, becomes `=SUM(A2:A3)+1` or `=SEQUENCE(1,A2+1)`) sees that area empty instead of the old values | | 2026-10-12 | Rows auto-fit oversized fonts: opening a file and whole-row/column formatting grow non-custom rows to their tallest font (#833) | | 2026-10-12 | Number formats: parsing a format code is linear in its length (long runs of `[`, `,` or `/` no longer take quadratic time), and an unclosed `[` is a literal when splitting sections as well as when tokenizing (#810) | | 2026-10-12 | Number formats: a fraction denominator with 65,536 zeros no longer crashes; format codes are capped at 255 characters like Excel (longer codes display as General, `TEXT` gives `#VALUE!`) (#807) | @@ -193,6 +194,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in | Date | Change | Summary | |---|---|---| +| 2026-10-14 | minor | Progress log: spill anchor reading its old spill area | | 2026-10-12 | minor | Rows auto-fit oversized fonts on open and whole-row/column formatting (#833); progress log row | | 2026-10-12 | minor | Progress log: multi-area row/column delete (#758) | | 2026-10-12 | trivial | Progress log: XLSX chart sheets kept on save (#727) | diff --git a/crates/calc/src/recalc.rs b/crates/calc/src/recalc.rs index b391821d7..0905b8fb2 100644 --- a/crates/calc/src/recalc.rs +++ b/crates/calc/src/recalc.rs @@ -1036,7 +1036,15 @@ impl Calc { if let Some(r) = changed_range { let mut deps = Vec::new(); self.graph.dependents_of_range(k.0, r, &mut deps); - spill_changes.extend(deps.into_iter().filter(|d| d != k)); + // Not the anchor itself (that would read its own array), unless its old array + // is gone or partly freed: its new formula may have read the old values, still + // laid out while it was evaluated. + let freed = match (old, sheet.spill_ranges.get(&k.1)) { + (Some(o), Some(n)) => !(n.contains(o.start) && n.contains(o.end)), + (_, None) => true, + (None, Some(_)) => false, + }; + spill_changes.extend(deps.into_iter().filter(|d| d != k || freed)); } if let Some(o) = old && !sheet.spill_ranges.get(&k.1).is_some_and(|n| n.contains(o.start) && n.contains(o.end)) diff --git a/crates/calc/src/tests.rs b/crates/calc/src/tests.rs index 196c57de7..924c2db66 100644 --- a/crates/calc/src/tests.rs +++ b/crates/calc/src/tests.rs @@ -543,6 +543,25 @@ fn spills() { assert_eq!(t.num("D2"), 40.0); } +#[test] +fn an_anchor_given_a_formula_that_reads_its_old_spill_area_sees_it_empty() { + // The new formula was evaluated while the old array was still laid out, and only the + // anchor's dependents were evaluated again once it was gone. + let mut t = T::new(); + t.set("A1", "=SEQUENCE(3)"); + assert_eq!(t.num("A3"), 3.0); + t.set("A1", "=SUM(A2:A3)+1"); + assert_eq!((t.num("A1"), t.get("A3")), (1.0, Value::Empty)); + // Still an array, now spilling elsewhere: it read A2 as 2 and spilled three columns. + t.set("A1", "=SEQUENCE(3)"); + t.set("A1", "=SEQUENCE(1,A2+1)"); + assert_eq!((t.num("A1"), t.get("B1"), t.get("A2")), (1.0, Value::Empty, Value::Empty)); + // A smaller array inside the old area: it read A3 as 3 and spilled over A1:A2. + t.set("A1", "=SEQUENCE(3)"); + t.set("A1", "=SEQUENCE(MAX(1,A3-1))"); + assert_eq!((t.num("A1"), t.get("A2"), t.get("A3")), (1.0, Value::Empty, Value::Empty)); +} + #[test] fn references_keep_their_size_past_the_used_range() { let mut t = T::new();