-
Notifications
You must be signed in to change notification settings - Fork 0
Review 5454
Versions: Review Loop 1.1.0 · Component Audit Rubric 1.10
Verdict: request changes
#5454 fix(Table): three defects surfaced by frozen columns and grouped rows by ernestt (bucket: internal — ENGOWNERS)
e8df392c9a8387b9e62bde26f37698444a85e0a5
LOOP VERSION: 1.0.0 AUDIT RUBRIC: 1.10
- A checked row using the default selection wash becomes two visual states at a frozen-column boundary: selected in scrolling cells, unselected in pinned cells.
- A grouped row's label and collapse control scroll away while frozen data columns stay visible.
- A partially loaded group shows its loaded count as though it were the final total.
All three are reproducible user problems, but they are independent decisions and can land in either order.
VERDICT: clear
The row-selection owner now publishes the same color through the sticky-cell overlay contract that it already paints on the row. The grouped-row owner makes the heading cluster sticky at the table's logical start edge. Separately, grouped rows accept a result-set total and format a partial count.
SOLUTION (3 independent decisions · 118 runtime changed lines of 322 total; evidence:runtime 1.73)
- Synchronize the selection wash into
--table-row-overlayand remove it with the row wash. [fix 1] - Move the group-heading start gutter onto a
position: sticky; width: fit-contentinner wrapper. [fix 2] - Add
getGroupTotal, a fourthrenderGroupHeaderargument, and localizedcount of totaloutput. [new API]
The commits and changesets are already split one per decision. The third can be removed without weakening the first two.
VERDICT: note — three separable decisions; the new API is not required by the two fixes
OWNER: Table's plugin pipeline owns grouping/selection; TableRow + useTableStickyColumns own the row-overlay transport and pinned-cell paint.
TIER 1: collection/selection; theming/visual state; container padding/bleed; text/i18n/direction; public render-function composition.
TIER 2: none.
SEAMS: synthetic selection column; synthetic grouped row replacing pre-rendered cells; public renderGroupHeader; sticky cells; scroll wrapper using the four logical container-padding vars; RTL.
BEHAVIOR UNIT: the existing hooks are the right units; no new state machine is introduced.
Driven seams:
| seam | result |
|---|---|
| default grouped header | fixed at logical start in LTR/RTL, narrow/wide, 200% text, forced colors |
| selection + sticky start run | row and pinned-cell fills match in all 14 shipped theme/mode pairs |
custom full-width renderGroupHeader
|
fails: its available width collapses from 1,334px to 180px at wide/no-scroll |
| logical container bleed | heading button holds 4–6px from the scroll wrapper's logical start |
The default path is owned correctly, but width: fit-content changes the public render-function seam rather than only the plugin's built-in heading.
VERDICT: BLOCKS — the pin fix breaks the shipped custom-renderer composition seam
The first fix makes a selected row read as one state across a freeze line. The second keeps the section name and collapse control reachable while the reader scans horizontally, including RTL. The third changes nothing until a caller opts into a new total callback.
Landing the header change also newly collapses full-width custom heading layouts to their content width. With the two new performance paths, a builder's unrelated state update makes a 10,000-row table repeat 20,000 overlay calls in Storybook dev and 10,000 data-cell renders; the exact base did neither.
VERDICT: BLOCKS — default users improve, but existing custom renderers can visibly reflow
Real proposed call site from the source JSDoc:
useTableGroupedRows({
data: pageRows,
groupBy: row => row.team,
getGroupTotal: key => totalsFromServer.get(key),
// ...
});| change | public? | class / existing seam | doc'd? | verdict | |
|---|---|---|---|---|---|
+ |
getGroupTotal?: (groupKey: string) => number | undefined |
yes — exported UseTableGroupedRowsConfig
|
useTablePagination.totalItems is a scalar total; existing renderGroupHeader(key, count) can already close over per-group totals |
source + .doc.mjs
|
BLOCKS — no current consumer and documented use destabilizes rows |
~ |
renderGroupHeader(key, count, collapsed, total) |
yes — shipped in v0.5.0 | additive argument on existing render seam; 3-arg functions remain assignable | source + .doc.mjs
|
compatible by type |
Ossification: #5448, the only motivating in-repo consumer, now pages by whole sections and does not call getGroupTotal. The existing render callback can display a caller-owned total today. The new getter is therefore a permanent second spelling without a live consumer, and removing it later would require deprecation.
VERDICT: BLOCKS — split/drop the new API until a real consumer proves the shape
No theme target is added, removed, or moved. --table-row-overlay is component-author plumbing, not a theme surface; it carries the existing --color-accent-muted token through the pinned-cell paint owner. headerInner is structural positioning and correctly receives no target.
The known pre-existing gap remains: selected row state is not reflected through themeProps('table-row'), so themes cannot target selection. This PR does not worsen that contract and is not charged for it.
All seven shipped themes × light/dark resolved identical row and pinned overlay colors after selection.
VERDICT: clear
- API: additive; existing 3-argument renderers compile and run. New API is opt-in.
- Visual: yes — a custom renderer using the existing full-width content area changes from 1,334px to 180px at 1,400px viewport.
- Theme: no target/token/override changes; 14 theme/mode pairs matched.
- Behaviour: selection/uncheck/flag flip retain existing semantics; grouped collapse/expand retains focus. The new total changes only opted-in default text.
The visual break reaches the already-published renderGroupHeader surface in v0.5.0 and has no breaking changeset.
VERDICT: BLOCKS — existing custom-header layout can change on upgrade
Effects: no new effect; selection keeps its existing no-deps notify effect. Listeners/observers/layout reads/bundle dependencies: none added.
Measured in one Chromium run and one worktree, exact PR base vs head, at N=10/100/500/2,000/10,000 (the repo's Table Lab exposes 10,000):
| path on unrelated owner rerender | base | head | guarded probe |
|---|---|---|---|
data-cell renders from inline getGroupTotal
|
0 at every N | N at every N (10,000 at max) | not attempted |
--table-row-overlay set/remove calls on unchecked rows |
0 at every N | 2N in Storybook dev (20,000 at max) | 0 at 10,000 |
The getter's identity enters the plugin memo dependency list, so the documented inline callback creates a new plugin and invalidates every memoized row. The selection path unconditionally removes an already-absent property on every notification; a one-line current-value guard reduced the 10,000-row probe from 20,000 calls to 0 without changing the visual result.
Repro: review-artifacts/pr-5454/scale-probe.cjs, scale-before.json, scale-after.json, scale-guarded.json.
VERDICT: BLOCKS — both new paths add linear work to unrelated renders
Containment: the Table scroll wrapper consumes all four logical container-padding vars and bleeds to the container edge; the group cell spans the complete table; the sticky inner is bounded by that cell.
| case | before | after | result |
|---|---|---|---|
| wide 1,400px | shots/before__wide.png |
shots/after__wide.png |
default layout unchanged except intentional partial count |
| narrow 320px, scrolled 420px | shots/before__narrow.png |
shots/after__narrow.png |
intentional: collapse button −416px → +4px from start |
| RTL 700px, scrolled −420px | shots/before__rtl.png |
shots/after__rtl.png |
intentional: collapse button −416px → +4px from logical start |
| 200% text | shots/before__zoom200.png |
shots/after__zoom200.png |
intentional pin holds; no clipping observed |
| forced colors | shots/before__forced-colors.png |
shots/after__forced-colors.png |
pin holds; checkbox + ARIA preserve selected state when fills normalize |
| full-width custom renderer | shots/before__custom-wide.png |
shots/after__custom-wide.png |
unintentional: custom content 1,334px → 180px; trailing action moves 1,154px |
Theme matrix: shots/after__{neutral,stone,butter,chocolate,gothic,matcha,y2k}__{light,dark}.png; every selected row/pinned cell pair matched.
VERDICT: BLOCKS — default fix works, public custom composition visibly regresses
Auto-covered: pr-a11y and pr-rtl ran and passed at the reviewed head; no baseline entry was added. The new visible string uses @astryx.tableGroupedRows.partialCount with a non-empty translator description.
Browser-checked: Enter collapses and expands the group; focus remains on the same chevron; aria-expanded updates; the control stays reachable in LTR/RTL, narrow, 200% text, and forced colors. Selection retains its checked checkbox and aria-selected when forced colors normalize the fills.
No new focus owner, announcement, role, or keyboard path is introduced.
VERDICT: clear
| slot | verdict |
|---|---|
| PROBLEM | clear |
| SOLUTION | note — 3 independent decisions |
| ARCHITECTURE | BLOCKS — custom render seam |
| IMPACT | BLOCKS — custom layout reflow |
| API | BLOCKS — unused/destabilizing new surface |
| THEMING | clear |
| BREAKING | BLOCKS — visual |
| PERFORMANCE | BLOCKS — linear work |
| VISUAL | BLOCKS — custom composition |
| A11Y & I18N | clear |
GOAL: partly met — default selection and heading defects are fixed across the driven matrix; the pin breaks a documented composition seam, and the total API adds a separate unstable path.
DISPOSITION:
- Custom renderer width regression → blocks now; preserve its available-width behavior while keeping the built-in heading/control reachable.
- Selection overlay's repeated no-op write → blocks now; on an unrelated owner update the builder pays one extra DOM call per row per notification, and the tested current-value guard makes that flat again.
-
getGroupTotalsurface + row invalidation → blocks now; split/drop the third commit until a real consumer and stable plugin path exist.
ADVICE: bounded direction — retain the custom render slot's full-width contract; skip unchanged overlay writes; treat total-count support as a separate API decision with a consumer and a row-memoization invariant.
AUTHOR CAN PROCEED: yes — acceptance criteria are the three disposition lines, re-run against the banked probes.
WORST OUTCOME: “a custom renderer using the existing full-width content area changes from 1,334px to 180px at 1,400px viewport” → request changes.
request changes
-
[BLOCKS]
width: fit-contentshrink-wraps the public custom-header slot → existing full-width heading layouts visibly collapse on upgrade ·packages/core/src/Table/plugins/groupedRows/useTableGroupedRows.tsx:173 -
[BLOCKS] selection rewrites an unchanged overlay on every row notification → large tables pay linear DOM calls on unrelated owner renders ·
packages/core/src/Table/plugins/selection/useTableSelection.tsx:168 -
[BLOCKS] inline
getGroupTotalbecomes plugin identity → every data row re-renders when its owner re-renders ·packages/core/src/Table/plugins/groupedRows/useTableGroupedRows.tsx:470
Thanks — the selection wash and default group heading now work across the browser matrix. Three far-side blockers remain. A full-width
renderGroupHeadercollapses from 1,334px to 180px. With the documented inlinegetGroupTotal, a builder's unrelated state update on a 10,000-row table re-rendered all 10,000 data cells; selection's unguarded overlay sync made 20,000 no-op calls in Storybook dev. The exact base did neither. The new total API also has no current consumer—#5448 now loads whole sections—and the existing renderer can already show a supplied total. Could we preserve the custom slot's width, skip unchanged overlay writes, and split/dropgetGroupTotaluntil it has a consumer and preserves row memoization?
-
packages/core/src/Table/plugins/groupedRows/useTableGroupedRows.tsx:173— This shrink-wraps publicrenderGroupHeadercontent: my full-width case collapsed 1,334px → 180px. -
packages/core/src/Table/plugins/selection/useTableSelection.tsx:168— This adds a no-op CSS-variable mutation per row on every owner render; guard unchanged values. -
packages/core/src/Table/plugins/groupedRows/useTableGroupedRows.tsx:470— The documented inline getter invalidates every row: 10,000 data rows re-rendered; base stayed at zero.
- The selected row and pinned cell resolved identical colors in all 14 shipped theme/mode pairs.
- The default collapse button moved from −416px off-start to +4px in both LTR and RTL.
- The reviewed head is mergeable and all 17 substantive CI checks ran green; only
review-requiredis pending.
Versions: Review Loop 1.4.0 · Component Audit Rubric 1.12
Head reviewed: e24ef4c50ab3a5eede328242e9cfdf7f0b9a54d7 (ernestt pushed 17:47:47Z, after our changes-requested at 05:55:24Z)
Verdict: approve · drafted, not posted
Lane: full — our own CHANGES_REQUESTED was standing, and the diff is Table interaction + layout.
Title changed since round 1: three defects became two. The PR is now +179/−9 across 6 files.
All three blockers walked at the exact head. None is a reversal; round 2 extends round 1 with one note that is a consequence of ask 1.
| our ask | at e24ef4c
|
evidence |
|---|---|---|
| preserve the custom slot's width | satisfied |
renderGroupHeader inner box 1,404 → 1,408px; the trailing element sits at x=1345 at rest and x=985 scrolled in both arms; the scrolled frames share one sha256 |
| skip unchanged overlay writes | satisfied | 3 notifications × 6 rows = 18 row passes → 2 setProperty + 1 removeProperty, exactly the rows that moved |
split/drop getGroupTotal
|
satisfied | prop, changeset, tests, locale key and doc entry all gone; grep for getGroupTotal|partialCount across packages/ apps/ .changeset/ → 0 hits |
Did main invalidate it (R31c)? No. 55 commits since the merge-base cd1a2da, and git log cd1a2da..origin/main is empty for all four touched source files. mergeable: MERGEABLE; 19 CI checks green on this head (one skipped: Anchor review-clear chain).
A checked row on a table with a frozen first column reads as selected on the scrolling side and unselected under every pinned column. And a grouped table scrolled sideways loses its section headings with the content, leaving unlabelled grey bands and no pointer-reachable collapse control.
-
useTableSelectionpublishes and withdraws--table-row-overlayalongside the row wash, both writes guarded on the current value. - The group heading's contents become a sticky, start-inset box carrying the cell's start gutter;
width: fit-contentonly when the plugin draws the heading.
Both trace to a stated problem. The rest of the diff is evidence: 62 test lines, two patch changesets, a JSDoc pagination section and two .doc.mjs descriptions.
Measured, both arms, one build, one Storybook, BEFORE = the two source files reverted to the merge-base in place (before-arm.diff, 5,848 B, 81 changed lines, the exact reverse of the PR's own hunks and nothing else):
| case, scrolled 360px | base | e24ef4c |
|---|---|---|
| built-in heading, chevron x | −356, off-screen, static
|
+4, visible, sticky |
| built-in heading, RTL, from the logical start | −356, off-screen | +4, visible |
custom renderGroupHeader, chevron x |
−356, off-screen | −356, off-screen |
custom-scrolled__after.png, custom-scrolled__before.png and default-scrolled__before.png are all one sha256 (74ac3326…): with a custom heading, head emits the same bytes as the pre-fix bug.
Not a regression, and not a block. At rest the two custom arms differ by 267 of 248,320 pixels (0.108%) at a maximum channel delta of 2/255 — text antialiasing from the heading becoming a sticky box — with the chevron at x=4, the trailing element at x=1345 and the cell at 1,420px in both. The collapse control is still keyboard-reachable there (focus pulls it into view, scrollLeft 360 → 0, which is what base does for both paths). And it is the direct consequence of round 1's own ask to protect that slot's width.
So: GOAL: partly met, and the note exists to stop the changelog reading as unconditional.
Overlay writes over 3 selection notifications × 6 rows = 18 row passes:
| arm | set | remove | total |
|---|---|---|---|
| merge-base — no overlay code | 0 | 0 | 0 |
round-1 head e8df392 — unguarded |
4 | 14 | 18 (one per row per notification) |
e24ef4c — guarded |
2 | 1 | 3 (exactly the rows that moved) |
unguarded-arm.diff (1,397 B) removes exactly the three guard conditionals. Zero effects added, moved or deleted.
API no · Theme no · Visual yes and intended on the two default paths, both patch-changeset'd; the heading cell's outer box is 1,420px in both arms, so nothing grew and nothing reflows around it · Behaviour: six states walked, the only new conditional is !renderGroupHeader and both sides are driven.
Eight frames, real Chromium, every one opened and looked at, every one carrying a captureWithSensors() receipt with runningAnimations: 0, fonts: loaded, no page or Storybook error, and non-generic state sensors matching across arms. The first capture round recorded 7 and 14 running animations (Table's background-color transitions); rather than argue the pixels were settled, all frames were re-shot with animations frozen.
- built-in heading scrolled: three unlabelled grey bands become "Design Systems (3)", "Infra (2)", "Growth (1)", each pinned with its chevron — 0.79% of pixels
- checked row scrolled: frozen checkbox and Name cells
rgb(255,255,255)→rgb(241,241,241), matching the scrolled side; unselected rows unchanged on both sides — 5.32% - custom heading scrolled: 0.00%, same sha as the pre-fix default
No shipped story combines grouped rows or selection with useTableStickyColumns, so the green pr-visual covers neither endpoint — the PR Analysis Report on this head reads "No visual change across 2 compared shot(s). 46 shot(s) have no baseline yet."
approve. Ten slots: seven clear, three note (ARCHITECTURE, IMPACT, VISUAL) — and the three are one thing seen from three sides, so the review says one thing. No slot's prose describes a user who is stuck, cannot reach something, or loses their place.
- The heading pin is conditional on the plugin drawing the heading → a consumer passing
renderGroupHeaderstill gets unlabelled bands and a chevron 356px off the start edge when scrolled ·useTableGroupedRows.tsx:390 - Both changesets credit
@ernesttien→gh api users/ernesttien404s,@ernesttis the account and what the other eight landed entries use, so the release notes lose the credit ·.changeset/grouped-rows-pinned-heading.md:21and.changeset/table-selection-sticky-overlay.md:18
ADVICE: omitted on 1 — a fix that pins the plugin's chevron while leaving the caller's slot full-width is designable but I did not build it (R2l). AUTHOR CAN PROCEED: yes. JUDGEMENT NEEDED: none — defect fix against the existing contract, no API, no theme target, no new capability.
Dropped without raising: the two new em dashes in the .doc.mjs prose, two days after #5413 and #5488 swept them. 34 of 220 core .doc.mjs files still carry one on main and no lint enforces it, so the count killed the finding (#5557 is a third open sweep and will take these).
Thanks — all three fixed at
e24ef4c. The pin only applies when the plugin draws the heading, so a customrenderGroupHeaderstill loses its chevron when scrolled. Both changesets need@ernestt.
Inlines:
-
packages/core/src/Table/plugins/groupedRows/useTableGroupedRows.tsx:390— Custom headings skip the shrink-wrap, so the chevron can't pin. -
.changeset/grouped-rows-pinned-heading.md:21—@ernesttien404s.@ernesttis the account, and both changesets have the wrong one.
Posting note. Our round-1 CHANGES_REQUESTED is still standing and only the cixzhang account can clear it — an approve has to be posted from that account or the PR stays blocked however green it is (R31b.4).
Three passes, all failed-then-fixed except the last.
-
Pass 1 — R2 (47 words against a 30 cap), R2m/R12 (a "nothing to change" framing directly above an inline asking for a change, plus a closing question on an approve), R2h (an inline carrying its own measurement), the GOAL label (
metwherepartly metwas right), R38 (runningAnimations: 7in every receipt, unmentioned). All frames re-shot. - Pass 2 — R12b: the trim to 28 words had dropped "when scrolled", which made the posted sentence false at rest. Plus three prose-vs-artifact drifts: 80 changed lines vs 81, an over-stated keyboard row, and a PERFORMANCE column labelled with a head I had cited rather than driven. The unguarded arm was then driven, which turned a cited number into a measured one.
- Pass 3 — "would she have posted it: yes", with one violation: R14h, "byte-identical to base in both scroll states" when only the scrolled state had been captured. The missing state was captured rather than the sentence softened, and the critic's doubt was better than its guess — the at-rest frames are not byte-identical, they differ by 267 antialiased pixels.
The residual failure class across all three gates was the same: a summary sentence quantifying slightly past the artifact it came from.
Not posted. This record is the round-2 draft as handed over.
44m total — setup 3m (warm main not reused; an in-worktree BEFORE arm is the right baseline when main is 55 commits past the merge-base), reading 5m, measuring 12m, writing 10m, critic 14m, waste 3m. Waste: a selection probe that read Storybook's own argstable <tbody><tr> instead of the story's table; capturing all frames before asserting runningAnimations; and reading the critic file in chunks that truncated mid-file.
Artifacts: {review-root}/review-artifacts/pr-5454-r2/ — gate-1..4.md, measure-{before,after}.json, probe2-{before,unguarded,after}.json, pixels.json, atrest-diff.json, before-arm.diff, unguarded-arm.diff, shots/ (8 PNGs + receipts).