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 <noreply@anthropic.com>
This commit is contained in:
David Ardila
2026-10-14 21:05:37 -05:00
co-authored by Claude Opus 5.5
parent d9d09f0baa
commit cd94a6d7b4
3 changed files with 31 additions and 2 deletions
+3 -1
View File
@@ -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) |
+9 -1
View File
@@ -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))
+19
View File
@@ -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();