Skip to content

fix(react,row-model): external sort authority actually suppresses local sorting - #466

Closed
blove wants to merge 2 commits into
mainfrom
blove/external-sort-authority
Closed

fix(react,row-model): external sort authority actually suppresses local sorting#466
blove wants to merge 2 commits into
mainfrom
blove/external-sort-authority

Conversation

@blove

@blove blove commented Aug 17, 2026

Copy link
Copy Markdown
Contributor

Stacked on #464. Review that first; the base will retarget to main when it merges.

processing: { sort: "external" } declares that something outside the grid chose the order of the records it was handed. The engine went on applying query.sort anyway.

The case, since #447 asked for it to be made

#447 deferred this on purpose, and the stated reason was that the case looked weaker than filtering's: a consumer with a complete window who sorts locally is doing something reasonable.

That objection dissolves once you ask who it protects. That consumer declares "engine" — the default. Suppression never binds them. It binds only the consumer who explicitly said the server owns ordering, and for that consumer the old behaviour was worse than doing nothing at all.

Measured before changing anything: sort: "external" was read in exactly two places, both advisory, both in data-scope.ts. The row model had a filterAuthority and no sort equivalent, so the declaration could not reach evaluation. What it did reach was:

  • warnOnEngineSortOverPartialWindow, which fires only when sort authority is not external. Declaring it silenced the one warning that says a local sort over a partial window "presents the wrong SAMPLE, not just the wrong order" — while the sort it was warning about kept running.
  • resolveAriaRowCount, which requires it to publish the population count. The same declaration therefore asserted that every loaded row sits at its true dataset position — about rows the engine had just reordered.

So the declaration bought a false positional claim and cost a true warning. external-sort-authority.test.tsx pins both halves as they now stand: the warning still fires under engine authority, where it is still the right thing to say, and the population count is now honest because the rows are no longer reordered.

The seam is the one #447 built

CompiledQueryPlan keeps #publicQuery (read by get query() → snapshot, header, onQueryChange) separate from #runtimeQuery (read by evaluate()). Under external sort authority the runtime query gets sort: [] while the public one keeps it.

Only query.sort is dropped. rowGroups keeps its own ordering, because grouping is not in PretableProcessingOptions and declaring external sort said nothing about it.

Suppression changes what is APPLIED, never what is REPORTEDaria-sort still announces the column, onQueryChange still publishes the sort, the snapshot still reports it. That guardrail has its own test.

It reaches the model the same two ways filter authority does, and for the same reasons: an @internal construction argument (the initial store is built inside createLocalRowModel, so a model created under engine authority would paint one locally-sorted frame first), plus a WeakMap-registry setter for later flips, guarded on ownedModel so a consumer's own model is never touched. react.api.md is unchanged; core.api.md gains one @internal ɵ-prefixed line.

Verification

  • The motivating test written first and watched fail: expected [ 'r2', 'r3', 'r5', 'r1', 'r6', 'r4' ] to deeply equal [ 'r1' … 'r6' ].
  • Independently re-verified by neutralising the suppression in canonicalRuntimeQuery: 2 of 7 tests fail, and restoring it fixes them.
  • The fixture carries its own controls — the two orders are proven distinct permutations of each other, and engine authority is proven to still reorder — so no assertion can pass by coincidence.
  • One existing test changed on purpose. fix(react,row-model): external filter authority actually suppresses local filtering #447's "publishes a query change through onQueryChange unchanged" asserted a locally-sorted body under EXTERNAL, which is exactly the behaviour this removes. Its reporting assertions — the test's actual subject — are untouched.
  • Repo-wide pnpm test green (react 1234, website 556, bench 156, renderer-dom 127), plus typecheck, lint, format, build and api:check.

Docs

The old behaviour was documented as deliberate in four places, and a reader following them would now be misled — the trap #449 was written to close.

query-ownership loses its asymmetry section and its "suppresses nothing" bullet; index and pretable-surface lose the same claim; lifecycle no longer says a header stays sortable through an error — it stays interactive and publishes the sort, but the rows keep the order the last successful response gave them. The renamed heading's anchor is updated at both inbound links.

🤖 Generated with Claude Code

blove and others added 2 commits August 16, 2026 21:14
`@pretable/core` and `@pretable-internal/renderer-dom` are topological
siblings -- both depend on `grid-core`, neither on the other -- so
`pnpm -r` starts them at the same instant. Both scripts then rebuilt
packages pnpm had already built: core ran `pnpm --filter grid-core
build`, renderer-dom ran three such prefixes. A cold
`pnpm -r --filter './packages/*' build` shows it directly:

    20:33:43  grid-core build$ tsc -b
    20:33:45  grid-core build: Done            <- pnpm already built it
    20:33:45  core build$ pnpm --filter grid-core build && tsup
    20:33:45  renderer-dom build$ pnpm --filter text-core build && ...
    20:33:46  renderer-dom build: > layout-core > tsc -b

Two `tsc -b` processes emitting one `dist/` is unsafe by construction:
`writeFileSync` truncates before it writes, so a concurrent reader can
see `layout-core/dist/index.d.ts` empty and report "is not a module".
Measured directly, two cold `tsc -b` runs launched together emitted that
file twice in 2 of 3 runs and once in the third -- the nondeterminism is
the race. It failed the required `typecheck` gate intermittently, which
is the corrosive kind of flake: it teaches people to re-run until green.

