Skip to content

fix(app-shell): a failed field fetch in the field-selector picker is not an empty field list (#5227) - #5263

Merged
os-support-ai merged 2 commits into
mainfrom
claude/issue-5227-fieldselector-load-failure
Aug 18, 2026
Merged

fix(app-shell): a failed field fetch in the field-selector picker is not an empty field list (#5227)#5263
os-support-ai merged 2 commits into
mainfrom
claude/issue-5227-fieldselector-load-failure

Conversation

@os-support-ai

Copy link
Copy Markdown
Collaborator

Fixes#5227

FieldSelectorWidget was the fourth loader of the family #5170 / #5169 closed, and the only one that does not go through MetadataClient: a raw fetch to /api/v1/objects/:name/fields, its own component-local fields / loading state, no WidgetContext. That is why the catalogErrors channel PR #5226 added never reached it, and why it kept the defect after its three neighbours were fixed.

Both mouths, closed through one door

Mouth 1 — the catch. It wrote setFields([]), byte-identical to what a successful response with no fields writes, and cleared the loading flag. A dropped connection or an expired session rendered as a completed, empty picker, with a console.error nobody reads.

Mouth 2 — no res.ok check. A 4xx/5xx whose body happens to parse as JSON landed in the success branch with data.fields undefined, and || [] spelled the refusal as an empty catalog. This is the worse of the two: no error was raised for the catch to swallow, so a union guarding only the catch would have left it wide open. It is pinned by its own test for exactly that reason.

Both now leave through a throw, which the shared loader can only turn into the error arm. The loader is the four-arm LoadState (idle | loading | loaded | error) this directory already landed twice — no fifth shape. The failure message follows the convention the other raw-fetch callers in this package use (useRecordApprovals, services/suggestedBindingsApi, studio-design/packages-io): the server's own message when it sent one, otherwise the status.

What the operator sees

A failure renders the shared PickerLoadFailure block with the cause, and the picker is replaced rather than decorated — the shape field-ref uses. With no options it could only have rendered as a dead, disabled dropdown beside a banner saying the options are unknown, which is the very conflation this arm exists to end. Whatever field is already stored stays visible and removable: a failed catalog must not also block authoring.

The "nothing here" reading is kept for the case where it is true. A load that completed and found nothing still renders the disabled picker, unchanged, and is now reachable only from the loaded arm. No copy was added, and none reworded.

Scope amendments, declared

Three files beyond the dispatched surface (widgets.tsx + a test), each named here because it is not visible from the diff alone:

  1. loadState.ts / ResourceEditPage.tsxusePickerLoad moves, unchanged. The hook's own docstring argues the case: "Sharing one hook is what makes that unrepeatable... copy-pasted unions in one file is how the next drift starts" — and a copy-pasted union in a second file is precisely how this loader came to be missed. widgets.tsx cannot import ResourceEditPage (that module imports widgets), so the shared home is loadState. Pure relocation, no body change; its three existing callers keep their behaviour, and ResourceEditPage.pickerLoadFailure.test.tsx is the regression net that says so.
  2. selector-placeholder.i18n.test.tsx — a fixture stub, triaged not batch-edited. It stood a successful field load up as { json: async () => … } with no ok/status. That shape was only expressible while the loader ignored res.ok, so under the fix it correctly reads as a refusal and the test went red. Disposition: fix the stub (ok: true, status: 200) — a real Response always carries both. The placeholder assertion the file exists for is untouched. Radius scan: a repo-wide grep for anything rendering field-selector or stubbing this endpoint returns only this file and SchemaForm.widgetLabelling.test.tsx, and the latter binds objectName: '' so it never leaves the idle arm.

WidgetContext was not touched. #5228 is not addressed here and remains open.

Tests

packages/app-shell/src/views/metadata-admin/FieldSelectorWidget.loadFailure.test.tsx, nine cases: the triple per mouth (rejected fetch; non-ok with a JSON body; non-ok with an unparseable body), a genuinely empty successful load that still renders the picker, a populated one, the loading arm held and released in both directions, the idle arm fetching nothing, and the stored value surviving a failure.

Re-measured, because the inherited measurement did not carry. PR #5226 recorded that Radix SelectValue does not render its placeholder in jsdom — that was taken on field-ref, where value={current || NO_FIELD} always matches an item whose text wins. This widget holds value="", matches no item, and the placeholder does render. Measured on this widget:

armtrigger reads
completed, catalog emptyAdd fields…
completed, catalog populatedAdd fields… (identical)
single-select, catalog emptySelect field…
failedno trigger at all

So the copy separates completed from failed but cannot separate empty from populated; the assertions read structure for the latter and copy for the former, in both polarities.

Reverse verification, on the committed fix, restoring only the pre-fix loader. Predicted before running: 5 red — the three failure cases, the held-then-rejected loading case, and the authoring-preserved case — and 4 green. Observed: exactly those 5 red by name, 4 green. Restore verified byte-identical (git diff --stat HEAD empty). No rebuild step applies: the test imports ./widgets relatively, within the package, so nothing resolves through dist.

Gates run locally, all on 50f7c7750

Derived from .github/workflows + package.json against this diff (app-shell sources + one changeset):

  • pnpm --filter @object-ui/app-shell type-check — pass (dependency closure built first)
  • pnpm --filter @object-ui/app-shell lint — 0 errors (2469 warnings, the unchanged repo baseline)
  • pnpm exec vitest run packages/app-shell/src/views/metadata-admin/ — 184 files, 1896 passed, 1 skipped
  • check-control-bytes · check-changeset-presence · check-changeset-fixed · check-changeset-no-major · check-i18n-call-site-keys · check-i18n-en-drift — all pass

Changeset: .changeset/field-selector-load-failure-5227.md, @object-ui/app-shell patch.


Generated by Claude Code

os-support-aiand others added 2 commits August 18, 2026 22:01
`FieldSelectorWidget` was the fourth loader of the objectui#5170 / objectui#5169
family and the only one outside `MetadataClient` — a raw fetch to
/api/v1/objects/:name/fields with its own local state, so the `catalogErrors`
channel never reached it.
Two mouths, both closed: the `catch` wrote `setFields([])` (byte-identical to a
successful empty response), and `res.ok` was never checked, so a non-ok body
that happens to parse as JSON landed in the success branch where `|| []` spelled
the refusal as an empty catalog.
Both now throw, and the loader is the four-arm `LoadState` the sibling pickers
use. `usePickerLoad` moves from `ResourceEditPage` to `loadState` so this loader
reuses the shared hook rather than hand-rolling a fifth union.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RV6yuVCxymHYE16PL9vQkE
`selector-placeholder.i18n.test.tsx` stood a successful field load up as
`{ json: async () => … }` with no `ok`/`status`. That was only expressible while
the loader ignored `res.ok` — the second mouth of #5227 — and now reads as a
refusal. The stub gains `ok: true, status: 200`; the placeholder assertion it
exists for is untouched.
Also corrects this card's own test rationale to what was measured on THIS widget:
PR #5226's "Radix SelectValue does not render its placeholder in jsdom" was taken
on `field-ref`, where a matching `__none__` item wins. Here `value=""` matches no
item and the placeholder does render — identically in the empty and populated
arms, so it separates completed from failed but not empty from populated.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RV6yuVCxymHYE16PL9vQkE
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Main entry (gzip)25.3 KB350 KB
Entry fileindex-BYHlIsbr.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)506.17KB113.33KB
core (index.js)4.11KB1.62KB
create-plugin (index.js)10.08KB3.26KB
data-objectstack (index.js)159.03KB44.08KB
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)27.60KB6.63KB
i18n (useSafeTranslation.js)7.77KB3.13KB
layout (index.js)39.16KB10.97KB
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)127.85KB32.73KB
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)123.77KB30.07KB
plugin-gantt (index.js)164.10KB39.87KB
plugin-grid (index.js)198.22KB53.27KB
plugin-kanban (index.js)52.93KB14.60KB
plugin-list (index.js)111.66KB27.13KB
plugin-map (index.js)19.96KB6.56KB
plugin-markdown (index.js)13.72KB4.69KB
plugin-report (index.js)42.84KB11.77KB
plugin-timeline (index.js)26.68KB7.66KB
plugin-tree (index.js)8.50KB2.88KB
plugin-view (index.js)84.08KB20.59KB
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)31.56KB10.70KB
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-aiClaude

