fix(grid-core): stop an evicted selection painting rows nobody selected - #460
Merged
Conversation
… 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>
|
The latest updates on your projects. Learn more about Vercel for GitHub. |
Contributor
Vercel preview readyPreview: https://pretable-bjzg9zjjl-cacheplane.vercel.app Updated automatically by the |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 samedatasetKey— correct per our own docs, since the key identifies a query — then scroll back:Five rows painted selected; four did not exist when the user selected. The rows actually selected painted nothing.
datasetKeyidentifies 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.countis stamped onto the span besidedatasetKey, 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.spanReadableInWindownow 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 GAPtest 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
narrowDeletedEndpointshad 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
totalis 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
windowedfact on the eviction context — does the consumer publishresultMeta.windowat 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 towindowSpacers !== nullreddens the tests.windowed: false→ local mode, prune, byte-for-byte as beforewindowed: true+window: null→ cannot verify, retaingetSelectionWindowbecamegetWindowing, returning both facts from one read so they cannot describe different instants — the same skew class theobservedpairing already guards against.evictionWindowUnknownis shared by selection and focus so they cannot drift.A real behaviour change worth knowing
windowed: truewith 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 fordatasetKeyalone. 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, withverified: falseas the signal; and a paragraph on gate closure holding rather than discarding.Proven
spanReadableInWindowcomparesdatasetKeyonlywindowed: windowStart !== undefined→windowSpacers !== nulldatasetTotal: matchingTotal.count→0Local mode is proven unchanged by running one grid-core fixture both ways —
windowed: trueretains and returns the identical object;windowed: falseand 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-selectedon the row element, which is onlytruefor 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/mainfirsttypecheck, lint, prettier clean repo-wide.
pnpm build→pnpm api→pnpm api:checkin that order, all clean. Forgotten-exports guard 5/5.Needs a human decision
indexedRangeContainsCellshould 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