Recalc

104,000 logical cells on a hand-rolled windowed grid, driven by a formula engine written from scratch — tokenizer, Pratt parser, dependency graph, minimal-dirty-set recompute. Type a formula, insert a row and watch every reference rewrite itself, build a circular reference on purpose. Then press F4 and the sheet leaves: what arrives is the engine's own dependency graph, with the last edit's dirty set highlighted and the exact topological order the recompute used replayed across it.

Generated data — every figure in this model comes from a seed, not from a real business.

—
#
logical cells
104,000
populated cells
measuring
dependency edges
measuring
DOM cells mounted
measuring
boot
measuring
last edit recomputed
—
recompute
—
last structural edit
—
saved
—
selection
—

The HUD samples the route's real frame loop: it runs while you scroll or type, stops after half a second of idle, and “measuring” means no loop is running.

Undo history is in memory only — 0 of 100 steps, not persisted across reloads.

A cell holding a parse error is not saved: its text never parses, and the restore refuses any source that does not — so it is left out rather than allowed to discard the whole sheet. Everything else comes back. A formula is refused at the moment you commit it if the sheet's own way of writing it runs past 256 characters — typing .5 stores 0.5, so what is written can be longer than what is typed — because that is the length the restore enforces, and a refusal you can see beats an edit that disappears on reload.

FPS
measuring
ms/frame p95
measuring
KB transfer
—

How this is built

The diagram is the engine

Everything below is generated. Twelve cells are committed with the same assignCell the grid uses, A1 is changed from 8 to 5, and the recompute that answers that edit hands its report to buildGraph — the function the F4 view calls. The result is drawn by the same node and edge components the route draws with, in a frame that cannot be clicked. No coordinate in this figure was written by a person: 12 node positions and 15 edge paths come out of the layout.

layer 1layer 2layer 3layer 4layer 5layer 6A15A23B1101B230C1133C2352D119.54E1145F1266G1127H1528I171.59
Layers run down, so an arrow always points from a cell to something that reads it. The badge on a cell is its position in report.order — the sequence the recompute actually used, not a redrawing of it. A1 carries no badge because it is a literal: the edit wrote its value, and only the 9 formulas below it had to be evaluated.

The sheet this is a picture of

  • A155
  • A233
  • B1=A1*210
  • C1=B1+A213
  • D1=ROUND(C1*1.5,2)19.5
  • E1=C1+114
  • F1=C1*226
  • G1=C1-112
  • H1=SUM(E1:G1)52
  • I1=H1+D171.5
  • B2=A2*1030
  • C2=B2+A135

Editing A1 makes 9 of those 10 formulas dirty, and the recompute evaluated them in exactly this order: B1 → C2 → C1 → D1 → E1 → F1 → G1 → H1 → I1. Read it against the picture: nothing is evaluated before something it reads, and I1 is last because it is the only cell that waits on both ends of the fan-out.

At the sheet's real size the same call is bounded rather than complete: the view draws at most 400 nodes and takes at most 6 precedents per cell as context, then says how many cells it is not drawing. Twelve cells fit under both, which is why this figure shows the whole graph and the route usually shows a slice of one.

Recompute the minimal dirty set, not the sheet

Decision
An edit walks the reverse-edge map for everything reachable from the cell, orders that induced subgraph with Kahn's algorithm, and evaluates it once. On the shipped model that is about 623 cells out of 13,226 formulas.
Why
Measured in the browser: 2.4 ms median for that edit — 4.6 ms at the 95th percentile, and 0.83 ms for the same call with no browser around it — against 21.5 ms for the full pass over all 13,226 formulas, which is what a memoised implementation degenerates to. But speed is the smaller half of the argument. The minimal design produces a dirty set and an evaluation order as by-products, and those two things ARE the flip view: "these 623 cells, in this order" is something a full recompute would have to fabricate. It also scales with the size of the edit rather than the size of the sheet, which is a property a viewer can feel by typing.
Consequence
The reverse-edge map has to be correct at all times, so every commit relinks the cell and every structural edit rebuilds the map from scratch. That cost lands on the structural path, and it is measured there rather than hidden.
Rejected
Full recompute with memoisation: simpler, and not a strawman at these sizes — it is under two frames — but it has nothing to draw and it gets slower as the sheet grows rather than as the edit grows. Dirty flags without a topological order: evaluates cells before their precedents and needs a fixed-point loop to converge, which is both slower and unable to state an order. A worker thread: a few milliseconds does not need one, and it would put the engine out of reach of the render that has to visualise it.

Windowing by arithmetic, with no virtualization library