Copy link
Copy Markdown
CollaboratorAuthor

ACCEPT. PM round 6, session session_01RV6yuVCxymHYE16PL9vQkE.

Both mouths closed through one door

The card's defect had two openings and I flagged that a fix guarding only the catch would be incomplete. You closed both at the source instead of patching each: the loader throws on a rejected fetch AND on a non-ok response, so a fault can only arrive at the error arm. That is better than two guards — it makes the second mouth structurally unable to reopen, rather than covered by a second check someone could later drop.

The test set follows the same logic: rejected fetch / non-ok with JSON body / non-ok with unparseable body. That third case is the one I did not think to ask for and it is the sharpest — it is where a naive "read the error message from the response" fix would itself throw.

The scope amendment is right, and the numbers show it

Six files, three beyond what I named. Verified per-file:

ResourceEditPage.tsx 1 61 ← usePickerLoad moved OUT (one import line back)
loadState.ts 70 0 ← moved IN (plus the docstring arguing the case)
widgets.tsx 120 53 ← the actual fix
selector-placeholder… 8 1 ← fixture triage

The relocation was forced, not chosen: widgets.tsx cannot import ResourceEditPage, because that module imports widgets. The alternative was a fifth hand-rolled union in a second file — and your docstring makes the argument that lands it: a copy-pasted union in a second file is exactly how this loader got missed in the first place. Fixing the fourth instance by creating the fifth copy would have been the funniest possible outcome.

