Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions .changeset/6507-boot-gate-redirect-splash.md
Original file line numberDiff line numberDiff line change
@@ -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.
39 changes: 39 additions & 0 deletions e2e/console-boot-indicator.spec.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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;
Expand Down
14 changes: 13 additions & 1 deletion packages/app-shell/src/console/AppContent.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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';
Expand DownExpand Up@@ -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 <Navigate to="/home" replace />;
// `RedirectWithSplash`, not a bare `<Navigate>` (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 <RedirectWithSplash to="/home" replace />;
}

if (!activeApp && !isCreateAppRoute && !isSystemRoute && !isMetadataRoute) return (
Expand Down
63 changes: 57 additions & 6 deletions packages/app-shell/src/console/ConsoleShell.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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';
Expand DownExpand Up@@ -348,12 +349,21 @@ export function RequireOrganization({ children }: { children: ReactNode }) {
if (isOrganizationsLoading) return <LoadingFallback />;
const orgList = organizations ?? [];
const orgFeatureEnabled = orgList.length > 0 || !!activeOrganization;
if (orgFeatureEnabled && !activeOrganization) return <Navigate to="/organizations" replace />;
// `RedirectWithSplash` at every DECIDE below, not a bare `<Navigate>`
// (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 <RedirectWithSplash to="/organizations" replace />;
// 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 <LoadingFallback />;
if (multiOrgEnabled) return <Navigate to="/organizations" replace />;
if (multiOrgEnabled) return <RedirectWithSplash to="/organizations" replace />;
}
return <>{children}</>;
}
Expand All@@ -379,7 +389,14 @@ export function RequireAiSurface({
}) {
const { enabled, isLoading } = useAiSurfaceEnabled();
if (isLoading) return <LoadingFallback />;
if (!enabled) return <Navigate to={redirectTo} replace />;
// 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 <RedirectWithSplash to={redirectTo} replace />;
return <>{children}</>;
}

Expand All@@ -398,7 +415,18 @@ export function AuthenticatedRoute({
loginPath?: string;
}) {
return (
<AuthGuard fallback={<Navigate to={loginPath} />} loadingFallback={<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 `<Navigate>` 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.
<AuthGuard
fallback={<RedirectWithSplash to={loginPath} />}
loadingFallback={<LoadingFallback />}
>
<ConnectedShell>
{requireOrganization ? <RequireOrganization>{children}</RequireOrganization> : children}
</ConnectedShell>
Expand All@@ -413,7 +441,12 @@ export function AuthenticatedRoute({
export function RootRedirect() {
const { loading } = useMetadata();
if (loading) return <LoadingFallback />;
return <Navigate to="/home" replace />;
// 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 <RedirectWithSplash to="/home" replace />;
}

/**
Expand All@@ -427,6 +460,18 @@ export function RootRedirect() {
* that makes the hub mount at all.
*/
export function SystemRedirect() {
// ⚠️ DELIBERATELY a bare `<Navigate>`, 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';
Expand DownExpand Up@@ -512,5 +557,11 @@ export function SetupRedirect() {
const location = useLocation();
if (loading) return <LoadingFallback />;
const target = resolveSetupAppPath(apps as SetupAppLike[] | undefined);
return <Navigate to={`${target}${location.search}${location.hash}`} replace />;
// 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/<segment>` directly, not through this alias.)
return <RedirectWithSplash to={`${target}${location.search}${location.hash}`} replace />;
}
Loading
Loading