Decision
Every row is exactly 24 px and every column exactly 96 px, so the visible window is floor(scrollTop / 24) and a count, with 2 rows and 2 columns of overscan. The render set is that window plus the focused cell plus the cell being edited, and nothing is ever measured to find it.
Why
Measured on the production build: 276 cells in the DOM at the top of the sheet, 336 mid-scroll, 312 at the bottom and 276 while a cell is open for editing — against 104,000 logical cells and a ceiling of 1,200. Scrolling holds 120 fps with a p95 frame of 10 ms.
Consequence
Column resize is a non-goal because it would cost all of this. It also decides scroll anchoring: CSS `overflow-anchor` is supported in Chrome, Edge and Firefox and absent in Safari, so the scroller switches it off where it exists and the arithmetic — move the offset by exactly one 24 px row — is the anchor in every engine.
Rejected
@tanstack/react-virtual, react-window and react-virtuoso. They buy dynamic measurement, which fixed geometry does not need, and they take ownership of the scroll element that also has to host sticky headers, the anchoring correction above, and a render set that always contains the edited cell.

References are rewritten as an AST transform, and undo records the difference

Decision
Inserting or deleting a line walks every formula's syntax tree in the original coordinates, shifting refs and resizing ranges, then re-keys the sheet and rebuilds the reverse edges. A delete's undo record holds the deleted line's own cells and the formulas whose references die — nothing else.
Why
Counted at build time on the shipped model: deleting row 3 moves the references of 13,181 formulas, and the command records 26 removed cells and 23 broken ones — 1,006 bytes, against 375,619 for a snapshot of the same sheet. That is 373× smaller, and the reason is not compression: a shift is mechanically invertible, so the 13,181 formulas that merely moved are re-derived on undo instead of stored.
Consequence
The rewrite is the expensive edit, and it is the one place this route is over budget. Inserting at row 2 — the worst case, because every formula below it moves — measured 38–70 ms in the browser across eight runs, of which 18–39 ms is the rewrite and the rest the full recompute that follows it. That is several frames, not one, and it is stated rather than rounded: the readout shows the last structural edit and its rewrite half, every time. Scrolling and typing stay inside a frame; changing the shape of the sheet does not.
Rejected
A snapshot per edit: simple, and orders of magnitude larger. Rewriting formula text with a regular expression: the classic way this feature ships broken, because a formula is a language and `A10` is not a substring problem. The AST transform is pinned by a build-time invariant that inserts a line, deletes it again, and asserts the sheet's fingerprint is unchanged.

Longest-path layering and exactly one barycentre sweep

Decision
The graph view lays nodes out by longest path from the sources — computed with an explicit queue, because the model's running-total column links all 600 data rows into one chain and a recursive walk would run out of stack — then reorders each band once by the mean position of its precedents in the band above.
Why
Counted at build time on two graphs this route really draws. Editing A300, an input cell in the middle of the model: 400 nodes over 323 bands, up to 15 wide, and 201 edge crossings before the sweep against 131 after — 35% fewer for one pass. Editing F12, a cell partway along a row chain: 400 nodes over 377 bands, never more than 4 wide, and 0 crossings before and after — a band holding one node has nothing to uncross. That is the honest shape of this model's graphs, and it is the whole measurement behind the choice: one pass earns its place on the wide graph, and a second has no measurement behind it at all.
Consequence
The view is bounded to 400 nodes and takes at most 6 precedents per cell as context, because a single column total reads 600 cells and would otherwise fill the picture with its own inputs. When it truncates it states how many cells it is not drawing — on the F12 edit above, 952.
Rejected
dagre and elkjs, either of which is larger than this whole route, for a graph that is capped at 400 nodes and lays out inside a frame. d3-hierarchy, already installed for the org-chart route: a dependency graph is not a hierarchy, and a cell has many precedents, so it cannot lay this out at all.

No dependencies, because the dependencies are the subject

Decision
This route adds nothing to package.json. The tokenizer, the Pratt parser, the evaluator, the dependency graph, the recompute, the cycle classification, the reference rewriting, the windowing and the graph layout are 2,547 lines of engine and 2,851 lines of interface in this repository.
Why
An imported formula engine has no data structure this route would be entitled to draw, and that is the whole experiment. The sizes are the argument that writing it was reasonable: the parser and its inverse are 471 lines, the recompute and the graph 396, the layered layout 356, the windowed grid 1299. The whole route transfers 208.5 KB on a cold load, which is the number the HUD counts for itself.
Consequence
There is no test runner in this repository, so correctness is pinned by 886 lines of invariants that run at module scope during the production build: parser round-trip and precedence, error propagation, dirty-set minimality and topological validity, command apply-then-invert across every kind, reference rewriting, cycle classification, and the layout's own edges. Break any one of them and `pnpm build` fails with a sentence naming it.
Rejected
hyperformula, formulajs and fast-formula-parser for the engine; @tanstack/react-virtual for the grid; dagre and elkjs for the layout. Also d3-hierarchy, zod and motion, all three already installed for other routes here and all three deliberately unused: the payload is validated by re-parsing every formula with the shipped parser, which is the only check that matters and one no schema library can perform.