Skip to content

fix(app-shell): a WidgetContext option catalog carries its own load state - #5355

Merged
os-support-ai merged 1 commit into
mainfrom
claude/issue-5228-widget-catalog-load-state-union
Aug 20, 2026
Merged

fix(app-shell): a WidgetContext option catalog carries its own load state#5355
os-support-ai merged 1 commit into
mainfrom
claude/issue-5228-widget-catalog-load-state-union

Conversation

@os-support-ai

@os-support-aios-support-ai commented Aug 19, 2026

Copy link
Copy Markdown
Collaborator

Fixes#5228

WidgetContext spelled a failed option catalog as an empty array, with the fault
parked on a side channel and the requirement to consult it living in a doc
comment. This makes each catalog the four-arm LoadState the loaders already
produce, so the wrong read is a compile error at exactly the sites that must
decide what a failure looks like.

Reference implementation followed throughout: PR #5263 (objectui#5227), which
landed the shared PickerLoadFailure render and moved usePickerLoad into
loadState. No second failure presentation was invented.

The residue, restated

Three loaders in ResourceEditPage were fixed by #5170 / #5169, but the fix was
projected back down at the boundary: the catalogs stayed plain arrays, [] on a
failed load and byte-identical to a load that completed and found nothing, with
the fault travelling alongside on catalogErrors plus a *Loading flag. The
contract lived in CatalogErrors' own doc comment, which had to open with "a
picker MUST consult this before it renders its catalog". One line was enough to
ignore it:

constfields=context?.objectFields??[];

Type-correct, natural to read, and it renders a refusal or an expired session as
the metadata graph's own answer of "this object has no fields".

The move

Each catalog is handed over intact as LoadState< T >. Two pieces make the
failure decision a precondition rather than a convention:

  • FaultFreeLoadState< T > — every arm except the failure. LoadState is
    declared in terms of it, so the two cannot drift.
  • offeredOptions(state, empty) — takes FaultFreeLoadState, so a caller still
    holding the error arm cannot call it. const fields = offeredOptions(...)
    does not compile until the line above it has decided what a failure renders as.

That is the difference from loadedData, which accepts any arm and can therefore
only ask, in prose, for the same discipline. No ?? [] convenience overload
was left anywhere on the boundary, and this is a type change only — no runtime
check was added.

objectFields and objectActions are now derived from the ONE catalog state via
mapLoaded, because they come from one client.get('object', …) request. They
agree by construction instead of by the reader knowing they share a fetch — which
is what the old catalogErrors.fields key, consulted by the action picker to
learn whether actions had failed, quietly asked of them.

Re-measured on today's main (58398ba)

The card priced 11 reachable sites + 1 and a fixture radius of ~17 lines / 5
files. Re-measured, since the card is ~24h old and this directory took PR5263 and
PR5327 since:

cardmeasurednote
catalog reads in widgets.tsx119listed below
SchemaForm.tsx11detectFieldRefWidget
other production hosts01inspectors/ViewVariantInspector.tsx — missed by the card, found by the compiler
fixture files55exactly the five named; 24 error sites

The nine reads in widgets.tsx: RefObjectWidget, ObjectSelectorWidget,
FieldRefWidget, ViewRefWidget, FieldRefMultiWidget, FilterModeWidget,
ActionMultiWidget, FilterBuilderWidget, ConditionWidget.

SchemaForm.tsx's site is deliberately not migrated: it tests WIRING
(if (!widgetContext?.objectFields)), not contents. Every arm must answer yes,
or a failed catalog would silently demote the picker back to the free-text input
whose typos it exists to prevent. Annotated in place rather than changed.

A live fault the tightening surfaced

inspectors/ViewVariantInspector.tsx is a second host of WidgetContext, and the
card never named it. Its memo forwarded only fields out of the triple
useObjectFields reports (fields / loading / error), so a failed field
catalog reached its field-ref / field-multi pickers as [] and the picker
said "No object bound" about an object that IS bound. That is the #5170 defect
class, still open on this host, and it is here precisely because nothing in the
old type required a producer to carry the fault at all — the array WAS the
contract. It cannot be dropped now; the three arms are spelled out. No idle arm
is synthesized (that would change what an unbound inspector renders, a separate
question).

