Skip to content

fix(console): an unloadable app list is UNKNOWN, not "no default app"; wire the Applications page's writes (#4233) - #4300

Merged
yinlianghui merged 1 commit into
mainfrom
claude/issue-4233-default-app-landing
Aug 11, 2026
Merged

fix(console): an unloadable app list is UNKNOWN, not "no default app"; wire the Applications page's writes (#4233)#4300
yinlianghui merged 1 commit into
mainfrom
claude/issue-4233-default-app-landing

Conversation

@yinlianghui

Copy link
Copy Markdown
Collaborator

Fixes #4233. Touches the landing resolver #4048 last changed, and finishes the entry-path work #4042 started.

Premise check first — one of the two halves had partly expired

Rule: verify the card against origin/main before implementing. Half A's stated chain has four links, and link 3 turned out to be about a build that is no longer main.

QA ran vendored console 09987b68 (2026-08-09). At that commit the root route was:

Route path="/" element={ConnectedShell wrapping RootLandingRedirect}

— no auth guard above the resolver, so an unauthenticated visitor really did run the whole resolution against a list emptied by a 401, Navigated to /home, and the guard on /home then captured that already-wrong path into ?redirect=%2Fhome. #4042 (commit 41d602274, 2026-08-10 — one day after that build, one day before this card was filed) wrapped / in ProtectedRoute. On current main an unauthenticated visitor never mounts the resolver at all, and LoginRedirect declines to capture the bare / (redirect !== '/'), so LoginPage falls back to / and the resolver re-runs post-auth against a real list.

So the cold-sign-in reproduction is already green on main — not because of anything in this PR. The card's re-verification at bb68488 checked RootLandingRedirect.tsx (correctly: unchanged) and carried link 3 forward from the observation, which was made on the older bundle.

What has not expired is the principle, and the defect it names is still live. The guard proves a session resolved; it does not prove GET /meta/app succeeded. Behind a valid session — server restart, 5xx, a request racing a session refresh — ensureType catches and resolves [], loading goes false, nothing rejects, and the error arrives wearing exactly the shape of the answer. resolveLandingPath([]) then reports "this deployment has no default app" about a deployment it never managed to ask, and Navigate … replace makes it stick: / is rewritten to /home, a reload re-enters at /home, and if the session does turn out to be dead the guard captures /home into ?redirect= after all. Same defect, narrower door.

Half A — mechanism: option 1, because option 2 is already satisfied

The ruling offered two mechanisms. Measuring the code decided it:

  • Option 2 (the guard stops baking the fallthrough into ?redirect=) is already true on main and needs no change — / is guarded, and the bare / is explicitly not captured. Changing LoginRedirect here would also have meant editing the console auth-guard module while type: 'form' action fired from a record opens an empty CREATE form — /forms/:name ignores the ?recordId= ActionRunner forwards #4278 is in flight, for no behavioural gain. It is instead pinned in both polarities so it cannot silently regress: a legitimate deep link (/apps/crm_app/record/1, query string included) is still captured and honored; the bare / still is not.
  • Option 1 is the live half, and it is what this PR implements. RootLandingRedirect produces no conclusion from a list that is not an answer.

The distinguishing fact already existed on the metadata context, so no second dialect of loading/auth state was added: getTypeStatus('app')MetadataProvider's own per-type status (idle | loading | ready | error), which the provider already sets to error in ensureType's catch. One source of truth, read where the decision is made.

resolveLandingPath is unchanged. Its rules were never wrong — including [] ⇒ /home, which is right when the emptiness is real. The new predicate gates whether the policy runs at all, so #4048's Setup-only pins and every other policy pin stay green untouched.

Because a wrong conclusion is never produced, the ?redirect=%2Fhome amplification is closed at its source rather than patched at the capture site.

"No conclusion" is made recoverable rather than terminal: the component holds at / on the loading fallback and re-asks the metadata layer once (bounded by a ref, not by effect-dep identity), past ERROR_RETRY_COOLDOWN_MS so the re-ask is not answered from the failed cache entry. A transient failure heals with no user action; a real outage settles on a screen that is at least not a claim about which apps exist — which is what /home was, since the launcher reads the same failed list.

