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])
+}