From 4a1afaf019b0f886f73f9f357c7bfb68be342a01 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 12:31:48 +0000 Subject: [PATCH 1/2] feat(shell): lift the cover and dispatch held routes on the Console's ready The loading cover and a route held on cold start now wait for the Console's `ready` bridge event instead of the WebView's load end, with an 8 s timeout after load end for Consoles that never post it. The last route a re-created WebView resumes at comes from `routeChanged`; navigation state is only the fallback for Consoles that never post it. One pure state machine (lib/shell/console-load.ts) drives all three. Claude-Session: https://claude.ai/code/session_01QSeKYnW1Xf6HDSWz1eGAqv Co-authored-by: Claude --- __tests__/components/ConsoleWebView.test.tsx | 127 +++++++++++-- __tests__/lib/shell/console-load.test.ts | 180 +++++++++++++++++++ components/shell/ConsoleWebView.tsx | 143 +++++++++------ lib/shell/console-load.ts | 172 ++++++++++++++++++ 4 files changed, 553 insertions(+), 69 deletions(-) create mode 100644 __tests__/lib/shell/console-load.test.ts create mode 100644 lib/shell/console-load.ts diff --git a/__tests__/components/ConsoleWebView.test.tsx b/__tests__/components/ConsoleWebView.test.tsx index c91d104..72a2ada 100644 --- a/__tests__/components/ConsoleWebView.test.tsx +++ b/__tests__/components/ConsoleWebView.test.tsx @@ -6,6 +6,7 @@ import { type ConsoleWebViewHandle, type ConsoleWebViewProps, } from "~/components/shell/ConsoleWebView"; +import { READY_TIMEOUT_MS } from "~/lib/shell/console-load"; const mockWebView = { props: null as any, @@ -81,6 +82,20 @@ function finishLoad() { act(() => mockWebView.props.onLoadEnd({ nativeEvent: { loading: false } })); } +/** A bridge event posted by the page, from the configured server's origin. */ +async function postEvent(name: string, payload?: unknown, url = `${SERVER}/_console/home`) { + await act(async () => { + mockWebView.props.onMessage({ + nativeEvent: { data: JSON.stringify({ type: "event", name, payload }), url }, + }); + }); +} + +const consoleReady = () => postEvent("ready"); + +const navigatedTo = (route: string) => + expect.stringContaining(`${SERVER}/_console${route}`); + beforeEach(() => { mockIsConnected = true; mockWebView.props = null; @@ -103,13 +118,30 @@ describe("ConsoleWebView", () => { expect(mockWebView.props.applicationNameForUserAgent).toMatch(/^ObjectStackShell\//); }); - it("shows a loading overlay until the page finishes loading", () => { + it("keeps the loading cover up past load end until the Console says ready", async () => { renderConsole(); expect(screen.getByText("Loading…")).toBeTruthy(); - act(() => mockWebView.props.onLoadEnd({ nativeEvent: { loading: false } })); + finishLoad(); + expect(screen.getByText("Loading…")).toBeTruthy(); + await consoleReady(); + expect(screen.queryByText("Loading…")).toBeNull(); + }); + + it("lifts the cover on ready even before load end", async () => { + renderConsole(); + await consoleReady(); + expect(screen.queryByText("Loading…")).toBeNull(); + finishLoad(); expect(screen.queryByText("Loading…")).toBeNull(); }); + it("ignores ready from a page on another origin", async () => { + renderConsole(); + finishLoad(); + await postEvent("ready", undefined, "https://evil.example.com/"); + expect(screen.getByText("Loading…")).toBeTruthy(); + }); + it("applies the navigation policy", () => { const { props } = renderConsole(); const decide = (url: string, isTopFrame = true) => @@ -151,19 +183,65 @@ describe("ConsoleWebView", () => { expect(mockWebView.injectJavaScript).not.toHaveBeenCalled(); }); - it("holds a route requested before the page loads, then applies it", () => { + it("holds a route asked for on cold start past load end, and dispatches it on ready", async () => { + // A notification tap on cold start: the Console's navigate listener mounts + // only after its boot, so a route injected at load end would reload the page. const { ref } = renderConsole(); act(() => ref.current!.navigate("/apps/hr")); + finishLoad(); expect(mockWebView.injectJavaScript).not.toHaveBeenCalled(); - act(() => mockWebView.props.onLoadEnd({ nativeEvent: { loading: false } })); - expect(mockWebView.injectJavaScript).toHaveBeenCalledWith( - expect.stringContaining(`${SERVER}/_console/apps/hr`) - ); + await consoleReady(); + expect(mockWebView.injectJavaScript).toHaveBeenCalledTimes(1); + expect(mockWebView.injectJavaScript).toHaveBeenCalledWith(navigatedTo("/apps/hr")); + }); + + it("dispatches a held route on ready that arrives before load end, once", async () => { + const { ref } = renderConsole(); + act(() => ref.current!.navigate("/apps/hr")); + await consoleReady(); + finishLoad(); + expect(mockWebView.injectJavaScript).toHaveBeenCalledTimes(1); + expect(mockWebView.injectJavaScript).toHaveBeenCalledWith(navigatedTo("/apps/hr")); }); - it("navigates the page, or hands native routes off, on request", () => { + describe("when the Console never says ready", () => { + beforeEach(() => jest.useFakeTimers()); + afterEach(() => jest.useRealTimers()); + + it("lifts the cover and dispatches the held route after the ready timeout", () => { + const { ref } = renderConsole(); + act(() => ref.current!.navigate("/apps/hr")); + finishLoad(); + act(() => jest.advanceTimersByTime(READY_TIMEOUT_MS - 1)); + expect(screen.getByText("Loading…")).toBeTruthy(); + expect(mockWebView.injectJavaScript).not.toHaveBeenCalled(); + act(() => jest.advanceTimersByTime(1)); + expect(screen.queryByText("Loading…")).toBeNull(); + expect(mockWebView.injectJavaScript).toHaveBeenCalledTimes(1); + expect(mockWebView.injectJavaScript).toHaveBeenCalledWith(navigatedTo("/apps/hr")); + }); + + it("does not start the timeout before the document has loaded", () => { + renderConsole(); + act(() => jest.advanceTimersByTime(READY_TIMEOUT_MS * 3)); + expect(screen.getByText("Loading…")).toBeTruthy(); + }); + + it("does not fire the timeout into a failed load", () => { + const { ref } = renderConsole(); + act(() => ref.current!.navigate("/apps/hr")); + finishLoad(); + act(() => mockWebView.props.onHttpError({ nativeEvent: { statusCode: 502 } })); + act(() => jest.advanceTimersByTime(READY_TIMEOUT_MS)); + expect(mockWebView.injectJavaScript).not.toHaveBeenCalled(); + expect(screen.getByTestId("shell-offline-fallback")).toBeTruthy(); + }); + }); + + it("navigates the page, or hands native routes off, on request", async () => { const { ref, props } = renderConsole(); - act(() => mockWebView.props.onLoadEnd({ nativeEvent: { loading: false } })); + finishLoad(); + await consoleReady(); act(() => ref.current!.navigate("/apps/hr")); expect(mockWebView.injectJavaScript).toHaveBeenCalledWith( expect.stringContaining(`${SERVER}/_console/apps/hr`) @@ -172,15 +250,16 @@ describe("ConsoleWebView", () => { expect(props.onOpenNative).toHaveBeenCalledWith("approvals", "/approvals/3"); }); - it("refuses a route that would escape the Console", () => { + it("refuses a route that would escape the Console", async () => { const { ref } = renderConsole(); finishLoad(); + await consoleReady(); act(() => ref.current!.navigate("/../../api/v1/me")); act(() => ref.current!.navigate("/%2e%2e/api/v1/me")); expect(mockWebView.injectJavaScript).not.toHaveBeenCalled(); }); - it("keeps a route queued on cold start across a failed load, and opens it on retry", () => { + it("keeps a route queued on cold start across a failed load, and opens it on retry", async () => { const { ref } = renderConsole(); act(() => ref.current!.navigate("/apps/hr")); failLoad(); @@ -193,6 +272,7 @@ describe("ConsoleWebView", () => { expect(mockWebView.props.source).toEqual({ uri: `${SERVER}/_console/apps/hr` }); // It is the start route of the new WebView, so it is not injected again. finishLoad(); + await consoleReady(); expect(mockWebView.injectJavaScript).not.toHaveBeenCalled(); expect(screen.queryByText("Loading…")).toBeNull(); }); @@ -283,6 +363,31 @@ describe("ConsoleWebView", () => { expect(screen.getByText("Loading…")).toBeTruthy(); }); + it("re-creates the WebView at the route the Console reported, over navigation state", async () => { + renderConsole(); + finishLoad(); + await consoleReady(); + await postEvent("routeChanged", { route: "/apps/crm/lead/9?tab=notes" }); + // Some engines do not report SPA pushState here; once routeChanged has + // been seen, navigation state no longer moves the last route. + act(() => + mockWebView.props.onNavigationStateChange({ url: `${SERVER}/_console/apps/crm`, canGoBack: true }) + ); + act(() => mockWebView.props.onContentProcessDidTerminate()); + expect(mockWebView.props.source).toEqual({ + uri: `${SERVER}/_console/apps/crm/lead/9?tab=notes`, + }); + }); + + it("drops a routeChanged payload that is not a safe Console route", async () => { + renderConsole(); + finishLoad(); + await postEvent("routeChanged", { route: "/../../api/v1/me" }); + await postEvent("routeChanged", "/apps/hr"); + act(() => mockWebView.props.onContentProcessDidTerminate()); + expect(mockWebView.props.source).toEqual({ uri: `${SERVER}/_console/apps/crm` }); + }); + it("re-creates the WebView when the iOS web content process dies", () => { renderConsole(); finishLoad(); diff --git a/__tests__/lib/shell/console-load.test.ts b/__tests__/lib/shell/console-load.test.ts new file mode 100644 index 0000000..d4817cf --- /dev/null +++ b/__tests__/lib/shell/console-load.test.ts @@ -0,0 +1,180 @@ +import { + INITIAL_CONSOLE_LOAD_STATE, + routeChangedRoute, + stepConsoleLoad, + type ConsoleLoadEffect, + type ConsoleLoadEvent, + type ConsoleLoadState, +} from "~/lib/shell/console-load"; + +/** Run events from the initial state; collect every effect in order. */ +function run(...events: ConsoleLoadEvent[]): { state: ConsoleLoadState; effects: ConsoleLoadEffect[] } { + let state = INITIAL_CONSOLE_LOAD_STATE; + const effects: ConsoleLoadEffect[] = []; + for (const event of events) { + const step = stepConsoleLoad(state, event); + state = step.state; + effects.push(...step.effects); + } + return { state, effects }; +} + +const goTo = (route: string): ConsoleLoadEffect => ({ type: "goTo", route }); + +describe("console load state machine", () => { + it("starts loading, with nothing held", () => { + expect(INITIAL_CONSOLE_LOAD_STATE).toEqual({ + phase: "loading", + pendingRoute: null, + lastRoute: null, + routeReported: false, + }); + }); + + it("waits for ready after load end, then dispatches the held route once", () => { + const afterLoad = run({ type: "navigate", route: "/apps/hr" }, { type: "loadEnd" }); + expect(afterLoad.state.phase).toBe("loaded"); + expect(afterLoad.state.pendingRoute).toBe("/apps/hr"); + expect(afterLoad.effects).toEqual([{ type: "armReadyTimer" }]); + + const ready = stepConsoleLoad(afterLoad.state, { type: "ready" }); + expect(ready.state.phase).toBe("ready"); + expect(ready.state.pendingRoute).toBeNull(); + expect(ready.effects).toEqual([{ type: "clearReadyTimer" }, goTo("/apps/hr")]); + + // A second ready, a late timeout and a later load end change nothing. + for (const event of [{ type: "ready" }, { type: "readyTimeout" }, { type: "loadEnd" }] as const) { + const again = stepConsoleLoad(ready.state, event); + expect(again.state).toBe(ready.state); + expect(again.effects).toEqual([]); + } + }); + + it("takes ready before load end, and does not wait for the load event", () => { + const { state, effects } = run( + { type: "navigate", route: "/apps/hr" }, + { type: "ready" }, + { type: "loadEnd" } + ); + expect(state.phase).toBe("ready"); + // No ready timer is ever armed, and the route goes out exactly once. + expect(effects).toEqual([goTo("/apps/hr")]); + }); + + it("falls back to the timeout when ready never comes", () => { + const { state, effects } = run( + { type: "navigate", route: "/apps/hr" }, + { type: "loadEnd" }, + { type: "readyTimeout" } + ); + expect(state.phase).toBe("ready"); + expect(effects).toEqual([{ type: "armReadyTimer" }, goTo("/apps/hr")]); + }); + + it("ignores a timeout that fires outside the loaded phase", () => { + expect(run({ type: "readyTimeout" }).state.phase).toBe("loading"); + expect(run({ type: "fail" }, { type: "readyTimeout" }).state.phase).toBe("failed"); + }); + + it("holds only the newest route", () => { + const { effects } = run( + { type: "navigate", route: "/apps/hr" }, + { type: "navigate", route: "/apps/finance" }, + { type: "ready" } + ); + expect(effects).toEqual([goTo("/apps/finance")]); + }); + + it("sends a route straight to a ready page", () => { + const { state, effects } = run({ type: "ready" }, { type: "navigate", route: "/apps/hr" }); + expect(effects).toEqual([goTo("/apps/hr")]); + expect(state.pendingRoute).toBeNull(); + }); + + it("keeps the held route through a failed load and hands it to the new WebView", () => { + // react-native-webview order: onError, then onLoadEnd. + const failed = run({ type: "navigate", route: "/apps/hr" }, { type: "fail" }, { type: "loadEnd" }); + expect(failed.state.phase).toBe("failed"); + expect(failed.state.pendingRoute).toBe("/apps/hr"); + expect(failed.effects).toEqual([]); + + // A failed page lifts nothing and receives nothing. + const stray = stepConsoleLoad(failed.state, { type: "ready" }); + expect(stray.state.phase).toBe("failed"); + expect(stray.effects).toEqual([]); + + const recreated = stepConsoleLoad(failed.state, { type: "recreate" }); + expect(recreated.state.phase).toBe("loading"); + expect(recreated.state.pendingRoute).toBeNull(); + expect(recreated.effects).toEqual([{ type: "recreate", startRoute: "/apps/hr" }]); + }); + + it("clears the ready timer when a loaded page fails, reloads or is re-created", () => { + const loaded = run({ type: "loadEnd" }).state; + expect(stepConsoleLoad(loaded, { type: "fail" }).effects).toEqual([{ type: "clearReadyTimer" }]); + const reloaded = stepConsoleLoad(loaded, { type: "reload" }); + expect(reloaded.state.phase).toBe("loading"); + expect(reloaded.effects).toEqual([{ type: "clearReadyTimer" }]); + expect(stepConsoleLoad(loaded, { type: "recreate" }).effects).toEqual([ + { type: "clearReadyTimer" }, + { type: "recreate", startRoute: null }, + ]); + }); + + it("holds routes again after a reload of a ready page", () => { + const { state, effects } = run( + { type: "ready" }, + { type: "reload" }, + { type: "navigate", route: "/apps/hr" } + ); + expect(state.phase).toBe("loading"); + expect(state.pendingRoute).toBe("/apps/hr"); + expect(effects).toEqual([]); + }); + + describe("last route", () => { + it("follows navigation state while the Console reports no route", () => { + const { state } = run( + { type: "navigationState", route: "/apps/crm" }, + { type: "navigationState", route: null } + ); + expect(state.lastRoute).toBe("/apps/crm"); + }); + + it("follows routeChanged once the Console reports one, ignoring navigation state", () => { + const { state } = run( + { type: "navigationState", route: "/apps/crm" }, + { type: "routeChanged", route: "/apps/crm/lead/9?tab=notes" }, + { type: "navigationState", route: "/apps/crm" } + ); + expect(state.routeReported).toBe(true); + expect(state.lastRoute).toBe("/apps/crm/lead/9?tab=notes"); + }); + + it("is where a re-created WebView starts when no route is held", () => { + const { effects } = run( + { type: "ready" }, + { type: "routeChanged", route: "/apps/crm/lead/9" }, + { type: "recreate" } + ); + expect(effects).toEqual([{ type: "recreate", startRoute: "/apps/crm/lead/9" }]); + }); + }); + + describe("routeChangedRoute", () => { + it("reads a safe route from the payload", () => { + expect(routeChangedRoute({ route: "/apps/crm?x=1#y" })).toBe("/apps/crm?x=1#y"); + }); + + it.each([ + ["no payload", undefined], + ["a bare string", "/apps/crm"], + ["a missing route", {}], + ["a non-string route", { route: 7 }], + ["a protocol-relative route", { route: "//evil.example.com" }], + ["an escaping route", { route: "/../../api/v1/me" }], + ])("refuses %s", (_label, payload) => { + expect(routeChangedRoute(payload)).toBeNull(); + }); + }); +}); diff --git a/components/shell/ConsoleWebView.tsx b/components/shell/ConsoleWebView.tsx index 255458f..cec238c 100644 --- a/components/shell/ConsoleWebView.tsx +++ b/components/shell/ConsoleWebView.tsx @@ -33,6 +33,14 @@ import { buildResolveScript, } from "~/lib/shell/bridge/scripts"; import { createBridgeHandler, type BridgeDeps } from "~/lib/shell/bridge/handler"; +import { + INITIAL_CONSOLE_LOAD_STATE, + READY_TIMEOUT_MS, + routeChangedRoute, + stepConsoleLoad, + type ConsoleLoadEvent, + type ConsoleLoadPhase, +} from "~/lib/shell/console-load"; import { addBreadcrumb, captureMessage } from "~/lib/sentry"; import { useNetworkStatus } from "~/hooks/useNetworkStatus"; @@ -84,7 +92,6 @@ export const ConsoleWebView = forwardRef(null); const canGoBackRef = useRef(false); - const [loaded, setLoaded] = useState(false); const [failure, setFailure] = useState(null); // The WebView is re-created (new `key`) after a failed load or a dead web @@ -99,17 +106,18 @@ export const ConsoleWebView = forwardRef(null); - // The last Console route the page was on, so a re-created WebView resumes - // there instead of at the start route. - const lastRouteRef = useRef(null); + // Cover, held route and last route follow one state machine + // (`lib/shell/console-load.ts`). A route requested before the Console can + // take it — while the page loads or boots (a notification tap on cold + // start), or while the failure page is up — is held, and dispatched once + // the Console says `ready` (or the ready timeout passes), or becomes the + // start route of the next re-created WebView. The machine lives in a ref + // so a bridge call sees the state the previous event left; `phase` + // mirrors it for rendering. + const loadRef = useRef(INITIAL_CONSOLE_LOAD_STATE); + const [phase, setPhase] = useState(INITIAL_CONSOLE_LOAD_STATE.phase); + const readyTimerRef = useRef | null>(null); + const dispatchRef = useRef<(event: ConsoleLoadEvent) => void>(() => {}); const goTo = useCallback( (route: string) => { @@ -120,6 +128,45 @@ export const ConsoleWebView = forwardRef { + if (readyTimerRef.current !== null) clearTimeout(readyTimerRef.current); + readyTimerRef.current = null; + }, []); + + const dispatch = useCallback( + (event: ConsoleLoadEvent) => { + const { state, effects } = stepConsoleLoad(loadRef.current, event); + loadRef.current = state; + setPhase(state.phase); + for (const effect of effects) { + switch (effect.type) { + case "goTo": + goTo(effect.route); + break; + case "armReadyTimer": + clearReadyTimer(); + readyTimerRef.current = setTimeout(() => { + readyTimerRef.current = null; + addBreadcrumb({ category: "shell", message: "ready timeout" }); + dispatchRef.current({ type: "readyTimeout" }); + }, READY_TIMEOUT_MS); + break; + case "clearReadyTimer": + clearReadyTimer(); + break; + case "recreate": + if (effect.startRoute !== null) setSourceRoute(effect.startRoute); + setFailure(null); + setInstance((i) => i + 1); + break; + } + } + }, + [goTo, clearReadyTimer] + ); + dispatchRef.current = dispatch; + useEffect(() => clearReadyTimer, [clearReadyTimer]); + const navigate = useCallback( (requested: string) => { const route = safeConsoleRoute(requested); @@ -134,55 +181,30 @@ export const ConsoleWebView = forwardRef { - // A failed load also ends with `onLoadEnd`; there is no page to show or - // to move, so the pending route stays pending. - if (failedRef.current) return; - loadedRef.current = true; - setLoaded(true); - const pending = pendingRouteRef.current; - if (pending) { - pendingRouteRef.current = null; - goTo(pending); - } - }, [goTo]); - - const fail = useCallback((next: LoadFailure) => { - failedRef.current = true; - loadedRef.current = false; - setFailure(next); - }, []); + const fail = useCallback( + (next: LoadFailure) => { + dispatch({ type: "fail" }); + setFailure(next); + }, + [dispatch] + ); /** Re-create the WebView at the pending route, else where the page was. */ - const remount = useCallback(() => { - const next = pendingRouteRef.current ?? lastRouteRef.current; - pendingRouteRef.current = null; - if (next !== null) setSourceRoute(next); - failedRef.current = false; - loadedRef.current = false; - setFailure(null); - setLoaded(false); - setInstance((i) => i + 1); - }, []); + const remount = useCallback(() => dispatch({ type: "recreate" }), [dispatch]); const reload = useCallback(() => { - if (failedRef.current || !webviewRef.current) { + if (loadRef.current.phase === "failed" || !webviewRef.current) { remount(); return; } - loadedRef.current = false; - setLoaded(false); + dispatch({ type: "reload" }); webviewRef.current.reload(); - }, [remount]); + }, [remount, dispatch]); useImperativeHandle(ref, () => ({ navigate, reload }), [navigate, reload]); @@ -231,11 +253,15 @@ export const ConsoleWebView = forwardRef { - if (name === "ready") markLoaded(); + if (name === "ready") dispatch({ type: "ready" }); + if (name === "routeChanged") { + const route = routeChangedRoute(payload); + if (route !== null) dispatch({ type: "routeChanged", route }); + } addBreadcrumb({ category: "bridge", message: name, data: { payload } }); }, }), - [capabilities, serverUrl, navigate, markLoaded] + [capabilities, serverUrl, navigate, dispatch] ); const onMessage = useCallback( @@ -275,10 +301,11 @@ export const ConsoleWebView = forwardRef { canGoBackRef.current = nav.canGoBack; + // Fallback only: once the Console posts `routeChanged`, it decides. const route = nav.url ? routeFromConsoleUrl(serverUrl, nav.url) : null; - if (route !== null) lastRouteRef.current = route; + dispatch({ type: "navigationState", route }); }, - [serverUrl] + [serverUrl, dispatch] ); if (failure) { @@ -315,9 +342,9 @@ export const ConsoleWebView = forwardRef { // `loading` is still true when a load is superseded by a redirect. - // react-native-webview also calls this after `onError`; - // `markLoaded` ignores a failed load. - if (!e.nativeEvent.loading) markLoaded(); + // react-native-webview also calls this after `onError`; the + // load state ignores it on a failed load. + if (!e.nativeEvent.loading) dispatch({ type: "loadEnd" }); }} onError={(e) => { captureMessage(`console load failed: ${e.nativeEvent.description}`, "warning"); @@ -347,7 +374,7 @@ export const ConsoleWebView = forwardRef - {!loaded ? ( + {phase !== "ready" ? ( diff --git a/lib/shell/console-load.ts b/lib/shell/console-load.ts new file mode 100644 index 0000000..74e29d0 --- /dev/null +++ b/lib/shell/console-load.ts @@ -0,0 +1,172 @@ +/** + * Load states of the hosted Console — the one place that decides when the + * loading cover lifts, when a held route is dispatched into the page, and + * which route a re-created WebView resumes at. + * + * loading ──loadEnd──▶ loaded ──ready / readyTimeout──▶ ready + * │ │ + * └──────ready────────┼──────────────────────────────▶ ready + * └───────fail────────┴──────────▶ failed ──recreate──▶ loading + * + * - `loading`: the document is loading. The cover is up; routes are held. + * - `loaded`: the document has loaded (`onLoadEnd`) but the Console has not + * said `ready`. The Console boots after its document loads (boot round-trips, + * then the first render), and its `objectstack-native:navigate` listener + * mounts with that first render — a route dispatched now goes unhandled and + * the shell's fallback reloads the page. So the cover stays up and routes + * stay held until `ready`, or until {@link READY_TIMEOUT_MS} passes (a + * Console that never posts `ready`: an older one, or a boot that failed). + * - `ready`: the cover is down; a held route is dispatched once, and later + * routes go straight to the page. + * - `failed`: the failure page is up; routes are held for the next WebView. + * + * Pure and synchronous: `stepConsoleLoad` returns the next state plus the side + * effects the component performs (inject a route, arm or clear the ready + * timer, re-create the WebView). + */ +import { safeConsoleRoute } from "./console-url"; + +/** + * How long after the document has loaded the shell waits for the Console's + * `ready` before it lifts the cover and dispatches a held route anyway. + * + * The Console's boot after load is three concurrent round-trips (one capped at + * about 500 ms) and a locale chunk, then the first render; 8 s covers that on + * a slow cellular link with room to spare, and bounds how long an older + * Console (no `ready`) or a broken boot sits behind the cover. + */ +export const READY_TIMEOUT_MS = 8000; + +export type ConsoleLoadPhase = "loading" | "loaded" | "ready" | "failed"; + +export interface ConsoleLoadState { + phase: ConsoleLoadPhase; + /** A route asked for before the page could take it; applied once, at `ready`. */ + pendingRoute: string | null; + /** The last Console route the page was on — where a re-created WebView resumes. */ + lastRoute: string | null; + /** + * The Console has reported its route with `routeChanged`. From then on that + * is the only source of `lastRoute`; WebView navigation state is the + * fallback for Consoles that never post it (it misses SPA `pushState` on + * some engines). + */ + routeReported: boolean; +} + +export type ConsoleLoadEvent = + /** `onLoadEnd` of a load that was not superseded. */ + | { type: "loadEnd" } + /** The Console's `ready` bridge event. */ + | { type: "ready" } + /** {@link READY_TIMEOUT_MS} passed in `loaded` without `ready`. */ + | { type: "readyTimeout" } + /** The document failed (network error, 5xx). */ + | { type: "fail" } + /** The same WebView reloads its page. */ + | { type: "reload" } + /** A new WebView replaces the old one (retry after a failure, dead process). */ + | { type: "recreate" } + /** A web-owned, already validated Console route was asked for. */ + | { type: "navigate"; route: string } + /** The Console's `routeChanged` bridge event, payload already parsed. */ + | { type: "routeChanged"; route: string } + /** `onNavigationStateChange`, reduced to the Console route it points at. */ + | { type: "navigationState"; route: string | null }; + +export type ConsoleLoadEffect = + /** Move the loaded page to `route` (navigate event, else a page load). */ + | { type: "goTo"; route: string } + | { type: "armReadyTimer" } + | { type: "clearReadyTimer" } + /** Mount a new WebView, at `startRoute` when one is given. */ + | { type: "recreate"; startRoute: string | null }; + +export interface ConsoleLoadStep { + state: ConsoleLoadState; + effects: ConsoleLoadEffect[]; +} + +export const INITIAL_CONSOLE_LOAD_STATE: ConsoleLoadState = { + phase: "loading", + pendingRoute: null, + lastRoute: null, + routeReported: false, +}; + +/** Enter `ready`: dispatch the held route, if any. */ +function becomeReady(state: ConsoleLoadState, effects: ConsoleLoadEffect[]): ConsoleLoadStep { + if (state.pendingRoute !== null) effects.push({ type: "goTo", route: state.pendingRoute }); + return { state: { ...state, phase: "ready", pendingRoute: null }, effects }; +} + +export function stepConsoleLoad(state: ConsoleLoadState, event: ConsoleLoadEvent): ConsoleLoadStep { + const same: ConsoleLoadStep = { state, effects: [] }; + switch (event.type) { + case "loadEnd": + // `ready` can beat the document's load event (load waits for every + // subresource); a failed load also ends with onLoadEnd. + if (state.phase !== "loading") return same; + return { state: { ...state, phase: "loaded" }, effects: [{ type: "armReadyTimer" }] }; + + case "ready": + // The Console posts `ready` from the render that mounts its navigate + // listener, so the page can take a route from here on — even before + // the document's load event. A failed page shows the failure page. + if (state.phase === "loading") return becomeReady(state, []); + if (state.phase === "loaded") return becomeReady(state, [{ type: "clearReadyTimer" }]); + return same; + + case "readyTimeout": + if (state.phase !== "loaded") return same; + return becomeReady(state, []); + + case "fail": + if (state.phase === "failed") return same; + return { + state: { ...state, phase: "failed" }, + effects: state.phase === "loaded" ? [{ type: "clearReadyTimer" }] : [], + }; + + case "reload": + return { + state: { ...state, phase: "loading" }, + effects: state.phase === "loaded" ? [{ type: "clearReadyTimer" }] : [], + }; + + case "recreate": { + // The new WebView starts at the held route, else where the page was; + // the held route is then its start route, not something to inject. + const startRoute = state.pendingRoute ?? state.lastRoute; + const effects: ConsoleLoadEffect[] = + state.phase === "loaded" ? [{ type: "clearReadyTimer" }] : []; + effects.push({ type: "recreate", startRoute }); + return { state: { ...state, phase: "loading", pendingRoute: null }, effects }; + } + + case "navigate": + if (state.phase === "ready") { + return { state, effects: [{ type: "goTo", route: event.route }] }; + } + return { state: { ...state, pendingRoute: event.route }, effects: [] }; + + case "routeChanged": + return { state: { ...state, lastRoute: event.route, routeReported: true }, effects: [] }; + + case "navigationState": + if (state.routeReported || event.route === null) return same; + return { state: { ...state, lastRoute: event.route }, effects: [] }; + + default: + return same; + } +} + +/** + * The route a `routeChanged` event reports (`payload: { route }`), or `null` + * when the payload is not a well-formed, safe Console route. + */ +export function routeChangedRoute(payload: unknown): string | null { + if (typeof payload !== "object" || payload === null) return null; + return safeConsoleRoute((payload as Record).route); +} From 2e52b7e4a649912ef673f2c4ad5ab3e2ec1337c3 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 12:32:31 +0000 Subject: [PATCH 2/2] docs(shell): document the load states and that the shell owns the safe area Claude-Session: https://claude.ai/code/session_01QSeKYnW1Xf6HDSWz1eGAqv Co-authored-by: Claude --- .changeset/162-ready-route-safe-area.md | 19 ++++++++ docs/HYBRID-SHELL.md | 62 +++++++++++++++++++++---- 2 files changed, 72 insertions(+), 9 deletions(-) create mode 100644 .changeset/162-ready-route-safe-area.md diff --git a/.changeset/162-ready-route-safe-area.md b/.changeset/162-ready-route-safe-area.md new file mode 100644 index 0000000..fb647cb --- /dev/null +++ b/.changeset/162-ready-route-safe-area.md @@ -0,0 +1,19 @@ +--- +"mobile": patch +--- + +Clause-②: no +Metadata-protocol: none + +The Console shell now acts on the Console's `ready` and `routeChanged` bridge +events. The loading cover stays up until the Console posts `ready`, and a +route asked for before then (a notification tap on cold start) is dispatched +at `ready`, when the Console's navigate listener is mounted, instead of at the +WebView's load end, where it went unhandled and reloaded the page. A Console +that never posts `ready` gets the cover lifted and the held route dispatched +8 s after load end. The route a re-created WebView resumes at comes from +`routeChanged`, with WebView navigation state as the fallback for Consoles +that never post it. + +The shell keeps owning the safe-area padding; `docs/HYBRID-SHELL.md` now says +so, and that `--os-native-safe-*` are informational. diff --git a/docs/HYBRID-SHELL.md b/docs/HYBRID-SHELL.md index 22c4c2a..a1f2925 100644 --- a/docs/HYBRID-SHELL.md +++ b/docs/HYBRID-SHELL.md @@ -33,6 +33,7 @@ components/shell/ OfflineFallback.tsx native page for network / 5xx failures lib/shell/ console-url.ts server URL normalisation, Console URL ⇄ route + console-load.ts load states: loading cover, held route, last route route-owner.ts per-route ownership: web (default) or native native-screens.ts native screens a route rule may target (empty today) navigation-policy.ts what each WebView navigation is allowed to do @@ -123,18 +124,21 @@ interface ObjectStackNative { navigate(route: string): Promise; // Console route; route ownership applies resetServer(): Promise; // forget the server, back to the picker - ready(): void; // event: page is interactive (hides the loading cover) - routeChanged(route: string): void; // event: SPA route changed + ready(): void; // event: the Console has mounted (lifts the cover, releases a held route) + routeChanged(route: string): void; // event: SPA route changed (the shell's last route) } ``` Safe-area insets are also published as CSS variables: -`--os-native-safe-top|bottom|left|right`. +`--os-native-safe-top|bottom|left|right`. They are informational: the shell +pads the WebView with the safe area itself (see "Safe area"), so a page must +not pad with them again. ### Shell → Console events When a notification tap, an app link or a bridge `navigate()` asks for a -Console route while the page is loaded, the shell dispatches on `window`: +Console route once the page is ready (see "Load states"), the shell +dispatches on `window`: ```ts new CustomEvent("objectstack-native:navigate", { @@ -179,13 +183,50 @@ same name), handle it in `bridge/handler.ts`, expose it in the bootstrap (`bridge/scripts.ts`), and document it here. That is additive — no protocol version bump. +### Load states + +`lib/shell/console-load.ts` is one state machine for the loading cover, a held +route and the last route: + +| Phase | Entered on | Cover | A requested route | +| --------- | -------------------------------------------- | ----- | -------------------- | +| `loading` | mount, `reload()`, a re-created WebView | up | held | +| `loaded` | the WebView's load end | up | held | +| `ready` | `ready()`, or 8 s after load end without it | down | dispatched to page | +| `failed` | a network error or a 5xx on the document | — | held for the next WebView | + +- **Why the cover waits for `ready`.** The Console boots after its document + has loaded (boot round-trips, then the first render), and its + `objectstack-native:navigate` listener mounts with that first render. A + route dispatched at load end goes unhandled and the fallback reloads the + page, so a held route — a notification tap on cold start — waits for + `ready` too. `ready` that arrives before load end counts. +- **Timeout.** A Console that never posts `ready` (one older than shell mode, + or a boot that failed before the app mounted) gets the cover lifted and the + held route dispatched `READY_TIMEOUT_MS` (8 s) after load end. A held route + is dispatched once, whichever comes first. The timeout does not run before + load end or on a failed load. +- **Last route.** Once the page has posted `routeChanged`, its `route` is the + last route; WebView navigation state (which misses SPA `pushState` on some + engines) is used only until then. A re-created WebView starts at the held + route, else at the last route. + +## Safe area + +The shell owns the safe area: it pads the WebView natively with the top and +bottom insets, and the failure page with the top inset. The Console adds no +padding of its own; `--os-native-safe-*` and `ObjectStackNative.safeArea` are +informational. Handing the padding to the Console would need a handshake, so +that a Console older than the change does not lose its padding. + ## Console-side integration (framework repository) The Console needs, in `@object-ui/mobile`: - a `useNativeHost()` hook that detects `window.ObjectStackNative`; -- in the shell: hide desktop chrome, pad with the safe-area variables, call - `ready()` once interactive and `routeChanged()` on navigation; +- in the shell: hide desktop chrome, call `ready()` once its router has + mounted and `routeChanged()` on navigation; do not pad for the safe area + (the shell does); - listen for `objectstack-native:navigate` on `window`, navigate to `event.detail.route` with the Console router, and call `event.preventDefault()` (otherwise the shell reloads the page at the @@ -193,8 +234,9 @@ The Console needs, in `@object-ui/mobile`: - register the push token after sign-in (`getPushToken()` → the server's device registry) and unregister on sign-out. -Until then the shell works without it: the loading cover lifts on the WebView's -own load event and the shell pads the WebView with the safe area itself. +A Console without it still works in the shell: the cover lifts and a held +route is dispatched at the ready timeout, a held route then reloads the page +at that route, and the last route comes from WebView navigation state. ## Push notifications @@ -203,6 +245,7 @@ See [PUSH-NOTIFICATIONS.md](./PUSH-NOTIFICATIONS.md). ## Testing - Pure modules (`lib/shell/**`, push, stores) — unit tests under `__tests__/lib`. + The load state machine: `__tests__/lib/shell/console-load.test.ts`. - The bootstrap and navigate scripts are executed against a fake `window` in `__tests__/lib/shell/bridge-scripts.test.ts`. - `ConsoleWebView` is tested with a mocked `react-native-webview` @@ -219,7 +262,8 @@ See [PUSH-NOTIFICATIONS.md](./PUSH-NOTIFICATIONS.md). - **Recovery re-creates the WebView.** After a load failure ("Try again", or the network coming back) or a dead web content process, the WebView is re-created at the route a notification or link asked for meanwhile, else at - the last Console route it reported. In-page state and history are lost. + the last Console route (`routeChanged`, else WebView navigation state). In-page + state and history are lost. - **Android reports every navigation as top-frame.** react-native-webview always sends `isTopFrame: true` on Android, so a cross-origin iframe navigation is treated as a top-frame one (opened outside, not in the frame).