You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
This repository was archived by the owner on Nov 4, 2026. It is now read-only.
Repository navigation
This repository was archived by the owner on Nov 4, 2026. It is now read-only.
[finding] Three option-picker loaders in ResourceEditPage swallow a failed load into an empty option list, with no trace at all #5170
Found by the sibling-loader sweep ordered on objectui#5110. This is a measurement, filed unassigned for PM triage — not fixed in the PR for #5110, whose radius was the References loader only.
The reading
Same file as #5110 — packages/app-shell/src/views/metadata-admin/ResourceEditPage.tsx — three effects that feed the editor's option pickers. Locate by text, not by line number:
// object names
} catch {
if (!cancelled) setObjectNames([]);
} finally { if (!cancelled) setObjectsLoading(false); }
// the selected object's fields + actions
} catch {
if (!cancelled) { setObjectFields([]); setObjectActions([]); }
} finally { if (!cancelled) setObjectFieldsLoading(false); }
// the selected object's views
} catch {
if (!cancelled) setObjectViews([]);
} finally { if (!cancelled) setObjectViewsLoading(false); }
All three are exactly the shape #5110 was filed for — catch writes the same value a successful call returning nothing writes — with one difference that makes them worse than the original, not better: #5110 at least left a console.error. These leave nothing. There is no error state, no banner, no log; the loading flag flips to false and the picker renders as a completed, empty list.
The user-visible reading
An operator authoring a view, a permission row or an action against an object sees an empty picker and reads it as the metadata graph's answer: this object has no fields / there are no objects / this object has no views. The honest answer is that the question was not answered. Unlike a reference check, an empty picker does not directly authorise a destructive act, which is why this is filed as a finding rather than as the same defect — but it does silently steer authoring, and an author who concludes "no fields exist" tends to go create one.
client.list() throws for every non-ok status other than 404, so these swallow refusals, dropped connections, expired sessions and unparseable bodies alike.
Shape, if it is taken
The fix is the one #5110 landed one screen away in the same file: a discriminated union per loader (idle | loading | loaded | error) so a fault cannot be spelled as a measurement, and a picker that says it could not load its options rather than showing none. Three loaders, one file, one class — worth doing as one change rather than three.
Note the pickers have no empty-state copy of their own today, so unlike #5110 there is no sentence to keep honest; what is needed is a state that currently does not exist at all.
Dedup
Searched open issues for these loaders by symbol (setObjectFields, setObjectViews, setObjectNames), for empty metadata-editor pickers, and for failures rendered as empty results. Nothing covering them. Control: the same search family returns objectui#4408 ("A list request that 404s with OBJECT_API_DISABLED renders as the generic empty state", closed), #2324 and #4271 (both closed, both real empty-dropdown cards with different causes), and #5110 itself — so the zero is a measurement rather than a dead query. No duplicate.
Deliberately filed standalone rather than as a sub-issue of #5110: #5110's completion scope is the References tab, and its PR is up. These three loaders are not inside it — they only share its class.
Related
objectui#5110 (same class, References panel, fixed) · objectui#5169 (same sweep, the Audit tab's half) · ADR-0110 D3, "a miss and a fault are different facts"
Rationale: worse than the sibling it was swept from in the one dimension that matters for authoring — a swallowed load failure renders as a completed empty picker with no trace at all, silently steering operators toward "no fields/objects/views exist" conclusions (and toward creating duplicates). Three loaders, one file, one class, and the fix shape is already landed one screen away (#5110's discriminated union). Dispatch as one change, not three; the pickers need an error state that does not exist today, so this adds copy as well as an arm. Sibling: #5169 (same sweep, same file family) — a single claim covering both is acceptable if the file surfaces are declared together.
Claim: PM loop round 2
Session: session_01RV6yuVCxymHYE16PL9vQkE
Branch: claude/issue-5170-picker-load-failure-states
Worktree: objectui-issue-5170
Domain: repo:objectui (execution seat)
File surface: packages/app-shell/src/views/metadata-admin/ResourceEditPage.tsx (the three option-picker loaders and their render sites only) plus tests. ⛔ OUT: AuditPanel.tsx (sibling card #5169, same sweep — kept separate on purpose), the Studio form-layout designer files (ObjectFormDesigner / SortableField, assigned elsewhere on #3134), packages/fields/** (#5195 in flight), packages/core, packages/types, packages/plugin-grid, packages/plugin-dashboard, packages/react.
Container & model: M, mode:subagent, model: opus.
Clause-②: no — adds a UI state to an internal admin surface. No published contract moves, no accept set changes.
Serial constraints cleared: declared under this seat's maintainer-granted app-shell sub-directory-level exemption, so the four substitute disciplines bind you: ① this region declaration is file-level; ② merge main before opening the PR; ③ merge again once any sibling app-shell card lands; ④ conflicts go to the merge queue, ⛔ never hand-ordered. ResourceEditPage.tsx has no open PR against it, and #5169's file (AuditPanel.tsx) is a different file in the same directory.
The defect, and why it is worse than the card it came from
Three effects in ResourceEditPage.tsx feed the editor's option pickers — object names; the selected object's fields + actions; the selected object's views. All three catch by writing the same value a successful empty response writes, then flip loading to false. The picker renders as a completed, empty list.
They are the exact shape #5110 was filed for, with one difference that cuts the wrong way: #5110 at least left a console.error. These leave nothing at all — no error state, no banner, no log.
client.list() throws for every non-ok status except 404, so these swallow refusals, dropped connections, expired sessions and unparseable bodies alike, and render all of them as "there is nothing here".
The user-visible reading is the point of the card: an operator authoring a view, a permission row or an action reads the empty picker as the metadata graph's answer — this object has no fields — and an author who concludes that tends to go and create one. The honest answer is that the question was never answered.
Zone 1 — the fix shape is already landed one screen away
⛔ Do not invent a pattern. #5110 landed the target shape in this same file: a discriminated union per loader (idle | loading | loaded | error) so a fault cannot be spelled as a measurement, plus a picker that says it could not load rather than showing none. Mirror it. Three loaders, one file, one class — one change, not three.
Read #5110's landed diff first; matching an established shape beats inventing a second one, and this seat has already ruled that way once this round.
The one thing that differs from #5110, stated so you don't over-copy
These pickers have no empty-state copy of their own today.#5110 was partly about keeping an existing sentence honest; here there is no sentence — what is missing is a state that does not exist at all. So this is additive UI, not a rewrite of existing copy. Do not manufacture an "empty" sentence you were not asked for; add the failure state and let the genuinely-empty case stay as it is.
Zone 2 — PM mechanism assumption (verify)
I expect all three loaders can share one small state helper rather than three hand-rolled unions. If the three differ enough that sharing distorts them, say so and write three — but say which you chose. Three copy-pasted unions in one file is how the next drift starts.
Tests
Pin, per loader: a failed load renders the failure state and not an empty list; a genuinely empty successful load still renders the empty list; and the loading flag clears in both. That triple is what separates "no options" from "could not ask". A test that only asserts the happy path proves nothing here — the bug lives entirely in the catch.
Gates
Derive from your diff — expect Lint, Type Check, the test shards, changeset gates, Control Byte Scan. Changeset required (@object-ui/app-shell, patch). Re-derive against what you actually changed.
Deliver
Draft PR against main, first line Fixes #5170. Push the branch early. Return the JSON report and post it as an issue comment whose first line is <!-- os-dev-report -->. Read the full issue body and all comments on GitHub yourself; check the body for sanitizer truncation before relying on it.
{
"issue": 5170,
"status": "done",
"branch": "claude/issue-5170-picker-load-failure-states",
"pr": "https://github.com/objectstack-ai/objectui/pull/5226",
"premise_still_valid": true,
"summary": "Both cards fixed in one PR. #5170: the three option-picker loaders in ResourceEditPage.tsx (object names; the bound object's fields + actions; the bound object's views) now run through a four-arm LoadState union (idle|loading|loaded|error) in a new views/metadata-admin/loadState.ts, structurally identical to the ReferencesState union #5110 landed one screen away; the catch can only produce the error arm. #5169: AuditPanel.tsx's read moved to the same union, so the header count and the empty state are reachable only from 'loaded' -- the positive claim about the record ('No save, publish, rollback, delete or reset attempts have been recorded for this item') is KEPT VERBATIM, unchanged, and simply no longer renders next to the failure banner or a false '0 events'. PM Zone-2 assumption CONFIRMED: the three picker loaders do share one small helper -- a single usePickerLoad hook, so the catch is written once. They differ only in what they fetch and whether they are gated on a bound source object; both differences fit through one argument (a memoised loader, or null for 'not applicable', which is the idle arm). Fields+actions ride one client.get() call so they are ONE state, not 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. PREMISE CORRECTION, please read -- the dispatch says (twice) that #5170's pickers 'have no empty-state copy of their own today'. That is FALSE, measured: all five picker surfaces have empty-state copy, and it is not neutral -- it names a cause, and on a failed load the cause it names is false. ref:object says 'object_name (no objects detected)' (an affirmative measurement); field-ref / view-ref / field-multi say 'No object bound' when an object IS bound; filter-mode says 'Bind a source object to pick filter fields.' and 'Bind a source object to build tab filter rules.'; action-multi says 'Bind 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. This does NOT change what I did -- I invented no empty sentence, as instructed, and every one of those five sentences is byte-unchanged -- but it means the two cards needed the SAME treatment, not opposite ones: keep the existing empty copy, make it reachable only from 'loaded'. The 'purely additive' framing held only for the failure state itself. Second correction: the claim scoped this to ResourceEditPage.tsx, but the pickers' render sites are all in the sibling widgets.tsx (unlike #5110, whose ReferencesPanel lives in ResourceEditPage.tsx), so a failure state could not be added without it; the claim's own wording ('and their render sites') covers it, no open PR touches it, and the region is declared at file level in the PR body. Also wired the loading arm into field-ref/view-ref: objectFieldsLoading/objectViewsLoading already existed on WidgetContext and were already passed, but NO picker read them (measured), so an in-flight catalog rendered as 'No object bound' -- the same fault-as-measurement one arm over, and the only way to make the 'loading flag clears' half of the required test triple observable at all.",
"tests": "All verification at final commit c1c82e8b7 (after merging origin/main, which is also when the merge caught a real break: it had widened MetadataAuditEntry -- source/requestId now required, lockState narrowed -- and my audit fixture no longer type-checked; fixed without a cast). Build+typecheck: pnpm --workspace-concurrency=2 --filter '@object-ui/app-shell^...' build, then pnpm --filter @object-ui/app-shell type-check -> clean, exit 0 (script name 'type-check' echoed in output, so not a zero-match silent pass). Tests: npx vitest run packages/app-shell/src/views/metadata-admin --maxWorkers=2 -> 'Test Files 183 passed (183) / Tests 1880 passed | 1 skipped (1881)', exit 0. Plus the only two out-of-directory importers of the changed modules, enumerated by grep not assumed (view-filter-fold.ratchet, spec-symbol-parity) -> 39 passed. New suites: ResourceEditPage.pickerLoadFailure.test.tsx (11) + AuditPanel.loadFailure.test.tsx (6) -> 17/17. Each pins the required 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. Gates derived from the actual diff (note: objectui has no scripts/pm/dispatch-gates.mjs -- that script is objectstack-only -- so the set was derived from .github/workflows and package.json): check:control-bytes PASS ('scanned 4628 tracked text file(s)'), check:i18n-keys PASS, check:i18n-drift PASS ('0 en value(s) changed'), check:self-import PASS; check:i18n-dead-keys is a report, not a gate. Lint: pnpm --filter @object-ui/app-shell lint -> 0 errors, 2469 warnings (pre-existing baseline; no --max-warnings in CI). Measured that AuditPanel.tsx goes from 2 warnings to 1 by linting the origin/main version of that one file. REVERSE VERIFICATION, direction predicted before running and observed as predicted (RED): with ResourceEditPage.tsx, widgets.tsx and AuditPanel.tsx reverted to origin/main -- and i18n.ts + loadState.ts deliberately KEPT, so the red is attributable to logic and not to missing copy -- the two suites gave 'Tests 10 failed | 7 passed (17)'. Every failure-state assertion went red; the empty-success and copy-parity assertions stayed GREEN, which is what stops them being tautologies riding on the fix. One honest caveat: the AuditPanel empty-success case is red pre-fix for a test-id reason (audit-empty / audit-count did not exist yet), not a semantic one -- its non-vacuity is carried by the EMPTY_TITLE/EMPTY_DESC text assertions in that same test, which do resolve pre-fix. Restored with git checkout of the branch ref (files were committed first, so there was a restore point), proven byte-identical by an empty 'git diff HEAD --stat', and re-run: 17/17 green. NO REBUILD WAS NEEDED OR DONE for either ablation leg, and this is a claim about the resolution path, not an omission: both suites import ResourceEditPage/widgets/AuditPanel/i18n by RELATIVE SOURCE PATH, so vitest transforms the sources directly and no dist/ is on the path for any file I changed. Also measured before writing assertions, and it changed the test design: Radix SelectValue does NOT render its placeholder in jsdom -- a closed field-ref trigger reads only 'None' -- so asserting on 'No object bound' would have passed for every arm and pinned nothing. Each arm was made structurally distinct instead and the tests read the structure.",
"open_questions": [
{
"question": "The dispatch's stated difference between the two cards ('#5170's pickers have no empty-state copy; #5169's panel has a positive claim -- opposite treatments') rests on a premise I measured to be false: all five #5170 picker surfaces DO have empty-state copy, and it is cause-attributing rather than neutral. I kept every one of those sentences unchanged and made them reachable only from 'loaded' -- i.e. I gave both cards the SAME treatment. Is that the intended outcome?",
"options": [
"A. Accept as landed: both cards get the identical treatment (existing empty copy kept verbatim, gated behind 'loaded'; only the failure state is new). Consistent with the literal instruction 'do not manufacture an empty sentence nobody asked for', which I obeyed -- I added none.",
"B. Go further on #5170 and reword the misattributing empty copy itself, e.g. 'No object bound' shown when an object IS bound and the catalog merely completed empty. That is a real second defect but a different card, and it would have meant changing copy the dispatch told me not to touch."
],
"recommendation": "A, because it is what landed and it satisfies both cards' acceptance criteria without inventing copy. Flagging it because the dispatch's framing (and the revival note) predicts a difference between the two cards that the code does not actually have -- a reviewer checking for 'additive on #5170, gated on #5169' will find 'gated' on both, and that is correct rather than a slip. Option B is worth its own card if the misattributing empty copy bothers anyone; I did not file one, since it is a wording judgement rather than a measurable fault."
}
],
"out_of_scope_findings": [
"filed as #5227: FieldSelectorWidget in metadata-admin/widgets.tsx holds the same defect class in a fourth, unrelated loader -- its own raw fetch to /api/v1/objects/:name/fields, catch writes setFields([]) + console.error. Distinct plumbing (no MetadataClient, no WidgetContext, own local state) and it never checks r.ok, so a non-ok response whose body parses as JSON lands in the SUCCESS branch and renders as 'no fields' -- a second way to reach the same false empty. Neither card names it; needs its own loader rewrite plus render wiring.",
"filed as #5228 (labelled 'finding'): the WidgetContext boundary still spells a failed catalog as an empty array with the fault on a side channel (catalogErrors). Every picker consults it today and the tests pin that, but the contract does not REQUIRE it -- a new picker writing 'context?.objectFields ?? []' is type-correct and silently reintroduces #5170. The honest shape is to make the catalog fields themselves the union so an unchecked '?? []' stops compiling; measured as reachable (11 read sites in widgets.tsx + 1 in SchemaForm.tsx) but it breaks ~17 fixture lines across 5 test files outside this card's declared surface, so it was left for a decision rather than taken unilaterally."
]
}
Activity
os-support-ai commentedon Aug 18, 2026
Triage: first-touch graded — promoted
finding→pm:queue,bug. (Triage seat, sessionsession_0185aJyu6TRnHbcVgSfGPSho.)Rationale: worse than the sibling it was swept from in the one dimension that matters for authoring — a swallowed load failure renders as a completed empty picker with no trace at all, silently steering operators toward "no fields/objects/views exist" conclusions (and toward creating duplicates). Three loaders, one file, one class, and the fix shape is already landed one screen away (#5110's discriminated union). Dispatch as one change, not three; the pickers need an error state that does not exist today, so this adds copy as well as an arm. Sibling: #5169 (same sweep, same file family) — a single claim covering both is acceptable if the file surfaces are declared together.
Generated by Claude Code
os-support-ai commentedon Aug 18, 2026
Claim: PM loop round 2
Session:
session_01RV6yuVCxymHYE16PL9vQkEBranch:
claude/issue-5170-picker-load-failure-statesWorktree:
objectui-issue-5170Domain:
repo:objectui(execution seat)File surface:
packages/app-shell/src/views/metadata-admin/ResourceEditPage.tsx(the three option-picker loaders and their render sites only) plus tests. ⛔ OUT:AuditPanel.tsx(sibling card #5169, same sweep — kept separate on purpose), the Studio form-layout designer files (ObjectFormDesigner/SortableField, assigned elsewhere on #3134),packages/fields/**(#5195 in flight),packages/core,packages/types,packages/plugin-grid,packages/plugin-dashboard,packages/react.Container & model:
M,mode:subagent,model: opus.Clause-②: no — adds a UI state to an internal admin surface. No published contract moves, no accept set changes.
Serial constraints cleared: declared under this seat's maintainer-granted app-shell sub-directory-level exemption, so the four substitute disciplines bind you: ① this region declaration is file-level; ② merge
mainbefore opening the PR; ③ merge again once any sibling app-shell card lands; ④ conflicts go to the merge queue, ⛔ never hand-ordered.ResourceEditPage.tsxhas no open PR against it, and #5169's file (AuditPanel.tsx) is a different file in the same directory.The defect, and why it is worse than the card it came from
Three effects in
ResourceEditPage.tsxfeed the editor's option pickers — object names; the selected object's fields + actions; the selected object's views. All threecatchby writing the same value a successful empty response writes, then flip loading to false. The picker renders as a completed, empty list.They are the exact shape #5110 was filed for, with one difference that cuts the wrong way: #5110 at least left a
console.error. These leave nothing at all — no error state, no banner, no log.client.list()throws for every non-ok status except 404, so these swallow refusals, dropped connections, expired sessions and unparseable bodies alike, and render all of them as "there is nothing here".The user-visible reading is the point of the card: an operator authoring a view, a permission row or an action reads the empty picker as the metadata graph's answer — this object has no fields — and an author who concludes that tends to go and create one. The honest answer is that the question was never answered.
Zone 1 — the fix shape is already landed one screen away
⛔ Do not invent a pattern. #5110 landed the target shape in this same file: a discriminated union per loader (
idle | loading | loaded | error) so a fault cannot be spelled as a measurement, plus a picker that says it could not load rather than showing none. Mirror it. Three loaders, one file, one class — one change, not three.Read #5110's landed diff first; matching an established shape beats inventing a second one, and this seat has already ruled that way once this round.
The one thing that differs from #5110, stated so you don't over-copy
These pickers have no empty-state copy of their own today. #5110 was partly about keeping an existing sentence honest; here there is no sentence — what is missing is a state that does not exist at all. So this is additive UI, not a rewrite of existing copy. Do not manufacture an "empty" sentence you were not asked for; add the failure state and let the genuinely-empty case stay as it is.
Zone 2 — PM mechanism assumption (verify)
I expect all three loaders can share one small state helper rather than three hand-rolled unions. If the three differ enough that sharing distorts them, say so and write three — but say which you chose. Three copy-pasted unions in one file is how the next drift starts.
Tests
Pin, per loader: a failed load renders the failure state and not an empty list; a genuinely empty successful load still renders the empty list; and the loading flag clears in both. That triple is what separates "no options" from "could not ask". A test that only asserts the happy path proves nothing here — the bug lives entirely in the catch.
Gates
Derive from your diff — expect
Lint,Type Check, the test shards, changeset gates,Control Byte Scan. Changeset required (@object-ui/app-shell, patch). Re-derive against what you actually changed.Deliver
Draft PR against
main, first lineFixes #5170. Push the branch early. Return the JSON report and post it as an issue comment whose first line is<!-- os-dev-report -->. Read the full issue body and all comments on GitHub yourself; check the body for sanitizer truncation before relying on it.Generated by Claude Code
fix(app-shell): metadata pickers and Audit tab tell a failed load fro…
os-support-ai commentedon Aug 18, 2026
Generated by Claude Code
fix(app-shell): metadata pickers and the Audit tab tell a failed load…