From 04eee129ef2fc3829e8b7fcb279e85739a71e8e7 Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Mon, 21 Sep 2026 07:58:25 -0400 Subject: [PATCH] feat(mobile): deliver a pane request to the mounted page over a re-sent init (OTA phase C, C7.7 round 1) Ruling 33.1. The session switch keyed on the whole route, so a notification tap for another pane of the session on screen either remounted the shell (a bridge teardown and a page reload for a tab switch) or, for the pane already showing, moved nothing at all: the page cleared `paneKey` on its own router and the native param kept it, so `SET_PARAMS` wrote the value already there. `paneKey` leaves the key and travels as a route update. The page declares `accepts: ['route-update']` on `ready`; the shell re-sends `init` for a same-path param change only to a page that declared it, and treats a second `init` for the session the page already holds as a route update rather than a replacement -- in-flight requests, subscriptions, the storage snapshot (the same object, asserted) and the generation all stay. The screen reports delivery and the switch clears the native param, so no later `init` replays a spent tap. `use-notification-pane-navigation.web.ts` reads the request off a standing listener; the native file is unchanged. Wire-compatible both ways without a version bump: `accepts` is optional, an older page is never sent a second `init`, and an older shell never sends one. Both degrade to today's lost repeat tap. `BRIDGE_PROTOCOL_VERSION` and every released native RPC are untouched. Two files were at their line cap, so two modules came out at their own boundaries rather than a cap bump: `bridge-init-route.ts` (the route half of `init`, wanted by the switches, the host and the page) and `bridge-host-route.ts` (one host's held route and what it may publish). Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb --- .../app/h/[hostId]/session/[worktreeId].tsx | 34 +++++++--- .../mobile-web-shell/MobileWebShellScreen.tsx | 35 +++++++++- .../mobile-web-shell/bridge-host-contract.ts | 4 ++ .../src/mobile-web-shell/bridge-host-route.ts | 68 +++++++++++++++++++ mobile/src/mobile-web-shell/bridge-host.ts | 41 +++++++---- .../mobile-web-shell/bridge/bridge-caps.ts | 10 +++ .../bridge/bridge-envelope.ts | 54 +++++++-------- .../bridge/bridge-init-route.ts | 39 +++++++++++ .../bridge/bridge-route-update.test.ts | 40 +++++++---- .../bridge/bridge-route-update.ts | 59 ++++++++++++++++ .../bridge/bridge-rpc-client-frames.test.ts | 7 +- .../bridge/bridge-rpc-client.ts | 59 ++++++++++++++-- .../use-mobile-web-shell-bridge.ts | 29 +++++++- .../use-notification-pane-navigation.web.ts | 66 ++++++++++++++++++ 14 files changed, 465 insertions(+), 80 deletions(-) create mode 100644 mobile/src/mobile-web-shell/bridge-host-route.ts create mode 100644 mobile/src/mobile-web-shell/bridge/bridge-init-route.ts create mode 100644 mobile/src/mobile-web-shell/bridge/bridge-route-update.ts create mode 100644 mobile/src/session/use-notification-pane-navigation.web.ts diff --git a/mobile/app/h/[hostId]/session/[worktreeId].tsx b/mobile/app/h/[hostId]/session/[worktreeId].tsx index 271ed38940d..19ab31e5e35 100644 --- a/mobile/app/h/[hostId]/session/[worktreeId].tsx +++ b/mobile/app/h/[hostId]/session/[worktreeId].tsx @@ -1,4 +1,5 @@ -import { useLocalSearchParams } from 'expo-router' +import { useCallback } from 'react' +import { useLocalSearchParams, useRouter } from 'expo-router' import { MobileSessionRouteScreen } from '../../../../src/session/MobileSessionRouteScreen' import { firstParam } from '../../../../src/navigation/route-param-reader' import { @@ -16,10 +17,13 @@ import { useMobileWebShellEnabled } from '../../../../src/mobile-web-shell/use-m * would open the terminal, chat and tab subscriptions behind the page as well as in front of it. * As an element it is built and not mounted, and only `fallback` ever mounts it. * - * Four query params rather than the review's four, and one of them is consumed by the screen: the - * notification hook rewrites `paneKey` to empty the moment it has switched to the pane, so a tap - * cannot be replayed by a later snapshot. That rewrite is `setParams` on the handoff, which inside - * the page is the document's own router, so the param has to arrive in the page for it to happen. + * Four query params rather than the review's four, and one of them is not part of this screen's + * identity: `paneKey`. A notification tap for a pane of the session already on screen is a tab + * switch, so keying on it would tear the bridge down and reload the page for one, and keying on it + * while the page cleared its own copy lost a repeat tap outright (ruling 33.1). It travels as a + * route update instead — a re-sent `init` to a page that said it takes one — and this file clears + * the native param once the page has been handed it, exactly as the notification hook did, so no + * later `init` can replay a spent tap. */ export default function MobileSessionScreen() { // Through `firstParam` on every param, as every switch does: expo-router answers a repeated query @@ -37,7 +41,13 @@ export default function MobileSessionScreen() { const hostId = firstParam(params.hostId) const worktreeId = firstParam(params.worktreeId) const enabled = useMobileWebShellEnabled() + const router = useRouter() const native = + // Empty rather than absent, which is what the notification hook wrote and what the route builder + // below drops: a cleared key and a key that was never there are the same route. + const clearPaneKey = useCallback(() => { + router.setParams({ paneKey: '' }) + }, [router]) // Each omitted when empty, because the screen reads the difference: `created` is a one-shot flag // the create flow sets to `1`, `warning` is the host's own text, `name` is a label the screen @@ -59,15 +69,21 @@ export default function MobileSessionScreen() { if (enabled !== true || !hostId || route === null) { return native } - // Keyed on the route: a host captures the grants its session was opened with, so a screen reused - // across a route change would keep authorising frames under the grants of the route the page has - // left. The key is what makes the change a remount, which disposes that bridge in the commit. + // Keyed on the route minus `paneKey`: a host captures the grants its session was opened with, so + // a screen reused across a route change would keep authorising frames under the grants of the + // route the page has left, and the key is what makes that change a remount. A pane is not such a + // change — it is a tab of the session this key already names — so it is left out here and + // delivered to the mounted page instead. Derived from the same builder every other switch uses; + // `shellScreenRouteKey` is untouched, because for the other four a param change *is* an identity + // change. + const { paneKey: _paneKey, ...identity } = routeParams return ( ) } diff --git a/mobile/src/mobile-web-shell/MobileWebShellScreen.tsx b/mobile/src/mobile-web-shell/MobileWebShellScreen.tsx index dda08f8987e..637f0bb46e4 100644 --- a/mobile/src/mobile-web-shell/MobileWebShellScreen.tsx +++ b/mobile/src/mobile-web-shell/MobileWebShellScreen.tsx @@ -1,4 +1,4 @@ -import { useEffect, type ReactNode } from 'react' +import { useEffect, useRef, type ReactNode } from 'react' import { ActivityIndicator, Linking, Pressable, StyleSheet, Text, View } from 'react-native' import { useRouter } from 'expo-router' import { useSafeAreaInsets } from 'react-native-safe-area-context' @@ -20,6 +20,7 @@ import { } from './mobile-web-shell-dev-facts' import { cancelledShellNavigationTarget } from './cancelled-navigation-target' import { playPageHaptic } from './page-haptics' +import { shellScreenRouteKey } from './shell-screen-route' import { useMobileWebShellBridge } from './use-mobile-web-shell-bridge' import type { MobileWebShellRuntime } from './mobile-web-shell-runtime' import { useNativeDeviceVerbs } from '../platform/use-native-device-verbs' @@ -130,6 +131,17 @@ export type MobileWebShellScreenProps = { * the negotiation falls back to, and a shell with nothing behind it would paint a blank instead. */ fallback: ReactNode + /** + * Called once the page has been handed this route, so a caller carrying a one-shot param can + * clear it (the session switch and its `paneKey`, ruling 33.1). + * + * Both ways a route reaches the page: the `init` that answered its `ready`, and a re-sent one + * for a route that moved while the screen stayed mounted. Never for a publish that posted + * nothing — a param cleared after a frame nobody sent is a tap the page never heard. Only a + * switch whose key leaves a param out ever sees the second kind; every other one keys on the + * whole route, so a param change there is a remount. + */ + onRouteDelivered?: (route: BridgeInitRoute) => void runtime?: MobileWebShellRuntime } @@ -144,6 +156,7 @@ export function MobileWebShellScreen({ hostId, route, fallback, + onRouteDelivered, runtime }: MobileWebShellScreenProps) { const insets = useSafeAreaInsets() @@ -205,6 +218,9 @@ export function MobileWebShellScreen({ onPageReady: () => { reportPageReady() void refreshStorage() + // The `init` this ready is answered with is built and posted before this runs, so the route + // it carries has reached the page and a one-shot param on it is spent. + onRouteDelivered?.(route) }, // `document-load-failed` because that is what happens: the document loads and the page refuses // the session, so no tree is ever built. The refetch it costs is wasted on a route this shell @@ -241,6 +257,23 @@ export function MobileWebShellScreen({ onBinaryFramesDropped: reportDroppedBinaryFrames }) + // A route that moved under a screen that stayed mounted, which the key is what decides: the + // session switch keeps `paneKey` out of its key so a notification tap for another pane is a tab + // switch rather than a page reload, and this is how the page hears about it. Keyed on the page's + // own identity for a route, so a re-render holding an equal route publishes nothing. + const publishedRouteKey = useRef(shellScreenRouteKey(route)) + const publishRoute = bridge.publishRoute + useEffect(() => { + const key = shellScreenRouteKey(route) + if (key === publishedRouteKey.current) { + return + } + publishedRouteKey.current = key + if (publishRoute(route)) { + onRouteDelivered?.(route) + } + }, [onRouteDelivered, publishRoute, route]) + // A profile read that rejected never becomes a host, so the session would otherwise sit in // `ready` behind an un-hidden view with nothing serving it and the page asking forever. // `document-load-failed` because that is the outcome: the document loads and no session opens. diff --git a/mobile/src/mobile-web-shell/bridge-host-contract.ts b/mobile/src/mobile-web-shell/bridge-host-contract.ts index 2e623323828..4b8d88e6f97 100644 --- a/mobile/src/mobile-web-shell/bridge-host-contract.ts +++ b/mobile/src/mobile-web-shell/bridge-host-contract.ts @@ -42,6 +42,10 @@ export type BridgeHostDiagnostic = /** The shell asked this host to open a screen the protocol does not allow. The host serves no * session at all in that state: an `init` the page refuses is worse than no `init`. */ | { kind: 'route-refused'; issue: string } + /** A rewritten route this host would not hand its page: a different screen, or a shape the + * page's own reader would refuse. Local only — nothing crosses, and the tap it came from is + * then the lost repeat tap it was before ruling 33.1. */ + | { kind: 'route-update-refused'; issue: string } /** A page subscribed with `wantsBinary` on a session whose route was never granted the lane. * Local only: the subscription proceeds and its JSON events cross, so nothing crosses back and * this line is the only thing that can say why the frames never became binary. */ diff --git a/mobile/src/mobile-web-shell/bridge-host-route.ts b/mobile/src/mobile-web-shell/bridge-host-route.ts new file mode 100644 index 00000000000..a65e2c3d8b8 --- /dev/null +++ b/mobile/src/mobile-web-shell/bridge-host-route.ts @@ -0,0 +1,68 @@ +import { + BridgeInitRouteSchema, + type BridgeClientMessage, + type BridgeInitRoute +} from './bridge/bridge-envelope' +import { readBridgeRouteUpdate } from './bridge/bridge-route-update' + +/** The screen one host is serving, which is the one field of `init` that moves under a live page. */ +export type BridgeHostRoute = { + /** Null when this shell named a screen the protocol does not allow; no session is served then. */ + readonly current: () => BridgeInitRoute | null + /** Why the opened route was refused, for the line the host prints at construction. */ + readonly openIssue: () => string + /** What the page's latest `ready` said it can be sent. Reset by each document's `ready`. */ + readonly readReady: (message: Extract) => void + /** + * Hands the page a rewritten param for the screen it is already on, and answers whether a frame + * went out. The held route moves either way, so a page that reloads inside this mount is given + * the newest one on its next `ready` even when it is too old to be sent one in flight. + */ + readonly publish: (next: BridgeInitRoute, deliverable: boolean) => boolean +} + +/** + * One host's route, parsed once and reassigned only by `publish`. + * + * Parsed here against the same schema the page reads it with, rather than trusted. The producer + * interpolates a host id into a pathname, so a host id carrying `?`, `#`, whitespace or a dot + * segment reaches the wire as a route no page will accept; without this the page refuses the whole + * `init`, asks again on its backoff forever, and the shell un-hides a WebView that will never + * paint. + * + * Its own module because the route stopped being a constant (ruling 33.1): a notification tap for + * another pane of the session on screen rewrites one param of a page that is already mounted, and + * what the host may do about that is a decision with four inputs rather than a field. + */ +export function createBridgeHostRoute(args: { + /** What the shell asked for, unparsed. */ + opened: BridgeInitRoute + /** True when the pair beside the route was itself refused; then no session is served either. */ + refused: boolean + sendInit: () => void + onRefused: (issue: string) => void +}): BridgeHostRoute { + const parsed = BridgeInitRouteSchema.safeParse(args.opened) + let route = parsed.success && !args.refused ? parsed.data : null + let accepts: readonly string[] = [] + return { + current: () => route, + openIssue: () => (parsed.success ? 'unknown' : (parsed.error.issues[0]?.message ?? 'unknown')), + readReady: (message) => { + accepts = message.accepts ?? [] + }, + publish: (next, deliverable) => { + const update = readBridgeRouteUpdate({ held: route, next, accepts, deliverable }) + if (update.kind === 'refuse') { + args.onRefused(update.issue) + return false + } + route = update.route + if (update.kind === 'hold') { + return false + } + args.sendInit() + return true + } + } +} diff --git a/mobile/src/mobile-web-shell/bridge-host.ts b/mobile/src/mobile-web-shell/bridge-host.ts index a8503fa47de..4b627567e45 100644 --- a/mobile/src/mobile-web-shell/bridge-host.ts +++ b/mobile/src/mobile-web-shell/bridge-host.ts @@ -10,11 +10,11 @@ import { BRIDGE_FAULT_GRANT, BRIDGE_NAVIGATE_BACK_NOTIFY, BRIDGE_PROTOCOL_VERSION, - BridgeInitRouteSchema, readBridgeClientMessage, type BridgeClientMessage, type BridgeConnectionSnapshot, - type BridgeHostMessage + type BridgeHostMessage, + type BridgeInitRoute } from './bridge/bridge-envelope' import { BridgePageRouteGrantsSchema } from './bridge/bridge-page-route-grants' import { captureBridgeError } from './bridge/bridge-error-capture' @@ -23,6 +23,7 @@ import { BRIDGE_HAPTICS_NOTIFY } from './bridge/bridge-haptics-notify' import { bridgeNotifyRefusal } from './bridge/bridge-notify-grants' import { splitBridgeReply } from './bridge/bridge-reply-chunking' import { isPageStorageKeyForRoute } from './page-storage-keys' +import { createBridgeHostRoute } from './bridge-host-route' import type { BridgeHostOptions } from './bridge-host-contract' // Re-exported so a caller reaches the host and what it reports through one module. @@ -32,6 +33,18 @@ type NotifyMessage = Extract export type BridgeHost = { receive: (json: string) => void + /** + * Hands this session a rewritten route: same screen, different params (ruling 33.1). + * + * The held route moves either way, so a page that reloads inside this mount is told the newest + * one; the frame goes out only to a page that declared `BRIDGE_ROUTE_UPDATE_ACCEPT`, because a + * page too old to name it reads a second `init` as a replacement. A different pathname is a + * different screen and is refused here — that is a remount, which is what the shell already does. + * + * True only when a frame went out, because the caller's next move is to clear the param it just + * delivered: clearing one the page was never handed would spend the tap on nothing. + */ + publishRoute: (next: BridgeInitRoute) => boolean dispose: () => void } @@ -47,11 +60,6 @@ export function createBridgeHost(options: BridgeHostOptions): BridgeHost { const { client, buildId, sessionId, pageRoutes, host } = options // The protocol's own grant rides with every session; the rest is what this route asked for. const granted: readonly string[] = [BRIDGE_FAULT_GRANT, ...options.routeGrants] - // Parsed here, once, against the same schema the page reads it with. The producer interpolates a - // host id into a pathname, so a host id carrying `?`, `#`, whitespace or a dot segment reaches - // the wire as a route no page will accept; without this the page refuses the whole `init`, asks - // again on its backoff forever, and the shell un-hides a WebView that will never paint. - const parsedRoute = BridgeInitRouteSchema.safeParse(options.route) // Checked here for the reason the route is: a pair the page's reader would refuse takes the whole // `init` with it, and a session that never gets one is worse than one that never starts. const parsedRouteGrants = @@ -62,7 +70,12 @@ export function createBridgeHost(options: BridgeHostOptions): BridgeHost { parsedRouteGrants !== null && !parsedRouteGrants.success ? (parsedRouteGrants.error.issues[0]?.message ?? 'unknown') : null - const route = parsedRoute.success && routeGrantsIssue === null ? parsedRoute.data : null + const routes = createBridgeHostRoute({ + opened: options.route, + refused: routeGrantsIssue !== null, + sendInit: () => sendInit(), + onRefused: (issue) => options.onDiagnostic?.({ kind: 'route-update-refused', issue }) + }) let closed = false // One document's turn at the bridge. `close` ends it and the next `ready` begins the next one; // between the two the view belongs to no document, so nothing is served and nothing is posted. @@ -154,6 +167,7 @@ export function createBridgeHost(options: BridgeHostOptions): BridgeHost { * would change what the first render of every replay sees. The caller keeps the map current. */ function sendInit(): void { + const route = routes.current() if (route === null) { return } @@ -263,6 +277,7 @@ export function createBridgeHost(options: BridgeHostOptions): BridgeHost { // Also local, and held to this host's own keys. The envelope allowlists the shape before // this runs, which lets `orca:pins:` through: a page opened for one host must // not rewrite another's pinned list, and the keys it was handed are the ones it may write. + const route = routes.current() if (route === null || !isPageStorageKeyForRoute(message.key, host.id, route.pathname)) { options.onDiagnostic?.({ kind: 'storage-refused', key: message.key }) return @@ -312,6 +327,7 @@ export function createBridgeHost(options: BridgeHostOptions): BridgeHost { // re-asked `ready` from the document already being served is answered the same way. if (message.type === 'ready') { serving = true + routes.readReady(message) sendInit() // Every time it is asked, not once: the page re-asks on a backoff, and the shell's wait ends // on the first of those that lands rather than on a particular one. @@ -365,14 +381,10 @@ export function createBridgeHost(options: BridgeHostOptions): BridgeHost { send({ v: BRIDGE_PROTOCOL_VERSION, type: 'state', connection: snapshot(state) }) }) - if (route === null) { + if (routes.current() === null) { // At construction rather than on the first `ready`: the verdict does not depend on the page // behaving, and a shell that waited for a frame would hold a blank view until one arrived. - const issue = routeGrantsIssue - ? `pageRouteGrants: ${routeGrantsIssue}` - : parsedRoute.success - ? 'unknown' - : (parsedRoute.error.issues[0]?.message ?? 'unknown') + const issue = routeGrantsIssue ? `pageRouteGrants: ${routeGrantsIssue}` : routes.openIssue() options.onDiagnostic?.({ kind: 'route-refused', issue }) options.onRouteRefused(issue) } @@ -391,6 +403,7 @@ export function createBridgeHost(options: BridgeHostOptions): BridgeHost { } dispatch(read.message) }, + publishRoute: (next) => routes.publish(next, serving && initSent), dispose } } diff --git a/mobile/src/mobile-web-shell/bridge/bridge-caps.ts b/mobile/src/mobile-web-shell/bridge/bridge-caps.ts index 90c9550a745..de7056a9b70 100644 --- a/mobile/src/mobile-web-shell/bridge/bridge-caps.ts +++ b/mobile/src/mobile-web-shell/bridge/bridge-caps.ts @@ -137,6 +137,16 @@ export function isBridgeExternalLinkUrl(url: string): boolean { return readBridgeExternalLinkUrl(url) !== null } export const BRIDGE_MAX_PAGE_ROUTES = 64 + +/** + * What a page may say it can be sent, bounded the way every other list on the envelope is. + * + * A frame bound and not a vocabulary: the shell acts on the names it knows and ignores the rest, + * which is what lets a newer page declare one an older shell has never heard of. + */ +export const BRIDGE_MAX_PAGE_ACCEPTS = 16 +export const BRIDGE_MAX_PAGE_ACCEPT_CHARS = 64 + /** A host id, its name and its endpoint. Bounded because the page renders all three. */ export const BRIDGE_MAX_HOST_FIELD_CHARS = 1024 diff --git a/mobile/src/mobile-web-shell/bridge/bridge-envelope.ts b/mobile/src/mobile-web-shell/bridge/bridge-envelope.ts index 5128446fc42..9b240d2d765 100644 --- a/mobile/src/mobile-web-shell/bridge/bridge-envelope.ts +++ b/mobile/src/mobile-web-shell/bridge/bridge-envelope.ts @@ -3,6 +3,7 @@ import { isRpcResponse } from '../../transport/rpc-response-shape' import type { RpcResponse } from '../../transport/types' import { BridgeErrorCaptureSchema } from './bridge-error-capture' import { BRIDGE_HAPTICS_NOTIFY_FIELDS } from './bridge-haptics-notify' +import { BridgeInitRouteSchema, type BridgeInitRoute } from './bridge-init-route' import { BridgePageRouteGrantsSchema } from './bridge-page-route-grants' import { isPageStorageKey, @@ -12,18 +13,17 @@ import { } from '../page-storage-keys' import { BRIDGE_MAX_METHOD_CHARS, + BRIDGE_MAX_PAGE_ACCEPT_CHARS, + BRIDGE_MAX_PAGE_ACCEPTS, BRIDGE_MAX_PAGE_ROUTES, BRIDGE_MAX_REPLY_PARTS, BRIDGE_MAX_EXTERNAL_LINK_CHARS, BRIDGE_MAX_ROUTE_HREF_CHARS, - BRIDGE_MAX_ROUTE_PARAM_CHARS, - BRIDGE_MAX_ROUTE_PARAMS, BRIDGE_MAX_ROUTE_PATHNAME_CHARS, BRIDGE_MAX_VIEWPORT_COLS, BRIDGE_MAX_VIEWPORT_ROWS, BRIDGE_MAX_HOST_FIELD_CHARS, BRIDGE_ROUTE_HREF_PATTERN, - BRIDGE_ROUTE_PATHNAME_PATTERN, isBridgeExternalLinkUrl, parseBridgeMessage, type BridgeDirection, @@ -102,33 +102,9 @@ export const BridgeGrantsSchema = z.object({ export type BridgeGrants = z.infer -/** - * Which screen the shell opened this page for. - * - * Additive, and optional for that reason: a shell built before C1.2 sends no `route`, and the page - * says so rather than painting expo-router's Unmatched screen. It has to cross, because the - * document is served at `/` and refuses every other path, so the page's own location matches no - * route in the tree it carries and there is nothing else to derive the screen from. - * - * `params` is the search half, kept out of `pathname` so neither side has to parse a URL: the page - * builds one, once, and writes it into its history before the first render. - */ -export const BridgeInitRouteSchema = z.object({ - pathname: z - .string() - .min(1) - .max(BRIDGE_MAX_ROUTE_PATHNAME_CHARS) - .regex(BRIDGE_ROUTE_PATHNAME_PATTERN), - params: z - .record( - z.string().min(1).max(BRIDGE_MAX_ROUTE_PARAM_CHARS), - z.string().max(BRIDGE_MAX_ROUTE_PARAM_CHARS) - ) - .refine((params) => Object.keys(params).length <= BRIDGE_MAX_ROUTE_PARAMS) - .optional() -}) - -export type BridgeInitRoute = z.infer +// The route half of `init`, in its own module; re-exported so the envelope stays one import for +// everything that reads a bridge frame. +export { BridgeInitRouteSchema, type BridgeInitRoute } /** * The host the shell opened this page for, minus everything secret about it. @@ -231,7 +207,23 @@ const replyPartSchema = z.object({ }) const BridgeClientMessageSchema = z.discriminatedUnion('type', [ - z.object({ v: versionSchema, type: z.literal('ready') }), + z.object({ + v: versionSchema, + type: z.literal('ready'), + /** + * What this page can be sent beyond its first `init`; the names and why live in + * `bridge-route-update.ts`, which is the only one there is. + * + * Optional, and safe in both directions without a version bump: a page that sends none is + * never sent a second `init`, and a shell that reads none never sends one. An unknown name is + * accepted by the schema and ignored by the shell, which is what a newer page declaring a + * capability this shell has never implemented has to look like. + */ + accepts: z + .array(z.string().min(1).max(BRIDGE_MAX_PAGE_ACCEPT_CHARS)) + .max(BRIDGE_MAX_PAGE_ACCEPTS) + .optional() + }), z.object({ v: versionSchema, type: z.literal('request'), diff --git a/mobile/src/mobile-web-shell/bridge/bridge-init-route.ts b/mobile/src/mobile-web-shell/bridge/bridge-init-route.ts new file mode 100644 index 00000000000..875cd8b27dd --- /dev/null +++ b/mobile/src/mobile-web-shell/bridge/bridge-init-route.ts @@ -0,0 +1,39 @@ +import { z } from 'zod' +import { + BRIDGE_MAX_ROUTE_PARAM_CHARS, + BRIDGE_MAX_ROUTE_PARAMS, + BRIDGE_MAX_ROUTE_PATHNAME_CHARS, + BRIDGE_ROUTE_PATHNAME_PATTERN +} from './bridge-caps' + +/** + * Which screen the shell opened this page for. + * + * Additive, and optional where it is read for that reason: a shell built before C1.2 sends no + * `route`, and the page says so rather than painting expo-router's Unmatched screen. It has to + * cross, because the document is served at `/` and refuses every other path, so the page's own + * location matches no route in the tree it carries and there is nothing else to derive it from. + * + * `params` is the search half, kept out of `pathname` so neither side has to parse a URL: the page + * builds one, once, and writes it into its history before the first render. + * + * Its own module rather than the envelope's, because three other modules want the route without + * the rest of the protocol: the native switches build one, the host holds one and republishes it + * (ruling 33.1), and the page reads one back out of `init`. + */ +export const BridgeInitRouteSchema = z.object({ + pathname: z + .string() + .min(1) + .max(BRIDGE_MAX_ROUTE_PATHNAME_CHARS) + .regex(BRIDGE_ROUTE_PATHNAME_PATTERN), + params: z + .record( + z.string().min(1).max(BRIDGE_MAX_ROUTE_PARAM_CHARS), + z.string().max(BRIDGE_MAX_ROUTE_PARAM_CHARS) + ) + .refine((params) => Object.keys(params).length <= BRIDGE_MAX_ROUTE_PARAMS) + .optional() +}) + +export type BridgeInitRoute = z.infer diff --git a/mobile/src/mobile-web-shell/bridge/bridge-route-update.test.ts b/mobile/src/mobile-web-shell/bridge/bridge-route-update.test.ts index c39b5dd52e7..48ffc96575a 100644 --- a/mobile/src/mobile-web-shell/bridge/bridge-route-update.test.ts +++ b/mobile/src/mobile-web-shell/bridge/bridge-route-update.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest' import { createFakeBridgePortPair } from './bridge-port-pair-test-harness' -import { BRIDGE_ROUTE_UPDATE_ACCEPT, type BridgeInitRoute } from './bridge-envelope' +import type { BridgeInitRoute } from './bridge-envelope' +import { BRIDGE_ROUTE_UPDATE_ACCEPT } from './bridge-route-update' const SESSION = '/h/host-a/session/wt-1' @@ -13,7 +14,10 @@ function sessionRoute(paneKey?: string): BridgeInitRoute { } async function openedOnTheSession(): Promise> { - const pair = createFakeBridgePortPair({ route: sessionRoute(), storage: { 'orca:a': '1' } }) + const pair = createFakeBridgePortPair({ + route: sessionRoute(), + storage: { 'orca:hostDockWidth': '320' } + }) await pair.flush() return pair } @@ -71,7 +75,7 @@ describe('a route update over a re-sent init', () => { pair.host.publishRoute(sessionRoute('pane-1')) await pair.flush() const sent = pair.rpc.requests.find((request) => request.method === 'worktree.list') - sent?.resolve({ ok: true, result: { worktrees: [] }, _meta: {} }) + sent?.resolve({ id: 'r1', ok: true, result: { worktrees: [] } }) await pair.flush() await expect(pending).resolves.toMatchObject({ ok: true }) }) @@ -91,17 +95,25 @@ describe('a route update over a re-sent init', () => { */ it('sends no second init to a page that never declared it accepts one', async () => { const pair = await openedOnTheSession() + expect(pair.readToShell().find((frame) => frame.type === 'ready')).toMatchObject({ + accepts: [BRIDGE_ROUTE_UPDATE_ACCEPT] + }) + // This page, then the same document reloaded as a build that declares nothing -- which is what + // a released page is. The host reads `accepts` off whichever `ready` it last answered. + pair.host.receive(JSON.stringify({ v: 1, type: 'ready' })) + await pair.flush() const framesBefore = pair.toPage.length - const ready = pair.readToShell().find((frame) => frame.type === 'ready') - expect(ready).toMatchObject({ accepts: [BRIDGE_ROUTE_UPDATE_ACCEPT] }) - // The same host, told by a page that declared nothing. - const older = createFakeBridgePortPair({ route: sessionRoute() }) - older.host.receive(JSON.stringify({ v: 1, type: 'ready' })) - await older.flush() - const olderFrames = older.toPage.length - older.host.publishRoute(sessionRoute('pane-1')) - await older.flush() - expect(older.toPage.length).toBe(olderFrames) - expect(pair.toPage.length).toBeGreaterThan(framesBefore - 1) + pair.host.publishRoute(sessionRoute('pane-1')) + await pair.flush() + expect(pair.toPage.length).toBe(framesBefore) + // And the contrast, so the case fails when the gate stops gating rather than when it starts. + pair.host.receive( + JSON.stringify({ v: 1, type: 'ready', accepts: [BRIDGE_ROUTE_UPDATE_ACCEPT] }) + ) + await pair.flush() + const framesAfterReady = pair.toPage.length + pair.host.publishRoute(sessionRoute('pane-2')) + await pair.flush() + expect(pair.toPage.length).toBe(framesAfterReady + 1) }) }) diff --git a/mobile/src/mobile-web-shell/bridge/bridge-route-update.ts b/mobile/src/mobile-web-shell/bridge/bridge-route-update.ts new file mode 100644 index 00000000000..314b63fef1e --- /dev/null +++ b/mobile/src/mobile-web-shell/bridge/bridge-route-update.ts @@ -0,0 +1,59 @@ +import { BridgeInitRouteSchema, type BridgeInitRoute } from './bridge-envelope' +import { shellScreenRouteKey } from '../shell-screen-route' + +/** + * The one thing a page can say it accepts, which is a second `init` for the session it already has. + * + * A notification tap for another pane of the session on screen rewrites one route param of a page + * that is already mounted, and the shell has no push lane to deliver it on: `notify` runs + * page-to-shell only, and a shell-to-page frame kind would need a capability of its own + * (`bridge-audio-verbs.ts` refused one for raw PCM). `init` already carries `route`, is already + * re-sent on every `ready` and is already re-read, so the pane request rides it. + * + * Declared by the page rather than assumed by the shell, in both directions. A page too old to + * name it reads a second `init` as a replacement and settles every request it holds, so a shell + * that sent one unasked would break it; a shell too old to re-send one leaves a newer page exactly + * where it is today. Both degrade to the repeat tap doing nothing, which is what it does now. + */ +export const BRIDGE_ROUTE_UPDATE_ACCEPT = 'route-update' + +/** + * What a host does with a rewritten route: hold it for the next `init`, send one now, or refuse. + * + * `hold` and `send` both move what the host will publish, because a page that reloads inside this + * mount must be handed the newest route on its next `ready` even when it is too old to be sent one + * in flight. Only `send` is a frame, and only a frame lets the caller clear the param it delivered. + */ +export type BridgeRouteUpdate = + | { readonly kind: 'send'; readonly route: BridgeInitRoute } + | { readonly kind: 'hold'; readonly route: BridgeInitRoute } + | { readonly kind: 'refuse'; readonly issue: string } + +/** + * Whether this host may hand its page that route, decided from the route it is already serving. + * + * A different pathname is a different screen, which the shell remounts for; the schema is the + * page's own reader, so a shape it would refuse never leaves. Movement is measured with + * `shellScreenRouteKey` rather than a second spelling of it, so "moved" here means exactly what a + * remount means at the switch. + */ +export function readBridgeRouteUpdate(args: { + held: BridgeInitRoute | null + next: BridgeInitRoute + /** What the page's last `ready` declared. Empty for every page built before this existed. */ + accepts: readonly string[] + /** Whether this host has a served document that has already had an `init`. */ + deliverable: boolean +}): BridgeRouteUpdate { + const { held } = args + const parsed = BridgeInitRouteSchema.safeParse(args.next) + if (held === null || !parsed.success) { + return { kind: 'refuse', issue: held === null ? 'no-route' : 'unreadable-route' } + } + if (parsed.data.pathname !== held.pathname) { + return { kind: 'refuse', issue: 'not-this-screen' } + } + const moved = shellScreenRouteKey(parsed.data) !== shellScreenRouteKey(held) + const sendable = moved && args.deliverable && args.accepts.includes(BRIDGE_ROUTE_UPDATE_ACCEPT) + return { kind: sendable ? 'send' : 'hold', route: parsed.data } +} diff --git a/mobile/src/mobile-web-shell/bridge/bridge-rpc-client-frames.test.ts b/mobile/src/mobile-web-shell/bridge/bridge-rpc-client-frames.test.ts index cc7e87906b2..0768e505bcf 100644 --- a/mobile/src/mobile-web-shell/bridge/bridge-rpc-client-frames.test.ts +++ b/mobile/src/mobile-web-shell/bridge/bridge-rpc-client-frames.test.ts @@ -12,6 +12,7 @@ import { BRIDGE_ACK_INTERVAL_FRAMES } from './bridge-client-subscriptions' import { BRIDGE_PROTOCOL_VERSION, type BridgeHostMessage } from './bridge-envelope' +import { BRIDGE_ROUTE_UPDATE_ACCEPT } from './bridge-route-update' import { BRIDGE_READY_RETRY_MAX_MS, BRIDGE_READY_RETRY_MIN_MS @@ -39,9 +40,11 @@ afterEach(() => { }) describe('bridge client handshake', () => { - it('asks for a session as soon as it exists', () => { + it('asks for a session as soon as it exists, naming what it can be sent', () => { const page = createPageClient() - expect(page.frames()).toEqual([{ v: BRIDGE_PROTOCOL_VERSION, type: 'ready' }]) + expect(page.frames()).toEqual([ + { v: BRIDGE_PROTOCOL_VERSION, type: 'ready', accepts: [BRIDGE_ROUTE_UPDATE_ACCEPT] } + ]) }) it('keeps asking on a widening backoff until init answers', () => { diff --git a/mobile/src/mobile-web-shell/bridge/bridge-rpc-client.ts b/mobile/src/mobile-web-shell/bridge/bridge-rpc-client.ts index 3961988bfe8..6b76013ccbf 100644 --- a/mobile/src/mobile-web-shell/bridge/bridge-rpc-client.ts +++ b/mobile/src/mobile-web-shell/bridge/bridge-rpc-client.ts @@ -26,11 +26,13 @@ import { createBridgeClientNotifications } from './bridge-client-notifications' import { BridgeClientRequests } from './bridge-client-requests' import { BridgeClientSubscriptions } from './bridge-client-subscriptions' import { isBridgeNativeMethod, type BridgeNativeVerb } from './bridge-native-verbs' +import { BRIDGE_ROUTE_UPDATE_ACCEPT } from './bridge-route-update' import { BRIDGE_PROTOCOL_VERSION, type BridgeClientMessage, type BridgeConnectionSnapshot, - type BridgeHostMessage + type BridgeHostMessage, + type BridgeInitRoute } from './bridge-envelope' export type { BridgeShellSession } from './bridge-client-session' @@ -60,6 +62,14 @@ export type BridgeRpcClientOptions = { export type BridgeRpcClient = RpcClient & { /** Fires once `init` has landed, immediately if it already has. Mount no screen before it. */ onReady: (listener: () => void) => () => void + /** + * Fires each time the shell rewrites a param of the screen this page is already on, which is a + * second `init` for the session it already holds. Never for the first one. + * + * One delivery per tap rather than one per distinct value: a notification tap for the pane + * already showing is a real request, and a listener that deduplicated by value would lose it. + */ + onRouteUpdate: (listener: (route: BridgeInitRoute | null) => void) => () => void getShellSession: () => BridgeShellSession | null /** * Asks the shell to open a screen this page does not render. False when the shell granted no @@ -126,6 +136,8 @@ export function createBridgeRpcClient(options: BridgeRpcClientOptions): BridgeRp const requests = new BridgeClientRequests() const cache = new BridgeConnectionCache() const readyListeners = new Set<() => void>() + /** Standing, unlike `readyListeners`: a route can move any number of times inside one session. */ + const routeUpdateListeners = new Set<(route: BridgeInitRoute | null) => void>() let session: BridgeShellSession | null = null let closed = false let idCounter = 0 @@ -184,7 +196,9 @@ export function createBridgeRpcClient(options: BridgeRpcClientOptions): BridgeRp }) const handshake = createBridgeInitHandshake(() => { - sendFrame({ v: BRIDGE_PROTOCOL_VERSION, type: 'ready' }) + // Declared on every ask, because the shell reads it off whichever `ready` it answers: this + // page build knows how to take a second `init` for the session it already holds. + sendFrame({ v: BRIDGE_PROTOCOL_VERSION, type: 'ready', accepts: [BRIDGE_ROUTE_UPDATE_ACCEPT] }) }) /** @@ -208,21 +222,46 @@ export function createBridgeRpcClient(options: BridgeRpcClientOptions): BridgeRp return held } - /** A second `init` is ordinary: the shell answers every `ready`, and a page that re-asked hears - * its own session again. A different id is not, and nothing the page held survives it. */ + /** + * A second `init` is ordinary: the shell answers every `ready`, and a page that re-asked hears + * its own session again. A different id is not, and nothing the page held survives it. + * + * For the same id it is a route update and nothing else (ruling 33.1). The screen is the one + * already mounted, so every request in flight, every subscription, the storage snapshot the page + * booted from and the generation stay exactly as they are, and only `route` moves — which is how + * a notification tap for another pane of this session reaches a page that is already on it. The + * session object is rebuilt only when the id changes, so the identity of what the page holds is + * itself the assertion that nothing was replaced. + */ function acceptInit(message: Extract): void { handshake.stop() - if (session !== null && session.sessionId !== message.sessionId) { + const held = session + const update = held !== null && held.sessionId === message.sessionId ? held : null + if (held !== null && update === null) { const replaced = new BridgeShellReplacedError() requests.closeAll(replaced) subscriptions.failAll(replaced.message) } - session = readShellSession(message) + // Updated in place for the session the page already holds, rebuilt for a different one. The + // identity of what survives is the assertion: same object, so the storage snapshot the page + // booted from is the one it keeps. + session = + update === null ? readShellSession(message) : { ...update, route: message.route ?? null } + // Re-primed either way, because a second `init` is also how the page recovers a cache it has + // refused a `state` frame into: the shell rebuilt under it publishes a generation the page's + // own is newer than, and this frame is what puts the two back in step. A pane update carries + // the same snapshot it already holds, which `prime` answers with no transition. cache.prime(message.connection) for (const listener of readyListeners) { listener() } readyListeners.clear() + if (update === null) { + return + } + for (const listener of routeUpdateListeners) { + listener(session.route) + } } /** A shell rebuilt under the page: what the cache holds is for a client that is already gone. */ @@ -398,6 +437,14 @@ export function createBridgeRpcClient(options: BridgeRpcClientOptions): BridgeRp readyListeners.delete(listener) } }, + // Not fired on subscribe, and no replay: a listener that arrives late reads the route it wants + // off `getShellSession`, and what this publishes is the fact that one moved. + onRouteUpdate: (listener) => { + routeUpdateListeners.add(listener) + return () => { + routeUpdateListeners.delete(listener) + } + }, getShellSession: () => session } } diff --git a/mobile/src/mobile-web-shell/use-mobile-web-shell-bridge.ts b/mobile/src/mobile-web-shell/use-mobile-web-shell-bridge.ts index fde2ee6f455..2f279b826b1 100644 --- a/mobile/src/mobile-web-shell/use-mobile-web-shell-bridge.ts +++ b/mobile/src/mobile-web-shell/use-mobile-web-shell-bridge.ts @@ -46,6 +46,12 @@ export type MobileWebShellBridgeView = { readonly bridgeEnabled: boolean readonly viewRef: (handle: OrcaMobileWebShellViewHandle | null) => void readonly onBridgeMessage: (event: MobileWebShellBridgeMessageEvent) => void + /** + * Hands the mounted host a rewritten route for the screen it is already serving, and answers + * whether a frame went out. False whenever there is no host yet, which the caller has to know: + * a param it clears after a publish nobody made is a request the page never heard. + */ + readonly publishRoute: (route: BridgeInitRoute) => boolean } /** @@ -100,9 +106,15 @@ export function useMobileWebShellBridge(args: { const buildId = ready?.buildId ?? null const viewRef = useRef(null) const hostRef = useRef(null) - // Fixed for the life of one host: the page routes once, before its first render, so a route that - // changed afterwards would have nothing left to change. Held in a ref for that reason — an inline - // object in the deps would rebuild the host on every render and settle its pendings each time. + // Held in a ref rather than in the deps: an inline object there would rebuild the host on every + // render and settle its pendings each time. The host reads this once, when it is built. + // + // It is not the whole story any more (ruling 33.1). A same-path param change used to be + // unreachable — the page routes once, before its first render, so a route that changed + // afterwards had nothing left to change, and every switch keyed on the whole route to make one + // a remount. The session switch does not: a notification tap for another pane of the session on + // screen is a tab switch, so it keeps `paneKey` out of its key and hands the change to + // `publishRoute` below, which re-sends `init` to a page that said it takes one. const routeRef = useRef(args.route) const pageRoutesRef = useRef(args.pageRoutes) const pageRouteGrantsRef = useRef(args.pageRouteGrants) @@ -239,6 +251,17 @@ export function useMobileWebShellBridge(args: { mounted.host.receive(event.nativeEvent.json) }, [sessionId] + ), + // Fenced on the session the same way inbound frames are: a host left over from a session this + // render has moved past must not be handed this one's route. + publishRoute: useCallback( + (route: BridgeInitRoute) => { + const mounted = hostRef.current + return ( + mounted !== null && mounted.sessionId === sessionId && mounted.host.publishRoute(route) + ) + }, + [sessionId] ) } } diff --git a/mobile/src/session/use-notification-pane-navigation.web.ts b/mobile/src/session/use-notification-pane-navigation.web.ts new file mode 100644 index 00000000000..b9cf3ede3eb --- /dev/null +++ b/mobile/src/session/use-notification-pane-navigation.web.ts @@ -0,0 +1,66 @@ +import { useEffect, useState } from 'react' +import { usePageBridgeClient } from '../transport/client-context.web' +import type { MobileSessionTab } from './mobile-session-route-types' +import { notificationPaneTab } from './use-notification-pane-navigation' + +export { notificationPaneTab } from './use-notification-pane-navigation' + +/** One tap. The ordinal is what tells a repeat tap for the pane already showing from a re-render. */ +type PaneRequest = { readonly paneKey: string; readonly ordinal: number } + +/** + * Web sibling: the pane a notification tap asked for, read off the shell rather than off a router. + * + * On the page there is no native route to read `paneKey` from and no native param to write back: + * the page is one document served at `/`, and `setParams` here would rewrite the document's own + * history entry while the app's route kept the spent tap. The shell delivers the request instead, + * as a re-sent `init` for the session this page already holds (ruling 33.1), and clears its own + * param once this page has been handed one. + * + * Counted rather than compared, because the tap that matters most is the one that looks like + * nothing changed: a notification for the pane already on screen is a real request, and a hook + * that deduplicated by value would drop exactly that one. The ordinal makes each delivery its own + * request, and the effect below consumes it. + */ +export function useNotificationPaneNavigation({ + sessionTabs, + terminalsLoaded, + switchSessionTab +}: { + sessionTabs: MobileSessionTab[] + terminalsLoaded: boolean + switchSessionTab: (tab: MobileSessionTab) => void +}) { + const client = usePageBridgeClient() + // Seeded from the route this page was opened on: a tap that opened the session arrives in the + // first `init` and never as an update, so a hook that only listened would lose it. + const [request, setRequest] = useState(() => { + const paneKey = client.getShellSession()?.route?.params?.paneKey ?? '' + return paneKey === '' ? null : { paneKey, ordinal: 0 } + }) + + useEffect( + () => + client.onRouteUpdate((route) => { + const paneKey = route?.params?.paneKey ?? '' + if (paneKey === '') { + return + } + setRequest((held) => ({ paneKey, ordinal: (held?.ordinal ?? 0) + 1 })) + }), + [client] + ) + + useEffect(() => { + if (request === null || !terminalsLoaded) { + return + } + const tab = notificationPaneTab(sessionTabs, request.paneKey) + // Consumed even when the pane was closed, for the reason the native hook consumes its param: + // a request left standing is one a later render would serve. + setRequest(null) + if (tab) { + switchSessionTab(tab) + } + }, [request, sessionTabs, switchSessionTab, terminalsLoaded]) +}