Repository navigation
fix(console): an app that serves the caller no navigation opens on an app-level empty state, not the Studio home (objectui#12079) - #12091
Conversation
… app-level empty state, not the Studio home (objectui#12079) The app index route fell back to StudioHomePage whenever the navigation resolved no landing. An app whose every group is capability-gated arrives as navigation: [] for a caller without those capabilities, so that caller landed on Studio's metadata overview inside an unrelated app. The fallback now splits on navigation emptiness: no navigation item at the top level or in any area renders AppNoAccessEmptyState, which names the app and, for a caller useWorkspaceAdminStatus confirms as an admin, links to Setup at resolveSetupAppPath. A navigation with no landing (the Studio app, built from component items) still renders StudioHomePage. Claude-Session: https://claude.ai/code/session_01B1gHb9baeX7oioD5sHVm7z Co-authored-by: Claude <noreply@anthropic.com>
…he empty-state file exports only a component (objectui#12079) Claude-Session: https://claude.ai/code/session_01B1gHb9baeX7oioD5sHVm7z Co-authored-by: Claude <noreply@anthropic.com>
…primitives its sibling screens already use (objectui#12079) EmptyHeader, EmptyMedia and EmptyContent had no caller in the console build, so using them grew the eager ui-components chunk; Empty, EmptyTitle and EmptyDescription are already eager through RouteNotFound and the app-verdict screens. Claude-Session: https://claude.ai/code/session_01B1gHb9baeX7oioD5sHVm7z Co-authored-by: Claude <noreply@anthropic.com>
✅ Console Performance Budget
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
Size Limits
|
Contract reviewServed-tier: ① Derived judgments
② Semver level
③ Boundary flagsDev report
Implemented-by: VERDICT: PASS Generated by Claude Code |
Dequeued on
|
✅ Console Performance Budget
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
Size Limits
|
Contract reviewServed-tier: ① Derived judgments
② Semver level
③ Boundary flagsDev report
Implemented-by: VERDICT: PASS Generated by Claude Code |
Fixes #12079
Clause-②: yes
What changes for the user
An app that serves the signed-in user no navigation now opens on an app-level empty state instead of the Studio home.
Measured case: objectstack-ai/hotclm#87. For the dev admin,
GET /api/v1/meta/app/clmanswerednavigation: [], because every HotCLM group is gated on aclm_*.accesscapability. That account holding noclm_*set is hotclm's open question (objectstack-ai/hotclm#28, objectstack-ai/hotclm#11). This PR does not take a position on it.Mechanism, measured on
origin/main023f00d4The PM's four assumptions, each measured:
One fallback site: confirmed.
StudioHomePageis rendered in one place, the app index route ofAppContentinpackages/app-shell/src/console/AppContent.tsx, whenresolveLandingRoutereturns nothing. Its other references are the lazy import beside it, themetadata-adminbarrel export, two of its own tests, and five test mocks.apps/console/src/AppContent.tsxwrapsDefaultAppContentand adds no landing logic.@objectstack/consoleis built from this same source.componentitems.findFirstRoutewalks onlyobject/page/dashboard/reportitems (and groups), so/apps/studiohas no landing and reaches the same branch with a full navigation.UnifiedSidebaralso gives the Studio app a dedicated "Studio home" entry that points at that root.The grant decision is reused, not invented. The Setup link is gated on
useWorkspaceAdminStatus().isAdmin. Its legs:user.rolescalar;user.isPlatformAdmin;org_owner/org_admininuser.positions.AppContentalready reads this verdict (isWorkspaceAdmin) to decide who stays on the no-app empty state with its "System Settings" button (everyone else is bounced home). The home sidebar reads the same verdict to show its administration links into Setup. Its readiness half (isResolved) already gatesAppContent's first render, so the link cannot flicker in.The URL is
resolveSetupAppPath(apps), the policy behind the/setupdeep link: the Setup app's root by package id, then by name.Eager closure. The before and after readings are in the Gates table below.
The empty-state primitive exists and is reused.
Empty,EmptyTitleandEmptyDescriptionfrom@object-ui/componentsare already in the eager closure, throughRouteNotFoundand the app-verdict screens in the same file. The new screen composes those three plusButton, and theLockicon the header already loads.EmptyHeader/EmptyMedia/EmptyContent. Those have no caller in the console build, and the cut added 46 bytes gzipped toui-components, which has about 3 KB of headroom. The second commit drops them.What changed
packages/app-shell/src/console/appServesNoNavigation.ts(new):appServesNoNavigation(app)is true when the top-levelnavigationholds no item and noareas[].navigationdoes either.hasVisibleNavigationItemswith no guards and withhasActionHandler: true, because the console sidebar wires an action dispatcher.separatoror a childlessgroupcounts as nothing, exactly as the sidebar draws it.packages/app-shell/src/console/AppNoAccessEmptyState.tsx(new): the screen described above.packages/app-shell/src/console/AppContent.tsx: the index route now runs these steps in order:appServesNoNavigation(activeApp), renderAppNoAccessEmptyStatewithsetupPath = isWorkspaceAdmin ? resolveSetupAppPath(apps) : null.StudioHomePageas before.packages/i18n/src/locales/*.ts: four new keys in all ten packs,empty.appNothingAvailable(holds{{app}}),empty.appNothingAvailableDescription,empty.appNothingAvailableGrantDescriptionandempty.openSetup..changeset/12079-empty-nav-landing.md: patch for@object-ui/app-shelland@object-ui/i18n.Clause-② is
yesas the claim declared: the four keys widen the published@object-ui/i18npacks. No package export was added. The two new app-shell modules are imported only byAppContent, andsrc/index.tsis unchanged.Studio is untouched
Triage says "
/studioitself is untouched". The/studiopillar builder is not edited. The Studio app root/apps/studiostill rendersStudioHomePage, pinned by a control test below.The card quotes the maintainer calling that page "Studio's old home, retired a long time ago". On this tree it is still the Studio app's live root: the sidebar's "Studio home" entry links to it. Retiring it would be a separate decision, and this PR leaves it alone.
Tests
New file
packages/app-shell/src/console/__tests__/AppContent.emptyNavigationLanding-12079.test.tsx, 8 tests, in the harness ofAppContent.inaccessibleAppStrand.test.tsx: realAppContentand router, with the shell and the lazy pages stubbed.navigation: []renders the empty state, notStudioHomePage, names the app, and stays on/apps/clm;/apps/com.objectstack.setup, theresolveSetupAppPathURL;objectitem still navigates to/apps/clm/contract;componentitems only) still rendersStudioHomePage;appServesNoNavigation: true for no navigation,[], only childless groups and separators, or empty areas; false for aurlitem, or for an item only inside an area.Reverse verification. The fix is committed first.
scripts/ablation-replace.mjsfromobjectstackthen mutated the anchorif (appServesNoNavigation(activeApp)) {inAppContent.tsxtoif (false && appServesNoNavigation(activeApp)) {, with the anchor count going 1 to 0 and the blob changing on disk.data-testid="studio-home-page", which is the defect. The two controls and the two predicate tests stay green.git diff HEADis empty, and the re-run is 8 passed.Gates
Run at HEAD
02569f3. objectui has nodispatch-gates.mjs, so this list is derived by hand from the rootpackage.jsonand.github/workflows/.pnpm turbo run type-check --filter=@object-ui/app-shell --filter=@object-ui/i18n --concurrency=2(builds the upstream closure first; app-shell'stsconfig.test.jsoncovers the new test file)pnpm exec vitest run --maxWorkers=2: the 14AppContent.*suites in app-shell, plus the 3 console suites that mockStudioHomePageanddefaults-maps-mirror-en-packpnpm exec vitest run --maxWorkers=2 packages/i18n/(includesall-locales-key-parity)pnpm check:eager-closure(consolevite buildbefore and after)pnpm check:eager-locale-cataloguesenresident 44.2 KB to 44.3 KBcheck:i18n-keys,check:i18n-drift,check:i18n-dead-keysenvalues changed, 4 keys addedcheck:new-line-citations,check:control-bytescheck:phantom-deps,check:self-import,check:unreferenced-sources,check:esm-specifierscheck:vi-mock-specifiers,check:vi-mock-inherit,check:vi-mock-override-shape,check:test-path-rootscheck-changeset-presence.mjs,check-changeset-no-major.mjs,check:changeset-claims,check:pending-changeset-literalscheck-governed-queue-guard.mjs --testover the 15 changed pathsEager closure size, before (
023f00d4) and after (02569f3):index: +283 bytes gzipped, +1232 raw. This is the new component, the predicate and the route branch.i18n-locale-en: +68 bytes gzipped, +322 raw. These are the fourenstrings.ui-components: +8 bytes gzipped, 0 raw.Not run locally, because CI owns them: the repo-wide
pnpm lint, the fullpnpm test, Bundle Analysis and E2E.ESLint was run on the 14 touched source files only. CI's
pnpm lintrunseslint .in each package.eslint.config.js. It sets noparserOptions.projectorprojectService, so linting is not type-aware. No rule undereslint-rules/reads another file from disk.--format jsonoutput.AppContent.tsx. The baseAppContent.tsxmeasured through--stdinalso has 33, so this diff adds none.Acceptance notes
StudioHomePage. That covers navigation of onlycomponent,urloractionitems, or items that live only underareas.navigation, whileresolveLandingRoutereads only the top level.account,setupand theobjectstackexamples all resolve a landing, and none declaresareas.AppContentand router.Session:
https://claude.ai/code/session_01B1gHb9baeX7oioD5sHVm7zGenerated by Claude Code