Reverse verification — predicted, run, and the prediction corrected

Predicted: reverting the gate turns the two conclusion cases red, everything else green.

Measured: 11 passed / 3 failed — three red, not two. The extra one is re-asks the metadata layer exactly once, and the mechanism is worth recording rather than papering over: without the gate the component Navigates away on its first render, unmounts, and the effect cleanup clears the pending timer, so the re-ask never fires. The retry is not a second behaviour bolted on beside the gate — it is only reachable because the gate keeps the component mounted. One seam, three pins.

× THE FIX: a failed `GET /meta/app` does NOT resolve /home
× ⛔ does not fossilize the guess in history — the URL stays `/`, so a reload re-resolves
× re-asks the metadata layer exactly once, so the unresolved state is recoverable
  Tests  3 failed | 11 passed (14)

Everything else stayed green, which is the property that matters: the cold-sign-in and deep-link cases cannot go red for this change's reason, so they remain independent evidence that the pre-auth door is still shut.

Half B — the API exists, so the controls are wired

Measured before deciding, per the ruling. @objectstack/client 17.0.0-rc.6 exposes meta.saveItem and meta.deleteItem; the objectstack route ledger carries PUT /api/v1/meta/:type/:name and DELETE /api/v1/meta/:type/:name, both gated on manage_metadata (ADR-0066 D1). useNavigationSync in @object-ui/app-shell has been persisting app schemas through meta.saveItem('app', …) all along. The TODO's premise is false — this was never blocked on the backend — so the disabled-with-a-notice branch does not apply and the handlers are wired.

Each handler now awaits a real mutation and reports success only afterwards; a refusal surfaces the server's own message instead of a lie. Two details that are judgment, not transcription:

  • Set as default demotes the outgoing default before promoting the new one. resolveLandingPath takes the first isDefault match, so two holders is not a cosmetic inconsistency — it makes the landing depend on the order the server happens to list apps in. Demote-then-promote also means a mid-way failure leaves the deployment with no default (rule 3, /home) rather than two.
  • The bulk toggle counts the writes that landed, not the size of the selection. No transaction spans those N writes, so a mixed outcome is the normal one under a permission gate; "12 apps disabled" after 9 successes and 3 refusals would be the same class of lie this card is closing, just harder to notice.

The causal note from the card: because these controls never wrote, an operator could not set isDefault from the console at all — the app had to be republished through metadata — so half A had no in-product workaround. Half B is what restores one.

Reverse verification

Restoring origin/main's stub handlers wholesale: 12 of 12 red, in both directions — the issues a real … cases fail on the missing client call, and the error-path cases fail because a stub cannot fail and so reports success where the server refused. That pair is precisely what the stubs were able to satisfy before, which is why no assertion here rests on a toast appearing.

Verification

  • pnpm exec vitest run apps/console/39 files, 421 tests, all passing (26 of them new).
  • tsc --noEmit and tsc -b tsconfig.node.json --force in apps/console — both exit 0, after building the dependency closure (pnpm --filter '@object-ui/console^...' build).
  • eslint on all five changed files — 0 errors. Remaining warnings are the file's pre-existing any parameters; the react-hooks/refs warning an earlier draft introduced was removed by dropping the ref.
  • Changeset present (check-changeset-presence.mjs green, minor, never major).

Scope

