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
This commit is contained in:
Jinwoo-H
2026-09-21 07:58:25 -04:00
parent f2a8804866
commit 04eee129ef
14 changed files with 465 additions and 80 deletions
+25 -9
View File
@@ -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 = <MobileSessionRouteScreen />
// 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 (
<MobileWebShellScreen
key={shellScreenRouteKey(route)}
key={shellScreenRouteKey({ pathname: route.pathname, params: identity })}
hostId={hostId}
route={route}
fallback={native}
onRouteDelivered={clearPaneKey}
/>
)
}
@@ -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.
@@ -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. */
@@ -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<BridgeClientMessage, { type: 'ready' }>) => 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
}
}
}
+27 -14
View File
@@ -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<BridgeClientMessage, { type: 'notify' }>
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:<any host>` 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
}
}
@@ -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
@@ -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<typeof BridgeGrantsSchema>
/**
* 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<typeof BridgeInitRouteSchema>
// 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'),
@@ -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<typeof BridgeInitRouteSchema>
@@ -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<ReturnType<typeof createFakeBridgePortPair>> {
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)
})
})
@@ -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 }
}
@@ -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', () => {
@@ -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<BridgeHostMessage, { type: 'init' }>): 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
}
}
@@ -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<MountedView | null>(null)
const hostRef = useRef<MountedHost | null>(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]
)
}
}
@@ -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<PaneRequest | null>(() => {
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])
}