diff --git a/.changeset/6507-boot-gate-redirect-splash.md b/.changeset/6507-boot-gate-redirect-splash.md
new file mode 100644
index 000000000..a8f40444a
--- /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 767614679..7ca2294e1 100644
--- a/e2e/console-boot-indicator.spec.ts
+++ b/e2e/console-boot-indicator.spec.ts
@@ -223,6 +223,45 @@ 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.
+ *
+ * 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 rather than the milliseconds, and under the ablation above they turn
+ * red where the e2e scenarios did not.
*/
interface CoverProbe {
reactMountAt?: number;
diff --git a/packages/app-shell/src/console/AppContent.tsx b/packages/app-shell/src/console/AppContent.tsx
index 0963374bd..c5c279eb7 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 9b4fb7eca..a8509f201 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 000000000..9d0716513
--- /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 }) => (
+
+
+ {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 000000000..a129411ab
--- /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);
+ });
+});