Tests

New: WidgetContext.catalogUnion.test.tsx — 21 tests, two halves that fail in
different tools on purpose.

Compile-time (tsc -p tsconfig.test.json, the only project that compiles this
directory's tests).
Five @ts-expect-error pins, chosen over a type-assertion
helper because each is inherently two-way: @ts-expect-error is itself an error
(TS2578) once the line below it starts compiling, so a loosening turns the file
red instead of quietly passing.

  • PIN A — the naive ?? [] read. Fails when the boundary is tightened;
    reports TS2578 when it is loosened back to an array. This is the card's pin.
  • PIN BofferedOptions refuses a state that still carries the error
    arm, with a positive control right below it (the same state, narrowed, is
    accepted) so PIN B cannot pass merely by the accessor being uncallable.
  • PIN C — a fault is not a completed-empty measurement, at the type level.
  • PIN DcatalogErrors and objectsLoading are gone, not merely unused,
    so the old shape cannot creep back one key at a time.
  • PIN E — absence resolves to the idle arm (compiles; type-checks the
    substitution every migrated picker performs).

Runtime (vitest). For every migrated call site: a COMPLETED-but-empty catalog
and a FAILED catalog render differently, asserted in both directions, and the
failure arm renders the SHARED PickerLoadFailure block — read by its test id,
its heading and its cause, so a bespoke second presentation fails here. The
empty arm is what stops the failure assertions being tautologies. Also pinned:
a failed FIELD catalog no longer posts a banner on the ACTION picker.

Two measurements worth recording. filter-builder keeps its failure block inside
a Radix popover that does not open in jsdom, so the popover parts are rendered
inline via vi.mock — this repo's existing way of reaching such content (the
InboxPopover suites). And the shared failure heading is not unique on
screen for the ordered-set pickers: field-multi and action-multi also put
that sentence in their add-trigger placeholder, so the helper asserts within
the block rather than document-wide.

Reverse verification — predicted before running, per leg, per tool

No build artifact sits between an edit and either subject: the pins import
./widgets / ./loadState as same-package relative source, and the root vitest
config aliases every workspace name to src. The artifact matters only for the
cross-package imports these files also pull, which is why pnpm --filter '@object-ui/app-shell^...' build was run first — without it type-check is a
false red (TS2307 on every workspace import). It was run; both tsc projects
then pass.

Leg 1 — loosen WidgetContext.objectFields back to a plain array.

predictedobserved
tsc -p tsconfig.test.jsonRED, PIN A reports TS2578RED — catalogUnion.test.tsx(107,3): error TS2578: Unused '@ts-expect-error' directive, 36 errors total
tsc --noEmit (source)RED at the objectFields consumersRED — widgets.tsx(1248,19) etc., ResourceEditPage.tsx(744,7), ViewVariantInspector.tsx(306,54)
vitestGREEN — types erased, fixtures still pass real state objectsGREEN, 21/21

Not predicted: PIN D flipped to TS2578 as well (line 146), because the
already-failing sibling property in the same literal suppresses the excess-property
check that PIN D relies on. Same direction, one extra red.

Leg 2 — keep the union, hand-narrow field-ref so it drops the failure arm
(status === 'loaded' ? data : [], the early return deleted — what a caller who
bypasses offeredOptions can still write).

predictedobserved
tsc both projectsGREEN (a hand ternary compiles)GREEN, 0 errors both
vitestRED on exactly one testRED, 1 failed / 20 passed — field-ref … a FAILED catalog replaces the picker with the shared failure block

Leg 2 is the honest limit of the type half and the reason both halves exist: the
compiler forces the decision at every site that uses the accessor, and vitest
catches the one who hand-narrows around it. Restore after each leg was
git checkout from the commit, verified byte-identical (git status and
git diff --stat both empty).

