Uh oh!
There was an error while loading. Please reload this page.
fix(components,plugin-list): let DataEmptyState declare role="status", so an empty result is not shaped like a failed one - #7144
Conversation
…, so an empty result is not shaped like a failed one `DataLoadingState` has always been `role="status"` and `DataErrorState` `role="alert"`. `DataEmptyState`, in the same file, declared nothing -- so an empty box and a failed box were the same node shape to a screen reader and to any structural test. Measured before changing anything, by rendering every surface and reading its empty box: the bare primitive, plugin-list's empty list, plugin-list's load-error panel, plugin-detail's two activity timelines and the `ui:empty` schema renderer all carried NO role, while plugin-dashboard's WidgetEmptyState (#7063), plugin-kanban's empty board and plugin-charts' ObjectChart (PR #7139) each carried `role="status"` typed out at their own call site. So the surfaces were not legitimately differing. Every surface that wanted the property had hand-copied the same line, and the ones that had not yet done so were silently missing it -- one platform default, paid for per package, which is the tax objectstack#13848 rules against. It is a DEFAULT, not a fixed attribute: `role` is spread from props, so a call site keeps the last word. That is what makes this inert for the two already ruled surfaces -- both pass `role="status"` explicitly and receive the identical attribute either way. Neither ruled surface's behaviour changes, and the metric/KPI carve-out is untouched because it gates whether an empty state is rendered at all, upstream of this component. The measurement also turned up a real defect. plugin-list renders its load FAILURE through `DataEmptyState`, borrowing it for layout, so "You don't have access" and "Nothing here yet" were indistinguishable. That panel now declares `role="alert"` -- which fixes the pre-existing collision and stops the new default from announcing an outage as a routine status. Pinned in data-empty-state-role-7132.test.tsx and ListView.emptyVsErrorRole-7132.test.tsx: the default, the override that keeps the ruled surfaces inert, the sibling contrast, and the two list branches by exact role value. objectui#7132. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012wwHa4aaFybxXrfmfHioDM
✅ 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
|
os-warren
commented
Sep 1, 2026
✅ Reviewed and armedAll 30 check runs read in one call at Why this one goes in without a round tripProperty 2 came back measured-and-DECLINED, and that is a FULL PASS. Giving A naive hoist would have been actively harmful, and only rendering found that. That is invisible to a source read. A reader of
|
| probe | result |
|---|---|
<DataEmptyState sites in ListView.tsx | 2 — :3584 (load error) and :3675 (empty) |
DataErrorState in ListView.tsx | 0 — control: DataEmptyState hits 3× in the same file, so the zero reads |
role= declarations in view-states.tsx vs Data* components | 2 vs 3 — DataEmptyState alone declared none |
consuming files across packages/ | 6, including components' own ui:empty renderer, which the card never listed |
The dev swept both exported names, not just the canonical one, and reported EmptyState's alias as a real zero — with the control hitting the alias line itself at view-states.tsx:199, which is what separates "no consumers" from "the query missed."
Assumptions falsified and reported rather than absorbed
- "
DataEmptyStateis the only shared empty-state primitive."False —custom/empty.tsxcarries a whole second family, plusBuiltinSelectEmptyStateandCommandEmpty. None is a view-level data empty state and none was touched, but the claim was wrong and saying so is the right call. - "All consumers pass compatible props."False, and it is exactly why the naive default was unsafe.
- The KPI carve-out confirmed but explicitly non-generalising:
rows.length === 0 && !isMetricis a branch gate on whether an empty state renders at all, upstream of this component — a different question from what structure it has.
Evidence
Ablation predicted before running, observed exactly: Tests 4 failed | 4 passed (8), the four survivors being both override arms, the sibling-contrast arm and the error arm — which are themselves the proof that objectui#7063's and objectui#7064's ruled surfaces are inert. Mutation proven on disk by blob hash and guard-line count; restore proven by state.
The three-value arm is asserted by exact value, not by inequality — on main both roles were null, so a partial fix leaving the empty branch at null would still satisfy null !== 'alert' and pass a mere-difference check. Naming both values is what makes that arm able to fail.
typecheck clean would have been a false green on its own (packages/components/tsconfig.json excludes src/__tests__ and **/*.test.tsx); proven covered by the second pass instead, with --listFiles placing both pins in the checked set and a control showing 0.test.tsx in the src pass.
Blast radius run rather than sampled: 1507 test files across direct and indirect consumers, zero failures.
Carried into this lane and it paid
objectui#7130's dev named a gap on themselves — blast radius chosen from declared consumers when the right criterion is which fixtures answer queries with no rows. This lane ran the full graph instead of sampling.
Landing will be verified by content with a live control once main advances; a queue sha is not a landing.
Generated by Claude Code
Uh oh!
There was an error while loading. Please reload this page.
Fixes#7132
The card asked for a measurement first, so here is the measurement
Every surface was rendered and its empty box read directly —
role,aria-live,data-slot, non-blank text-node count and text — at2b3964aff. Not read off the source; rendered.roleDataEmptyStatebare defaultNo dataplugin-listempty listNothing here yet+Create your first record…plugin-listload ERROR (403)You don't have access…+Retryplugin-detailActivityTimelineNo activity recordedui:emptyschema rendererplugin-dashboardWidgetEmptyState(#7063)statusNo data yet+ explanation +Source: work_orderplugin-kanbanempty boardstatus+aria-live=politeNo cards+2 columnsplugin-chartsObjectChart(PR #7139)statusNo data yet+ explanationThe answer: they do NOT legitimately differ
The two properties the rulings name are not in tension across these surfaces — one surface had simply never been given the platform default, and the rest had each bought it retail.
DataLoadingStatehas always beenrole="status"andDataErrorStaterole="alert".DataEmptyState, in the same file, declared nothing. So every surface that wanted the property typed it at its own call site, and three independently did — kanban, dashboard, and the in-flightObjectChart— whileplugin-list,plugin-detail's two timelines and theui:emptyrenderer silently did not.Three hand-copies of one line, paid per package, is the per-app tax objectstack#13848 rules against, and the spread was not principled divergence but the presence or absence of one attribute nobody owned.
⇒ The primitive carries property 1 now. It still does not carry property 2 and deliberately is not given it: every shipped surface already supplies its own self-describing copy, and changing the
'No data'title default would be a copy change reaching all consumers plus an i18n project in a component that currently has no translation hook. That half is measured-and-declined, recorded here rather than done quietly.The fences, each checked rather than asserted
#7063 and #7064 are not re-litigated — neither surface's behaviour changes, structurally.
hideEmpty ?? trueover DetailSection's own stated heuristic #7064's fix is inpackages/plugin-detail/src/renderers/record-details.tsx, which does not consumeDataEmptyStateat all (census below). It cannot be reached by a change to this component.WidgetEmptyStatepassesrole="status"explicitly.roleis spread frompropsafter the default, so that call site keeps the last word and receives the identical attribute with or without this change. Pinned as a negative control, and proven by ablation: removing the default leaves both override arms green.No authoring surface is widened. No new prop, no new schema key, no new i18n key.
ListViewalready readschema.emptyState?.title/.messagebefore this change and still does, untouched. Clause ② is not engaged, so the blocked Fable 5 quota is not in the path.The KPI datum does not generalise, and the code says why.
DatasetWidget's carve-out isif (state.rows.length === 0 && !isMetric)— a branch gate on whether an empty state is rendered at all, upstream of this component. A metric widget never reachesDataEmptyState, so a KPI still reads0. That decision and this one are about different questions: whether to show an empty state vs what structure the empty state has. Untouched either way.One real defect fell out of the measurement
plugin-listrenders its load failure throughDataEmptyState, borrowing it for layout. With no role on either branch, "You don't have access" and "Nothing here yet" were the same node shape — and arole="status"default alone would have made it worse, announcing a 403 as a routine status update. That call site now declaresrole="alert".This is why naive hoisting was not safe, and it is the one thing the card's hypothesis did not anticipate. The deeper structural question — that
DataErrorStatesits unused next to it — is filed as #7143 rather than refactored here.Consumer census, re-derived with controls
Both exported names were swept, not just the canonical one:
DataEmptyState— 6 call sites in 4 packages onmain, not the four the card assumed:plugin-list(two — empty and error),plugin-kanban,plugin-detail(two —ActivityTimelineandRecordActivityTimeline), andpackages/components' ownui:emptyrenderer, which the card did not list at all. PR fix(plugin-charts): a self-describing empty state for ObjectChart over an empty result #7139 addsObjectChartas a seventh.EmptyState— the alias re-export has zero consumers. Real zero: the control (git grep -nwagainst the alias line itself) hits atview-states.tsx:199. It is public API of a published package, so this is an observation, not a defect.packages/*: zero code consumers — one changeset mentions the name in prose. Control: 238 files outsidepackages/*reference@object-ui/components, so the channel reads.DataEmptyStateis not the only shared empty-state primitive inpackages/components.custom/empty.tsxcarries a second, independent family —Empty/EmptyHeader/EmptyMedia/EmptyTitle/EmptyDescription/EmptyContent/EmptyValue— plusBuiltinSelectEmptyStateandCommandEmptyelsewhere. None of them is a view-level data empty state and none is touched here, but "the only shared primitive" was not true.Blast radius, measured before proposing it
packages/componentsis broad-blast, so the whole consumer graph was run, not sampled.components,plugin-list,plugin-kanban,plugin-detail,plugin-dashboardTest Files 504 passed (504)·Tests 4780 passed (4780)app-shell,apps/console,fields,plugin-gantt,plugin-timeline,i18n,plugin-charts,plugin-viewTest Files 1003 passed (1003)·Tests 10939 passed | 1 skipped (10940)Zero snapshot exposure: no snapshot file contains a
DataEmptyStaterender (control: 10data-slothits insnapshot.test.tsx.snapprove the grep reads).Evidence
The pins can fail — ablation, direction predicted before running. Predicted: removing the
role="status"default reddens the two DEFAULT arms and the two ListView arms that namestatus, and leaves the two OVERRIDE arms, the SIBLING CONTRAST arm and the ERROR arm green.294d845ato9e24fccf, guard-line count 2 to 1,git diff --statnon-emptyTests 4 failed | 4 passed (8)— exactly the four predicted arms, with the passing count asserted so a collapsed suite could not masquerade as the red294d845a, guard-lines back to 2,git diff HEADandgit diff --cachedboth emptyThe three-value arm is asserted by exact value, not by inequality: on
mainboth roles werenull, and a partial fix leaving the empty branch atnullwould still satisfynull !== 'alert'and pass a mere-difference assertion. Naming both values is what makes that arm able to fail.Gates, at final commit
ff43f39f7— each verdict quoted from the gate's own output, exit captured before any pipe.vitest run(the two new pins)Test Files 2 passed (2)·Tests 8 passed (8)vitest run(5 direct consumer packages)Test Files 504 passed (504)·Tests 4780 passed (4780)vitest run(8 indirect packages)Test Files 1003 passed (1003)·Tests 10939 passed | 1 skippedtsc --noEmit+tsc -p tsconfig.test.json(components)tsc --noEmit+tsc -p tsconfig.test.json(plugin-list)eslint(plain form, 4 changed files)184 problems (0 errors, 184 warnings)— all pre-existingno-explicit-any; the new pin's 2 matchListView.loadErrorKind.test.tsx's own profile exactlycheck-changeset-presence4 source file(s) of 2 released package(s) changed, and this change declares 1 changeset(s)check-changeset-fixedAll workspace packages are in the changeset fixed group.check-changeset-no-majorNo changeset declares a major bump.check-changeset-overwriteNo pre-existing changeset was modified or deleted.check-control-bytesOK (scanned 5917 tracked text file(s); skipped 85 binary)check-i18n-call-site-keys2845 keys— every in-scope call-site key resolvescheck-i18n-en-driftNo en value changed in this range.check-phantom-dependenciesEvery in-scope import is declared by the package that publishes it.typecheckclean would have been a false green on its own —packages/components/tsconfig.jsonexcludessrc/__tests__and**/*.test.tsx. Proven covered by the second pass instead:--listFilesputs the components pin at 1 of 1895 checked files and the plugin-list pin at 1 of 1625; both edited sources are in the src pass (1 of 1367, 1 of 1334); control — the src pass contains 0.test.tsx, confirming the two-pass split is what covers them.Declared narrowing: repo-wide
pnpm lintand the fullcheck:*farm were not run locally; CI runs them regardless.pnpm install, the closure build (turbo run build --filter='@object-ui/plugin-list^...',12 successful) and every run above happened in a dedicated worktree off2b3964aff.On PR #7139: measured on
mainas instructed; it is still open and based on the same2b3964aff. It hand-typesrole="status"at its call site, so it is unaffected by this default whichever order the two land in — and afterwards that line becomes removable, which is the collapse its author placed it for. No file inpackages/plugin-chartsis touched here.Out-of-scope findings
ActivityTimeline's empty state is a hardcoded English literal, so a zh session reads "No activity recorded" #7142 —ActivityTimeline's empty title is a hardcoded English literal while its sibling usest('detail.noActivity')ListViewrenders its load FAILURE throughDataEmptyStatewhileDataErrorStatesits unused next to it #7143 —[finding]ListViewrenders its load failure throughDataEmptyStatewhileDataErrorStatesits unused beside itGenerated by Claude Code