From fea035e176a53fc4c614197171966c7995c1168f Mon Sep 17 00:00:00 2001 From: Yibo Wang Date: Wed, 26 Aug 2026 12:02:19 +0000 Subject: [PATCH 1/3] fix(app-shell): keep the splash painted across the remaining boot-gate redirects (#6507) Seven of the eight candidate gates named on the card hand off from a `LoadingFallback` to a bare ``, which renders null: the splash is dropped at the deciding commit and nothing replaces it until the destination renders at transition priority. Measured on the three sibling gates #6506 fixed: 41-147ms of empty `#root`. Converted with per-site evidence, not on sight. `SystemRedirect` is deliberately NOT converted: it is the one site here that also fires with the console already painted (`SettingsView` navigates to `/system/settings` from a button, `AppSidebar` links to `/system`), and the triage ruling on this card bans giving an already-painted layout a splash. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_011SfZeFWrhGLHmfq61xbz4q --- packages/app-shell/src/console/AppContent.tsx | 14 +- .../app-shell/src/console/ConsoleShell.tsx | 63 +++- .../AppContent.bootRedirectCoverage.test.tsx | 232 +++++++++++++ .../__tests__/bootRedirectCoverage.test.tsx | 305 ++++++++++++++++++ 4 files changed, 607 insertions(+), 7 deletions(-) create mode 100644 packages/app-shell/src/console/__tests__/AppContent.bootRedirectCoverage.test.tsx create mode 100644 packages/app-shell/src/console/__tests__/bootRedirectCoverage.test.tsx diff --git a/packages/app-shell/src/console/AppContent.tsx b/packages/app-shell/src/console/AppContent.tsx index 0963374bd8..c5c279eb7b 100644 --- a/packages/app-shell/src/console/AppContent.tsx +++ b/packages/app-shell/src/console/AppContent.tsx @@ -40,6 +40,7 @@ import { ConsoleLayout } from '../layout/ConsoleLayout.js'; import { CommandPalette } from '../chrome/CommandPalette.js'; import { ErrorBoundary } from '../chrome/ErrorBoundary.js'; import { LoadingScreen } from '../chrome/LoadingScreen.js'; +import { RedirectWithSplash } from '../chrome/RedirectWithSplash.js'; import { ObjectView } from '../views/ObjectView.js'; import { KeyboardShortcutsDialog } from '../chrome/KeyboardShortcutsDialog.js'; import { OnboardingWalkthrough } from '../chrome/OnboardingWalkthrough.js'; @@ -805,7 +806,18 @@ export function AppContent({ extraRoutes, extraRoutesNoApp }: AppContentProps = // envelope (objectstack#8013 → objectui#4252). Nothing here waits on it: the // guard reads only the per-user-filtered list that ships today. if (!activeApp && !isCreateAppRoute && !isSystemRoute && !isMetadataRoute && !isWorkspaceAdmin) { - return ; + // `RedirectWithSplash`, not a bare `` (objectui#6378 / #6507). + // Every readiness gate above this branch renders `LoadingScreen`, and the + // branch returns ABOVE the single `ConsoleLayout` mount — so a bare + // redirect, which renders null, hands the WHOLE viewport back to the page + // background while `/home` renders at transition priority. Measured on the + // three sibling gates #6506 fixed: 41-147 ms of empty `#root`. This one is + // a boot-path gate on the same evidence rule and keeps the splash painted + // across the handoff; the URL-rewrite redirects further down this file + // (`LegacyMetadataRedirect`, `ShorthandRecordRedirect`) deliberately do NOT + // convert -- they fire INSIDE `ConsoleLayout`, with the console already on + // screen, and a splash there would cover a layout that never went away. + return ; } if (!activeApp && !isCreateAppRoute && !isSystemRoute && !isMetadataRoute) return ( diff --git a/packages/app-shell/src/console/ConsoleShell.tsx b/packages/app-shell/src/console/ConsoleShell.tsx index 9b4fb7ecae..a8509f201b 100644 --- a/packages/app-shell/src/console/ConsoleShell.tsx +++ b/packages/app-shell/src/console/ConsoleShell.tsx @@ -36,6 +36,7 @@ import { } from '../context/UserStateAdapters.js'; import { ThemeProvider } from '../chrome/ThemeProvider.js'; import { LoadingScreen } from '../chrome/LoadingScreen.js'; +import { RedirectWithSplash } from '../chrome/RedirectWithSplash.js'; import { RemediationOverlay } from './RemediationOverlay.js'; import { HostNavigationBridge } from './HostNavigationBridge.js'; import { ImpersonationBanner } from '../layout/ImpersonationBanner.js'; @@ -348,12 +349,21 @@ export function RequireOrganization({ children }: { children: ReactNode }) { if (isOrganizationsLoading) return ; const orgList = organizations ?? []; const orgFeatureEnabled = orgList.length > 0 || !!activeOrganization; - if (orgFeatureEnabled && !activeOrganization) return ; + // `RedirectWithSplash` at every DECIDE below, not a bare `` + // (objectui#6378 / #6507): this gate renders `LoadingFallback` while it waits + // (one line up), and a bare redirect renders null — so the splash the user is + // looking at is dropped and nothing replaces it until `/organizations` + // renders at transition priority. Measured on the three sibling gates #6506 + // fixed: 41-147 ms of empty `#root`, a white flash on 67/87 boots. The + // replacement paints the SAME `LoadingScreen` this gate was already showing, + // so the handoff changes no pixels. + if (orgFeatureEnabled && !activeOrganization) + return ; // No org at all: on multi-org, send them to /organizations (the create // screen); wait for the flag so we don't flash /home then redirect. if (orgList.length === 0 && !activeOrganization) { if (multiOrgEnabled === null) return ; - if (multiOrgEnabled) return ; + if (multiOrgEnabled) return ; } return <>{children}; } @@ -379,7 +389,14 @@ export function RequireAiSurface({ }) { const { enabled, isLoading } = useAiSurfaceEnabled(); if (isLoading) return ; - if (!enabled) return ; + // Splash-preserving handoff (objectui#6507). This is a BOOT-path redirect + // even though `/ai` is reachable from inside the console: every in-app entry + // point gates on this same `useAiSurfaceEnabled` signal (`AppHeader`'s + // assistant button, `ConsoleLayout`'s dock, `HomeLayout`/`HomePage`), so on a + // runtime where this branch fires none of them is rendered. What reaches it + // is a stale bookmark or an external link — a first navigation, with the + // splash still up and no layout underneath. + if (!enabled) return ; return <>{children}; } @@ -398,7 +415,18 @@ export function AuthenticatedRoute({ loginPath?: string; }) { return ( - } loadingFallback={}> + // The same splash-preserving handoff as the gates above, written as two + // props rather than two returns (objectui#6507): `loadingFallback` paints + // while the session resolves and `fallback` is what replaces it the moment + // it decides. A bare `` there renders null, which is the same + // blank viewport #6378 measured — and `apps/console` already converted its + // own copy of this composition (`ProtectedRoute.tsx`) under #6506, so a + // consumer assembling protected routes from THIS wrapper would otherwise + // get the unfixed handoff. + } + loadingFallback={} + > {requireOrganization ? {children} : children} @@ -413,7 +441,12 @@ export function AuthenticatedRoute({ export function RootRedirect() { const { loading } = useMetadata(); if (loading) return ; - return ; + // Splash-preserving handoff (objectui#6507). `apps/console` mounts its own + // `RootLandingRedirect` rather than this one, and #6506 converted that twin + // after measuring the WIDEST window of the campaign on it (147 ms) — this is + // byte-for-byte the same shape, published to consumers via + // `@object-ui/app-shell`. + return ; } /** @@ -427,6 +460,18 @@ export function RootRedirect() { * that makes the hub mount at all. */ export function SystemRedirect() { + // ⚠️ DELIBERATELY a bare ``, unlike every other redirect in this + // file (objectui#6507). It does carry the null-render shape on a first + // navigation — but it is the one site here that ALSO fires with the console + // already painted: `SettingsView.tsx` navigates to `/system/settings` from a + // button, and `AppSidebar.tsx` links to `/system`. Neither is gated on + // anything, so both are live in exactly the runtimes this component serves. + // The #6507 triage ruling is explicit that a redirect firing under an + // already-painted layout must KEEP that layout rather than gain a splash, so + // converting this one would trade a boot-path blank for a full-screen splash + // flashing over a working console. Splitting the two paths (deep link vs + // in-app navigation) needs a measurement neither #6378 nor #6507 has taken. + // Pinned as unconverted by `__tests__/bootRedirectCoverage.test.tsx`. const location = useLocation(); const suffix = location.pathname.replace(/^\/system/, ''); const target = suffix ? `/apps/setup/system${suffix}` : '/apps/setup/system'; @@ -512,5 +557,11 @@ export function SetupRedirect() { const location = useLocation(); if (loading) return ; const target = resolveSetupAppPath(apps as SetupAppLike[] | undefined); - return ; + // Splash-preserving handoff (objectui#6507) — the gate one line up renders + // `LoadingFallback`, so a bare redirect would drop it for nothing. Unlike + // `SystemRedirect` beside it, `/setup` has no in-app producer: it is mounted + // as a route and reached by bookmark, deep link or runbook, i.e. always as a + // first navigation with the splash up. (The home launcher's card links to + // `/apps/` directly, not through this alias.) + return ; } diff --git a/packages/app-shell/src/console/__tests__/AppContent.bootRedirectCoverage.test.tsx b/packages/app-shell/src/console/__tests__/AppContent.bootRedirectCoverage.test.tsx new file mode 100644 index 0000000000..9d07165133 --- /dev/null +++ b/packages/app-shell/src/console/__tests__/AppContent.bootRedirectCoverage.test.tsx @@ -0,0 +1,232 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * objectui#6507 — the `AppContent` boot bounce, and the control that keeps the + * conversion honest. + * + * ## The two arms + * + * `bootRedirectCoverage.test.tsx` states the probe in full: hold the + * destination un-painted, then ask whether the app tree holds anything for the + * viewport to show at the commit where the gate DECIDES. This file runs that + * same probe on the two populations that only exist inside `AppContent`: + * + * SUBJECT — the no-accessible-app bounce (`AppContent.tsx`, the + * `!activeApp && … && !isWorkspaceAdmin` branch). Every readiness gate above + * it renders `LoadingScreen`, and the branch returns ABOVE the single + * `ConsoleLayout` mount, so what it hands off to is the whole viewport. + * + * CONTROL — `LegacyMetadataRedirect`, one of the five URL-rewrite redirects + * the #6507 triage ruling names as OUT of shape. It is declared INSIDE + * `ConsoleLayout`, so it fires with the console already painted. It must read + * "covered" with no change to this codebase, and it must keep reading + * "covered" after the subject converts — converting it would hand it a splash + * it never had, which is the failure mode the ruling bans. + * + * The control is not decoration. Without it, "the tree was empty" is a claim + * the probe could make about every redirect in the repository; with it, the + * probe is shown to distinguish the two populations the ruling turns on. + */ + +import '@testing-library/jest-dom/vitest'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import React, { useLayoutEffect } from 'react'; +import { render, screen, waitFor } from '@testing-library/react'; +import { MemoryRouter, Routes, Route, useLocation } from 'react-router-dom'; + +vi.mock('@object-ui/plugin-designer', () => ({ + CreateAppPage: () =>
create app
, + EditAppPage: () =>
, + DashboardDesignPage: () =>
, +})); + +/** + * The real layout, reduced to the fact under test: it PAINTS. Whatever renders + * inside it is renders with the console already on screen. + */ +vi.mock('../../layout/ConsoleLayout', () => ({ + ConsoleLayout: ({ activeAppName, children }: { activeAppName?: string; children?: React.ReactNode }) => ( +
+
chrome
+ {children} +
+ ), +})); +vi.mock('../../chrome/CommandPalette', () => ({ CommandPalette: () => null })); +vi.mock('../../chrome/KeyboardShortcutsDialog', () => ({ KeyboardShortcutsDialog: () => null })); +vi.mock('../../chrome/OnboardingWalkthrough', () => ({ OnboardingWalkthrough: () => null })); +vi.mock('../../views/ObjectView', () => ({ + ObjectView: () =>
, +})); + +vi.mock('@object-ui/i18n', async (importOriginal) => ({ + ...(await importOriginal>()), + useObjectTranslation: () => ({ + t: (key: string, options?: Record) => String(options?.defaultValue ?? key), + }), + useObjectLabel: () => ({ + objectLabel: ({ label }: { label?: string }) => label, + }), +})); + +let isWorkspaceAdmin = false; +vi.mock('@object-ui/auth', async (importOriginal) => ({ + ...(await importOriginal>()), + useAuth: () => ({ + user: { id: 'u_b', name: 'B', email: 'b@example.com', role: 'member' }, + getAuthConfig: async () => ({ features: {} }), + activeOrganization: { id: 'org_jia', name: '甲' }, + }), + useWorkspaceAdminStatus: () => ({ isAdmin: isWorkspaceAdmin, isResolved: true }), +})); + +const dataSourceStub = { + onConnectionStateChange: () => () => {}, + getConnectionState: () => 'connected', +}; +vi.mock('../../providers/AdapterProvider', async (importOriginal) => ({ + ...(await importOriginal>()), + useAdapter: () => dataSourceStub, +})); + +/** As the SERVER hands it to this session — empty is the no-accessible-app case. */ +let metadataApps: unknown[] = []; +vi.mock('../../providers/MetadataProvider', async (importOriginal) => ({ + ...(await importOriginal>()), + useMetadata: () => ({ + apps: metadataApps, + objects: [], + loading: false, + ensureType: undefined, + error: null, + refresh: vi.fn(async () => {}), + }), +})); + +const actionRunnerStub = { registerHandler: vi.fn(), getContext: () => ({}) }; +vi.mock('@object-ui/react', async (importOriginal) => ({ + ...(await importOriginal>()), + useActionRunner: () => ({ execute: vi.fn(), runner: actionRunnerStub }), + useGlobalUndo: () => {}, + useMutationInvalidationBridge: () => {}, +})); + +import { AppContent } from '../AppContent'; + +const APP_ROOT = 'app-root'; + +/** + * One sample per React commit: what the app tree held, and where the router + * was when it held it. Same record shape as the e2e probe's `uncovered` + * ledger (`{ t, centre, path }`), which is what this stands in for. + */ +interface Sample { + empty: boolean; + path: string; +} +let samples: Sample[] = []; + +/** + * Records every commit from INSIDE the app root, rendering nothing itself. + * + * `useLayoutEffect` with no dependency array, so it runs after EVERY commit and + * — this is the load-bearing part — before ``'s passive effect fires. + * That is what lets the deciding commit be read while the router is still on + * the app URL, which is the window the defect lives in. A read taken after the + * route change would see the destination and report the same thing for a fixed + * build as for a broken one. + */ +function CommitRecorder() { + const location = useLocation(); + useLayoutEffect(() => { + const root = document.querySelector(`[data-testid="${APP_ROOT}"]`); + samples.push({ + empty: (root?.innerHTML ?? '').trim() === '', + path: location.pathname, + }); + }); + return null; +} + +/** + * Renders the console at `initialUrl` and returns every commit it produced. + * The destination is UN-PAINTED (renders null), which is what "the destination + * renders at transition priority" reduces to in a DOM-only environment. + */ +async function commitsAt(initialUrl: string) { + samples = []; + render( + +
+ + + } /> + + + +
+
, + ); + await waitFor(() => { + expect(screen.getByTestId(APP_ROOT)).toBeTruthy(); + }); + return samples; +} + +/** The commits taken while the router was still on the app URL — the window. */ +const onAppUrl = (all: Sample[]) => all.filter((sample) => sample.path.startsWith('/apps/')); + +beforeEach(() => { + vi.clearAllMocks(); + isWorkspaceAdmin = false; + metadataApps = []; +}); + +describe('boot-gate coverage — AppContent (objectui#6507)', () => { + it('the no-accessible-app bounce keeps the viewport covered', async () => { + const window0 = onAppUrl(await commitsAt('/apps/setup')); + + // Guard against a vacuous pass: if the branch never ran there is nothing to + // measure and "no empty commit" would be true for the wrong reason. + expect(window0.length, 'the bounce branch never rendered').toBeGreaterThan(0); + + const blanks = window0.filter((sample) => sample.empty); + expect( + blanks.length, + `AppContent no-accessible-app bounce: ${blanks.length} of ${window0.length} ` + + `commit(s) on the app URL held an EMPTY tree. Every readiness gate above ` + + `this branch renders LoadingScreen and the branch returns above the ` + + `ConsoleLayout mount, so a bare hands the whole viewport back ` + + `to the page background while /home renders at transition priority — ` + + `objectui#6378's white flash.`, + ).toBe(0); + }); +}); + +describe('boot-gate coverage — AppContent control arm (out-of-shape redirects)', () => { + /** + * `LegacyMetadataRedirect` — declared INSIDE `ConsoleLayout`, so the console + * is already painted when it rewrites the URL. It must read "covered" both + * before and after the subject above converts, and it must be covered by the + * LAYOUT rather than by a splash. A red here means the conversion leaked into + * the URL-rewrite population the #6507 triage ruling excluded by name. + * + * This is what makes the subject's reading a measurement: the same probe, + * run on a redirect that fires under a painted layout, comes out the other way. + */ + it('LegacyMetadataRedirect fires under a painted layout — no splash needed, none added', async () => { + metadataApps = [{ name: 'crm', _packageId: 'com.example.crm' }]; + const all = await commitsAt('/apps/crm/component/metadata/directory'); + + expect(all.length, 'nothing rendered').toBeGreaterThan(0); + expect( + all.filter((sample) => sample.empty).length, + 'an out-of-shape redirect is covered by the layout it never left', + ).toBe(0); + expect(screen.getByTestId('console-layout')).toBeInTheDocument(); + expect( + screen.getByTestId(APP_ROOT).querySelector('div.h-screen.bg-background'), + 'an out-of-shape redirect must NOT have gained a splash', + ).toBeNull(); + }); +}); diff --git a/packages/app-shell/src/console/__tests__/bootRedirectCoverage.test.tsx b/packages/app-shell/src/console/__tests__/bootRedirectCoverage.test.tsx new file mode 100644 index 0000000000..a129411aba --- /dev/null +++ b/packages/app-shell/src/console/__tests__/bootRedirectCoverage.test.tsx @@ -0,0 +1,305 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * objectui#6507 — the remaining console boot gates must not hand the viewport + * to an empty document. + * + * ## What this file measures, and what it cannot + * + * The defect established by objectui#6378 is a TIMING one: a gate renders + * `LoadingFallback` while it WAITS and a bare `` the moment it + * DECIDES. `` renders null and react-router runs the navigation as a + * transition, so the destination tree renders at transition priority while the + * commit that already dropped the splash is what the compositor shows — + * measured at 41–147 ms of empty `#root` on the three sites that card fixed. + * + * happy-dom has no compositor, no CSS engine and no transition scheduler, so + * **nothing here measures that window in milliseconds**. The pixel/frame ledger + * needs a real browser and is recorded on the pull request; the end-to-end + * invariant lives in `e2e/console-boot-indicator.spec.ts`. + * + * What IS measurable here is the half that decides WHICH sites may convert. + * The triage ruling on #6507 is explicit that converting on sight is the + * failure mode: "a redirect firing under an already-painted layout keeps that + * layout rather than gaining a splash". So the question each case below asks is + * not "does this redirect work" but: + * + * > At the commit where this gate DECIDES, with the destination still + * > un-painted, does the app tree hold anything for the viewport to show? + * + * That is the same question `e2e/console-boot-indicator.spec.ts` asks with a + * hit test at the viewport centre (`el.closest('#root')`), reduced to what a + * DOM-only environment can answer honestly. + * + * ## Why nothing here renders into a `` sink + * + * The window is the gate's OWN deciding commit, not the route change that + * follows it. In order: + * + * 1. the gate decides and commits — it returns a redirect element INSTEAD of + * the `LoadingFallback` it was rendering, so the splash is dropped HERE; + * 2. ``'s effect fires and react-router starts the navigation as a + * transition; + * 3. the destination tree renders, at transition priority. + * + * The blank viewport is step 1, and it lasts until step 3 — which is why a real + * browser shows it for 41–147 ms. happy-dom has no transition scheduler, so + * step 3 lands synchronously and any read taken after it sees the destination, + * not the window. Reading there would report "empty" for a CORRECT fix as + * loudly as for a broken one, i.e. it would measure nothing. + * + * So each case below mounts the gate OUTSIDE a `` sink: the element + * stays mounted after it navigates, and what is read is what it RENDERS at + * step 1 — the frames the compositor is actually showing during the window. + * (`chrome/RedirectWithSplash.test.tsx` states the same reason for the same + * reduction.) + * + * ## Why the control arm is load-bearing + * + * A probe that can only ever report "empty" would prove nothing — it would + * convict every redirect in the codebase, including the five URL-rewrite + * redirects in `AppContent.tsx` that must NOT be converted. So the same probe + * is run against gates in their PASS-THROUGH state, where a real view is + * mounted. Those cases must read "covered". They are what makes an "empty" + * reading a measurement rather than a foregone conclusion. + * + * The out-of-shape redirects under a mounted `ConsoleLayout` are controlled + * separately, where that layout actually exists — + * `AppContent.bootRedirectCoverage.test.tsx`. + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, waitFor } from '@testing-library/react'; +import { MemoryRouter, Routes, Route } from 'react-router-dom'; +import type { ReactNode } from 'react'; + +// Imported at module scope, not inside a test: `@object-ui/components` is a +// heavy barrel and resolving it mid-assertion would race the RTL timeouts +// (AGENTS.md § 测试纪律). +import '@object-ui/components'; + +/** The AI-surface signal. Its own plumbing is covered by useAiSurface.test.ts. */ +let aiSurface = { enabled: true, isLoading: false }; +vi.mock('../../hooks/useAiSurface', () => ({ + useAiSurfaceEnabled: () => aiSurface, +})); + +/** + * The session. `AuthGuard` itself is deliberately NOT stubbed — the shape under + * test at `AuthenticatedRoute` is what the REAL guard renders when it decides, + * and a stubbed guard would be asserting the stub. + */ +let auth: Record = {}; +vi.mock('@object-ui/auth', async (importOriginal) => ({ + ...(await importOriginal>()), + useAuth: () => auth, +})); + +let metadata: Record = { apps: [], loading: false }; +vi.mock('../../providers/MetadataProvider', async (importOriginal) => ({ + ...(await importOriginal>()), + useMetadata: () => metadata, +})); + +import { + RequireOrganization, + RequireAiSurface, + AuthenticatedRoute, + RootRedirect, + SystemRedirect, + SetupRedirect, +} from '../ConsoleShell'; + +/** + * Stands in for `#root` — the element the e2e probe's hit test resolves + * through. Anything the router renders lands inside it; nothing else does. + */ +const APP_ROOT = 'app-root'; + +/** Renders `ui` under a router and reports what the viewport would hold. */ +async function coverageAt(initialPath: string, ui: ReactNode) { + render( + +
{ui}
+
, + ); + // The gates that decide from an effect (`RequireOrganization` awaits + // `getAuthConfig`) need their decision to have LANDED before the tree is + // read; otherwise this measures the waiting state, which is covered by + // construction and would report a false "covered". + await waitFor(() => { + expect(screen.getByTestId(APP_ROOT)).toBeTruthy(); + }); + const root = screen.getByTestId(APP_ROOT); + return { + covered: root.innerHTML.trim() !== '', + html: root.innerHTML, + }; +} + +/** + * The invariant, phrased so a failure prints the reading that produced it. + * `LoadingScreen` renders a full-viewport root (`div.h-screen.bg-background`), + * which is what "covered" has to mean — a stray text node would not do. + */ +function expectCovered(reading: { covered: boolean; html: string }, site: string) { + expect( + reading.covered, + `${site}: at the deciding commit the app tree was EMPTY, so the viewport is ` + + `the bare page background for the whole transition — objectui#6378's white ` + + `flash. Hand off with RedirectWithSplash, not a bare .`, + ).toBe(true); + expect( + screen.getByTestId(APP_ROOT).querySelector('div.h-screen.bg-background'), + `${site}: something rendered, but not the full-viewport splash — the page ` + + `background still shows around it.`, + ).toBeTruthy(); +} + +beforeEach(() => { + aiSurface = { enabled: true, isLoading: false }; + metadata = { apps: [], loading: false }; + auth = { + isAuthenticated: true, + isLoading: false, + user: { id: 'u_1', role: 'admin' }, + organizations: [], + activeOrganization: { id: 'org_1' }, + isOrganizationsLoading: false, + getAuthConfig: async () => ({ features: { multiOrgEnabled: true } }), + }; +}); + +describe('boot-gate coverage — ConsoleShell (objectui#6507)', () => { + it('RequireOrganization :351 — orgs exist but none is active', async () => { + auth = { ...auth, organizations: [{ id: 'org_1' }], activeOrganization: null }; + const reading = await coverageAt( + '/home', + +
APP
+
, + ); + expectCovered(reading, 'RequireOrganization (org exists, none active)'); + }); + + it('RequireOrganization :356 — no org at all, multi-org enabled', async () => { + auth = { ...auth, organizations: [], activeOrganization: null }; + const reading = await coverageAt( + '/home', + +
APP
+
, + ); + expectCovered(reading, 'RequireOrganization (no org, multi-org on)'); + }); + + it('RequireAiSurface :382 — the runtime serves no agent', async () => { + aiSurface = { enabled: false, isLoading: false }; + const reading = await coverageAt( + '/ai', + +
AI
+
, + ); + expectCovered(reading, 'RequireAiSurface'); + }); + + it('AuthenticatedRoute :401 — the session resolves to signed-out', async () => { + auth = { ...auth, isAuthenticated: false, user: null }; + const reading = await coverageAt( + '/apps/crm', + +
APP
+
, + ); + expectCovered(reading, 'AuthenticatedRoute'); + }); + + it('RootRedirect :416 — metadata has settled', async () => { + metadata = { apps: [], loading: false }; + const reading = await coverageAt('/', ); + expectCovered(reading, 'RootRedirect'); + }); + + it('SetupRedirect :515 — the /setup deep link resolves', async () => { + metadata = { apps: [{ name: 'setup', _packageId: 'com.objectstack.setup' }], loading: false }; + const reading = await coverageAt('/setup', ); + expectCovered(reading, 'SetupRedirect'); + }); +}); + +/** + * CONTROL ARM — the same probe, on states that must read "covered" without any + * change to this codebase. If these ever go red the probe has stopped + * discriminating and every "empty" reading above is worthless. + */ +describe('boot-gate coverage — control arm (the probe can report "covered")', () => { + it('RequireOrganization passes through when an org is active', async () => { + const reading = await coverageAt( + '/home', + +
APP
+
, + ); + expect(reading.covered, 'a pass-through gate renders its children').toBe(true); + expect(screen.getByText('APP')).toBeTruthy(); + }); + + it('RequireAiSurface passes through when the runtime serves agents', async () => { + aiSurface = { enabled: true, isLoading: false }; + const reading = await coverageAt( + '/ai', + +
AI
+
, + ); + expect(reading.covered).toBe(true); + expect(screen.getByText('AI')).toBeTruthy(); + }); + + it('a waiting gate is covered by its own LoadingFallback', async () => { + aiSurface = { enabled: false, isLoading: true }; + const reading = await coverageAt( + '/ai', + +
AI
+
, + ); + // The WAITING state was never the defect — this pins the baseline the + // deciding state has to match. + expect(reading.covered).toBe(true); + expect( + screen.getByTestId(APP_ROOT).querySelector('div.h-screen.bg-background'), + ).toBeTruthy(); + }); + + /** + * `SystemRedirect` is DELIBERATELY absent from the conversion set and is + * pinned here as a bare redirect. It carries the card's shape on a first + * navigation, but unlike every gate above it is also reached from INSIDE a + * painted console — `SettingsView.tsx` navigates to `/system/settings` from a + * button, and `AppSidebar.tsx` links to `/system` — and on those paths a + * splash would cover a layout that is already on screen. That is exactly the + * regression the #6507 triage ruling bans, so this site keeps its bare + * `` until someone can measure the two paths apart. + */ + it('SystemRedirect :433 stays a bare redirect — it also fires under a painted layout', async () => { + // NOTE the `` sink here, which the cases above deliberately avoid. + // `SystemRedirect` derives its target from `location.pathname`, so mounted + // outside a sink it would re-navigate on every render, each hop appending + // its own suffix (`/apps/setup/system/apps/setup/system/…`) — an infinite + // loop, not a measurement. The sink costs nothing here because this case + // asserts the ABSENCE of a splash, which the route change cannot manufacture. + const reading = await coverageAt( + '/system/settings', + + } /> + + , + ); + expect( + reading.covered, + 'SystemRedirect is intentionally NOT converted (see the comment above this test)', + ).toBe(false); + }); +}); From 76a8c204bcc82c6e4d79ca44e31c1dfcbbaca963 Mon Sep 17 00:00:00 2001 From: Yibo Wang Date: Wed, 26 Aug 2026 12:23:11 +0000 Subject: [PATCH 2/3] docs(e2e,changeset): record the #6507 conversion set and the harness boundary The e2e continuity spec's boot is signed OUT, so it cannot reach the seven gates this card converted (all of them decide only after a session exists, and two are not mounted by `apps/console` at all). Records which sites are pinned where instead of leaving the next reader to rediscover it. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_011SfZeFWrhGLHmfq61xbz4q --- .changeset/6507-boot-gate-redirect-splash.md | 32 ++++++++++++++++++++ e2e/console-boot-indicator.spec.ts | 26 ++++++++++++++++ 2 files changed, 58 insertions(+) create mode 100644 .changeset/6507-boot-gate-redirect-splash.md diff --git a/.changeset/6507-boot-gate-redirect-splash.md b/.changeset/6507-boot-gate-redirect-splash.md new file mode 100644 index 0000000000..a8f40444ac --- /dev/null +++ b/.changeset/6507-boot-gate-redirect-splash.md @@ -0,0 +1,32 @@ +--- +'@object-ui/app-shell': patch +--- + +Keep the boot splash painted across seven more console redirects (objectui#6507) + +Every readiness gate on the console boot path renders `LoadingScreen` while it +waits and a bare `Navigate` the moment it decides. `Navigate` renders null and +react-router runs the navigation as a transition, so the destination tree +renders while the commit that already dropped the splash is what the compositor +shows — measured at 41-147 ms of empty `#root` on the three sibling gates +objectui#6506 fixed. + +Converted to `RedirectWithSplash`, which pairs the same navigation with the same +`LoadingScreen` so the handoff changes no pixels: + +- `RequireOrganization` — both decisions (orgs exist but none active; no org at + all with multi-org enabled) +- `RequireAiSurface` — a runtime that serves no agent +- `AuthenticatedRoute` — the signed-out fallback (published for consumers; + `apps/console` converted its own `ProtectedRoute` copy under objectui#6506) +- `RootRedirect` — byte-for-byte the shape that measured the widest window +- `SetupRedirect` — the `/setup` deep link +- `AppContent` — the no-accessible-app bounce, which returns above the single + `ConsoleLayout` mount + +`SystemRedirect` is deliberately left as a bare `Navigate`. It carries the same +shape on a first navigation, but it is the only site in this set that also fires +with the console already painted (`SettingsView` navigates to `/system/settings` +from a button; `AppSidebar` links to `/system`), and a redirect firing under an +already-painted layout must keep that layout rather than gain a splash. The five +URL-rewrite redirects in `AppContent` are excluded for the same reason. diff --git a/e2e/console-boot-indicator.spec.ts b/e2e/console-boot-indicator.spec.ts index 7676146799..5b3b75b08b 100644 --- a/e2e/console-boot-indicator.spec.ts +++ b/e2e/console-boot-indicator.spec.ts @@ -223,6 +223,32 @@ test.describe('Console boot indicator', () => { * transition, so the destination tree renders while the commit that dropped the * splash is already on screen. Measured on the production bundle: 41–147 ms of * empty `#root`. + * + * ## What this boot reaches, and what it does not (objectui#6507) + * + * The mocked boot below is SIGNED OUT, which is what makes it short enough to + * be deterministic: `/console/` takes the catch-all redirect, then the auth + * gate's redirect to `/login`. Those are two of the three sites #6506 fixed. + * + * #6507 converted seven further gates, and every one of them decides only AFTER + * a session exists — `RequireOrganization` (no active org), `RequireAiSurface` + * (a runtime serving no agent), `SetupRedirect` (the `/setup` deep link) and + * `AppContent`'s no-accessible-app bounce all sit behind `ProtectedRoute`, so a + * signed-out boot bounces to `/login` before reaching any of them. Two more — + * `RootRedirect` and `AuthenticatedRoute` — are published by + * `@object-ui/app-shell` for consumers and are not mounted by `apps/console` at + * all (it uses its own `RootLandingRedirect` and `ProtectedRoute`), so no boot + * of THIS bundle can reach them at any session state. + * + * Bringing the first four under this assertion needs a signed-in mock boot — + * a session, the metadata bucket fetches and the org/app endpoints each gate + * reads — which is a materially larger fixture than the four endpoints below + * and is deliberately not attempted here. Until it exists, those sites are + * pinned at the DOM level, one file per population, with an explicit control + * arm that must read "covered" so an "empty" reading stays falsifiable: + * `packages/app-shell/src/console/__tests__/bootRedirectCoverage.test.tsx` and + * `…/AppContent.bootRedirectCoverage.test.tsx`. Those measure the deciding + * COMMIT, not the milliseconds — the pixel ledger still needs a real browser. */ interface CoverProbe { reactMountAt?: number; From f7d6d3844feb8928bde2387603341fe0ad111915 Mon Sep 17 00:00:00 2001 From: Yibo Wang Date: Wed, 26 Aug 2026 12:57:35 +0000 Subject: [PATCH 3/3] =?UTF-8?q?docs(e2e):=20correct=20the=20#6507=20covera?= =?UTF-8?q?ge=20note=20=E2=80=94=20the=20gap=20is=20the=20window,=20not=20?= =?UTF-8?q?a=20browser?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The block claimed the signed-in fixture was "deliberately not attempted" and that "the pixel ledger still needs a real browser". Both were falsified by measurement: a browser is present, this spec runs against the production bundle and passes, and the signed-in scenarios were built and run. What they showed is that they stay green against a bundle rebuilt from ablated source, and under 20x CPU throttling — the pre-React `#boot-splash` is still covering when the gate decides, so there is no blank window to catch. As written the note sent the next reader to acquire a browser, which is the wrong errand. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_011SfZeFWrhGLHmfq61xbz4q --- e2e/console-boot-indicator.spec.ts | 27 ++++++++++++++++++++------- 1 file changed, 20 insertions(+), 7 deletions(-) diff --git a/e2e/console-boot-indicator.spec.ts b/e2e/console-boot-indicator.spec.ts index 5b3b75b08b..7ca2294e17 100644 --- a/e2e/console-boot-indicator.spec.ts +++ b/e2e/console-boot-indicator.spec.ts @@ -240,15 +240,28 @@ test.describe('Console boot indicator', () => { * all (it uses its own `RootLandingRedirect` and `ProtectedRoute`), so no boot * of THIS bundle can reach them at any session state. * - * Bringing the first four under this assertion needs a signed-in mock boot — - * a session, the metadata bucket fetches and the org/app endpoints each gate - * reads — which is a materially larger fixture than the four endpoints below - * and is deliberately not attempted here. Until it exists, those sites are - * pinned at the DOM level, one file per population, with an explicit control - * arm that must read "covered" so an "empty" reading stays falsifiable: + * A signed-in mock boot for the first four WAS built and run, and the result is + * the reason no per-site case was added here: those scenarios stay GREEN against + * a bundle rebuilt from ablated source — with the fix removed — and they stay + * green under 20x CPU throttling too. They do not bind to the defect, so + * committing them would have added a gate that cannot fail. + * + * The diagnosis is not a missing browser. A browser is available and this file + * runs against the production bundle; the acceptance spec passes. What is + * missing is a reproducible WINDOW: the pre-React `#boot-splash` counts as + * covering, and on those mocked boots the redirect chain resolves before the + * indicator is torn down, so at the moment the gate decides there is no blank + * for a sampler to catch. Making a per-site e2e gate that CAN fail therefore + * needs the window reproduced with the indicator already gone — not more + * endpoints, and not a browser. + * + * Until such a gate exists, those sites are pinned at the DOM level, one file + * per population, with an explicit control arm that must read "covered" so an + * "empty" reading stays falsifiable: * `packages/app-shell/src/console/__tests__/bootRedirectCoverage.test.tsx` and * `…/AppContent.bootRedirectCoverage.test.tsx`. Those measure the deciding - * COMMIT, not the milliseconds — the pixel ledger still needs a real browser. + * COMMIT rather than the milliseconds, and under the ablation above they turn + * red where the e2e scenarios did not. */ interface CoverProbe { reactMountAt?: number;