Repository navigation
feat(empty-state): draw the declared empty-state link through the action runner, and let a list page's own emptyState win (#12270) - #12350
Conversation
…ion runner; a list page's own emptyState wins
One shared `EmptyStateLink` (@object-ui/components) follows the spec's
`EmptyStateSchema.link` ({ label, url }) as a `type: 'url'` action through the
ActionRunner, so `classifyNavigationTarget` is the one answer to what the
click opens. ListView's empty branch (grid and gallery) and ObjectGrid's
authored empty state draw it; InterfaceListPage prefers the page's own
`interfaceConfig.emptyState` over its source view's.
Claude-Session: https://claude.ai/code/session_01CsyMRsFJBKNEMa4oPY1b5z
Co-authored-by: Claude <noreply@anthropic.com>
…ge's own emptyState precedence Real renders: the shared EmptyStateLink with and without an ActionProvider; ListView (grid, gallery), object-grid through the registry, and InterfaceListPage with the real ListView (grid, gallery, plus a gallery-mount control). Refusals are read off a bare runner for the same target. Claude-Session: https://claude.ai/code/session_01CsyMRsFJBKNEMa4oPY1b5z Co-authored-by: Claude <noreply@anthropic.com>
…t page's own emptyState; changeset Claude-Session: https://claude.ai/code/session_01CsyMRsFJBKNEMa4oPY1b5z Co-authored-by: Claude <noreply@anthropic.com>
✅ 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
|
Contract reviewServed-tier: opus — maintainer waiver 6160996883, in place of CONTRACT_REVIEW_TIER Read: card #12270 body and all 5 comments (triage Checks on this head, re-read at signing: 29 success, 3 skipped (the two coverage jobs, dependabot), 0 pending, 1 failure. The failure is Spec Main Shape Gate. Its failed step is "Report which objectui file fails against which objectstack commit", and its first error line is ① Derived judgments
② Semver levelChangeset: RIGHT. Clause-②: yes. RIGHT, and consistent across claim ③ Boundary flags
Dev deviations:
Out-of-scope findings:
Implemented-by: VERDICT: PASS |
Seat ACCEPT: objectui PR #12350 at
|
Fixes #12270
Clause-②: yes. The renderers start honouring two keys the spec declares (
emptyState.link, and a list page's owninterfaceConfig.emptyState), so what they draw widens. A contract review is owed before the queue.What this does
This is the renderer half of objectstack-ai/objectstack#23004 and objectstack-ai/objectstack#23005 (spec PR objectstack-ai/objectstack#23098, merge
55828c5145). It is unblocked becausemainc5406c2installs@objectstack/spec18.0.0-next.0. The change covers three doors, which share one piece:EmptyStateLink, in@object-ui/componentsbesideDataEmptyState, draws the spec'sEmptyStateSchema.link({ label, url }). It follows the link as atype: 'url'action through the ActionRunner, so what a click opens isclassifyNavigationTarget's answer. It has no scheme list or URL check of its own. Thelabelis a string or an inline locale map, resolved against the caller's display locale. A label that resolves to no text draws nothing.ListView's empty branch (gridandgallery).ListEmptyStatetakeslinkand draws it beside the add-record action. The spec keeps the two apart, so either, both or neither may be drawn. With no link, the DOM is unchanged.InterfaceListPage. The relay is nowemptyState: cfg.emptyState ?? view.emptyState. The page's own block wins, whole, on every visualization, because the precedence is set before the visualization is chosen. The source view's block is the fallback, as itsappearanceanduserFiltersalready are.ObjectGrid's authored empty state. It draws the link inDataEmptyState's action slot. The registryinputsdescription forobject-grid.emptyStatenow nameslink.Docs: the
emptyStatepassages in the plugin-grid docs, README and schema reference now coverlink. The plugin-list README gains an "Empty state" section; its typed example is checked below. Changeset:minorfor the four packages touched.For the spec seat (scope addition
6132635356): the renderer half has landed with this PR. The follow-up is the spec seat's to file: dropping the two[PLANNED — not yet rendered]markers, rewriting the two ledger notes, offeringinterfaceConfig.emptyStatein the page form and deleting its reconciliation omit row.How a declared link becomes a runner navigation (measured with the real runner)
onNavigate+onToast)/...or./...onNavigate(url, { external: false, newTab: false }), in placeredirect; followed withlocation.assignhttps://...onNavigate(url, { external: true, newTab: true })window.open(url, '_blank', 'noopener,noreferrer')mailto:location.href = url, in place; router and new tab untouched//host,thanks,javascript:)A followed link raises no success toast. The html-tier anchor and the dashboard header link raise none either.
Deviation from the suggested route: a refused value is refused at the click, not hidden at render
The suggested route said "a refused value draws no link". Here the link is drawn, and the runner refuses it at the click and reports its refusal. Reason, measured: the spec admits at parse a url that starts with a
${...}template, and its describe says such a url "is judged when it is clicked, after interpolation".EmptyStateSchema.safeParseof a link whose url is${ctx.origin}/docssucceeds on18.0.0-next.0. ButclassifyNavigationTargetanswersnullfor that string before interpolation. Classifying at render would therefore hide a spec-valid link, and it would also be a second judgement beside the runner's. The pinjudges nothing at renderholds this.Mechanism assumptions, measured
EmptyStateSchema.linkis strict{ label: I18nLabel, url: string }:urlhasmin(1)andnavigationTargetRefusalat parse.InterfacePageConfigSchema.emptyStateisEmptyStateSchema.optional(). The hold's restart probe exits 0 fromapps/console.c5406c2.ListViewhandedListEmptyStatetitle,messageandicononly, andObjectGridread the same three.InterfaceListPagerelayed the source view's whole block (emptyState: view.emptyState) and read nocfg.emptyState; it did not read individual members.classifyNavigationTargetis the runner's one classifier (navigateTo,navigateOnSuccess).@object-ui/typesmirrors: none needed.ListViewSchema['emptyState']andObjectGridSchema.emptyStateboth take the spec'sEmptyStateby reference. The README example (aListViewSchemaliteral withlink: { label, url }) compiles against the built@object-ui/types. The same literal with an undeclaredhrefinside the link is refused with TS2353.Tests (real renders, real runner)
packages/components/src/__tests__/empty-state-link-12270.test.tsx(14). Under a provider: in-app, https, mailto, three refused values (each compared with what a bare runner answers for the same target), a${...}template drawn, the label map, and absent or empty label. With no provider: redirect followed, https in a new tab, refusals logged.packages/plugin-list/src/__tests__/ListView.emptyStateLink-12270.test.tsx(4). Grid: link beside add-record, followed in place. Gallery: https. A refused value. A control with no link.packages/plugin-grid/src/__tests__/ObjectGrid.emptyStateLink-12270.test.tsx(4). Through the registry andSchemaRenderer: in-app, https, refused, and a control with no link.packages/app-shell/src/views/InterfaceListPage.emptyStatePrecedence-12270.test.tsx(6). The real page renders the realListView. The page's own sentence and link win on grid and on gallery, whole (the view's message does not fill the gap). The page's link is followed in place. With no page block, the view's is kept, on grid and on gallery. A control proves the gallery page mounts the gallery (cards, no table).Ablation (three legs)
Each leg went through
ablation-replace.mjs: the anchor hit exactly once, the blob changed, and the restore was proven by blob == HEAD and an emptygit diff HEAD. The direction was red, as predicted.InterfaceListPage:cfg.emptyState ?? view.emptyStatebecameview.emptyState. 3 failed, 3 passed: the three page-precedence rows failed; the two fallback rows and the gallery control passed.ListView:link={schema.emptyState?.link}becamelink={undefined}. 3 failed, 1 passed: the control passed.ObjectGrid: the link'sactionslot was emptied. 3 failed, 1 passed: the control passed.Local verification (HEAD
f20667b0e)turbo run build --filter=@object-ui/plugin-list^... --filter=@object-ui/plugin-grid^... --filter=@object-ui/app-shell^..., plus the two plugins: 28/28 tasks.packages/plugin-list/: 131 files, 1419 tests passed.packages/plugin-grid/: 211 files, 1920 tests passed.packages/components/: 382 files passed, 1 skipped; 3875 tests passed, 24 skipped.InterfaceListPageoremptyState: 273 tests passed.type-check(tsc --noEmit && tsc -p tsconfig.test.json; eachtsconfig.test.jsonincludessrc/**/*.test.tsx): components, plugin-list and plugin-grid all exit 0.type-check, whole package: NOT MEASURED. Reason: the verify-lock queue had not reached it after about 30 minutes, behind another seat's long locked run (issue-11773final2.sh). It is declared here as a narrowing, and CI runs the whole package. The narrowed check:tscwith the package's owntsconfig.test.json, whosefilesare the three touched app-shell files (the new test,InterfaceListPage.tsxand the census test). That program spans 629 workspace files, 79 of them underapp-shell/src, and exits 0.no-explicit-anymock pattern its siblings use. The narrowing is sound: the config enables no type-aware linting (noproject/projectService), and no local rule reads another file, so this diff cannot move the verdict on an untouched file.check-changeset-presence,check:changeset-claims,check:pending-changeset-literals,check:new-line-citations,check:control-bytes,check:test-path-roots,check:vi-mock-specifiers,check:vi-mock-inherit,check:vi-mock-override-shape,check-doc-links,check:doc-json,check:doc-fences,check:doc-example-ids,check:doc-types,check:unreferenced-sources,check:phantom-deps,check:self-import,check:action-forward-parity,check:handler-key-reads,check:spec-symbols,check:esm-specifiers,check:i18n-keys,check-changeset-no-major,check-changeset-fixedandcheck-changeset-overwrite. No string or i18n key was added, so the rest of thecheck:i18n-*family is not owed.check:doc-snippetsandcheck:readme-exports. Reason: PRECONDITION NOT MET, because packages outside this closure are unbuilt (plugin-gantt, plugin-map, plugin-markdown, plugin-timeline and others). CI builds them. The judged rows ofreadme-exportsshow 0 fabricated names. The new README snippet was compiled on its own (item 3 above).Acceptance notes
apps/console/src/__tests__/registry-inputs-spec-parity.test.ts, in theMEMBER_PINSrowobject-grid.emptyState, still says "All three members the spec's strictEmptyStateSchemadeclares", but spec 18 declares four. It is mechanically green, because the check reads only that the pin file names the block and the key. Thelinkmember's pin isObjectGrid.emptyStateLink-12270.test.tsx. This is prose drift, not filed. Carrier: whoever next edits that ledger row, for example the spec seat's follow-up above.gridandgalleryvisualizations only, as before. The other visualizations draw their own empty surfaces. That is unchanged here; the page precedence holds whatever the visualization.Spec Main Shape Gateis red onmainand on every PR (feat(spec)!: retire the view-levelapidata source — a view binds its object or static rows; an API call is the page element's binding (#23082) objectstack#23160; consumer the view-levelapidata source is retired in @objectstack/spec (objectstack#23082): delete theViewData.provider === 'api'readers, keep the approvals inbox's code-builtApiDataSource#12325). It is advisory and not this change's.OAuthConsentPage.public-client-12301.test.tsxis a known flake (test(console): OAuthConsentPage.public-client-12301 races its own lookup — line 171 counts fetch calls before the effect has called fetch, so the in-flight case flakes red on a loaded runner #12344).Generated by Claude Code