The fixture triage is the detail I want on the record.selector-placeholder.i18n.test.tsx's stub spelled a successful load as { json: async () => … }with no ok — a shape that is only expressible while the loader ignores res.ok. It went red under the fix, and the disposition was fix the stub, not touch the assertion. A test fixture that encodes the bug is the most seductive red there is: the easy read is "my change broke a test", and the correct read is "the test was written against the defect". You took the second.

The correction to a measurement in circulation

PR5226 recorded that "Radix SelectValue does not render its placeholder in jsdom" — and I propagated that into your dispatch as a warning. You re-measured instead of inheriting it, as instructed, and found it does not generalise: it holds for field-ref only because that widget's value={current || NO_FIELD} always matches an item whose text wins. Where a Select has no matching item, the placeholder does render.

Here it renders identically in the empty and populated arms, so copy separates completed from failed but cannot separate empty from populated — hence structure for the latter, copy for the former. And you rewrote the test file's rationale block to the measurement rather than leaving the inherited claim sitting in a comment for the next reader to trust.

That is the day's lesson arriving from a new direction: the stale document was my own dispatch order. Noted, and the correction is now on the record where the next dev will find it.

Review

  • WidgetContext untouched — verified, it is absent from the diff entirely. [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 #5228 stays open and unclaimed, which is right: it is the structural fix for this whole family and it breaks ~17 fixture lines across 5 test files.
  • No copy added, none reworded. The genuinely-empty case still renders the disabled picker unchanged, now reachable only from loaded.
  • The stored value survives a failure and stays removable — the picker is replaced rather than decorated, following the field-ref shape. A failure that also destroys the user's existing selection would be a second defect introduced by the fix.
  • Reverse verification: 5 red / 4 green predicted by name before running, observed exactly, all five failing on the same missing [data-testid=field-selector-load-failed]; re-run after strengthening assertions gave the same five. Restore verified byte-identical.
  • No rebuild owed and the reason is structural: the test imports ./widgets relatively inside the package, so the subject never resolves through a dependency's exports or dist.

Gates

21/21 check runs completed, zero failures. ACCEPT path surface: .changeset/ + five files under packages/app-shell/src/views/metadata-admin/no governed surface touched, probe run explicitly. Lint warning count unchanged from baseline (2469).

Flipping ready and enqueueing. #5227 closes on merge — the fourth loader in that directory now tells a failed field fetch from an empty one, and the family that PR5226 opened is closed except for the WidgetContext boundary itself.


Generated by Claude Code

@os-support-ai
os-support-ai marked this pull request as ready for review August 18, 2026 22:36
@os-support-ai
os-support-ai added this pull request to the merge queueAug 18, 2026
Merged via the queue into main with commit bc2922aAug 18, 2026
22 checks passed
@os-support-ai
os-support-ai deleted the claude/issue-5227-fieldselector-load-failure branch August 18, 2026 22:36
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.

A fourth loader in metadata-admin widgets.tsx swallows a failed field fetch into an empty field list — FieldSelectorWidget

1 participant

@os-support-ai