Skip to content

fix(app-shell): a failed package lookup is not an empty result (objectui#7881) - #7906

Merged
os-sam merged 1 commit into
mainfrom
claude/issue-7881-fetchfullpackage-res-ok
Sep 6, 2026
Merged

fix(app-shell): a failed package lookup is not an empty result (objectui#7881)#7906
os-sam merged 1 commit into
mainfrom
claude/issue-7881-fetchfullpackage-res-ok

Conversation

@claude

@claude claude Bot commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

Fixes #7881

The defect

fetchFullPackage — the PackageSwitcher helper behind "Package info & settings" —
fetched /api/v1/packages and went straight to res.json(), never reading res.ok.

The platform answers a failed read in the ADR-0112 envelope,
{ success: false, error: { code, message } }, and that envelope parses cleanly
through the reader below it: root becomes the error object, which is neither an array
nor carries packages, so the list fell to [] and .find() to null. Nothing threw,
so openManage's catch — the one that toasts formatMetadataError — never ran, and
the two lines after it still fired: setManage(null) then setManageOpen(true).

PackageDetailSheet starts with if (!pkg) return null, so during an outage the author
clicked the menu item and got silence: no sheet, no toast, no explanation — and
manageOpen stuck true with no rendered sheet to close it.

Third variant of the objectui#7368 family after objectui#7821 (landed as #7879): not a
lost toast and not an inverted decision, but a failure laundered into a
successful-looking empty result. An empty list is a completely legitimate success
answer, which is exactly why it must never be the value a failure produces.

What was measured before deciding what to read

GET /api/v1/packages is served by the direct-mount registrar
(@objectstack/rest package-routes.ts), which mounts first in the production stack
and is pinned by check:route-envelope at zero hand-written bodies — every failure
leaves through the shared sendError / sendThrownError. Reachable on this path:

status code where it comes from message on the wire
401 UNAUTHENTICATED the anonymous-deny floor the deny sentence
403 FORBIDDEN the studio.access / setup.access capability gate the capability sentence
503 SERVICE_UNAVAILABLE either half of the two-source merge refusing a read it could not perform generic (prose withheld)
500 INTERNAL_ERROR a fault generic (prose withheld)

All four carry one shape, pinned wire-side by package-envelope.conformance.test.ts
(body.success === false, error an object, error.code a registered code,
error.message a non-empty string).

So the envelope's own success is not a second bit here. sendOk writes true on
every 2xx and the error writers write false on every non-2xx, which makes it !res.ok
restated — reading it as a second decision input would be a tolerant path for a shape
this seam cannot produce. res.ok is the decision; the envelope is read for the
words. In the 5xx band the platform withholds the producer's prose and substitutes
the generic Internal server error (INTERNAL_ERROR_MESSAGE), leaving error.code as
the only discriminating word — which is why the code travels with the message.

A second response shape exists and is reported here as asked: a non-JSON error body,
i.e. a proxy's HTML 502/504. That is the one arm the pre-fix code already reached the
catch on — by way of res.json() rejecting — and it showed the author
Unexpected token '<'. Covered: the tolerant read names the status instead. No third
shape was found on this path.

The fix

  1. res.ok is read, and a non-2xx throws carrying the server's own error.message
    plus error.code, or HTTP nnn when the body is not the envelope at all.
  2. Reported through this file's existing posture, not a second one
    formatMetadataError on the shared studio-package-list sonner id, so one outage
    across this surface's four callers of that endpoint is one toast, not four.
    No new state machine, no new slot, no new channel.
  3. The sheet no longer opens on a null package — this card's user-visible
    deliverable. A successful list that simply does not contain the package (deleted or
    uninstalled elsewhere) now says so through the same channel rather than opening over
    nothing.

One deliberate deviation, flagged for review

The failure is not also recorded in pkgsErr. That slot is the switcher list's
state and is written exactly where pkgs is — the mount effect and onManageChanged.
openManage never writes pkgs, so the names in the trigger are precisely as current as
they were a moment ago, and marking the trigger failed would say otherwise. The two
sibling fetchPackages call sites that likewise do not write the list (the writability
courtesy gate, the namespace lookup) report the same way — shared toast id, no slot
write. Say the word if the dispatch intended the slot write too.

Evidence — the ablation

Mutation leg: the source file put back to its pre-fix state (origin/main
d9580f464), the new pins kept.

  • mutation proved on disk before any reading — HEAD blob deba94ef vs on-disk
    781e0c89, and the anchors flipped: if (!res.ok) 2 to 1,
    setManage(await fetchFullPackage(id)); 0 to 1, manageMissing 1 to 0.
  • No rebuild leg applies: the pins import ./StudioDesignSurface by relative
    specifier
    , so vitest transforms the .tsx source directly and no dist/ stands
    between the mutation and the run.
Tests  6 failed | 8 passed (14)

6 red, and the 3 negative controls green — plus all 5 of #7879's pins in the same
run. That last clause is what proves the new pins are not restating an existing
assertion, and that a "fix" which merely stopped opening the sheet could not pass this
file.

The reds are the defect itself, verbatim from the log:

  • data-managed-id="" on four of them — the sheet opened on a null package;
  • AssertionError: expected "vi.fn()" to be called at least onceopenManage's
    catch never ran
    , nothing was reported at all;
  • the non-JSON arm asserted to be 'HTTP 502' and got the pre-fix answer instead: a
    SyntaxError reading "Unexpected token", naming the opening angle bracket of the
    proxy's HTML error page. That is what the author used to be shown.

Restore leg proved by state, never by an exit code: on-disk blob back to
deba94ef = the HEAD blob, git diff HEAD --name-only empty, anchors back to
2 / 0 / 1. The script carried a trap ... EXIT INT TERM restoring an absolute
path, and the restore named HEAD explicitly (a bare git checkout -- path restores
from the index, which git checkout ref -- path had just written with the mutation).

Verification — all on the pushed commit c2c0f04f8, git diff HEAD empty

run result
the 9 new pins + objectui#7821's 5 + objectui#7368's 7 Test Files 3 passed (3) · Tests 21 passed (21)
whole studio-design/ directory Test Files 50 passed (50) · Tests 271 passed (271)
all 16 metadata-admin/ suites that read the edited i18n table Test Files 16 passed (16) · Tests 136 passed (136)
pnpm --filter @object-ui/app-shell run type-check exit 0, script name echoed
check:governed-queue-guard (--test over the 4 changed paths) ✅ NOT GOVERNED — 4 path(s) checked against 5 governed surface(s); none matched.
check:governed-queue-guard (--self-test) OK ... 132 cases pass
node scripts/check-changeset-presence.mjs ✅ 2 source file(s) of 1 released package(s) changed, and this change declares 1 changeset(s)
check:i18n-keys · check:i18n-drift · check:i18n-dead-keys exit 0
check:control-bytes ✅ check-control-bytes: OK (scanned 6393 tracked text file(s); skipped 85 binary).
check:vi-mock-specifiers · check:vi-mock-inherit exit 0

The type-check green is not vacuous: tsc -p tsconfig.test.json --listFiles lists all
three edited/added files (1 hit each), so the program really contains them.

Lint — a measured narrowing, not a skip

The repo-wide scan is CI's run. What was measured here instead:

  • Population read from eslint itself, not guessed: eslint . over the whole affected
    package linted 1079 files (count from --format json), 0 errors.
  • The 3 changed files on their own: 0 errors. The 19 warnings on StudioDesignSurface.tsx
    are all pre-existing — they sit at lines 691-4211, and every line this PR adds is in
    363-460.
  • Config invariance for untouched files: eslint.config.js extends
    tseslint.configs.recommended with languageOptions: { ecmaVersion, globals } and
    declares no parserOptions.project and no projectService — type-aware linting is
    not enabled, so every rule's verdict is a function of that file's own text plus the
    shared config. A diff confined to three files cannot move any untouched file's verdict.

Boundaries held

  • The hard-coded /home recovery destination is not touched — that belongs to
    objectui#7373, which stays open.
  • onManageChanged is byte-for-byte unchangedfix(app-shell): a failed package-list refresh is not a deletion (objectui#7821) #7879 just repaired it.
  • The hand-rolled fetch is deliberately kept: retiring it in favour of the
    packages-io reader is the card's option 3, and it cannot reuse fetchPackages
    as-is because parsePackages trims the record this sheet needs. Out of scope here.
  • Clause-② holds: no export added, no signature changed, no gate touched.

Generated by Claude Code

…tui#7881)

`fetchFullPackage` — the `PackageSwitcher` helper behind "Package info &
settings" — fetched `/api/v1/packages` and went straight to `res.json()`,
never reading `res.ok`. The platform answers a failed read in the ADR-0112
envelope, `{ success: false, error: { code, message } }`, and that envelope
parses cleanly through the reader below it: `root` becomes the error object,
which is neither an array nor carries `packages`, so the list fell to `[]` and
`.find()` to `null`. Nothing threw, so `openManage`'s `catch` — the one that
toasts `formatMetadataError` — never ran, and the two lines after it still
fired: `setManage(null)` then `setManageOpen(true)`.

`PackageDetailSheet` renders `null` for a null package, so during an outage the
author clicked the menu item and got silence: no sheet, no toast, no
explanation — and `manageOpen` stuck true with no rendered sheet to close it.
Third variant of the objectui#7368 family after objectui#7821 (PR #7879): not a
lost toast and not an inverted decision, but a failure laundered into a
successful-looking empty result. An empty list is a completely legitimate
success answer, which is exactly why it must never be the value a failure
produces.

Measured before deciding what to read. `GET /api/v1/packages` is served by the
direct-mount registrar, which mounts first in the production stack and is
pinned at zero hand-written bodies, so every failure leaves through the shared
`sendError` / `sendThrownError`: 401 UNAUTHENTICATED, 403 FORBIDDEN, 503
SERVICE_UNAVAILABLE and 500 INTERNAL_ERROR, all one shape. The envelope's own
`success` is therefore not a second bit here — `sendOk` writes `true` on every
2xx and the error writers `false` on every non-2xx, which is `!res.ok`
restated. So `res.ok` is the decision and the envelope is read for the words;
in the 5xx band the platform withholds the producer's prose for the generic
`Internal server error`, leaving `error.code` as the only discriminating word,
so the code travels with the message. A non-JSON error body — the one other
reachable shape, a proxy's HTML 502/504 — names the status instead of the JSON
syntax error the author used to be shown.

Reported through this surface's existing posture, not a second one:
`formatMetadataError` on the shared `studio-package-list` sonner id, so one
outage across this surface's four callers of that endpoint is one toast rather
than four. Deliberately not also recorded in `pkgsErr`: that slot is the
switcher list's own state, written exactly where `pkgs` is, and this callback
never writes `pkgs`.

And the sheet no longer opens on a `null` package at all — this card's
user-visible deliverable. A successful list that does not contain the package
(deleted or uninstalled elsewhere) now says so.

Nine behavioural pins in StudioDesignSurface.packageLookupFailure.test.tsx.
Three of them are negative controls that stay green with the fix reverted, so
the other six are provably not restating an existing assertion — and a "fix"
that merely stopped opening the sheet cannot pass the file.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KbJQ1y1J12nZxYzFWhP8Q3
@github-actions

github-actions Bot commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

Metric Value Budget
Eager closure (gzip, 50 chunks) 3187.4 KB 3191.4 KB
Main entry chunk (gzip) 143.5 KB 350 KB
Entry file index-BKsUYTX3.js
Status PASS

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

Package Size Gzipped
app-shell (consoleActionDispatch.js) 0.20KB 0.19KB
app-shell (index.js) 15.67KB 5.75KB
app-shell (runtime-config.js) 20.68KB 7.36KB
app-shell (types.js) 0.01KB 0.04KB
app-shell (urlParams.js) 10.06KB 3.86KB
auth (ActiveOrganizationStorage.js) 25.05KB 9.16KB
auth (AuthContext.js) 0.31KB 0.24KB
auth (AuthGuard.js) 2.07KB 1.00KB
auth (AuthProvider.js) 40.18KB 10.59KB
auth (AuthShell.js) 3.49KB 1.40KB
auth (ForgotPasswordForm.js) 12.21KB 3.45KB
auth (LoginForm.js) 18.15KB 5.39KB
auth (PreviewBanner.js) 0.90KB 0.50KB
auth (RegisterForm.js) 6.65KB 2.22KB
auth (SocialSignInButtons.js) 9.61KB 3.89KB
auth (UserMenu.js) 3.41KB 1.23KB
auth (auth-gate-events.js) 1.29KB 0.66KB
auth (authStyles.js) 5.04KB 1.72KB
auth (createAuthClient.js) 40.21KB 10.80KB
auth (createAuthenticatedFetch.js) 8.46KB 3.43KB
auth (index.js) 3.19KB 1.44KB
auth (invitation-status.js) 1.22KB 0.70KB
auth (org-roles.js) 6.66KB 2.78KB
auth (phone-identifier.js) 1.11KB 0.66KB
auth (types.js) 0.59KB 0.35KB
auth (useAuth.js) 5.30KB 1.02KB
auth (useWorkspaceAdminStatus.js) 5.13KB 2.35KB
collaboration (CommentThread.js) 26.08KB 7.56KB
collaboration (LiveCursors.js) 3.17KB 1.27KB
collaboration (PresenceAvatars.js) 6.49KB 2.64KB
collaboration (PresenceProvider.js) 2.79KB 1.13KB
collaboration (index.js) 1.68KB 0.73KB
collaboration (useCollaborationTranslation.js) 6.05KB 2.52KB
collaboration (useCommentSearch.js) 1.98KB 0.88KB
collaboration (useConflictResolution.js) 7.75KB 1.86KB
collaboration (useMentionNotifications.js) 1.81KB 0.68KB
collaboration (usePresence.js) 6.33KB 1.84KB
collaboration (useRealtimeSubscription.js) 7.91KB 2.01KB
components (index.js) 510.60KB 116.20KB
core (index.js) 6.96KB 2.79KB
create-plugin (index.js) 10.08KB 3.26KB
data-objectstack (index.js) 182.08KB 50.62KB
fields (index.js) 242.44KB 61.25KB
i18n (LocalizationContext.js) 1.76KB 0.96KB
i18n (builtinAggregateLabels.js) 0.86KB 0.49KB
i18n (currency.js) 1.22KB 0.64KB
i18n (fallbackInterpolation.js) 6.25KB 2.77KB
i18n (i18n.js) 4.28KB 1.75KB
i18n (index.js) 3.65KB 1.47KB
i18n (pickLocalized.js) 7.62KB 3.26KB
i18n (provider.js) 26.89KB 9.04KB
i18n (useDisplayLocale.js) 2.85KB 1.45KB
i18n (useObjectLabel.js) 34.34KB 9.17KB
i18n (useSafeTranslation.js) 5.60KB 2.33KB
layout (index.js) 38.98KB 10.98KB
mobile (MobileProvider.js) 0.92KB 0.49KB
mobile (ResponsiveContainer.js) 0.94KB 0.38KB
mobile (breakpoints.js) 1.51KB 0.70KB
mobile (createOfflineDataSource.js) 5.61KB 1.75KB
mobile (index.js) 1.99KB 0.87KB
mobile (offlineQueue.js) 3.91KB 1.35KB
mobile (pwa.js) 0.97KB 0.49KB
mobile (serviceWorker.js) 1.48KB 0.62KB
mobile (serviceWorkerSource.js) 3.41KB 1.48KB
mobile (useBreakpoint.js) 1.54KB 0.65KB
mobile (useGesture.js) 6.96KB 1.98KB
mobile (useOfflineSync.js) 1.99KB 0.72KB
mobile (usePullToRefresh.js) 2.53KB 0.85KB
mobile (useResponsive.js) 0.72KB 0.42KB
mobile (useSpecGesture.js) 4.39KB 1.66KB
mobile (useTouchTarget.js) 1.01KB 0.54KB
permissions (MePermissionsProvider.js) 11.71KB 4.29KB
permissions (PermissionContext.js) 0.31KB 0.25KB
permissions (PermissionGuard.js) 0.89KB 0.45KB
permissions (PermissionProvider.js) 6.24KB 2.16KB
permissions (discardProofCache.js) 1.04KB 0.55KB
permissions (evaluator.js) 5.12KB 1.74KB
permissions (index.js) 0.93KB 0.41KB
permissions (store.js) 0.91KB 0.42KB
permissions (useFieldPermissions.js) 1.28KB 0.53KB
permissions (usePermissions.js) 4.83KB 2.27KB
plugin-ai (index.js) 15.75KB 3.80KB
plugin-calendar (index.js) 47.87KB 13.31KB
plugin-charts (index.js) 70.92KB 19.75KB
plugin-chatbot (index.js) 196.19KB 46.37KB
plugin-dashboard (index.js) 132.88KB 34.69KB
plugin-designer (index.js) 212.86KB 43.19KB
plugin-detail (index.js) 250.55KB 64.06KB
plugin-editor (index.js) 2.46KB 1.10KB
plugin-form (index.js) 132.87KB 32.66KB
plugin-gantt (index.js) 167.26KB 41.00KB
plugin-grid (index.js) 209.29KB 56.78KB
plugin-kanban (index.js) 52.71KB 14.55KB
plugin-list (index.js) 113.76KB 27.75KB
plugin-map (index.js) 20.44KB 6.78KB
plugin-markdown (index.js) 13.93KB 4.81KB
plugin-report (index.js) 43.59KB 11.97KB
plugin-timeline (index.js) 30.63KB 8.80KB
plugin-tree (index.js) 9.20KB 3.19KB
plugin-view (index.js) 85.24KB 20.94KB
providers (DataSourceProvider.js) 0.75KB 0.39KB
providers (MetadataProvider.js) 1.37KB 0.59KB
providers (ThemeProvider.js) 1.90KB 0.85KB
providers (UploadProvider.js) 11.66KB 3.50KB
providers (index.js) 0.45KB 0.23KB
providers (types.js) 0.01KB 0.04KB
react-runtime (index.js) 5.62KB 2.34KB
react (LazyPluginLoader.js) 4.47KB 1.63KB
react (SchemaRenderer.js) 81.07KB 26.86KB
react (data-invalidation.js) 5.05KB 2.08KB
react (index.js) 4.63KB 2.18KB
react (schema-input.js) 2.32KB 1.24KB
react (spec-input.js) 0.20KB 0.18KB
sdui-parser (codegen.js) 5.41KB 2.34KB
sdui-parser (dashboard-widget-options.js) 3.08KB 1.30KB
sdui-parser (index.js) 4.93KB 2.24KB
sdui-parser (input-type.js) 2.84KB 1.40KB
sdui-parser (parse.js) 20.57KB 5.88KB
sdui-parser (provenance.js) 3.66KB 1.82KB
sdui-parser (types.js) 0.28KB 0.23KB
sdui-parser (validate.js) 10.35KB 3.60KB
types (ai.js) 0.20KB 0.17KB
types (api-types.js) 0.20KB 0.18KB
types (app.js) 2.87KB 1.00KB
types (base.js) 0.20KB 0.18KB
types (blocks.js) 0.20KB 0.18KB
types (complex.js) 2.74KB 1.41KB
types (crud.js) 0.20KB 0.18KB
types (dashboard-filter-alias.js) 6.23KB 2.74KB
types (data-display.js) 3.75KB 1.85KB
types (data-protocol.js) 0.20KB 0.19KB
types (data.js) 0.20KB 0.18KB
types (designer.js) 1.85KB 0.85KB
types (disclosure.js) 0.20KB 0.18KB
types (error-code.js) 1.54KB 0.88KB
types (expression.js) 0.20KB 0.18KB
types (feedback.js) 0.20KB 0.18KB
types (field-types.js) 0.20KB 0.18KB
types (form.js) 0.20KB 0.18KB
types (http-inflight.js) 8.87KB 3.73KB
types (http-retry.js) 4.32KB 2.02KB
types (icon-key-migration.js) 4.26KB 1.63KB
types (index.js) 4.74KB 2.25KB
types (layout.js) 0.20KB 0.18KB
types (managed-by.js) 0.19KB 0.18KB
types (mobile.js) 4.73KB 2.28KB
types (navigation.js) 0.20KB 0.18KB
types (objectql.js) 0.20KB 0.18KB
types (overlay.js) 0.20KB 0.18KB
types (permissions.js) 0.20KB 0.18KB
types (plugin-scope.js) 0.20KB 0.18KB
types (record-components.js) 0.20KB 0.19KB
types (record-semantics.js) 1.28KB 0.67KB
types (registry.js) 0.20KB 0.18KB
types (reports.js) 0.20KB 0.18KB
types (select-option.js) 0.20KB 0.19KB
types (spec-report.js) 5.05KB 1.93KB
types (spec-ui-namespace.js) 0.20KB 0.19KB
types (system-fields.js) 3.33KB 1.54KB
types (theme.js) 6.28KB 2.87KB
types (ui-action.js) 8.11KB 3.32KB
types (views.js) 0.20KB 0.18KB
types (widget.js) 0.20KB 0.18KB

Size Limits

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

@os-sam
os-sam marked this pull request as ready for review September 6, 2026 02:47
@os-sam
os-sam added this pull request to the merge queue Sep 6, 2026
Merged via the queue into main with commit f5d2acc Sep 6, 2026
34 checks passed
@os-sam
os-sam deleted the claude/issue-7881-fetchfullpackage-res-ok branch September 6, 2026 03:00
os-sam pushed a commit that referenced this pull request Sep 6, 2026
…cord (objectui#7907)

The tail of `onManageChanged` — the managed-snapshot refresh that runs after every
lifecycle action fired from the `PackageDetailSheet` — swallowed a failed
`fetchFullPackage` under a bare `catch {}` commented "keep the current snapshot".
That snapshot is one the action itself had just made stale, so the author disabled a
package, was told nothing, and went on reading `Status: Enabled`.

`PackageDetailSheet` derives its lifecycle verb from the record it holds (`enabled`
picks both the button label and the endpoint it POSTs), so leaving it open over a
snapshot known to be pre-action re-armed the author with the verb they had just
fired. The failure is now reported through this surface's existing objectui#7368
posture — `formatMetadataError` on the shared `studio-package-list` sonner id, so one
outage that rejects both halves of this callback is still one toast — and the sheet
closes rather than present the pre-action record as current. Still a degradation and
never a throw: the editor, the top bar and the package list stay, and no navigation
is inferred from a refresh that could not happen (objectui#7821).

The same tail dropped `fresh === null` — a successful read whose list no longer
contains the package — just as quietly, and now reports it with the sentence
`openManage` already uses.

Not recorded in `pkgsErr`, measured rather than inherited: that slot is written
exactly where `pkgs` is — the mount effect and this callback's HEAD. The tail writes
neither; it writes `manage`. The head has just recorded the list's own verdict, so
writing the slot from here would mark the trigger `failed` over names the head
refreshed successfully a moment ago.

Pre-existing, and objectui#7881 (PR #7906) made it much easier to hit rather than
causing it: before that fix this `catch` could only ever see a `res.json()`
rejection; now that `fetchFullPackage` refuses a non-2xx it also swallowed every
401 / 403 / 503 / 500.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KbJQ1y1J12nZxYzFWhP8Q3
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

finding(app-shell): fetchFullPackage never reads res.ok, so a failed package lookup opens the management sheet on a null package - silently

2 participants