The graph was declared twice -- in `dependencies`, and again by hand
inside the script strings. pnpm already orders `-r` runs topologically,
so the second copy bought nothing and was what created the overlap.

This deletes it. Each package builds only itself; the root `test` and
`typecheck` scripts build the packages once, up front, before running
either. Apps keep their standalone `prepare:deps` but derive the closure
with pnpm's `<pkg>^...` filter instead of listing siblings, so it stays
correct when the graph changes. The two CI jobs that build only the
published packages now ask for their dependency closures (`<pkg>...`).

After the fix the same cold build shows every package built exactly once,
by one process, with no nested `pnpm --filter` at all.

`scripts/__tests__/workspace-scripts-own-one-package.test.mjs` holds the
invariant. It discovers the workspace rather than listing it, and it is
mutation-tested: re-adding the exact prefix this commit removes fails it,
and so does a filter naming a package that does not exist. Its first
draft passed both mutations -- it skipped any argument containing "/",
which is every scoped package name -- so the blind spot is now called out
in the code.

Verified cold, on this branch: typecheck, test (react 1216, website 554,
row-model 326, renderer-dom 127, grid-core 124, bench 155), build
including the Next site, lint, format, api:check, lint:packaging,
typecheck:public, typecheck:performance, the rewritten CI build command,
and a standalone `prepare:deps` from an empty tree -- all exit 0.

No changeset: this changes how the workspace builds, not what any
published package does.

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

`processing: { sort: "external" }` declares that something outside the grid
chose the order of the records it was handed. The engine went on applying
`query.sort` anyway.

#447 deferred this deliberately, because the case looked weaker than
filtering's: a consumer holding a complete window who sorts locally is being
reasonable. That objection dissolves on inspection — that consumer declares
`"engine"`, which is the default, so suppression never binds them. It binds
only the consumer who said the server owns ordering, and for that consumer
the previous behaviour was worse than doing nothing.

Measured before changing anything, `sort: "external"` was read in exactly two
places, both advisory, both in `data-scope.ts`. The row model had a
`filterAuthority` and no sort equivalent, so the declaration could not reach
evaluation at all. What it did reach was:

  - `warnOnEngineSortOverPartialWindow`, which fires only when sort authority
    is NOT external. Declaring it SILENCED the one warning that says a local
    sort over a partial window "presents the wrong SAMPLE, not just the wrong
    order" -- while the local sort it was warning about kept running.
  - `resolveAriaRowCount`, which requires it to publish the population count.
    So the same declaration asserted that every loaded row sits at its true
    dataset position, about rows the engine had just reordered.

So the declaration bought a false positional claim and cost a true warning.
`external-sort-authority.test.tsx` pins all of it, including both halves as
they now stand: the warning still fires under engine authority, and the
population count is now honest because the rows are no longer reordered.

The seam is the one #447 built. `CompiledQueryPlan` keeps `#publicQuery` (read
by `get query()` -> snapshot, header, `onQueryChange`) separate from
`#runtimeQuery` (read by `evaluate()`); under external sort authority the
runtime query gets `sort: []` while the public one keeps it. Only `query.sort`
is dropped -- `rowGroups` keeps its own ordering, because grouping is not in
`PretableProcessingOptions` and declaring external sort said nothing about it.

Suppression changes what is APPLIED, never what is REPORTED: `aria-sort` still
announces the column, `onQueryChange` still publishes the sort, the snapshot
still reports it. That guardrail has its own test.

Reached the model the same two ways filter authority is, and for the same
reasons: an `@internal` construction argument (the initial store is built
inside `createLocalRowModel`, so a model created under engine authority would
paint one locally-sorted frame first) plus a WeakMap-registry setter for later
flips, guarded on `ownedModel` so a consumer's own model is never touched.
`react.api.md` is unchanged; `core.api.md` gains one `@internal` ɵ-prefixed
line.

Verification:

  - The motivating test written first and watched fail:
    `expected [ 'r2', 'r3', 'r5', 'r1', 'r6', 'r4' ] to deeply equal
    [ 'r1' ... 'r6' ]`.
  - Independently re-verified by neutralising the suppression in
    `canonicalRuntimeQuery`: 2 of 7 tests fail, and restoring it fixes them.
  - The fixture carries its own controls -- the two orders are proven to be
    distinct permutations, and engine authority is proven to still reorder,
    so none of the assertions can pass by coincidence.
  - One existing test changed on purpose. #447's "publishes a query change
    through onQueryChange unchanged" asserted a locally-sorted body under
    `EXTERNAL`, which is the behaviour this commit removes; its reporting
    assertions -- the test's actual subject -- are untouched.
  - Repo-wide `pnpm test` green (react 1223, website 554, row-model 327,
    renderer-dom 127, grid-core 124, bench 155), plus typecheck, lint,
    format, build and `api:check`.

Docs: the previous behaviour was documented as deliberate in four places, and
a reader following them would now be misled. `query-ownership` loses its
asymmetry section, `index` and `pretable-surface` lose the claim that sort
"suppresses nothing", and `lifecycle` no longer says a header stays sortable
through an error -- it stays interactive and publishes the sort, but the rows
keep the last successful order. The renamed heading's anchor is updated at
both inbound links.

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 4:22am

Request Review

@blove
blove deleted the branch main August 17, 2026 14:48
Base automatically changed from blove/fix-workspace-build-race to main August 17, 2026 14:48
@blove blove closed this 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