apps/console only — no packages/* touched, and none of FormPage.tsx / createdRecordPath.ts / the #4279 form-route modules, so nothing overlaps #4278. No new user-facing strings, so no i18n resource changes were needed.


Generated by Claude Code

…; wire the Applications page's writes (#4233)

Half A — the `/` landing resolved a path from an app list it had failed to
load. `MetadataProvider.ensureType` catches a failed `GET /meta/app` and
resolves `[]`, so `loading` goes false, nothing rejects, and the error arrives
wearing exactly the shape of the answer: `resolveLandingPath([])` falls through
to `/home` and reports "this deployment has no default app" about a deployment
it never managed to ask. `Navigate … replace` then fossilizes it — a reload
re-enters at `/home`, and a dead session gets `?redirect=%2Fhome` captured and
honored after sign-in.

`/` now resolves a landing only from a list that is an ANSWER, gated on the
metadata context's own per-type status (`getTypeStatus('app')`) — one source of
truth, no second dialect of loading/auth state. The landing POLICY is untouched:
every `resolveLandingPath` rule, the empty-list fallthrough included, still
answers exactly as before when the list genuinely loaded. While the list is
unknown the console holds at `/` and re-asks once, so a transient failure heals
and an outage settles on a screen that is not a claim about which apps exist.

The originally reported pre-auth link is already closed by #4042 (which wrapped
`/` in ProtectedRoute); that door, and the deep-link `?redirect=` capture that
must keep working, are pinned here for the first time.

Half B — Set as default, Disable, the bulk toggle and Delete each showed a
success toast having issued no request, then called `refresh()`, which
re-rendered unchanged server state underneath the confirmation. Their TODO's
premise was measured against @objectstack/client 17.0.0-rc.6 and is false: the
write surface exists (`meta.saveItem` / `meta.deleteItem`, gated on
`manage_metadata`) and is already how app schemas are persisted elsewhere in
this console. All four handlers now await a real mutation and report success
only afterwards; a refusal surfaces the server's message. Set-as-default demotes
the outgoing default first so the landing cannot depend on list order, and the
bulk toggle counts what actually landed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Qqyix2QcnpUC9XeYVDzx3
@vercel

vercel Bot commented Aug 11, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
objectui Ignored Ignored Aug 11, 2026 12:15pm

Request Review

@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

Metric Value Budget
Main entry (gzip) 29.5 KB 350 KB
Entry file index-UCvw2nUF.js
Status PASS

📦 Bundle Size Report

Package Size Gzipped
app-shell (index.js) 8.88KB 3.25KB
app-shell (runtime-config.js) 7.42KB 2.32KB
app-shell (types.js) 0.01KB 0.04KB
app-shell (urlParams.js) 8.27KB 3.23KB
auth (AuthContext.js) 0.31KB 0.24KB
auth (AuthGuard.js) 1.17KB 0.53KB
auth (AuthProvider.js) 22.10KB 4.37KB
auth (AuthShell.js) 3.49KB 1.40KB
auth (ForgotPasswordForm.js) 12.21KB 3.45KB
auth (LoginForm.js) 18.13KB 5.39KB
auth (PreviewBanner.js) 0.90KB 0.50KB
auth (RegisterForm.js) 6.64KB 2.21KB
auth (SocialSignInButtons.js) 9.60KB 3.89KB
auth (UserMenu.js) 3.40KB 1.22KB
auth (auth-gate-events.js) 1.29KB 0.66KB
auth (authStyles.js) 5.04KB 1.72KB
auth (createAuthClient.js) 35.76KB 9.11KB
auth (createAuthenticatedFetch.js) 4.37KB 1.69KB
auth (index.js) 2.35KB 1.07KB
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) 4.91KB 0.87KB
auth (useIsWorkspaceAdmin.js) 1.61KB 0.85KB
collaboration (CommentThread.js) 26.07KB 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.65KB 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) 488.62KB 108.26KB
core (index.js) 3.04KB 1.15KB
create-plugin (index.js) 10.08KB 3.26KB
data-objectstack (index.js) 150.04KB 39.79KB
fields (index.js) 228.45KB 56.62KB
i18n (LocalizationContext.js) 1.76KB 0.96KB
i18n (currency.js) 1.22KB 0.64KB
i18n (i18n.js) 4.32KB 1.77KB
i18n (index.js) 2.65KB 1.06KB
i18n (pickLocalized.js) 1.70KB 0.83KB
i18n (provider.js) 16.38KB 5.47KB
i18n (useObjectLabel.js) 27.59KB 6.63KB
i18n (useSafeTranslation.js) 4.52KB 1.96KB
layout (index.js) 38.98KB 10.85KB
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.74KB
mobile (index.js) 1.50KB 0.62KB
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.71KB 0.42KB
mobile (useResponsiveConfig.js) 1.36KB 0.63KB
mobile (useSpecGesture.js) 4.32KB 1.64KB
mobile (useTouchTarget.js) 1.01KB 0.54KB
permissions (MePermissionsProvider.js) 8.75KB 3.06KB
permissions (PermissionContext.js) 0.31KB 0.25KB
permissions (PermissionGuard.js) 0.89KB 0.45KB
permissions (PermissionProvider.js) 3.67KB 1.12KB
permissions (evaluator.js) 4.41KB 1.44KB
permissions (index.js) 0.91KB 0.41KB
permissions (store.js) 0.91KB 0.42KB
permissions (useFieldPermissions.js) 1.28KB 0.52KB
permissions (usePermissions.js) 1.55KB 0.71KB
plugin-ai (index.js) 15.71KB 3.79KB
plugin-calendar (index.js) 45.23KB 12.45KB
plugin-charts (index.js) 61.73KB 17.54KB
plugin-chatbot (index.js) 180.33KB 42.79KB
plugin-dashboard (index.js) 121.07KB 31.39KB
plugin-designer (index.js) 210.91KB 42.67KB
plugin-detail (index.js) 238.98KB 59.76KB
plugin-editor (index.js) 2.46KB 1.10KB
plugin-form (index.js) 114.58KB 27.68KB
plugin-gantt (index.js) 164.14KB 39.98KB
plugin-grid (index.js) 187.97KB 49.90KB
plugin-kanban (index.js) 48.60KB 13.41KB
plugin-list (index.js) 109.93KB 26.65KB
plugin-map (index.js) 17.00KB 5.32KB
plugin-markdown (index.js) 13.72KB 4.69KB
plugin-report (index.js) 40.60KB 10.58KB
plugin-timeline (index.js) 26.21KB 7.52KB
plugin-tree (index.js) 8.50KB 2.88KB
plugin-view (index.js) 84.03KB 20.55KB
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.71KB 3.53KB
providers (index.js) 0.44KB 0.22KB
providers (types.js) 0.01KB 0.04KB
react-runtime (index.js) 5.67KB 2.37KB
react (LazyPluginLoader.js) 3.77KB 1.33KB
react (SchemaRenderer.js) 23.71KB 7.96KB
react (data-invalidation.js) 5.05KB 2.08KB
react (index.js) 1.23KB 0.66KB
react (spec-input.js) 0.20KB 0.18KB
sdui-parser (codegen.js) 4.09KB 1.74KB
sdui-parser (index.js) 4.47KB 2.03KB
sdui-parser (parse.js) 10.04KB 2.82KB
sdui-parser (types.js) 0.29KB 0.24KB
sdui-parser (validate.js) 4.69KB 1.48KB
types (ai.js) 0.20KB 0.17KB
types (api-types.js) 0.20KB 0.18KB
types (app.js) 2.87KB 0.99KB
types (base.js) 0.20KB 0.18KB
types (blocks.js) 0.20KB 0.18KB
types (complex.js) 0.20KB 0.18KB
types (crud.js) 0.20KB 0.18KB
types (dashboard-filter-alias.js) 6.23KB 2.74KB
types (data-display.js) 0.20KB 0.18KB
types (data-protocol.js) 0.20KB 0.19KB
types (data.js) 0.20KB 0.18KB
types (designer.js) 1.87KB 0.85KB
types (disclosure.js) 0.20KB 0.18KB
types (error-code.js) 1.54KB 0.88KB
types (feedback.js) 0.20KB 0.18KB
types (field-types.js) 0.20KB 0.18KB
types (form.js) 0.20KB 0.18KB
types (http-retry.js) 4.32KB 2.02KB
types (index.js) 3.05KB 1.52KB
types (layout.js) 0.20KB 0.18KB
types (managed-by.js) 0.19KB 0.18KB
types (mobile.js) 2.59KB 1.31KB
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 (spec-report.js) 5.05KB 1.93KB
types (system-fields.js) 3.33KB 1.54KB
types (theme.js) 0.20KB 0.18KB
types (ui-action.js) 3.40KB 1.71KB
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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

2 participants