Skip to content

fix(app-shell): metadata pickers and the Audit tab tell a failed load from an empty one (#5170, #5169) - #5226

Merged
os-support-ai merged 3 commits into
mainfrom
claude/issue-5170-picker-load-failure-states
Aug 18, 2026
Merged

fix(app-shell): metadata pickers and the Audit tab tell a failed load from an empty one (#5170, #5169)#5226
os-support-ai merged 3 commits into
mainfrom
claude/issue-5170-picker-load-failure-states

Conversation

@os-support-ai

Copy link
Copy Markdown
Collaborator

Fixes#5170
Fixes#5169

Two cards from the same sweep ordered on #5110, in the same directory, with the
same fix: four loaders that caught a failed request by writing the value a
successful empty response writes, so a fault and a measurement became the
same state. Landed as one change because the shape is one shape — splitting them
would have meant two people re-deriving the same union.

What was wrong

#5170, the three option-picker loaders in ResourceEditPage.tsx (object
names; the bound object's fields + actions; the bound object's views) each wrote
the empty array in the catch and flipped loading to false, with no error state,
no banner and — unlike #5110, which at least left a console.error — no trace at
all. client.list() / client.get() throw for every non-ok status other than
the 404s they map to an empty result, so refusals, dropped connections, expired
sessions and unparseable bodies were all rendered as a completed, empty picker.

#5169, AuditPanel.tsx set an error and zeroed events, and nothing
gated the count or the empty branch on that error, so a failed read rendered the
rose failure banner, a header count of 0 events, and "No audit events yet — no
save, publish, rollback, delete or reset attempts have been recorded for this
item" all at once, contradicting each other. This is the surface people read for
compliance-shaped questions, so the false zero is the half that matters.

The shape

One four-arm LoadStateidle | loading | loaded | error — in a new
views/metadata-admin/loadState.ts, structurally identical to the
ReferencesState union #5110 landed one screen away. Nothing invented: the four
in-tree references named on the cards (#5110's landed union, RelatedPanel,
DiagnosticsPage, ResourceHistoryPage) were read first and they agree; #5110's
own union is deliberately left untouched because it is already correct.

Answering the PM's Zone-2 question: the three picker loaders do share one small
helper
— a single usePickerLoad hook, so the catch is written once and can
only produce the error arm. They differ only in what they fetch and whether
they are gated on a bound source object, and both differences fit through one
argument (a memoised loader, or null for "not applicable", which is the idle
arm). The field and action catalogs, which ride one client.get() call, are one
state rather than two that could disagree. AuditPanel shares the type and the
error-normalising helper but keeps its own callback, because its read is
manually re-runnable from Refresh.

The one place the two cards needed opposite treatment

The dispatch was right that they differ, and this PR treats them differently —
but with one correction, stated because it changes what "additive" means here.

#5170's pickers are not without empty-state copy. They have it, and it is not
neutral — it names a cause, and on a failed load the cause it names is false:

pickerempty-state copy today
ref:objectobject_name (no objects detected) — a measurement
field-ref / view-ref / field-multiNo object bound — but an object IS bound
filter-modeBind a source object to pick filter fields.
action-multiBind a source object to pick actions

So the operator was told, in so many words, that the thing they were looking for
does not exist. No "empty" sentence was invented, per the instruction — every one
of those sentences is unchanged, and the change is that they are now reachable
only from a completed load. #5169's positive claim about the record is
likewise kept verbatim and made reachable only from loaded.

What the pickers show now

A shared PickerLoadFailure — one component for every picker, so the answer is
the same wherever the question is asked. It states that the list did not load,
shows the cause, and makes no claim in either direction about whether options
exist. Selected values stay visible and editable, because a failed catalog must
not also block authoring.

The field and view pickers also gained the loading arm. That is not scope
creep: objectFieldsLoading / objectViewsLoading already existed on
WidgetContext and were already passed by ResourceEditPage, but no picker
read them
(measured), so a catalog still in flight rendered as "No object
bound" — the same fault-as-measurement, one arm over. It is also what makes the
"the loading flag clears" half of the required test triple observable at all.

Tests

ResourceEditPage.pickerLoadFailure.test.tsx (11) and
AuditPanel.loadFailure.test.tsx (6) pin the triple per loader: a failed load
renders the failure state and not an empty list; a genuinely empty
successful
load still renders the empty list; the loading flag clears in both,
with a held promise proving the loading arm is reachable so the "no longer
loading" assertions are not vacuous either.

Measured, not assumed: Radix SelectValue does not render its placeholder
in jsdom — a closed field-ref trigger reads "— None —". Asserting on
"No object bound" would have passed for every arm and pinned nothing, so each arm
is made structurally distinct and the tests read the structure.

Reverse verification. With ResourceEditPage.tsx, widgets.tsx and
AuditPanel.tsx reverted to origin/main (i18n and loadState.ts kept, so the
red is attributable to logic and not to missing copy): 10 failed, 7 passed
every failure-state assertion red, while the empty-success and copy-parity
assertions stayed green, which is what stops them being tautologies riding on the
fix. Restored and re-run: 17/17 green, working tree byte-identical to HEAD. Both
legs run from source (the suites import these modules by relative path), so no
dist is involved in either direction.

Verification at c1c82e8b7

  • pnpm --filter '@object-ui/app-shell^...' build then
    pnpm --filter @object-ui/app-shell type-check — clean. The merge with main
    widened MetadataAuditEntry (source / requestId now required, lockState
    narrowed) and caught the new fixture; it is now typed without a cast.
  • npx vitest run packages/app-shell/src/views/metadata-admin183 files,
    1880 passed
    , 1 skipped. Plus the only two out-of-directory importers
    (view-filter-fold.ratchet, spec-symbol-parity) — 39 passed.
  • check:control-bytes, check:i18n-keys, check:i18n-drift,
    check:self-import — all pass. check:i18n-dead-keys is a report, not a gate.
  • pnpm --filter @object-ui/app-shell lint — 0 errors. AuditPanel.tsx goes
    from 2 warnings to 1.

Declared narrowing: the full app-shell suite exceeds a single run window
here, so the local run was narrowed to views/metadata-admin (which contains
every consumer of the four changed modules) plus the two importers outside it,
enumerated by grep rather than assumed. CI runs the full farm regardless.

Region declared (app-shell sub-directory exemption)

packages/app-shell/src/views/metadata-admin/: ResourceEditPage.tsx,
AuditPanel.tsx, widgets.tsx, i18n.ts, new loadState.ts, and the two new
test files. main was merged before opening this PR; it will be merged again if
a sibling app-shell card lands first, and any conflict goes to the merge queue.

widgets.tsx is a surface the claim did not name explicitly — it says "the three
option-picker loaders and their render sites", and the render sites are all in
widgets.tsx, not in ResourceEditPage.tsx as #5110's ReferencesPanel was.
There is no way to add a failure state without it. No open PR touches it.

Out of scope, filed rather than fixed

  • FieldSelectorWidget in widgets.tsx holds the same defect class in a
    fourth, unrelated loader (its own raw fetch to
    /api/v1/objects/:name/fields, catch to setFields([]) + console.error).
    It is a distinct loader with distinct plumbing and neither card names it, so it
    is filed rather than folded in.
  • The WidgetContext catalog arrays are still [] on a failed load, with the
    fault carried alongside in catalogErrors. Every picker consults it, but a
    new one that forgets would re-render the defect.

Untouched and held by others: ObjectFormDesigner / SortableField,
packages/fields/**, packages/core, packages/types, packages/plugin-grid,
packages/plugin-dashboard, packages/react.


Generated by Claude Code

os-support-aiand others added 3 commits August 18, 2026 15:12
…m an empty one (#5170, #5169)
The three option-picker loaders in ResourceEditPage and AuditPanel's audit read
all caught a failure by writing the value a successful empty response writes, so
a fault was rendered as a measurement: a completed empty picker, and a '0 events'
count plus 'No audit events yet' next to the failure banner.
All four now run through one four-arm LoadState (idle/loading/loaded/error),
matching the union #5110 landed for the References panel. The empty states are
unchanged and reachable only from a completed load.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RV6yuVCxymHYE16PL9vQkE
The merge with main added required `source` / `requestId` and narrowed
`lockState`. Fixture now type-checks without a cast, so a future change to the
audit row shape surfaces here instead of being silently accepted.
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-Dxs2_0KL.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)25.13KB5.40KB
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.95KB113.30KB
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.39KB27.03KB
plugin-map (index.js)20.02KB6.58KB
plugin-markdown (index.js)13.72KB4.69KB
plugin-report (index.js)41.97KB11.33KB
plugin-timeline (index.js)26.68KB7.66KB
plugin-tree (index.js)8.50KB2.88KB
plugin-view (index.js)83.81KB20.49KB
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 — both cards. PM round 6, session session_01RV6yuVCxymHYE16PL9vQkE.

First: my dispatch was wrong on a measured fact, and the dev caught it

My claim said — twice, and the revival note repeated it — that #5170's pickers "have no empty-state copy of their own today", and built the whole two-card framing on it: additive on #5170, gated on #5169, "opposite treatments". That is false. All five picker surfaces have empty-state copy, and it is cause-attributing rather than neutral: field-ref / view-ref / field-multi say No object boundwhile an object is bound, and ref:object says (no objects detected) — an affirmative measurement — on a request that never completed.

Root cause on my side: I inferred "no empty copy" from the loaders in ResourceEditPage.tsx without opening the render sites in widgets.tsx, where the copy actually lives. Same failure mode as my PeoplePicker exclusion earlier today — I read the file list and not the call graph. Recorded so it is not re-learned a third time: a claim about what a surface renders has to be read at the render site, not at the loader.

The consequence matters for the review, not just for the record: because the copy exists and misattributes, the two cards needed the same treatment, not opposite ones — keep every existing sentence verbatim, make it reachable only from loaded. That is what landed.

Open question — answered: A

Both cards get the identical treatment. It satisfies both acceptance criteria, it obeys the literal instruction (no empty sentence was manufactured — every one of the five sentences and #5169's positive claim is byte-unchanged), and it is the correct shape once the false premise is removed. Option B — rewording the misattributing empty copy itself — is a real second defect and correctly not taken here; it is a wording judgement on published copy, so it belongs in its own card rather than riding a fix PR.

Review

  • Scope widening to widgets.tsx is accepted. The claim named "the three option-picker loaders and their render sites", and unlike The "Used by" panel collapses every references() failure into "Safe to delete" — a server error is rendered as a measured empty result #5110's ReferencesPanel those sites are all in the sibling file. There is no way to add a failure arm without it, no open PR touches it, and it is declared at file level in the body.
  • usePickerLoad answers the Zone-2 question with evidence — one helper, so the catch is written once and can only produce the error arm. Fields+actions riding one client.get() as one state (not two that can disagree) is the right call.
  • The loading arm is not scope creep.objectFieldsLoading / objectViewsLoading already existed on WidgetContext and were already passed while no picker read them — an in-flight catalog rendering as "No object bound" is the same fault-as-measurement one arm over, and without it half the required test triple is unobservable.
  • Reverse verification is real: 10 red / 7 green pre-fix with i18n.ts + loadState.ts deliberately kept, so the red is attributable to logic rather than missing copy; the empty-success and copy-parity assertions staying green is exactly what stops them being tautologies. The AuditPanel test-id caveat is disclosed rather than papered over, and the text assertions carry non-vacuity there.
  • The main merge caught a real break (MetadataAuditEntry widened source/requestId, narrowed lockState) and it was fixed by typing the fixture, not by a cast. This is the serial discipline paying for itself.
  • Radix SelectValue not rendering its placeholder in jsdom was measured before the assertions were written, and changed the test design. Exactly the right order.

Gates

21/21 check runs completed, no failures, mergeable_state: clean. Changeset present (@object-ui/app-shell, patch). ACCEPT path surface check: .changeset/** + packages/app-shell/src/views/metadata-admin/** only — no governed surface touched (docs/adr/**, .claude/**, skills/**, AGENTS.md, CLAUDE.md), so this may flip ready and enqueue.

Marking ready for review and enqueueing. Landing via the merge queue — the only sanctioned path.

#5227 and #5228 are correctly filed rather than folded in; they go to the triage seat for first grading, not back into this PR.


Generated by Claude Code

@os-support-ai
os-support-ai marked this pull request as ready for review August 18, 2026 16:18
@os-support-ai
os-support-ai added this pull request to the merge queueAug 18, 2026
Merged via the queue into main with commit 513e614Aug 18, 2026
22 checks passed
@os-support-ai
os-support-ai deleted the claude/issue-5170-picker-load-failure-states branch August 18, 2026 16:18
os-support-ai added a commit that referenced this pull request Aug 18, 2026
`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
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

2 participants

@os-support-ai@claude