Skip to content

fix(grid-core): stop an evicted selection painting rows nobody selected - #460

Merged
blove merged 2 commits into
mainfrom
blove/eviction-p0-fixes
Aug 17, 2026
Merged

fix(grid-core): stop an evicted selection painting rows nobody selected#460
blove merged 2 commits into
mainfrom
blove/eviction-p0-fixes

Conversation

@blove

@blove blove commented Aug 17, 2026

Copy link
Copy Markdown
Contributor

Two publicly-reachable correctness bugs found by an adversarial audit of the eviction work. Both contradicted published promises.

Bug 1 — an evicted selection painted rows the reader never selected

Reproduced through <PretableSurface> with the full honesty gate passing. Select rows 1–8, evict both endpoints, the server prepends five rows under the same datasetKey — correct per our own docs, since the key identifies a query — then scroll back:

[{"rowId":"new-0","selected":false},{"rowId":"new-1","selected":true},
 {"rowId":"new-2","selected":true},{"rowId":"new-3","selected":true},
 {"rowId":"new-4","selected":true},{"rowId":"row-0","selected":true}]

Five rows painted selected; four did not exist when the user selected. The rows actually selected painted nothing.

datasetKey identifies the query, not the population. An insert or delete upstream silently re-fills the remembered positions with different rows.

The fix: a population fingerprint. resultMeta.total.count is stamped onto the span beside datasetKey, and onto the window as a required field — the gate cannot pass without an exact total, so making it optional would only be a way to fail open by omission. spanReadableInWindow now requires key match and size match, failing closed on either.

What it does not catch, stated plainly rather than papered over: a mutation leaving the size identical — an insert and a delete in the same revision, or an in-place replacement. Pinned as an explicit KNOWN GAP test so it goes red the day a consumer-supplied population token exists.

Containment in the residual case still answers from the remembered span, reporting verified: false. Refusing all remembered-endpoint painting was considered and rejected: it breaks the ordinary partial-return window — selection 1–8 with the window at [4,14) would stop painting rows 4–7 — converting an over-paint bug into an under-paint bug.

A silent regression this nearly caused: a deletion is a population change, so a strict comparison would have refused every span the instant narrowDeletedEndpoints had something to narrow. The comparison now allows a span whose total is short by exactly the deletions it has proven, and re-stamps at the post-deletion size. A revision that also inserted, or deleted a row it could not prove, does not add up and is refused.

Bug 2 — one closed-gate revision permanently destroyed the selection and the cursor

A single render where total is briefly {kind:"estimate"} — an in-flight count query — dropped the ranges irrecoverably and emptied the cursor. They did not return when the gate reopened. processing.sort: "engine" for one revision did the same.

Dropping a range because the gate closed is the engine asserting the rows are gone — precisely the claim it cannot make without a window. The project's own fail-closed logic says the opposite: absent proof of deletion, retain.

The fix: a new windowed fact on the eviction context — does the consumer publish resultMeta.window at all — derived per render and never latched. It deliberately does not ride the honesty gate: whether the consumer serves a window is a fact about the consumer, not about this render. Mutating it to windowSpacers !== null reddens the tests.

  • windowed: false → local mode, prune, byte-for-byte as before
  • windowed: true + window: null → cannot verify, retain

getSelectionWindow became getWindowing, returning both facts from one read so they cannot describe different instants — the same skew class the observed pairing already guards against. evictionWindowUnknown is shared by selection and focus so they cannot drift.

A real behaviour change worth knowing

windowed: true with a permanently shut gate — grouping always on, or a total that is never exact — now means selection and cursor never prune on absence. That is the fail-closed direction and no test regressed, but a genuine deletion in those grids will no longer clear from the selection. The engine cannot distinguish it from an eviction, and those consumers genuinely are windowing.

Docs corrected

  • lifecycle.mdx"What it buys is never painting a row the reader did not select" was false for datasetKey alone. Replaced with what the key does and does not answer, the total's role, and the residual gap.
  • eviction.mdx — the exact total is now named as load-bearing for selection; a new "does not do" item states the same-size gap plainly, with verified: false as the signal; and a paragraph on gate closure holding rather than discarding.

Proven

Mutation Reddened
spanReadableInWindow compares datasetKey only react paints nothing rather than rows the reader never selected; grid-core an insert upstream of an evicted span refuses it, and recovers later
disable the selection retain-on-unknown guard 2 react tests (estimated total, engine sort) + grid-core a shut honesty gate retains, while local mode still prunes
disable the focus retain-on-unknown guard same 2 react tests + grid-core a shut honesty gate holds the cursor, while local mode still re-seats
windowed: windowStart !== undefinedwindowSpacers !== null 2 failed
datasetTotal: matchingTotal.count0 react paints nothing…

Local mode is proven unchanged by running one grid-core fixture both ways — windowed: true retains and returns the identical object; windowed: false and a context saying nothing both prune — plus a React control where rows genuinely vanish from an unwindowed grid and both selection and cursor go.

A vacuous fixture caught during the work: the first Bug-1 test read data-pretable-selected on the row element, which is only true for a range spanning every drawn column. It passed vacuously while the control failed. The tests now read the cell attribute and say why.

Verification, against baselines measured on origin/main first

