From ac7595975a973c47ef8c0dd5f804b15f44d67059 Mon Sep 17 00:00:00 2001 From: David Ardila Date: Wed, 14 Oct 2026 21:05:00 -0500 Subject: [PATCH] Recalculation: formulas reading an array that loses a spill conflict see #SPILL! When two dynamic arrays of one recalculation want the same empty cells, both look free while the pass runs, so formulas evaluated in that pass read both arrays. The one laid out second becomes #SPILL! at write-back, but nothing evaluated its readers again, and they kept its values. With B1 `=SEQUENCE(3)` and A2 `=SEQUENCE(1,3)` (both want B2), a full recalculation (opening a file, Calculate Now) leaves A2 at #SPILL!, yet `=A2*10` showed 10, `=SUM(A2#)` 6, and `=C2` and `=SUM(C2:C3)` 3, though C2 is empty. Entering the same formulas one at a time gave the right values. An array blocked at write-back now sends the formulas that read its anchor or the area it would have spilled into to the next round, where they read #SPILL! and empty cells. Regression test in calc (`formulas_reading_an_array_that_loses_a_spill_conflict_read_spill_errors`); it fails without the fix. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 4 +++- crates/calc/src/recalc.rs | 7 +++++++ crates/calc/src/tests.rs | 19 +++++++++++++++++++ 3 files changed, 29 insertions(+), 1 deletion(-) diff --git a/ROADMAP.md b/ROADMAP.md index 070552d07..3d021e17e 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 (formulas reading an array that loses a spill conflict see `#SPILL!`) · **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: when two arrays of one recalculation want the same empty cells, the formulas that read the one laid out second (at its anchor or over the cells it would have spilled into) read `#SPILL!` and empty cells instead of its 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: formulas reading an array that loses a spill conflict | | 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..2cd7b0b87 100644 --- a/crates/calc/src/recalc.rs +++ b/crates/calc/src/recalc.rs @@ -1023,6 +1023,13 @@ impl Calc { if let Some(cell) = sheet.cells.get_mut(k.1) { cell.value = Value::Error(CellError::Spill); } + // Not blocked while the pass ran (it would have been #SPILL! there), so + // blocked by another array of this pass: the formulas that read this one, + // at its anchor or over the area it would have spilled into, read #SPILL! + // or empty cells now. + let mut deps = Vec::new(); + self.graph.dependents_of_range(k.0, range, &mut deps); + spill_changes.extend(deps.into_iter().filter(|d| d != k)); } else { for (i, c) in range.iter().enumerate() { if c != k.1 { diff --git a/crates/calc/src/tests.rs b/crates/calc/src/tests.rs index 196c57de7..6e0b7b616 100644 --- a/crates/calc/src/tests.rs +++ b/crates/calc/src/tests.rs @@ -368,6 +368,25 @@ fn spill_into_the_old_area_of_a_blocked_neighbour() { assert_eq!([t.num("C8"), t.num("D8"), t.num("C9"), t.num("D9")], [1.0, 2.0, 3.0, 4.0]); } +#[test] +fn formulas_reading_an_array_that_loses_a_spill_conflict_read_spill_errors() { + // Two arrays of one recalculation want the same empty cell: the one laid out second is + // #SPILL!, and the formulas that read it in that pass (at its anchor, through `A2#`, or over + // the cells it would have spilled into) kept its values. + let mut t = T::new(); + t.set("B1", "=SEQUENCE(3)"); + t.set("A2", "=SEQUENCE(1,3)"); + t.set("D2", "=A2*10"); + t.set("E2", "=C2"); + t.set("F2", "=SUM(A2#)"); + t.set("G2", "=SUM(C2:C3)"); + // Both arrays recalculated together: B1 is laid out first and takes B2. + t.calc.recalc_all(&mut t.wb); + let spill = Value::Error(CellError::Spill); + assert_eq!((t.num("B2"), t.get("A2"), t.get("D2")), (2.0, spill.clone(), spill)); + assert_eq!((t.get("C2"), t.num("E2"), t.get("F2"), t.num("G2")), (Value::Empty, 0.0, Value::Error(CellError::Ref), 0.0)); +} + #[test] fn arrays_do_not_spill_in_tables() { // Excel: spilled array formulas aren't supported in tables.