Skip to content

fix(react): an invalid derivations update is rejected, not fatal - #550

Merged
blove merged 6 commits into
mainfrom
blove/followups-post-sp3a
Aug 31, 2026
Merged

blove merged 6 commits into
mainfrom
blove/followups-post-sp3a

Conversation

@blove

@blove blove commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

What this is

An invalid aggregate reaching the row model after mount used to destroy a live grid. The compiler's CompiledQueryValidationError is thrown synchronously from setDerivations inside a React layout effect, so it escaped the commit and React unmounted the subtree — measured as group rows 1 → 0, container 0 bytes.

It is now a rejected write: the row model keeps the derivations it already had, the grid stays interactive and keeps painting the values it was showing, and a later valid update still lands.

Spec: docs/superpowers/specs/2026-08-27-invalid-derivations-are-rejected-design.md. Plan: docs/superpowers/plans/2026-08-27-invalid-derivations-are-rejected.md.

The ticket understated the hazard

This was filed against setColumnAggregate, whose values grid-core stores uninterpreted. Measuring first showed that framing was too narrow — both doors into the derivations seam were fatal:

Path Before
Invalid aggregate on the columns prop, at mount throws; grid never renders
Invalid aggregate on the columns prop, on update throws; group rows 1 → 0, container 0 bytes
Invalid value via setColumnAggregate throws; same destruction

The prop door needs no pane, no grouping state, and no knowledge of this feature. So the guard went on the seam — one try/catch around the synchronous setDerivations call — rather than on the setter.

Mount still throws, deliberately. There is no running grid to protect and a hard error surfaces a config bug at its cheapest moment. That asymmetry is pinned, so a future "make it consistent" change has to argue with a test.

Two implementation details worth knowing

The guard cannot import the error class. It is not re-exported from @pretable/core, and packages/react has no runtime dependency on row-model — so detection is error.name === "CompiledQueryValidationError", which is also sturdier than instanceof across duplicated module instances. Only that name is caught; every other error rethrows, because a blanket catch inside a layout effect would hide exactly the class of fault this seam produces.

warnOnce latches — one fire disarms that key for the session, which has already silenced a real check in this codebase. The key is columnId + an index-stripped path + detail. Both directions are pinned: two faults differing only in property warn twice; the same fault after the column moves warns once.

What the reviews caught

Every task ran implementer → spec review → quality review with mutation testing.

  • The fix was one-sided: setQuery sits in the same effect and now runs where the throw used to pre-empt it. Reconciliation is no longer forced on a rejected update, and that is pinned.
  • The no-rollback invariant was stated as load-bearing in a comment and a commit message, yet a rollback-equivalent mutation left every test green. It now has a recompile-counting test.
  • The warning dropped path, the only field saying where — and for column-invariant faults (property getter threw while compiling and siblings) columnId and detail are identical across structurally different faults, so the second would have been latched away.

Three premises were disproved by measurement, two of them mine. I claimed a rename in row-model would "silently disarm the guard while every existing test passes" — false in both halves: a rename alone fails ten other tests, and the only mutation where the pin fails alone is a coordinated rename, where the guard is correct. The pin's docstring now carries a measured signature table instead of that invented mechanism. A reviewer's repro for the setQuery interaction was also misattributed — it changes the query prop, so controlledQueryChanged drives that call independently — and the implementer caught it rather than letting my framing stand.

Verification

Gate Result
react · website · grid-core · row-model · core · ui 1658 · 607 · 169 · 719 · 9 · 99 — all green
typecheck · lint · prettier · build · api:check clean; no report change (adds no public surface)
Hand probe, outside the suite "30" → invalid prop → still "30", 2 group rows, 10912 bytes → valid count"2"

The hand probe asserts the grid is still rendering its previous value, not merely that nothing threw — a destroyed grid renders nothing and would satisfy a no-throw check.

Still open, deliberately

Invalid filters or sort reaching setQuery is the sibling hazard on this same seam and is not fixed here. Its reject semantics are a different question — a query is consumer-controlled state with an onQueryChange round trip — so it is filed rather than bundled. This PR makes no claim about it.

🤖 Generated with Claude Code

blove and others added 6 commits August 30, 2026 20:22
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`rowModel.setDerivations` throws `CompiledQueryValidationError` from inside a
layout effect, so an invalid `aggregate` arriving after mount escaped the
commit and React unmounted the subtree. Both doors were fatal: the `columns`
prop and `setColumnAggregate`.

The update is now a rejected write — the row model keeps the derivations it
already had and the grid stays interactive. The catch is narrow (matched by
`name`, since the class is not reachable from this package's runtime deps and
`instanceof` does not survive duplicated module instances); everything else
rethrows.

