Repository navigation
fix(plugin-grid): a grouping-field switch paints only the server's groups for the new field (objectui#11574) - #11579
Merged
objectstack-fleet[bot] merged 4 commits intoOct 3, 2026
Conversation
…grid (objectui#11574) Red on main 0903617: Title (10 groups) -> Priority paints 13 headers, 9 phantom "(empty)" ones beside the server's 4, and a Priority -> Impact switch paints 3 phantoms beside Impact's 3. React warns "Encountered two children with the same key, `0:`" on the way. Claude-Session: https://claude.ai/code/session_01FjqrwXPfSMkSfkKYDSRkN2 Co-authored-by: Claude <noreply@anthropic.com>
…t their question (objectui#11574)
useServerGroupHeaders kept the previous answer across a grouping-field
change (`{ ...prev, loading: true }`) and returned it under the new
fields. A header row carries only the fields it was grouped by, so every
old header became an `(empty)` group on the composite key `0:`; React
keeps one fiber per duplicated key, and the other N-1 header subtrees
outlived the real answer as phantom headers stuck on "Loading grid...".
Both hooks now record what they hold with the question it answers
(object, grouping fields, composed filter, search pair; the summary
columns for the headers) and read a held answer only under that same
question, in the render the question changes. A refresh asks the same
question, so the answer in hand stays on screen while it reloads.
Claude-Session: https://claude.ai/code/session_01FjqrwXPfSMkSfkKYDSRkN2
Co-authored-by: Claude <noreply@anthropic.com>
…jectui#11574) Claude-Session: https://claude.ai/code/session_01FjqrwXPfSMkSfkKYDSRkN2 Co-authored-by: Claude <noreply@anthropic.com>
…ing to any (objectui#11574) Claude-Session: https://claude.ai/code/session_01FjqrwXPfSMkSfkKYDSRkN2 Co-authored-by: Claude <noreply@anthropic.com>
Contributor
✅ Console Performance Budget
The eager closure is every chunk the entry reaches through static imports — what the browser fetches and parses before the app renders. The entry chunk on its own is a small fraction of it. 📦 Bundle Size Report
Size Limits
|
objectstack-fleet
Bot
deleted the
claude/issue-11574-grouping-switch-headers
branch
October 3, 2026 22:41
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.
Fixes #11574
Clause-②: no
Implemented by an
os-devrun dispatched by thedomain:uiseat 1 PM loop, sessionhttps://claude.ai/code/session_01FjqrwXPfSMkSfkKYDSRkN2, claim comment 5973674012.What was wrong (measured)
Switching a server-grouped grid's grouping field painted phantom
(empty)group headers stuck on "Loading grid…" beside the server's real groups, until a reload.Mechanism.
useServerGroupHeadersmoved to its loading state with{ ...prev, loading: true }, so between the switch and the new answer it returned the previous field's header rows under the new grouping fields. A header row carries only the fields it was grouped by, souseGroupedDataread the new field's name off every old row asnull: N(empty)groups, all on the composite key0:.ObjectGridkeys each group subtree by that composite key, so React got N children with one key (it warns "Encountered two children with the same key,0:"). When the real answer arrived, React's keyed reconcile kept one fiber per duplicated key and deleted only that one (react-dom 19.2.8mapRemainingChildrencollects the old children in aMapby key, last one wins, and only that map's entries are deleted); the other N-1 subtrees were never deleted and stayed in the DOM, unmanaged, frozen on their "Loading grid…" placeholder. Hence the phantom count is always the previous field's group count minus one, which is the card's 9 (Title 10 to Priority), 3 (Priority 4 to Status) and 4 (Status 5 to Priority).Ruled out: a stale header response landing after the switch (the effect's
cancelledflag already dropped it), and headers merged by index (the hook replaces the whole set).The same stale render also asked a row query for the phantom leaf (
priorityis null), and each group's row page was held by composite key alone. So a page asked under the previous field, including one still in flight at the switch, could render under the next field's group whenever two fields share a key (0:highunder bothpriorityandimpact).The fix (only
packages/plugin-grid/src/useServerGrouping.ts)Per the triage ruling, both hooks hold what they hold against the question it answers, and read it only under that same question, in the render the question changes:
useServerGroupHeaders: the question is the object, the grouping fields and the header query they compile with (composed filter, search pair, summary columns). A different question reads as pending (headers undefined) at once, and the effect starts it with no headers, soObjectGrid's existing first-load branch shows the loading state until the new answer. A refresh (reloadKey) re-asks the same question, so the answer in hand stays on screen while it reloads, as before. The reference-label inputs are not part of the question: they label the same answer.useServerGroupRows: pages are held against the object, the grouping fields, the composed filter and the search pair. A page asked under another question is never read under this one, and an answer is written only while it still matches the held question and request signature, so a discarded header's in-flight row query is dropped on arrival. Sort, projection, page size and a refresh keep the page in hand while re-asking, as before.No client-side filtering of headers, no reload-only path, and
ObjectGrid.tsxis unchanged. Cancellation reuses the module's existing patterns (the effect'scancelledflag and the per-request signature): plugin-grid holds noAbortController, andDataSource.findtakes no signal.Visible behaviour change: a change of grouping field, filter or search on a server-grouped grid now shows the grid's loading state until the new groups arrive, instead of the previous groups (which answered a different question). The changeset says so.
Pins:
serverGroupingFieldSwitch-11574.test.tsx(empty)header and no group left on "Loading grid…". Every header list painted on the way (sampled by a MutationObserver) is one field's whole answer, and no row query is asked for a group the server did not answer.prioritywith thepriority = highpage held in flight, switch toimpact(same value set, sohighhas the same composite key). The held page answers after the switch, then Impact's headers answer: Impact'shighgroup shows "Loading grid…" and none of Priority-high's rows, then exactly Impact-high's rows.Red first on
main0903617 (the test was committed alone as 1f0610e): pin 1 failed withexpected [ '(empty)', '(empty)', …(11) ] to deeply equal [ 'high', 'low', 'medium', 'urgent' ], which is 13 headers with 9 phantom, the card's number. Pin 2 failed with three phantoms beside Impact's three.Ablations (one-shot, ⛔ no permanent test)
Each ran through objectstack's
scripts/ablation-replace.mjsin wrap mode on the committed fix (blob42a68861600c): the anchor must hit exactly once, the landed mutation is proven by anchor count and blob change, and the restore is proven by blob equal to HEAD and an emptygit diff HEAD, with a trap on exit, INT and TERM. There is no build or dist leg, because the pins import../ObjectGridby relative source path.{ ...PENDING, question }becomes{ ...prev, loading: true, error: null, question })e173e56dd1afreturn state;)6ccd2257ed64prioritynull; header counts green, since the effect's reset unmounts the stale render before it is sampledconst pages = prev.pages;)61e3c370a222highgroupAll three were restored to blob
42a68861600cwith an emptygit diff HEAD. NOT MEASURED: the row hook's read-time guard on its own. Removing it alone leaves a stale page painted before the effect runs, andact()flushes that render and the next one together, so no pin holds it alone.Verification (head 3861835)
pnpm --workspace-concurrency=2 --filter '@object-ui/plugin-grid^...' build: exit 0 (at c0f07c9; later commits touch only the new test file and the changeset).pnpm --filter @object-ui/plugin-grid type-check(tsc --noEmit && tsc -p tsconfig.test.json): exit 0.--listFilesOnlylists the new test once in the test project and the hook once in the build project.pnpm exec vitest run --maxWorkers=2 packages/plugin-grid/: 184 files, 1700 tests passed.check:control-bytes,check:new-line-citations(0 new citations),check:changeset-claims,check:pending-changeset-literals,check:test-path-roots,check:vi-mock-specifiers,check:vi-mock-inherit,check:vi-mock-override-shape,check:unreferenced-sources,check-changeset-presence,check-changeset-no-major,check-changeset-fixed: all exit 0.check-governed-queue-guard --testanswers NOT GOVERNED.--no-inline-config --format json): the JSON reports 2 files, 0 errors. The hook's 3 warnings are the same 3 rule ids as on the base blob (react-hooks/set-state-in-effecttwice, plus the pre-existing disabledexhaustive-depsonce inline config is off). The test has none.--print-configresolves a rule set for both files, so neither is ignored. The config enables no type-aware linting (noparserOptions.projectorprojectService), so this diff cannot move a verdict on an untouched file. The repo-widepnpm lintis CI's.examples/app-showcase.Acceptance notes
useGroupedDataderives a group's composite key segment withextractValueKey, which maps bothnulland''to the empty segment. If a header query ever answers anullgroup and an''group for the same field, the two would share a key, which is the same React duplicate-key shape as above. That needs a driver that groupsnulland''apart, which nobody has measured. Carrier: none.ObjectGridcomment above its unified load effect says it uses anAbortController; the code uses acancelledflag.Generated by Claude Code