baseline after
grid-core 120 124
layout-core 98 98
react 1208 1216
app-bench 155
playwright 28
website (docs guards) 554

typecheck, lint, prettier clean repo-wide. pnpm buildpnpm apipnpm api:check in that order, all clean. Forgotten-exports guard 5/5.

Needs a human decision

  1. Whether to add a consumer-supplied population token, which would close the equal-insert-and-delete gap that a size comparison cannot see.
  2. Whether indexedRangeContainsCell should become tri-state, so an unconfirmed span paints distinctly rather than as ordinary selection. That has UI and aria consequences beyond this fix.

🤖 Generated with Claude Code

blove and others added 2 commits August 16, 2026 19:59
… population

`datasetKey` identifies the QUERY, not the population — deliberately, and
`lifecycle.mdx` tells consumers to keep it stable while they page within one
result. So an insert or a delete made upstream of a selection whose own rows
are unloaded arrives with the key unchanged and silently re-fills the
remembered dataset positions with different rows.

Reproduced through `<PretableSurface>` with the honesty gate fully passing:
`row-1..row-8` selected, both endpoints evicted, five rows prepended to the
same result, and the returning window painted five rows selected — four of
which had not existed when the user selected — while the eight they did choose
painted nothing. `indexedRangeContainsCell` returns a bare boolean with no
`verified` channel, so it painted unqualified while the summary said
`verified: false`.

A span now records the population's SIZE alongside its key, taken from the
exact `resultMeta.total.count` the gate already demands, and a mismatch fails
closed exactly as a key mismatch does: nothing paints from it, the count
contributes nothing, and `verified` is false until a window covering both
endpoints re-measures the span in the new coordinates. Proven closed AND
recoverable, in grid-core and through the surface.

A proven deletion is the one allowance. A deletion IS a population change, so
the strict comparison would refuse every span the instant endpoint narrowing
had something to narrow; `narrowDeletedEndpoints` may read a span whose total
is short by exactly the rows it has proven gone, and re-stamps at the new size.
A revision that also inserted, or deleted a third row elsewhere, does not add
up and is refused.

`datasetTotal` is REQUIRED on the window, not optional like `datasetKey`: the
gate cannot pass without an exact total, so an optional field would only be a
way to fail open by omission. It is optional on the SPAN, where a consumer may
echo one back through `state.selection`, and absent there means refused.

What this does not catch is a change that leaves the size identical — an insert
and a delete in the same revision. `eviction.mdx` and `lifecycle.mdx` now say
that instead of promising "never painting a row the reader did not select", and
a `KNOWN GAP` test pins the residual behaviour so the day a population token
exists it goes red.

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

One revision on which `resultMeta.total` reports `{kind: "estimate"}` — an
in-flight count query, or a backend that stops counting past 10k — or one
revision of `processing.sort: "engine"`, was enough to destroy an evicted
selection AND the cursor permanently. Restoring the exact total afterwards
brought neither back: the range had already been dropped, span and all, and
`reconcileIndexedFocus` had already emptied the cursor. Reproduced through the
public API; uncontrolled consumers only, because a controlled one is
accidentally immune — the `state.selection` echo re-supplies on the next render
exactly what the engine threw away.

Every input to that gate is a transient property of a single render. Dropping a
range because it shut is the engine asserting the rows are gone, which is a
claim it cannot make without a window. The project's own fail-closed rule says
the opposite: absent proof of deletion, retain.

A null window was two situations sharing one representation, and they want
opposite answers:

  - LOCAL MODE — the consumer hands over the whole result every render, so an
    absent row genuinely has been deleted. Prune, as before eviction existed.
  - WINDOWED, window unknown this revision — the engine has learned nothing at
    all about which rows exist. Hold everything.

`PretableIndexedEvictionContext.windowed` separates them, derived per render
from whether the consumer publishes `resultMeta.window` and NOT latched, so a
grid that stops serving a window stops claiming one. It rides the same object
as the window (`getWindowing` replaces `getSelectionWindow`, and
`WindowState` replaces the react-side spacer channel's value) because two
getters read at two instants can disagree — the same skew class the `observed`
pairing already exists to prevent. Notably `windowed` is NOT gated: whether the
consumer serves a window is a fact about the consumer, not about this render.

`evictionWindowUnknown` is shared by the selection and the cursor so the two
cannot drift; a grid that keeps a selection under a cursor that jumped is not a
coherent grid. It is deliberately NOT the same question as
`evictionRetentionWindow`, which also returns null on a `datasetKey` change —
that is the engine being told plenty, and it still resets.

Local mode is unchanged in every branch, proven by tests that run one fixture
both ways in grid-core and by a react-level control where rows genuinely vanish
from an unwindowed grid and the selection and cursor both go.

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

vercel Bot commented Aug 17, 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 17, 2026 3:00am

Request Review

@github-actions

Copy link
Copy Markdown
Contributor

Vercel preview ready

Preview: https://pretable-bjzg9zjjl-cacheplane.vercel.app
Commit: c4334c62c62ddc5d2290430e3bef0607d474c662

Updated automatically by the deploy-preview job.

@blove
blove merged commit 2a4cd7a into main Aug 17, 2026
20 checks passed
@blove
blove deleted the blove/eviction-p0-fixes branch August 17, 2026 03:14
@blove blove mentioned this pull request Aug 17, 2026
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