From c82a76cc13203a9aeef93f852b1d7d4f5dd112b5 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 02:46:54 +0000 Subject: [PATCH 1/5] fix(shell): enforce the navigation policy when a top-frame load starts The navigation policy is also enforced when a top-frame load starts, so every top-frame load follows it. decideTopFrameLoad judges the URL the WebView reports at a load start or a load end, never the event alone: a page on the server's origin stays, as does anything the policy loads in the WebView; a page of an active sign-in excursion is the excursion's to judge. Any other page is stopped, sent where the policy sends it (the in-app browser or the OS), and the WebView is re-created on the server where the Console was. A second stop before a Console is ready shows the failure page instead of re-creating again. Reports from a WebView that is being re-created are dropped. Claude-Session: https://claude.ai/code/session_01QSeKYnW1Xf6HDSWz1eGAqv Co-authored-by: Claude --- components/shell/ConsoleWebView.tsx | 84 +++++++++++++++++++++++++--- components/shell/OfflineFallback.tsx | 26 ++++++--- lib/shell/navigation-policy.ts | 35 ++++++++++++ locales/ar.json | 2 + locales/de.json | 2 + locales/en.json | 2 + locales/es.json | 2 + locales/fr.json | 2 + locales/ja.json | 2 + locales/ko.json | 2 + locales/pt.json | 2 + locales/ru.json | 2 + locales/zh.json | 2 + 13 files changed, 150 insertions(+), 15 deletions(-) diff --git a/components/shell/ConsoleWebView.tsx b/components/shell/ConsoleWebView.tsx index 0e30ba1..2227a64 100644 --- a/components/shell/ConsoleWebView.tsx +++ b/components/shell/ConsoleWebView.tsx @@ -28,7 +28,11 @@ import { routeFromConsoleUrl, safeConsoleRoute, } from "~/lib/shell/console-url"; -import { decideNavigation, SIGN_IN_ROUTE } from "~/lib/shell/navigation-policy"; +import { + decideNavigation, + decideTopFrameLoad, + SIGN_IN_ROUTE, +} from "~/lib/shell/navigation-policy"; import { EXCURSION_TIMEOUT_MS, IDLE_EXCURSION, @@ -190,8 +194,14 @@ export const ConsoleWebView = forwardRef { + const caller = documentRef.current; + const step = applyExcursion({ type: "location", url, loaded, now: Date.now() }); + // An excursion that ended here has re-created the WebView already. + // The failure page has no top frame. + if (documentRef.current !== caller || loadRef.current.phase === "failed") return; + if (step.state.active !== null && step.state.location === url) return; + const decision = decideTopFrameLoad(url, { + serverUrl, + rules, + availableScreens, + trustedHosts, + }); + if (decision.kind === "stay") return; + webviewRef.current?.stopLoading(); + const again = stoppedRef.current; + addBreadcrumb({ + category: "shell", + message: "top-frame load stopped by the navigation policy", + data: { host: parseUrl(url)?.host ?? null, again }, + }); + if (again) { + fail({ kind: "policy" }); + return; + } + stoppedRef.current = true; + if (decision.open !== null) onOpenExternal(decision.open); + dispatch({ type: "recreate" }); + }, + [ + serverUrl, + rules, + availableScreens, + trustedHosts, + applyExcursion, + fail, + onOpenExternal, + dispatch, + ] + ); + + // A report from a WebView that is being re-created is not the top + // frame's: once the re-create is decided, the old WebView's reports are + // dropped. const onLoadStart = useCallback( (e: { nativeEvent: { url?: string } }) => { + if (documentRef.current !== instance) return; const url = e.nativeEvent.url ?? ""; - if (url) applyExcursion({ type: "location", url, loaded: false, now: Date.now() }); + if (url) observeTopFrame(url, false); }, - [applyExcursion] + [instance, observeTopFrame] ); const onLoadEnd = useCallback( (e: { nativeEvent: { url?: string; loading: boolean } }) => { + if (documentRef.current !== instance) return; const { loading } = e.nativeEvent; const url = e.nativeEvent.url ?? ""; - if (url) applyExcursion({ type: "location", url, loaded: !loading, now: Date.now() }); + if (url) observeTopFrame(url, !loading); + // This load sent the WebView back to the server: it is not the Console's. + if (documentRef.current !== instance) return; // `loading` is still true when a load is superseded by a redirect. // react-native-webview also calls this after `onError`; the load // state ignores it on a failed load. @@ -631,17 +700,18 @@ export const ConsoleWebView = forwardRef { + if (documentRef.current !== instance) return; canGoBackRef.current = nav.canGoBack; // Fallback only: once the Console posts `routeChanged`, it decides. const route = nav.url ? routeFromConsoleUrl(serverUrl, nav.url) : null; dispatch({ type: "navigationState", route }); }, - [serverUrl, dispatch] + [instance, serverUrl, dispatch] ); if (failure) { diff --git a/components/shell/OfflineFallback.tsx b/components/shell/OfflineFallback.tsx index 1f61b40..6107f49 100644 --- a/components/shell/OfflineFallback.tsx +++ b/components/shell/OfflineFallback.tsx @@ -1,14 +1,16 @@ import React from "react"; import { Text, View } from "react-native"; import { useTranslation } from "react-i18next"; -import { CloudOff, ServerCrash } from "lucide-react-native"; +import { CloudOff, ServerCrash, ShieldAlert } from "lucide-react-native"; import { Button } from "~/components/ui/Button"; import { useLayoutDirection } from "~/lib/rtl"; import { useThemeColors } from "~/lib/theme-colors"; export type LoadFailure = | { kind: "network"; description?: string } - | { kind: "server"; statusCode?: number }; + | { kind: "server"; statusCode?: number } + /** The server's page keeps going to a page the navigation policy does not load in the WebView. */ + | { kind: "policy" }; export interface OfflineFallbackProps { failure: LoadFailure; @@ -20,7 +22,8 @@ export interface OfflineFallbackProps { /** * Native page shown instead of a blank WebView when the Console cannot load — - * no network, DNS/TLS failure, or a 5xx from the server. + * no network, DNS/TLS failure, a 5xx from the server, or a server page that + * keeps going to a page the navigation policy does not load in the WebView. */ export function OfflineFallback({ failure, @@ -31,7 +34,8 @@ export function OfflineFallback({ const { t } = useTranslation(); const colors = useThemeColors(); const direction = useLayoutDirection(); - const Icon = failure.kind === "network" ? CloudOff : ServerCrash; + const Icon = + failure.kind === "network" ? CloudOff : failure.kind === "policy" ? ShieldAlert : ServerCrash; return ( - {failure.kind === "network" ? t("shell.offlineTitle") : t("shell.serverErrorTitle")} + {failure.kind === "network" + ? t("shell.offlineTitle") + : failure.kind === "policy" + ? t("shell.policyTitle") + : t("shell.serverErrorTitle")} {failure.kind === "network" ? t("shell.offlineMessage") - : t("shell.serverErrorMessage", { - code: failure.statusCode ?? "—", - })} + : failure.kind === "policy" + ? t("shell.policyMessage") + : t("shell.serverErrorMessage", { + code: failure.statusCode ?? "—", + })} {serverUrl} diff --git a/lib/shell/navigation-policy.ts b/lib/shell/navigation-policy.ts index e4322b0..51339d3 100644 --- a/lib/shell/navigation-policy.ts +++ b/lib/shell/navigation-policy.ts @@ -10,6 +10,10 @@ * - `mailto:` / `tel:` / `sms:` / app schemes → hand to the OS; * - `javascript:` / `file:` / top-frame `data:` → block. * + * The policy is enforced when a navigation is requested ({@link decideNavigation}) + * and again when a top-frame load starts ({@link decideTopFrameLoad}), so every + * top-frame load follows it. + * * This is the policy outside a sign-in excursion, and it does not change * during one. `lib/shell/sign-in-excursion.ts` sits on top of it. An * off-server https page that this policy sends to the system browser may @@ -112,3 +116,34 @@ export function decideNavigation(url: string, ctx: NavigationContext): Navigatio return { kind: "external", url }; } + +/** + * What the shell does with a top-frame load the WebView reports as started (or + * ended), judged on the URL it reports, never on the event alone: on Android + * every history change of a page reports a load start too. + * + * - `stay`: the policy loads `url` in the WebView, or `url` is on the server's + * origin. A Console route a native screen owns stays as well: the page got + * there by its own router, and only a navigation request hands it to the + * native screen. + * - `stop`: anything else. The shell stops the load and goes back to the + * server; `open` is where the policy sends `url` (the in-app browser or the + * OS, as for a request), or `null` when it blocks it. + * + * A page of an active sign-in excursion is the excursion's to judge + * (`sign-in-excursion.ts`), before this. + */ +export type TopFrameLoadDecision = { kind: "stay" } | { kind: "stop"; open: string | null }; + +export function decideTopFrameLoad(url: string, ctx: NavigationContext): TopFrameLoadDecision { + const decision = decideNavigation(url, { ...ctx, isTopFrame: true }); + switch (decision.kind) { + case "load": + case "native": + return { kind: "stay" }; + case "external": + return { kind: "stop", open: decision.url }; + default: + return { kind: "stop", open: null }; + } +} diff --git a/locales/ar.json b/locales/ar.json index 10091a9..7c21790 100644 --- a/locales/ar.json +++ b/locales/ar.json @@ -64,6 +64,8 @@ "offlineMessage": "تحقق من اتصالك. ستُعاد تحميل الصفحة تلقائيًا عند عودة الاتصال.", "serverErrorTitle": "الخادم لا يستجيب", "serverErrorMessage": "أجاب الخادم بالخطأ {{code}}. حاول مرة أخرى بعد قليل.", + "policyTitle": "تعذّر عرض هذه الصفحة في التطبيق", + "policyMessage": "تنتقل صفحة الخادم باستمرار إلى عنوان لا يفتحه التطبيق. حاول مرة أخرى أو بدّل المؤسسة.", "retry": "حاول مرة أخرى" }, "excursion": { diff --git a/locales/de.json b/locales/de.json index a1b5002..5eb3680 100644 --- a/locales/de.json +++ b/locales/de.json @@ -64,6 +64,8 @@ "offlineMessage": "Prüfen Sie Ihre Verbindung. Die Seite lädt automatisch neu, sobald Sie wieder online sind.", "serverErrorTitle": "Der Server antwortet nicht", "serverErrorMessage": "Der Server hat mit Fehler {{code}} geantwortet. Versuchen Sie es gleich noch einmal.", + "policyTitle": "Diese Seite kann in der App nicht angezeigt werden", + "policyMessage": "Die Seite des Servers wechselt immer wieder zu einer Adresse, die die App nicht öffnet. Versuchen Sie es erneut oder wechseln Sie die Organisation.", "retry": "Erneut versuchen" }, "excursion": { diff --git a/locales/en.json b/locales/en.json index 9814522..1ac3051 100644 --- a/locales/en.json +++ b/locales/en.json @@ -64,6 +64,8 @@ "offlineMessage": "Check your connection. The page reloads on its own when you're back online.", "serverErrorTitle": "The server isn't responding", "serverErrorMessage": "The server answered with error {{code}}. Try again in a moment.", + "policyTitle": "Can't show this page in the app", + "policyMessage": "The server's page keeps going to an address the app doesn't open. Try again, or switch organization.", "retry": "Try again" }, "excursion": { diff --git a/locales/es.json b/locales/es.json index 03fddbb..44d12a5 100644 --- a/locales/es.json +++ b/locales/es.json @@ -64,6 +64,8 @@ "offlineMessage": "Compruebe su conexión. La página se recargará sola cuando vuelva a estar en línea.", "serverErrorTitle": "El servidor no responde", "serverErrorMessage": "El servidor respondió con el error {{code}}. Inténtelo de nuevo en un momento.", + "policyTitle": "No se puede mostrar esta página en la app", + "policyMessage": "La página del servidor sigue yendo a una dirección que la app no abre. Inténtelo de nuevo o cambie de organización.", "retry": "Intentar de nuevo" }, "excursion": { diff --git a/locales/fr.json b/locales/fr.json index 98679b7..d8f293e 100644 --- a/locales/fr.json +++ b/locales/fr.json @@ -64,6 +64,8 @@ "offlineMessage": "Vérifiez votre connexion. La page se recharge d'elle-même dès que vous êtes de nouveau en ligne.", "serverErrorTitle": "Le serveur ne répond pas", "serverErrorMessage": "Le serveur a répondu avec l'erreur {{code}}. Réessayez dans un instant.", + "policyTitle": "Impossible d'afficher cette page dans l'application", + "policyMessage": "La page du serveur continue d'aller vers une adresse que l'application n'ouvre pas. Réessayez ou changez d'organisation.", "retry": "Réessayer" }, "excursion": { diff --git a/locales/ja.json b/locales/ja.json index 393fa24..45c9f03 100644 --- a/locales/ja.json +++ b/locales/ja.json @@ -64,6 +64,8 @@ "offlineMessage": "接続を確認してください。オンラインに戻るとページが自動的に再読み込みされます。", "serverErrorTitle": "サーバーが応答しません", "serverErrorMessage": "サーバーがエラー {{code}} を返しました。しばらくしてからもう一度お試しください。", + "policyTitle": "このページはアプリで表示できません", + "policyMessage": "サーバーのページが、アプリで開かないアドレスへ移動し続けています。もう一度お試しいただくか、組織を切り替えてください。", "retry": "再試行" }, "excursion": { diff --git a/locales/ko.json b/locales/ko.json index bb8cc2c..6f811a5 100644 --- a/locales/ko.json +++ b/locales/ko.json @@ -64,6 +64,8 @@ "offlineMessage": "연결을 확인하세요. 다시 온라인 상태가 되면 페이지가 자동으로 새로고침됩니다.", "serverErrorTitle": "서버가 응답하지 않습니다", "serverErrorMessage": "서버에서 오류 {{code}}을(를) 반환했습니다. 잠시 후 다시 시도하세요.", + "policyTitle": "이 페이지는 앱에서 표시할 수 없습니다", + "policyMessage": "서버 페이지가 앱에서 열지 않는 주소로 계속 이동합니다. 다시 시도하거나 조직을 전환하세요.", "retry": "다시 시도" }, "excursion": { diff --git a/locales/pt.json b/locales/pt.json index 8586db1..da771d5 100644 --- a/locales/pt.json +++ b/locales/pt.json @@ -64,6 +64,8 @@ "offlineMessage": "Verifique sua conexão. A página recarrega sozinha quando você voltar a ficar online.", "serverErrorTitle": "O servidor não está respondendo", "serverErrorMessage": "O servidor respondeu com o erro {{code}}. Tente novamente em instantes.", + "policyTitle": "Não é possível mostrar esta página no app", + "policyMessage": "A página do servidor continua indo para um endereço que o app não abre. Tente novamente ou troque de organização.", "retry": "Tentar novamente" }, "excursion": { diff --git a/locales/ru.json b/locales/ru.json index a05eb3e..c894ed5 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -64,6 +64,8 @@ "offlineMessage": "Проверьте подключение. Страница перезагрузится сама, когда сеть появится.", "serverErrorTitle": "Сервер не отвечает", "serverErrorMessage": "Сервер вернул ошибку {{code}}. Попробуйте ещё раз чуть позже.", + "policyTitle": "Эту страницу нельзя показать в приложении", + "policyMessage": "Страница сервера раз за разом переходит на адрес, который приложение не открывает. Попробуйте снова или смените организацию.", "retry": "Попробовать снова" }, "excursion": { diff --git a/locales/zh.json b/locales/zh.json index 76bc25d..c0a8862 100644 --- a/locales/zh.json +++ b/locales/zh.json @@ -64,6 +64,8 @@ "offlineMessage": "请检查网络连接。恢复联网后页面会自动重新加载。", "serverErrorTitle": "服务器无响应", "serverErrorMessage": "服务器返回错误 {{code}},请稍后重试。", + "policyTitle": "无法在应用内显示此页面", + "policyMessage": "服务器的页面一直跳转到应用不打开的地址。请重试,或切换组织。", "retry": "重试" }, "excursion": { From 4b681518594dbaff80abe8947b1516ce4acc8da7 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 02:48:54 +0000 Subject: [PATCH 2/5] test(shell): pin the navigation policy at load start A top-frame load the policy does not keep in the WebView is stopped, opened where the policy sends it (nothing for a blocked page), and the WebView goes back to the server where the Console was; at load end too, without counting that load end as the Console's. The server's own pages, an in-app history change, a route a native screen owns, a trusted host and a sign-in excursion's pages stay; the excursion ends by its own rules. A second stop before a Console is ready shows the failure page. Reports from a WebView being re-created are dropped. Claude-Session: https://claude.ai/code/session_01QSeKYnW1Xf6HDSWz1eGAqv Co-authored-by: Claude --- __tests__/components/ConsoleWebView.test.tsx | 192 ++++++++++++++++++ __tests__/lib/shell/navigation-policy.test.ts | 49 +++++ 2 files changed, 241 insertions(+) diff --git a/__tests__/components/ConsoleWebView.test.tsx b/__tests__/components/ConsoleWebView.test.tsx index 4e4d581..56a35fd 100644 --- a/__tests__/components/ConsoleWebView.test.tsx +++ b/__tests__/components/ConsoleWebView.test.tsx @@ -19,6 +19,7 @@ const mockWebView = { reload: jest.fn(), goBack: jest.fn(), clearHistory: jest.fn(), + stopLoading: jest.fn(), }; jest.mock("react-native-webview", () => { @@ -34,6 +35,7 @@ jest.mock("react-native-webview", () => { reload: mockWebView.reload, goBack: mockWebView.goBack, clearHistory: mockWebView.clearHistory, + stopLoading: mockWebView.stopLoading, })); return ; }); @@ -156,6 +158,7 @@ beforeEach(() => { mockWebView.reload.mockReset(); mockWebView.goBack.mockReset(); mockWebView.clearHistory.mockReset(); + mockWebView.stopLoading.mockReset(); }); describe("ConsoleWebView", () => { @@ -880,6 +883,152 @@ describe("ConsoleWebView — saved connections", () => { }); }); +describe("ConsoleWebView — the navigation policy at load start", () => { + const ELSEWHERE = "https://pages.elsewhere.example/form"; + + function loadEndAt(url: string, loading = false) { + act(() => mockWebView.props.onLoadEnd({ nativeEvent: { url, loading } })); + } + + /** The Console, loaded and ready, on `route`. */ + async function onConsolePage(route = "/apps/crm/lead/9", overrides: Partial = {}) { + const rendered = renderConsole(overrides); + loadStart(`${SERVER}/_console/apps/crm`); + loadEndAt(`${SERVER}/_console/apps/crm`); + await consoleReady(); + await postEvent("routeChanged", { route }); + mockWebView.injectJavaScript.mockReset(); + return rendered; + } + + afterEach(() => jest.useRealTimers()); + + it("stops a top-frame load the policy does not keep in the WebView, opens it where the policy sends it, and goes back to the server where the Console was", async () => { + const { props } = await onConsolePage(); + loadStart(ELSEWHERE); + expect(mockWebView.stopLoading).toHaveBeenCalledTimes(1); + expect(props.onOpenExternal).toHaveBeenCalledTimes(1); + expect(props.onOpenExternal).toHaveBeenCalledWith(ELSEWHERE); + // A new WebView on the server, at the route the Console was on, behind the cover. + expect(mockWebView.mounts).toBe(2); + expect(mockWebView.props.source).toEqual({ uri: `${SERVER}/_console/apps/crm/lead/9` }); + expect(screen.getByText("Loading…")).toBeTruthy(); + }); + + it("judges a load reported only at its end the same way, and never counts that load end as the Console's", async () => { + jest.useFakeTimers(); + const { props } = await onConsolePage(); + loadEndAt(ELSEWHERE); + expect(mockWebView.stopLoading).toHaveBeenCalledTimes(1); + expect(props.onOpenExternal).toHaveBeenCalledWith(ELSEWHERE); + expect(mockWebView.mounts).toBe(2); + // The new WebView's Console has not loaded: no ready timeout lifts the cover. + act(() => jest.advanceTimersByTime(READY_TIMEOUT_MS * 2)); + expect(screen.getByText("Loading…")).toBeTruthy(); + }); + + it("stops a load the policy blocks, opening nothing", async () => { + const { props } = await onConsolePage(); + loadStart("data:text/html,page"); + expect(mockWebView.stopLoading).toHaveBeenCalledTimes(1); + expect(props.onOpenExternal).not.toHaveBeenCalled(); + expect(mockWebView.mounts).toBe(2); + expect(mockWebView.props.source).toEqual({ uri: `${SERVER}/_console/apps/crm/lead/9` }); + }); + + it("keeps the server's own pages: a document, an in-app history change, a route a native screen owns", async () => { + const { props } = await onConsolePage(); + // Android reports an in-app history change as a load start: the URL decides. + loadStart(`${SERVER}/_console/apps/crm/lead/10`); + loadStart(`${SERVER}/_console/approvals/7`); + loadStart(`${SERVER}/api/v1/auth/callback/corp?code=abc`); + loadEndAt(`${SERVER}:443/_console/home`); + loadStart("about:blank"); + expect(mockWebView.stopLoading).not.toHaveBeenCalled(); + expect(props.onOpenExternal).not.toHaveBeenCalled(); + expect(props.onOpenNative).not.toHaveBeenCalled(); + expect(mockWebView.mounts).toBe(1); + expect(screen.queryByText("Loading…")).toBeNull(); + }); + + it("keeps a trusted host", async () => { + const { props } = await onConsolePage("/apps/crm", { trustedHosts: ["login.partner.example"] }); + loadStart("https://login.partner.example/authorize"); + loadEndAt("https://login.partner.example/authorize"); + expect(mockWebView.stopLoading).not.toHaveBeenCalled(); + expect(props.onOpenExternal).not.toHaveBeenCalled(); + expect(mockWebView.mounts).toBe(1); + }); + + it("shows the failure page when the server's page goes there again before a Console is ready", async () => { + const { props } = await onConsolePage(); + loadStart(ELSEWHERE); + expect(mockWebView.mounts).toBe(2); + // The new WebView's page goes there again before its Console is ready. + loadStart(`${SERVER}/_console/apps/crm/lead/9`); + loadStart(ELSEWHERE); + expect(mockWebView.stopLoading).toHaveBeenCalledTimes(2); + expect(screen.getByTestId("shell-offline-fallback")).toBeTruthy(); + expect(screen.getByText("Can't show this page in the app")).toBeTruthy(); + // Opened once, re-created once. + expect(props.onOpenExternal).toHaveBeenCalledTimes(1); + expect(mockWebView.mounts).toBe(2); + // Retry is the user's: one new WebView, which stops at the same page again. + fireEvent.press(screen.getByTestId("shell-retry")); + expect(mockWebView.mounts).toBe(3); + loadStart(ELSEWHERE); + expect(screen.getByTestId("shell-offline-fallback")).toBeTruthy(); + expect(props.onOpenExternal).toHaveBeenCalledTimes(1); + }); + + it("goes back to the server again once a Console has been ready in between", async () => { + const { props } = await onConsolePage(); + loadStart(ELSEWHERE); + loadEndAt(`${SERVER}/_console/apps/crm/lead/9`); + await consoleReady(); + loadStart(ELSEWHERE); + expect(screen.queryByTestId("shell-offline-fallback")).toBeNull(); + expect(props.onOpenExternal).toHaveBeenCalledTimes(2); + expect(mockWebView.mounts).toBe(3); + }); + + it("drops the reports of the WebView it replaces", async () => { + jest.useFakeTimers(); + const { props } = await onConsolePage(); + const replaced = mockWebView.props; + loadStart(ELSEWHERE); + expect(mockWebView.mounts).toBe(2); + act(() => { + replaced.onLoadEnd({ nativeEvent: { url: ELSEWHERE, loading: false } }); + replaced.onNavigationStateChange({ url: ELSEWHERE, loading: false, canGoBack: true }); + }); + expect(props.onOpenExternal).toHaveBeenCalledTimes(1); + expect(mockWebView.stopLoading).toHaveBeenCalledTimes(1); + expect(mockWebView.mounts).toBe(2); + expect(screen.queryByTestId("shell-offline-fallback")).toBeNull(); + act(() => jest.advanceTimersByTime(READY_TIMEOUT_MS * 2)); + expect(screen.getByText("Loading…")).toBeTruthy(); + }); + + it("never judges a report from the WebView a switch replaced against the new server", async () => { + const { props, view } = await onConsolePage(); + const replaced = mockWebView.props; + view.rerender(); + expect(mockWebView.mounts).toBe(2); + act(() => replaced.onLoadEnd({ nativeEvent: { url: `${SERVER}/_console/home`, loading: false } })); + expect(mockWebView.stopLoading).not.toHaveBeenCalled(); + expect(props.onOpenExternal).not.toHaveBeenCalled(); + expect(mockWebView.mounts).toBe(2); + // The new server's own pages stay; the one left is another site now. + loadStart("https://erp.example.com/_console/home"); + expect(mockWebView.mounts).toBe(2); + loadStart(`${SERVER}/_console/home`); + expect(props.onOpenExternal).toHaveBeenCalledWith(`${SERVER}/_console/home`); + expect(mockWebView.mounts).toBe(3); + expect(mockWebView.props.source).toEqual({ uri: "https://erp.example.com/_console/home" }); + }); +}); + describe("ConsoleWebView — sign-in excursion", () => { const LOGIN = `${SERVER}/_console/login?redirect=%2Fapps%2Fcrm`; const IDP = "https://login.idp-corp.example/oauth2/authorize?client_id=acme"; @@ -975,6 +1124,49 @@ describe("ConsoleWebView — sign-in excursion", () => { ); }); + it("keeps the excursion's pages at load start and load end, and returns by its own rule", async () => { + const { props } = await inExcursion(); + loadStart("https://mfa.idp-corp.example/verify"); + loadEndAt("https://mfa.idp-corp.example/verify"); + loadStart("https://federation.partner.example/saml"); + expect(screen.getByTestId("shell-excursion-host")).toHaveTextContent( + "federation.partner.example" + ); + expect(mockWebView.stopLoading).not.toHaveBeenCalled(); + expect(props.onOpenExternal).not.toHaveBeenCalled(); + // Back on the server: the excursion returns, nothing is stopped. + loadStart(CALLBACK); + loadEndAt(`${SERVER}/_console/home`); + expect(screen.queryByTestId("shell-excursion-bar")).toBeNull(); + expect(mockWebView.stopLoading).not.toHaveBeenCalled(); + expect(mockWebView.mounts).toBe(1); + // Outside the excursion, the provider's page is another site again. + loadStart("https://login.idp-corp.example/session"); + expect(mockWebView.stopLoading).toHaveBeenCalledTimes(1); + expect(props.onOpenExternal).toHaveBeenCalledWith("https://login.idp-corp.example/session"); + expect(mockWebView.mounts).toBe(2); + }); + + it("leaves a plain http page during the excursion to the excursion's own end", async () => { + const { props } = await inExcursion(); + loadStart("http://login.idp-corp.example/next"); + expect(screen.getByTestId("shell-excursion-notice-insecure")).toBeTruthy(); + expect(mockWebView.props.source).toEqual({ uri: `${SERVER}/_console/login` }); + expect(mockWebView.mounts).toBe(2); + expect(props.onOpenExternal).not.toHaveBeenCalled(); + }); + + it("opens nothing for the provider's page after 取消", async () => { + const { props } = await inExcursion(); + const replaced = mockWebView.props; + fireEvent.press(screen.getByTestId("shell-excursion-cancel")); + expect(mockWebView.mounts).toBe(2); + act(() => replaced.onLoadEnd({ nativeEvent: { url: IDP, loading: false } })); + expect(props.onOpenExternal).not.toHaveBeenCalled(); + expect(mockWebView.stopLoading).not.toHaveBeenCalled(); + expect(mockWebView.mounts).toBe(2); + }); + describe("the bridge", () => { it("answers no call while an excursion page is on screen, whatever origin it names", async () => { const { props } = await inExcursion(); diff --git a/__tests__/lib/shell/navigation-policy.test.ts b/__tests__/lib/shell/navigation-policy.test.ts index c4003be..72e5153 100644 --- a/__tests__/lib/shell/navigation-policy.test.ts +++ b/__tests__/lib/shell/navigation-policy.test.ts @@ -1,5 +1,6 @@ import { decideNavigation, + decideTopFrameLoad, isSignInSurface, type NavigationContext, } from "~/lib/shell/navigation-policy"; @@ -96,6 +97,54 @@ describe("decideNavigation", () => { }); }); +describe("decideTopFrameLoad", () => { + it("keeps the server's own pages, a route a native screen owns included", () => { + expect(decideTopFrameLoad("https://acme.example.com/_console/apps/crm", ctx)).toEqual({ + kind: "stay", + }); + expect(decideTopFrameLoad("https://acme.example.com/_console/approvals/9", ctx)).toEqual({ + kind: "stay", + }); + expect(decideTopFrameLoad("https://acme.example.com/api/v1/auth/callback/x", ctx)).toEqual({ + kind: "stay", + }); + expect(decideTopFrameLoad("https://acme.example.com:443/_console/home", ctx)).toEqual({ + kind: "stay", + }); + }); + + it("keeps what the policy loads in the top frame: trusted hosts, about:, blob:", () => { + expect(decideTopFrameLoad("https://login.acme-idp.example/authorize", ctx)).toEqual({ + kind: "stay", + }); + expect(decideTopFrameLoad("about:blank", ctx)).toEqual({ kind: "stay" }); + expect(decideTopFrameLoad("blob:https://acme.example.com/abc", ctx)).toEqual({ kind: "stay" }); + }); + + it("stops any other page, to open it where the policy sends it", () => { + expect(decideTopFrameLoad("https://docs.example.org/page", ctx)).toEqual({ + kind: "stop", + open: "https://docs.example.org/page", + }); + expect(decideTopFrameLoad("http://acme.example.com/_console/home", ctx)).toEqual({ + kind: "stop", + open: "http://acme.example.com/_console/home", + }); + }); + + it("stops a page the policy blocks, opening nothing", () => { + expect(decideTopFrameLoad("data:text/html,x", ctx)).toEqual({ kind: "stop", open: null }); + expect(decideTopFrameLoad("file:///etc/passwd", ctx)).toEqual({ kind: "stop", open: null }); + expect(decideTopFrameLoad("not a url", ctx)).toEqual({ kind: "stop", open: null }); + }); + + it("judges every load as the top frame's, whatever the context says", () => { + expect( + decideTopFrameLoad("https://maps.example.org/embed", { ...ctx, isTopFrame: false }) + ).toEqual({ kind: "stop", open: "https://maps.example.org/embed" }); + }); +}); + describe("isSignInSurface", () => { const server = "https://acme.example.com"; From 5275f8eeb49c311fa33020430003eb92e4c86ab9 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 02:50:33 +0000 Subject: [PATCH 3/5] test(shell): report through the WebView a re-create replaces The WebView mock keeps each mounted WebView's latest props, so a test reports through the instance that was replaced, as its native side would, including the render it got for a new server before a switch replaced it. Claude-Session: https://claude.ai/code/session_01QSeKYnW1Xf6HDSWz1eGAqv Co-authored-by: Claude --- __tests__/components/ConsoleWebView.test.tsx | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/__tests__/components/ConsoleWebView.test.tsx b/__tests__/components/ConsoleWebView.test.tsx index 56a35fd..9ba79d2 100644 --- a/__tests__/components/ConsoleWebView.test.tsx +++ b/__tests__/components/ConsoleWebView.test.tsx @@ -14,6 +14,8 @@ import { BRIDGE_RESOLVE_GLOBAL, CONNECTION_REMOVED_EVENT } from "~/lib/shell/bri const mockWebView = { props: null as any, + /** Each mounted WebView's latest props, in mount order: what its native side calls. */ + instances: [] as any[], mounts: 0, injectJavaScript: jest.fn(), reload: jest.fn(), @@ -26,6 +28,8 @@ jest.mock("react-native-webview", () => { const React = require("react"); const { View } = require("react-native"); const WebView = React.forwardRef(function MockWebView(props: any, ref: any) { + const [index] = React.useState(() => mockWebView.instances.length); + mockWebView.instances[index] = props; mockWebView.props = props; React.useEffect(() => { mockWebView.mounts += 1; @@ -153,6 +157,7 @@ const lastInjected = (): string => { beforeEach(() => { mockIsConnected = true; mockWebView.props = null; + mockWebView.instances = []; mockWebView.mounts = 0; mockWebView.injectJavaScript.mockReset(); mockWebView.reload.mockReset(); @@ -995,9 +1000,9 @@ describe("ConsoleWebView — the navigation policy at load start", () => { it("drops the reports of the WebView it replaces", async () => { jest.useFakeTimers(); const { props } = await onConsolePage(); - const replaced = mockWebView.props; loadStart(ELSEWHERE); expect(mockWebView.mounts).toBe(2); + const replaced = mockWebView.instances[0]; act(() => { replaced.onLoadEnd({ nativeEvent: { url: ELSEWHERE, loading: false } }); replaced.onNavigationStateChange({ url: ELSEWHERE, loading: false, canGoBack: true }); @@ -1012,9 +1017,10 @@ describe("ConsoleWebView — the navigation policy at load start", () => { it("never judges a report from the WebView a switch replaced against the new server", async () => { const { props, view } = await onConsolePage(); - const replaced = mockWebView.props; view.rerender(); expect(mockWebView.mounts).toBe(2); + // The WebView left behind was rendered once more, for the new server, before it was replaced. + const replaced = mockWebView.instances[0]; act(() => replaced.onLoadEnd({ nativeEvent: { url: `${SERVER}/_console/home`, loading: false } })); expect(mockWebView.stopLoading).not.toHaveBeenCalled(); expect(props.onOpenExternal).not.toHaveBeenCalled(); @@ -1158,9 +1164,9 @@ describe("ConsoleWebView — sign-in excursion", () => { it("opens nothing for the provider's page after 取消", async () => { const { props } = await inExcursion(); - const replaced = mockWebView.props; fireEvent.press(screen.getByTestId("shell-excursion-cancel")); expect(mockWebView.mounts).toBe(2); + const replaced = mockWebView.instances[0]; act(() => replaced.onLoadEnd({ nativeEvent: { url: IDP, loading: false } })); expect(props.onOpenExternal).not.toHaveBeenCalled(); expect(mockWebView.stopLoading).not.toHaveBeenCalled(); From bd89d9b9d03667747cf3c7a598e60f55b23214e5 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 02:52:28 +0000 Subject: [PATCH 4/5] style(shell): format the load-start policy hunks; name the excursion case Claude-Session: https://claude.ai/code/session_01QSeKYnW1Xf6HDSWz1eGAqv Co-authored-by: Claude --- __tests__/components/ConsoleWebView.test.tsx | 13 ++++++++++--- __tests__/lib/shell/navigation-policy.test.ts | 5 ++++- components/shell/ConsoleWebView.tsx | 7 ++----- lib/shell/navigation-policy.ts | 6 +++--- 4 files changed, 19 insertions(+), 12 deletions(-) diff --git a/__tests__/components/ConsoleWebView.test.tsx b/__tests__/components/ConsoleWebView.test.tsx index 9ba79d2..549c44b 100644 --- a/__tests__/components/ConsoleWebView.test.tsx +++ b/__tests__/components/ConsoleWebView.test.tsx @@ -896,7 +896,10 @@ describe("ConsoleWebView — the navigation policy at load start", () => { } /** The Console, loaded and ready, on `route`. */ - async function onConsolePage(route = "/apps/crm/lead/9", overrides: Partial = {}) { + async function onConsolePage( + route = "/apps/crm/lead/9", + overrides: Partial = {} + ) { const rendered = renderConsole(overrides); loadStart(`${SERVER}/_console/apps/crm`); loadEndAt(`${SERVER}/_console/apps/crm`); @@ -1017,11 +1020,15 @@ describe("ConsoleWebView — the navigation policy at load start", () => { it("never judges a report from the WebView a switch replaced against the new server", async () => { const { props, view } = await onConsolePage(); - view.rerender(); + view.rerender( + + ); expect(mockWebView.mounts).toBe(2); // The WebView left behind was rendered once more, for the new server, before it was replaced. const replaced = mockWebView.instances[0]; - act(() => replaced.onLoadEnd({ nativeEvent: { url: `${SERVER}/_console/home`, loading: false } })); + act(() => + replaced.onLoadEnd({ nativeEvent: { url: `${SERVER}/_console/home`, loading: false } }) + ); expect(mockWebView.stopLoading).not.toHaveBeenCalled(); expect(props.onOpenExternal).not.toHaveBeenCalled(); expect(mockWebView.mounts).toBe(2); diff --git a/__tests__/lib/shell/navigation-policy.test.ts b/__tests__/lib/shell/navigation-policy.test.ts index 72e5153..7461caa 100644 --- a/__tests__/lib/shell/navigation-policy.test.ts +++ b/__tests__/lib/shell/navigation-policy.test.ts @@ -133,7 +133,10 @@ describe("decideTopFrameLoad", () => { }); it("stops a page the policy blocks, opening nothing", () => { - expect(decideTopFrameLoad("data:text/html,x", ctx)).toEqual({ kind: "stop", open: null }); + expect(decideTopFrameLoad("data:text/html,x", ctx)).toEqual({ + kind: "stop", + open: null, + }); expect(decideTopFrameLoad("file:///etc/passwd", ctx)).toEqual({ kind: "stop", open: null }); expect(decideTopFrameLoad("not a url", ctx)).toEqual({ kind: "stop", open: null }); }); diff --git a/components/shell/ConsoleWebView.tsx b/components/shell/ConsoleWebView.tsx index 2227a64..c55606f 100644 --- a/components/shell/ConsoleWebView.tsx +++ b/components/shell/ConsoleWebView.tsx @@ -28,11 +28,7 @@ import { routeFromConsoleUrl, safeConsoleRoute, } from "~/lib/shell/console-url"; -import { - decideNavigation, - decideTopFrameLoad, - SIGN_IN_ROUTE, -} from "~/lib/shell/navigation-policy"; +import { decideNavigation, decideTopFrameLoad, SIGN_IN_ROUTE } from "~/lib/shell/navigation-policy"; import { EXCURSION_TIMEOUT_MS, IDLE_EXCURSION, @@ -627,6 +623,7 @@ export const ConsoleWebView = forwardRef Date: Tue, 3 Nov 2026 02:52:28 +0000 Subject: [PATCH 5/5] docs(shell): the navigation policy is enforced at request and at load start; changeset Claude-Session: https://claude.ai/code/session_01QSeKYnW1Xf6HDSWz1eGAqv Co-authored-by: Claude --- .changeset/nav-policy-load-start.md | 16 ++++++++++++++++ docs/HYBRID-SHELL.md | 3 ++- 2 files changed, 18 insertions(+), 1 deletion(-) create mode 100644 .changeset/nav-policy-load-start.md diff --git a/.changeset/nav-policy-load-start.md b/.changeset/nav-policy-load-start.md new file mode 100644 index 0000000..6fbf9ba --- /dev/null +++ b/.changeset/nav-policy-load-start.md @@ -0,0 +1,16 @@ +--- +"mobile": patch +--- + +Clause-②: no +Metadata-protocol: none + +The navigation policy is also enforced when a top-frame load starts, so every +top-frame load follows it. A page the policy does not load in the WebView is +stopped and goes where the policy sends it, as a link does: a web page to the +in-app browser, a blocked page nowhere. The WebView then goes back to the +server, to the route the Console was on. If the server's page goes there again +before the Console is ready, the app says it can't show the page, with Try +again and Switch organization. + +No native module and no new permission: an over-the-air update is enough. diff --git a/docs/HYBRID-SHELL.md b/docs/HYBRID-SHELL.md index 074683a..319884d 100644 --- a/docs/HYBRID-SHELL.md +++ b/docs/HYBRID-SHELL.md @@ -330,7 +330,8 @@ In the shell: ## Navigation policy Every navigation the page attempts goes through `decideNavigation` -(`lib/shell/navigation-policy.ts`): +(`lib/shell/navigation-policy.ts`). The policy is enforced when a navigation +is requested and again when a top-frame load starts. | Target | Decision | | ----------------------------------------------------------- | ---------------- |