A rejection is deliberately NOT a derivations change for the query
reconciliation that follows: nothing moved in the row model, and the forced
re-apply the guard newly exposes runs through an unguarded `setQuery`.

The rejected identity stays in `lastDerivations.current` so the failed update
is attempted once rather than recompiled every render; recovery is unaffected
because a later valid array is a new identity.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A rejected derivations write was silent: the grid kept painting the
derivations it already had, so a consumer saw a stale aggregate and
nothing else. Report it through `warnOnce`.

The key is `columnId` + index-stripped `path` + `detail`, never a
constant. `warnOnce` latches, so a constant key disarms the check for
the rest of the process. The raw `path` fails both ways: it is
value-blind (two different bad aggregates at one position collide) and
index-bearing (`derivations[1].aggregate`), so it re-fires on a mere
reorder. Stripping `[n]` keeps the part naming the property and drops
the part naming the position — which matters because details like
`property getter threw while compiling` are column-invariant and
position-only, so `columnId` + `detail` alone cannot tell two such
faults apart. The message carries the full `path`, since that is the
only field saying where.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…unt's fail-fast

The guard in use-pretable.ts matches `error.name === "CompiledQueryValidationError"`
across a package boundary — the class lives in row-model, is not re-exported
from @pretable/core, and react has no runtime dependency on row-model, so the
bare string is the only handle available.

The new pin does NOT add detection. `invalid-derivations-rejected.test.tsx`
already detects a disarmed guard and cannot be fooled: its tests assert the grid
survives, so a guard that stops catching rethrows into a layout effect, unmounts
the subtree, and takes nine of them red regardless of what the error is called.
What that file cannot say is which side of the boundary moved. This pin is the
second coordinate, and its failure signature is only meaningful read alongside
those nine.

Measured over three mutations:

  M1  guard literal only (disarmed)     9 update-path fail; pin green
  M2  class `name` only (disarmed)      9 update-path + mount pin + pin fail
  M3  coordinated rename (still armed)  pin + mount pin fail; 9 update-path pass

M3 is the case that shapes the docstring: a pin failing ALONE means the guard is
armed and correct and the pin is stale, which is the opposite of what a
"failure here means disarmed" note would have told a maintainer on a red build.

Pin B records decision 6: mount stays fail-fast on purpose, since there is no
running grid to protect and a hard error is the cheapest surfacing of a config
bug.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Also runs prettier over the design spec: the table and two emphasis
spans were unformatted, and `pnpm format` (a `prettier --check`) exited
1 on them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@vercel

vercel Bot commented Aug 31, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
pretable Ignored Ignored Aug 31, 2026 3:23am

Request Review

@github-actions

Copy link
Copy Markdown
Contributor

Vercel preview ready

Preview: https://pretable-80rg3odnh-cacheplane.vercel.app
Commit: 8907f1fec7911002c4840c6b354dc8e5138864ac

Updated automatically by the deploy-preview job.

@blove
blove merged commit 83090c4 into main Aug 31, 2026
21 checks passed
@blove
blove deleted the blove/followups-post-sp3a branch August 31, 2026 03:38
blove added a commit that referenced this pull request Aug 31, 2026
* docs: spec and plan for rejecting an invalid query

The derivations spec deferred this as a different design question. Measuring
showed it is smaller: only the query PROP on update is fatal (rows 3 to 0,
bytes 8702 to 0). Both grid.setQuery paths are already correct — controlled
reports intent and stops, uncontrolled throws catchably to its caller — so
they get pins, not changes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(react): an invalid query update is rejected, not fatal

An invalid `query` arriving on the prop after mount threw out of the layout
effect that reconciles it, escaping the commit and unmounting the live grid
(measured on 59835a4: rows 3 to 0, bytes 8702 to 0). It is now a rejected
write — the row model keeps the query it already had, the grid stays
interactive, and the fault is reported once per distinct fault via warnOnce.

The guard lives inside `applyQuery`, not around its call sites, because there
are two: the closure runs synchronously when no derivations transition is
pending and from a `.then()` callback when one is. Only the synchronous path
showed the fatal signature; both are covered and both are tested.

The mechanism itself is extracted as `reportRejectedWrite` and shared with the
derivations guard from #550, which was byte-identical in executable logic — the
name check, the rethrow, the cast, the three field reads and the warnOnce key
construction — so a fix to one no longer misses the other. It is parameterized
on a SET of accepted error names rather than one literal, because `setRows`,
the remaining unguarded call on this seam, would need two.

Two behaviours that were already correct are pinned, not changed: an invalid
query at mount still throws, and an uncontrolled `grid.setQuery` still throws
synchronously to its caller.

#550's comment claiming an open unguarded-`setQuery` hazard is now false and is
rewritten in both places it appears; the error-name pin's header and triage
table are rescoped from one guard to two.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* chore: changeset for rejected query updates

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant