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/__tests__/components/ConsoleWebView.test.tsx b/__tests__/components/ConsoleWebView.test.tsx index b480793..1e27a0d 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, @@ -97,6 +98,33 @@ function finishLoad() { act(() => mockWebView.props.onLoadEnd({ nativeEvent: { loading: false } })); } +/** + * A native load start, delivered as react-native-webview's `WebViewShared` + * does: `onLoadStart` when the host wires one, then `onNavigationStateChange`. + * Android fires one for every SPA history change too. + */ +function loadStart(url = `${SERVER}/_console/apps/crm`) { + const nativeEvent = { url, loading: true, title: "", canGoBack: false, canGoForward: false }; + act(() => { + mockWebView.props.onLoadStart?.({ nativeEvent }); + mockWebView.props.onNavigationStateChange(nativeEvent); + }); +} + +/** 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; @@ -204,24 +232,53 @@ describe("ConsoleWebView", () => { failLoad(); rotate({ type: "android", data: "fcm-3" }); expect(delivered()).toEqual([]); - fireEvent.press(screen.getByTestId("shell-retry")); // a re-created WebView - act(() => mockWebView.props.onLoadStart({ nativeEvent: {} })); + // A re-created WebView: the load state machine re-arms the hold itself + // (onLoadStart is not wired; Android fires it for SPA history changes). + fireEvent.press(screen.getByTestId("shell-retry")); finishLoad(); await postReady(); expect(delivered()).toEqual([{ token: "fcm-3", vendor: "fcm" }]); }); it("re-arms the hold when a new document starts loading", async () => { - renderConsole(); + const { ref } = renderConsole(); finishLoad(); await postReady(); - act(() => mockWebView.props.onLoadStart({ nativeEvent: {} })); // a reload + act(() => ref.current!.reload()); // a reload: a new document + expect(mockWebView.reload).toHaveBeenCalledTimes(1); rotate({ type: "ios", data: "apns-4" }); expect(delivered()).toEqual([]); await postReady(); expect(delivered()).toEqual([{ token: "apns-4", vendor: "apns" }]); }); + it("delivers a token held before ready on the ready timeout (a Console that never posts ready)", () => { + jest.useFakeTimers(); + try { + renderConsole(); + rotate({ type: "android", data: "fcm-5" }); + finishLoad(); + act(() => jest.advanceTimersByTime(READY_TIMEOUT_MS - 1)); + expect(delivered()).toEqual([]); + act(() => jest.advanceTimersByTime(1)); + expect(delivered()).toEqual([{ token: "fcm-5", vendor: "fcm" }]); + } finally { + jest.useRealTimers(); + } + }); + + it("keeps delivering after an in-app navigation (no second ready follows one)", async () => { + renderConsole(); + finishLoad(); + await postReady(); + await postEvent("routeChanged", { route: "/apps/crm/lead/9" }); + act(() => + mockWebView.props.onNavigationStateChange({ url: `${SERVER}/_console/apps/crm/lead/9`, canGoBack: true }) + ); + rotate({ type: "android", data: "fcm-6" }); + expect(delivered()).toEqual([{ token: "fcm-6", vendor: "fcm" }]); + }); + it("ignores a report that is not a native token", async () => { renderConsole(); finishLoad(); @@ -237,13 +294,30 @@ describe("ConsoleWebView", () => { }); }); - 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) => @@ -288,7 +362,7 @@ describe("ConsoleWebView", () => { scan, }, }); - act(() => mockWebView.props.onLoadStart()); + loadStart(); finishLoad(); act(() => { mockWebView.props.onMessage({ @@ -315,7 +389,7 @@ describe("ConsoleWebView", () => { // really was replaced cannot match the answer: its call ids carry a // token of their own document (bridge-scripts.test.ts). const settle = openScan(); - act(() => mockWebView.props.onLoadStart()); + loadStart(`${SERVER}/_console/apps/crm/lead/9`); await settle("4006381333931"); expect(mockWebView.injectJavaScript).toHaveBeenCalledWith( expect.stringContaining('"id":"d1.1","ok":true,"value":"4006381333931"') @@ -344,19 +418,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("navigates the page, or hands native routes off, on request", () => { + 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")); + }); + + 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`) @@ -365,15 +485,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(); @@ -386,6 +507,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(); }); @@ -477,6 +599,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..8325df0 --- /dev/null +++ b/__tests__/lib/shell/console-load.test.ts @@ -0,0 +1,241 @@ +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, + heldPushToken: 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("push token", () => { + const apns = (token: string) => ({ token, vendor: "apns" as const }); + const send = (token: string): ConsoleLoadEffect => ({ type: "sendPushToken", token: apns(token) }); + + it("holds the latest token until ready, then sends it once, after the held route", () => { + const { state, effects } = run( + { type: "pushToken", token: apns("a") }, + { type: "navigate", route: "/apps/hr" }, + { type: "loadEnd" }, + { type: "pushToken", token: apns("b") }, + { type: "ready" }, + { type: "ready" } + ); + expect(state.heldPushToken).toBeNull(); + expect(effects).toEqual([ + { type: "armReadyTimer" }, + { type: "clearReadyTimer" }, + goTo("/apps/hr"), + send("b"), + ]); + }); + + it("sends the held token on the ready timeout", () => { + const { effects } = run( + { type: "pushToken", token: apns("a") }, + { type: "loadEnd" }, + { type: "readyTimeout" } + ); + expect(effects).toEqual([{ type: "armReadyTimer" }, send("a")]); + }); + + it("sends straight to a ready page, and holds again after a reload", () => { + const ready = run({ type: "ready" }, { type: "pushToken", token: apns("a") }); + expect(ready.effects).toEqual([send("a")]); + const reloaded = stepConsoleLoad( + stepConsoleLoad(ready.state, { type: "reload" }).state, + { type: "pushToken", token: apns("b") } + ); + expect(reloaded.effects).toEqual([]); + expect(reloaded.state.heldPushToken).toEqual(apns("b")); + }); + + it("keeps the held token through a failure and a re-created WebView", () => { + const { state, effects } = run( + { type: "fail" }, + { type: "pushToken", token: apns("a") }, + { type: "recreate" }, + { type: "loadEnd" }, + { type: "ready" } + ); + expect(state.heldPushToken).toBeNull(); + expect(effects).toEqual([ + { type: "recreate", startRoute: null }, + { type: "armReadyTimer" }, + { type: "clearReadyTimer" }, + send("a"), + ]); + }); + }); + + 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 4da0a2a..096d123 100644 --- a/components/shell/ConsoleWebView.tsx +++ b/components/shell/ConsoleWebView.tsx @@ -35,6 +35,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 { getInstallId } from "~/lib/install-id"; import { addBreadcrumb, captureMessage } from "~/lib/sentry"; import { useNetworkStatus } from "~/hooks/useNetworkStatus"; @@ -88,7 +96,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 @@ -103,22 +110,24 @@ 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>(() => {}); // Counts WebView instances: a call's answer is never sent to a re-created // WebView. A `scan()` stays open as long as the user takes, and recovery // can re-create the WebView meanwhile. Load starts do not count: Android // reports one for every SPA history change too. A page replaced within the - // same WebView is covered by the bootstrap's per-document call ids. + // same WebView is covered by the bootstrap's per-document call ids. Bumped + // only by the load state machine's `recreate` effect. const documentRef = useRef(0); const goTo = useCallback( @@ -130,6 +139,58 @@ export const ConsoleWebView = forwardRef { + const script = buildPushTokenChangedScript(serverUrl, token); + if (script) webviewRef.current?.injectJavaScript(script); + }, + [serverUrl] + ); + + const clearReadyTimer = useCallback(() => { + 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 "sendPushToken": + sendPushToken(effect.token); + 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); + // A new WebView: answers to the old one's calls are dropped. + documentRef.current += 1; + setInstance((i) => i + 1); + break; + } + } + }, + [goTo, sendPushToken, clearReadyTimer] + ); + dispatchRef.current = dispatch; + useEffect(() => clearReadyTimer, [clearReadyTimer]); + const navigate = useCallback( (requested: string) => { const route = safeConsoleRoute(requested); @@ -144,56 +205,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); - documentRef.current += 1; - 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]); @@ -241,36 +276,18 @@ export const ConsoleWebView = forwardRef(null); - const sendPushToken = useCallback( - (token: PushTokenResult) => { - const script = buildPushTokenChangedScript(serverUrl, token); - if (script) webviewRef.current?.injectJavaScript(script); - }, - [serverUrl] - ); + // event listeners in the same commit that posts `ready`, so the load + // state machine holds a token reported outside `ready` (cold start, a + // reload, a re-created WebView, the failure page) — the latest one only — + // and sends it on entering `ready`, by the event or the ready timeout. + // `onLoadStart` is not a document start: on Android react-native-webview + // also fires it for SPA history changes (`doUpdateVisitedHistory`), and no + // second `ready` follows those. const deliverPushToken = useCallback( - (token: PushTokenResult) => { - if (consoleReadyRef.current) sendPushToken(token); - else heldPushTokenRef.current = token; - }, - [sendPushToken] + (token: PushTokenResult) => dispatch({ type: "pushToken", token }), + [dispatch] ); usePushTokenChanges(deliverPushToken); - const onConsoleReady = useCallback(() => { - consoleReadyRef.current = true; - const held = heldPushTokenRef.current; - heldPushTokenRef.current = null; - if (held) sendPushToken(held); - }, [sendPushToken]); - const onDocumentStart = useCallback(() => { - consoleReadyRef.current = false; - }, []); const handleMessage = useMemo( () => @@ -279,12 +296,15 @@ export const ConsoleWebView = forwardRef { - if (name === "ready") markLoaded(); - if (name === "ready") onConsoleReady(); + 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, onConsoleReady] + [capabilities, serverUrl, navigate, dispatch] ); const onMessage = useCallback( @@ -326,10 +346,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) { @@ -364,14 +385,11 @@ 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"); @@ -401,7 +419,7 @@ export const ConsoleWebView = forwardRef - {!loaded ? ( + {phase !== "ready" ? ( diff --git a/docs/HYBRID-SHELL.md b/docs/HYBRID-SHELL.md index feafdda..f6a07fc 100644 --- a/docs/HYBRID-SHELL.md +++ b/docs/HYBRID-SHELL.md @@ -42,6 +42,7 @@ lib/ server-url.ts persistence of the chosen server URL lib/shell/ console-url.ts server URL normalisation, Console URL ⇄ route + console-load.ts load states: loading cover, held route and push token, 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 @@ -254,13 +255,15 @@ interface ObjectStackNative { resetServer(): Promise; // forget the server, back to the picker scan(opts?: { formats?: string[] }): Promise; // see "Scanning codes" - 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. ### Install id @@ -319,7 +322,8 @@ if (ObjectStackNative.has("scan")) { ### 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", { @@ -351,9 +355,12 @@ The Console re-registers `detail` under the signed-in session (with - Delivered only to a page on the configured server's origin, the same pages `getPushToken()` answers; another page in the main frame (a trusted sign-in host) gets nothing. -- Delivered after the page's `ready()`, when the Console's listeners are - mounted. A token reported earlier — cold start, a reload, the failure page — - is held (the latest only) and delivered at `ready()`. +- Delivery follows the load state machine (see "Load states"): sent at once + in the `ready` phase, when the Console's listeners are mounted. A token + reported in any other phase — cold start, a reload, a re-created WebView, + the failure page — is held (the latest only) and sent on entering `ready`: + at `ready()`, or at the ready timeout for a Console that never posts it. A + held token survives a re-created WebView. - It can repeat an unchanged token: iOS reports every completed registration, including the one `getPushToken()` triggers, and Android replays the last known token when the shell starts listening. Registration is idempotent, so @@ -434,13 +441,60 @@ Android can inject the bridge late, the first load can still register a worker that the next load removes. Reading the user-agent marker as well closes that window, since it is there from the first byte. +### Load states + +`lib/shell/console-load.ts` is one state machine for the loading cover, a held +route, a held push token 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; a held push token is sent at the + same moment. The timeout does not run before load end or on a failed load. +- **What re-arms the hold.** The shell's own `reload()` and a re-created + WebView. The machine's re-create is also the one place that counts a new + WebView for bridge answers: an answer to a call the old WebView made is + dropped (see "Scanning codes"). `onLoadStart` does neither: on Android + react-native-webview (13.16) fires it from `doUpdateVisitedHistory`, so it + also fires for SPA history changes, after which no second `ready` comes. A full-document load the page + starts by itself after `ready` (an older Console's `location.assign` + fallback, an identity-provider round trip) is not seen as a new document: + the cover stays down, and a route or token sent during that load is lost + with the old document. +- **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 @@ -454,8 +508,9 @@ The Console needs, in `@object-ui/mobile`: - offer a scan affordance on text and lookup fields when `has("scan")`, and fill the field with what `scan()` resolves (nothing on `null`). -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 @@ -464,7 +519,8 @@ See [PUSH-NOTIFICATIONS.md](./PUSH-NOTIFICATIONS.md). ## Testing - Pure modules (`lib/shell/**`, the server probe, push, stores) — unit tests - under `__tests__/lib`. + under `__tests__/lib`. The load state machine: + `__tests__/lib/shell/console-load.test.ts`. - The picker and its confirm step — component tests under `__tests__/components` (`ServerConfigScreen`, `ServerConfirm`). - Locale list, phone-language matching and bundle completeness — @@ -490,7 +546,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). diff --git a/lib/shell/console-load.ts b/lib/shell/console-load.ts new file mode 100644 index 0000000..5674e86 --- /dev/null +++ b/lib/shell/console-load.ts @@ -0,0 +1,196 @@ +/** + * 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. + * + * A push token the OS reports (`objectstack-native:pushTokenChanged`) follows + * the same rule as a route: the Console installs that listener in the commit + * that posts `ready`, so a token reported outside `ready` is held — the latest + * one only — and delivered on entering `ready`, by the event or the timeout. + * Unlike a held route it survives a re-created WebView, whose start URL cannot + * carry it. + * + * Pure and synchronous: `stepConsoleLoad` returns the next state plus the side + * effects the component performs (inject a route or a token, arm or clear the + * ready timer, re-create the WebView). + */ +import { safeConsoleRoute } from "./console-url"; +import type { PushTokenResult } from "./bridge/protocol"; + +/** + * 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 latest push token reported before the page could take it; sent once, at `ready`. */ + heldPushToken: PushTokenResult | 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 OS reported a (rotated) push token, already validated. */ + | { type: "pushToken"; token: PushTokenResult } + /** 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 } + /** Dispatch `objectstack-native:pushTokenChanged` in the page. */ + | { type: "sendPushToken"; token: PushTokenResult } + | { 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, + heldPushToken: null, + lastRoute: null, + routeReported: false, +}; + +/** Enter `ready`: dispatch the held route and send the held push token, if any. */ +function becomeReady(state: ConsoleLoadState, effects: ConsoleLoadEffect[]): ConsoleLoadStep { + if (state.pendingRoute !== null) effects.push({ type: "goTo", route: state.pendingRoute }); + if (state.heldPushToken !== null) { + effects.push({ type: "sendPushToken", token: state.heldPushToken }); + } + return { state: { ...state, phase: "ready", pendingRoute: null, heldPushToken: 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 "pushToken": + if (state.phase === "ready") { + return { state, effects: [{ type: "sendPushToken", token: event.token }] }; + } + return { state: { ...state, heldPushToken: event.token }, 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); +}