Gates — union run on the final commit 79424e1bf

  • pnpm --filter @object-ui/app-shell type-check (tsc --noEmit && tsc -p tsconfig.test.json) — exit 0, script name echoed
  • npx vitest run packages/app-shell/src/views/metadata-admin from the repo root (CI's config, not the package-scoped one) — 186 files, 1941 passed, 1 skipped, 0 failed
  • check-changeset-presence.mjs — 11 guarded source files, 1 changeset · check-changeset-fixed.mjs · check-changeset-no-major.mjs (patch)
  • check-control-bytes.mjs — OK, plus a manual control-byte scan of the changed files
  • check-type-check-coverage.mjs — 45/46 and 41/41, unchanged
  • check-i18n-call-site-keys.mjs — OK · check-i18n-dead-keys.mjs — report only, no new hits
  • eslint packages/app-shell0 errors (2493 pre-existing warnings, none added: the one new warning this branch introduced was fixed)

Scope

Clause-② no: this tightens a type at a boundary. Nothing widens; the accepted set
of authored metadata does not move. Untouched, as instructed:
packages/components/src/renderers/complex/data-table.tsx (#5120),
packages/plugin-grid/src/ObjectGrid.tsx (#5349), packages/plugin-dashboard/**.

FieldSelectorWidget is deliberately left as PR #5263 wrote it. It is not a
WidgetContext consumer — its state is component-local — and it already checks
its error arm before rendering. Churning freshly-reviewed code for stylistic
consistency was not worth the review surface.

No docs change: WidgetContext appears in no README or guide page (grepped);
it is an internal type of the metadata-admin engine.

Serial disciplines for the app-shell subdirectory-level exemption, with #5216
(PR5354) open in src/hooks/, src/urlParams.ts, src/views/ObjectView.tsx,
src/console/home/, src/environment/: (a) surface declared to region level
(packages/app-shell/src/views/metadata-admin/**, disjoint from PR5354's
subtrees); (b)main merged once before opening this PR — already up to date
at 58398ba; (c)main to be merged again after PR5354 lands; (d) any
conflict goes to the merge queue, nothing hand-ordered.

Generated by Claude Code

…tate (#5228)
Each option catalog on `WidgetContext` (`objectNames`, `objectFields`,
`objectViews`, `objectActions`) becomes the four-arm `LoadState` the loaders
already produce, instead of a plain array with the fault parked on a separate
`catalogErrors` record and a `*Loading` flag beside it.
The old shape let a FAILED load reach a picker as `[]`, byte-identical to a
load that completed and found nothing, with the requirement to consult the
failure channel living in a doc comment. `context?.objectFields ?? []` no
longer compiles, and reading the list goes through `offeredOptions`, whose
parameter type excludes the failure arm — so a call site that has not decided
what a failure looks like does not compile.
Fixes one live instance found by the compiler: the View variant inspector, a
second host of `WidgetContext`, forwarded only the `fields` third of what its
loader knows, so a failed field catalog rendered as "No object bound".
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Main entry (gzip)25.3 KB350 KB
Entry fileindex-B3HSJxI-.js
StatusPASS

📦 Bundle Size Report

PackageSizeGzipped
app-shell (index.js)9.83KB3.70KB
app-shell (runtime-config.js)7.42KB2.32KB
app-shell (types.js)0.01KB0.04KB
app-shell (urlParams.js)8.92KB3.41KB
auth (AuthContext.js)0.31KB0.24KB
auth (AuthGuard.js)1.17KB0.53KB
auth (AuthProvider.js)29.33KB7.05KB
auth (AuthShell.js)3.49KB1.40KB
auth (ForgotPasswordForm.js)12.21KB3.45KB
auth (LoginForm.js)18.13KB5.39KB
auth (PreviewBanner.js)0.90KB0.50KB
auth (RegisterForm.js)6.64KB2.21KB
auth (SocialSignInButtons.js)9.60KB3.89KB
auth (UserMenu.js)3.40KB1.22KB
auth (auth-gate-events.js)1.29KB0.66KB
auth (authStyles.js)5.04KB1.72KB
auth (createAuthClient.js)40.21KB10.79KB
auth (createAuthenticatedFetch.js)6.34KB2.43KB
auth (index.js)2.71KB1.22KB
auth (invitation-status.js)1.22KB0.70KB
auth (org-roles.js)6.66KB2.78KB
auth (phone-identifier.js)1.11KB0.66KB
auth (types.js)0.59KB0.35KB
auth (useAuth.js)5.02KB0.88KB
auth (useIsWorkspaceAdmin.js)1.61KB0.85KB
collaboration (CommentThread.js)26.07KB7.56KB
collaboration (LiveCursors.js)3.17KB1.27KB
collaboration (PresenceAvatars.js)6.49KB2.64KB
collaboration (PresenceProvider.js)2.79KB1.13KB
collaboration (index.js)1.65KB0.73KB
collaboration (useCollaborationTranslation.js)6.05KB2.52KB
collaboration (useCommentSearch.js)1.98KB0.88KB
collaboration (useConflictResolution.js)7.75KB1.86KB
collaboration (useMentionNotifications.js)1.81KB0.68KB
collaboration (usePresence.js)6.33KB1.84KB
collaboration (useRealtimeSubscription.js)7.91KB2.01KB
components (index.js)505.53KB113.13KB
core (index.js)4.11KB1.62KB
create-plugin (index.js)10.08KB3.26KB
data-objectstack (index.js)159.80KB44.34KB
fields (index.js)237.07KB59.46KB
i18n (LocalizationContext.js)1.76KB0.96KB
i18n (currency.js)1.22KB0.64KB
i18n (i18n.js)4.28KB1.75KB
i18n (index.js)3.42KB1.39KB
i18n (pickLocalized.js)3.69KB1.73KB
i18n (provider.js)23.13KB7.63KB
i18n (useDisplayLocale.js)2.85KB1.45KB
i18n (useObjectLabel.js)30.51KB7.57KB
i18n (useSafeTranslation.js)7.77KB3.13KB
layout (index.js)38.75KB10.87KB
mobile (MobileProvider.js)0.92KB0.49KB
mobile (ResponsiveContainer.js)0.94KB0.38KB
mobile (breakpoints.js)1.51KB0.70KB
mobile (createOfflineDataSource.js)5.61KB1.74KB
mobile (index.js)1.50KB0.62KB
mobile (offlineQueue.js)3.91KB1.35KB
mobile (pwa.js)0.97KB0.49KB
mobile (serviceWorker.js)1.48KB0.62KB
mobile (serviceWorkerSource.js)3.41KB1.48KB
mobile (useBreakpoint.js)1.54KB0.65KB
mobile (useGesture.js)6.96KB1.98KB
mobile (useOfflineSync.js)1.99KB0.72KB
mobile (usePullToRefresh.js)2.53KB0.85KB
mobile (useResponsive.js)0.71KB0.42KB
mobile (useResponsiveConfig.js)1.36KB0.63KB
mobile (useSpecGesture.js)4.32KB1.64KB
mobile (useTouchTarget.js)1.01KB0.54KB
permissions (MePermissionsProvider.js)9.35KB3.31KB
permissions (PermissionContext.js)0.31KB0.25KB
permissions (PermissionGuard.js)0.89KB0.45KB
permissions (PermissionProvider.js)4.42KB1.42KB
permissions (evaluator.js)5.12KB1.74KB
permissions (index.js)0.91KB0.41KB
permissions (store.js)0.91KB0.42KB
permissions (useFieldPermissions.js)1.28KB0.52KB
permissions (usePermissions.js)1.81KB0.83KB
plugin-ai (index.js)15.75KB3.80KB
plugin-calendar (index.js)46.62KB12.83KB
plugin-charts (index.js)64.75KB18.37KB
plugin-chatbot (index.js)181.21KB43.14KB
plugin-dashboard (index.js)128.07KB32.77KB
plugin-designer (index.js)212.39KB42.83KB
plugin-detail (index.js)241.46KB60.56KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)124.19KB30.20KB
plugin-gantt (index.js)164.10KB39.87KB
plugin-grid (index.js)197.30KB53.06KB
plugin-kanban (index.js)52.93KB14.60KB
plugin-list (index.js)111.66KB27.13KB
plugin-map (index.js)20.08KB6.62KB
plugin-markdown (index.js)13.72KB4.69KB
plugin-report (index.js)43.49KB11.93KB
plugin-timeline (index.js)26.68KB7.66KB
plugin-tree (index.js)8.50KB2.88KB
plugin-view (index.js)84.52KB20.67KB
providers (DataSourceProvider.js)0.75KB0.39KB
providers (MetadataProvider.js)1.37KB0.59KB
providers (ThemeProvider.js)1.90KB0.85KB
providers (UploadProvider.js)11.66KB3.50KB
providers (index.js)0.44KB0.22KB
providers (types.js)0.01KB0.04KB
react-runtime (index.js)5.62KB2.34KB
react (LazyPluginLoader.js)3.77KB1.33KB
react (SchemaRenderer.js)36.10KB12.26KB
react (data-invalidation.js)5.05KB2.08KB
react (index.js)1.33KB0.69KB
react (schema-input.js)1.45KB0.83KB
react (spec-input.js)0.20KB0.18KB
sdui-parser (codegen.js)5.41KB2.34KB
sdui-parser (index.js)4.77KB2.16KB
sdui-parser (input-type.js)2.84KB1.40KB
sdui-parser (parse.js)10.76KB3.17KB
sdui-parser (provenance.js)3.66KB1.82KB
sdui-parser (types.js)0.29KB0.24KB
sdui-parser (validate.js)6.92KB2.40KB
types (ai.js)0.20KB0.17KB
types (api-types.js)0.20KB0.18KB
types (app.js)2.87KB0.99KB
types (base.js)0.20KB0.18KB
types (blocks.js)0.20KB0.18KB
types (complex.js)0.20KB0.18KB
types (crud.js)0.20KB0.18KB
types (dashboard-filter-alias.js)6.23KB2.74KB
types (data-display.js)0.20KB0.18KB
types (data-protocol.js)0.20KB0.19KB
types (data.js)0.20KB0.18KB
types (designer.js)1.87KB0.85KB
types (disclosure.js)0.20KB0.18KB
types (error-code.js)1.54KB0.88KB
types (feedback.js)0.20KB0.18KB
types (field-types.js)0.20KB0.18KB
types (form.js)0.20KB0.18KB
types (http-retry.js)4.32KB2.02KB
types (index.js)3.08KB1.53KB
types (layout.js)0.20KB0.18KB
types (managed-by.js)0.19KB0.18KB
types (mobile.js)2.59KB1.31KB
types (navigation.js)0.20KB0.18KB
types (objectql.js)0.20KB0.18KB
types (overlay.js)0.20KB0.18KB
types (permissions.js)0.20KB0.18KB
types (plugin-scope.js)0.20KB0.18KB
types (record-components.js)0.20KB0.19KB
types (record-semantics.js)1.28KB0.67KB
types (registry.js)0.20KB0.18KB
types (reports.js)0.20KB0.18KB
types (spec-report.js)5.05KB1.93KB
types (system-fields.js)3.33KB1.54KB
types (theme.js)0.20KB0.18KB
types (ui-action.js)3.40KB1.71KB
types (views.js)0.20KB0.18KB
types (widget.js)0.20KB0.18KB

Size Limits

  • ✅ Core packages should be < 50KB gzipped
  • ✅ Component packages should be < 100KB gzipped
  • ⚠️ Plugin packages should be < 150KB gzipped

@os-support-ai
os-support-ai marked this pull request as ready for review August 20, 2026 00:34
@os-support-aiClaude

Copy link
Copy Markdown
CollaboratorAuthor

ACCEPT — PM review, round 17.

  • Path surface: 12 files, all inside packages/app-shell/src/views/metadata-admin/** — exactly the region declared, and no overlap with Consume the declared nav runAction slot — retire the private ?runAction= string convention #5216's app-shell subtrees. The subdirectory-level serial exemption held. Zero governed-surface hits.
  • Gates: every gate job completed: success — Lint, Type Check, Test shards 1–4, Build & E2E, Build Docs, Doc Snippet / Doc Component Type Check, Changeset Declaration / Bump Policy / Fixed Group, Bundle Analysis, Control Byte Scan, Internal Docs Link Check, Skill Guide Path Check. Plus check-type-check-coverage (45/46 and 41/41, unchanged), check-i18n-call-site-keys green locally. No cancelled, no in_progress.
  • Bump: patch on @object-ui/app-shell. ⛔ not major.
  • Clause-②: no, and correctly so — the accepted set of authored metadata does not move. What changed is that a caller ignoring the failure channel stops compiling.

The shape is the one the ruling wanted, and it has no escape hatch

offeredOptions(state, empty) takes FaultFreeLoadState — every arm except the failure — so a caller still holding the error arm cannot call it. LoadState is declared in terms ofFaultFreeLoadState so the two cannot drift. CatalogErrors and the four *Loading flags are deleted, not deprecated. ⛔ No runtime check, ⛔ no ?? [] convenience overload. A doc comment stopped doing a type's job.

One structural improvement beyond the card: objectFields and objectActions are now derived from the one catalog state via mapLoaded, so the pair riding a single client.get('object') request agrees by construction rather than by the reader knowing they share a fetch.

The tightening found a live defect the card never named

inspectors/ViewVariantInspector.tsxa second WidgetContext host — forwarded only the fields third of the triple useObjectFields reports. So a failed field catalog rendered as "No object bound" about an object that is bound. That is the #5170 class exactly, and it was invisible until the union made the omission a type error. It now spells all three arms.

This is the argument for doing it at the type level rather than adding a runtime check, made concrete.

Two judgement calls I agree with

SchemaForm.tsx's detectFieldRefWidget is deliberately not migrated, with a stated reason: it tests wiring, not contents, and every arm must answer yes — otherwise a failed catalog would silently demote the picker back to the free-text input it exists to replace. Migrating it would have been the wrong kind of thorough.

The type pin uses @ts-expect-error, not an Assert-style helper, because it is inherently two-way: the directive itself becomes TS2578 "Unused directive" the moment the line below starts compiling, so loosening the union turns the file red instead of quietly passing. And PIN B carries a positive control immediately below it — the same state, narrowed, must be accepted — so it cannot pass merely by the accessor being uncallable. That is the difference between a pin and a decoration.

Verification, mixed direction, tool named per leg

Leg 1 (loosen objectFields back to a plain array): predicted tsc red with PIN A reporting TS2578 and vitest green (types are erased; the fixtures still pass real state objects). Observed exactly that — TS2578 at WidgetContext.catalogUnion.test.tsx(107,3), source project red at three consumers, vitest 21/21 green.

One unpredicted extra red, reported rather than absorbed: PIN D also flipped to TS2578, because the already-failing sibling property in the same object literal suppresses the excess-property check PIN D relies on. Same direction, one more red — and the dev's own framing is right: "the template's prediction was incomplete rather than wrong."

Leg 2 is the honest limit of the type half, and the reason both halves exist: a caller who hand-narrows around the accessor (status === 'loaded' ? data : [], early return deleted) leaves both tsc projects green and is caught by vitest1 failed | 20 passed, the failure being the FAILED-catalog case, with the empty-success test still green so it is not a tautology. The compiler forces the decision at every site that uses the accessor; the test catches the one who walks around it.

The card's count was corrected by measurement: 9 catalog reads in widgets.tsx (the card said 11) + 1 in SchemaForm.tsx + 1 previously-unknown host = 11 production sites, and exactly the 5 fixture files the card named.

Merging via the queue.


Generated by Claude Code

@os-support-ai
os-support-ai added this pull request to the merge queueAug 20, 2026
Merged via the queue into main with commit bdf8cf7Aug 20, 2026
22 checks passed
@os-support-ai
os-support-ai deleted the claude/issue-5228-widget-catalog-load-state-union branch August 20, 2026 00:34
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[finding] WidgetContext still spells a failed option catalog as an empty array, with the fault on a side channel a new picker can forget to read

1 participant

@os-support-ai