mirror of
https://github.com/stablyai/orca.git
synced 2026-09-21 16:02:20 +00:00
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:
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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])
|
||||
}
|
||||
Reference in New Issue
Block a user