mirror of
https://github.com/stablyai/orca.git
synced 2026-10-08 08:02:32 +00:00
fix(mobile): tie the hosted page broker to the document lifecycle
A hosted document is replaced without the shell session, its build or the view epoch moving: a native-route excursion deactivates the view to about:blank and reloads on return, the route error boundary reloads in place, and a re-attached view reloads its URL. The previous page's broker survived all three, and its subscription records kept holding every per-operation grant (one for workspace, account and source control), so the new document's subscribes were refused with rate_limited for the rest of the shell session and the host kept publishing to records nobody read. The shell owns the document lifecycle, so it now observes it: the native views report every document load start (Android through onPageStarted, iOS through didStartProvisionalNavigation, which is the only signal a page-initiated reload gives), and a load that displaces a document that finished loading bumps a page document epoch. The broker hook retires on that epoch exactly as it does on a view-epoch bump, before the incoming document is initialized. A first load and the duplicate load-start events for one navigation are not replacements and do not retire anything. Android also never destroyed its WebView: OnViewDestroys only dropped the registry entry, so every package swap, host switch and route exit leaked a renderer process, a JavaScript context, the message listener and both script handlers. Unmount now destroys it; deactivation still only parks it. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
This commit is contained in:
+14
-14
@@ -13,6 +13,7 @@ import {
|
||||
useMobileWebCapabilityBroker,
|
||||
type MobileWebBrokerPageIdentity
|
||||
} from '../src/mobile-web/use-mobile-web-capability-broker'
|
||||
import { useMobileWebPageDocument } from '../src/mobile-web/use-mobile-web-page-document'
|
||||
import { MOBILE_WEB_PRODUCTION_GRANTS } from '../src/mobile-web/mobile-web-production-grants'
|
||||
import { MobileWebHealthDeadline } from '../src/mobile-web/mobile-web-health-deadline'
|
||||
import { useMobileWebAlertSafePackageSession } from '../src/mobile-web/use-mobile-web-alert-safe-package-session'
|
||||
@@ -49,7 +50,6 @@ export default function HybridScreen() {
|
||||
const params = useLocalSearchParams<{ hostId?: string }>()
|
||||
const viewRef = useRef<MobileWebShellViewRef>(null)
|
||||
const activeSessionIdRef = useRef<string | undefined>(undefined)
|
||||
const initializedSessionRef = useRef<string | undefined>(undefined)
|
||||
const healthDeadlineRef = useRef(new MobileWebHealthDeadline(10_000))
|
||||
const brokerRef = useRef<MobileWebCapabilityBroker | null>(null)
|
||||
const postInitRef = useRef<() => Promise<void>>(() => Promise.resolve())
|
||||
@@ -60,7 +60,6 @@ export default function HybridScreen() {
|
||||
)
|
||||
const { hosts, hostsLoading, hostLoadError, refreshHosts } = useMobileWebHostCatalog()
|
||||
const [selectedHostId, setSelectedHostId] = useState<string | undefined>(params.hostId)
|
||||
const [pageReadySessionId, setPageReadySessionId] = useState<string>()
|
||||
const [brokerSessionId, setBrokerSessionId] = useState<string>()
|
||||
const [hostedViewActive, setHostedViewActive] = useState(true)
|
||||
const selectHost = useCallback((hostId: string | undefined) => setSelectedHostId(hostId), [])
|
||||
@@ -140,14 +139,12 @@ export default function HybridScreen() {
|
||||
}
|
||||
}, [params.hostId])
|
||||
|
||||
// A view-epoch bump replaces the document, so every page-scoped grant retires with it.
|
||||
useEffect(() => {
|
||||
initializedSessionRef.current = undefined
|
||||
nativeRouteHandoffRef.current.clear()
|
||||
setPageReadySessionId(undefined)
|
||||
healthDeadlineRef.current.clear()
|
||||
return () => healthDeadlineRef.current.clear()
|
||||
}, [session?.sessionId, viewEpoch])
|
||||
const pageDocument = useMobileWebPageDocument({
|
||||
sessionId: session?.sessionId,
|
||||
viewEpoch,
|
||||
healthDeadlineRef,
|
||||
routeHandoffRef: nativeRouteHandoffRef
|
||||
})
|
||||
|
||||
const postToWeb = useCallback(async (message: MobileWebBridgeShellMessage) => {
|
||||
if (responseDropRef.current.shouldDrop(message)) {
|
||||
@@ -229,6 +226,7 @@ export default function HybridScreen() {
|
||||
sessionId: session?.sessionId,
|
||||
buildId: session?.buildId,
|
||||
viewEpoch,
|
||||
documentEpoch: pageDocument.epoch,
|
||||
createBroker,
|
||||
onBrokerReady,
|
||||
onBrokerSessionChange: setBrokerSessionId
|
||||
@@ -243,7 +241,7 @@ export default function HybridScreen() {
|
||||
if (!current || activeSessionIdRef.current !== current.sessionId || !brokerRef.current) {
|
||||
return
|
||||
}
|
||||
initializedSessionRef.current = current.sessionId
|
||||
pageDocument.initializedSessionRef.current = current.sessionId
|
||||
healthDeadlineRef.current.arm(current.sessionId, (sessionId) => {
|
||||
if (activeSessionIdRef.current === sessionId) {
|
||||
void onHealthTimeout(sessionId)
|
||||
@@ -269,7 +267,7 @@ export default function HybridScreen() {
|
||||
useEffect(() => {
|
||||
brokerRef.current?.updateConnectionState(mobileWebBridgeConnectionState(state))
|
||||
const current = session
|
||||
if (!current || initializedSessionRef.current !== current.sessionId) {
|
||||
if (!current || pageDocument.initializedSessionRef.current !== current.sessionId) {
|
||||
return
|
||||
}
|
||||
void postToWeb({
|
||||
@@ -301,7 +299,7 @@ export default function HybridScreen() {
|
||||
if (parsed.value.type === 'ready') {
|
||||
// `ready` acknowledges init; echoing init here starves the health frame.
|
||||
if (activeSessionIdRef.current === current.sessionId) {
|
||||
setPageReadySessionId(current.sessionId)
|
||||
pageDocument.setReadySessionId(current.sessionId)
|
||||
}
|
||||
} else if (!backMessageHandled) {
|
||||
if (parsed.value.type === 'health') {
|
||||
@@ -356,7 +354,7 @@ export default function HybridScreen() {
|
||||
selectedHostId,
|
||||
connectionState: state,
|
||||
shellContext,
|
||||
pageReadySessionId,
|
||||
pageReadySessionId: pageDocument.readySessionId,
|
||||
brokerSessionId,
|
||||
getBroker,
|
||||
selectHost,
|
||||
@@ -394,7 +392,9 @@ export default function HybridScreen() {
|
||||
showWarning('That didn’t work. Try again.', 'recovery_action_failed')
|
||||
}
|
||||
onBridgeMessage={(message) => void handleBridgeMessage(message)}
|
||||
onDocumentLoadStarted={pageDocument.onLoadStart}
|
||||
onPageLoaded={() => {
|
||||
pageDocument.onLoaded()
|
||||
hardwareBackHandoff.resetPage()
|
||||
void postInit()
|
||||
}}
|
||||
|
||||
+2
@@ -94,6 +94,8 @@ class ExpoMobileWebShellModule : Module() {
|
||||
|
||||
OnViewDestroys { view ->
|
||||
sessionViews.entries.removeAll { it.value === view }
|
||||
// Deregister first: nothing may reach a WebView whose renderer process is being torn down.
|
||||
view.destroy()
|
||||
}
|
||||
|
||||
AsyncFunction("activateSessionView") { view: MobileWebShellView, sessionId: String ->
|
||||
|
||||
+37
-1
@@ -2,6 +2,7 @@ package expo.modules.mobilewebshell
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.Color
|
||||
import android.net.Uri
|
||||
import android.view.View
|
||||
@@ -76,6 +77,7 @@ internal class MobileWebShellView(
|
||||
private var networkBlockerScriptHandler: ScriptHandler? = null
|
||||
private var debugProbeScriptHandler: ScriptHandler? = null
|
||||
private var bridgeMessageListenerAttached = false
|
||||
private var destroyed = false
|
||||
private var webView: WebView
|
||||
|
||||
init {
|
||||
@@ -125,6 +127,7 @@ internal class MobileWebShellView(
|
||||
}
|
||||
|
||||
fun setSessionId(sessionId: String?) {
|
||||
if (destroyed) return
|
||||
if (sessionId == null) {
|
||||
deactivateSessionView()
|
||||
return
|
||||
@@ -158,6 +161,7 @@ internal class MobileWebShellView(
|
||||
}
|
||||
|
||||
fun deactivateSessionView() {
|
||||
if (destroyed) return
|
||||
removeBridgeMessageListener()
|
||||
networkBlockerScriptHandler?.remove()
|
||||
networkBlockerScriptHandler = null
|
||||
@@ -176,6 +180,7 @@ internal class MobileWebShellView(
|
||||
require(message.toByteArray(Charsets.UTF_8).size <= MOBILE_WEB_MESSAGE_BYTE_LIMIT) {
|
||||
"mobile_web_bridge_message_too_large"
|
||||
}
|
||||
if (destroyed) return
|
||||
webView.evaluateJavascript(
|
||||
"""
|
||||
(function(value){
|
||||
@@ -196,14 +201,36 @@ internal class MobileWebShellView(
|
||||
if (activeSessionId == sessionId) postMessage(message)
|
||||
}
|
||||
|
||||
/**
|
||||
* Detaching only parks the WebView — a deactivated view is reactivated in place — so the renderer
|
||||
* process, the JavaScript context and both script handlers only go away here. Expo calls this once
|
||||
* the view is unmounted, which is what a package swap, a host switch and a route exit all do.
|
||||
*/
|
||||
fun destroy() {
|
||||
if (destroyed) return
|
||||
destroyed = true
|
||||
removeBridgeMessageListener()
|
||||
networkBlockerScriptHandler?.remove()
|
||||
networkBlockerScriptHandler = null
|
||||
debugProbeScriptHandler?.remove()
|
||||
debugProbeScriptHandler = null
|
||||
activeSessionId = null
|
||||
documentLoaded = false
|
||||
webView.stopLoading()
|
||||
webView.loadUrl("about:blank")
|
||||
removeView(webView)
|
||||
webView.destroy()
|
||||
}
|
||||
|
||||
override fun onDetachedFromWindow() {
|
||||
removeBridgeMessageListener()
|
||||
webView.stopLoading()
|
||||
if (!destroyed) webView.stopLoading()
|
||||
super.onDetachedFromWindow()
|
||||
}
|
||||
|
||||
override fun onAttachedToWindow() {
|
||||
super.onAttachedToWindow()
|
||||
if (destroyed) return
|
||||
attachWebView()
|
||||
val sessionId = activeSessionId ?: return
|
||||
addBridgeMessageListener(sessionId)
|
||||
@@ -284,6 +311,15 @@ internal class MobileWebShellView(
|
||||
return !allowed
|
||||
}
|
||||
|
||||
override fun onPageStarted(view: WebView, url: String, favicon: Bitmap?) {
|
||||
// The page can replace its own document (the route error boundary reloads on a failed chunk),
|
||||
// and that is the only signal the shell gets. Every load start is reported so the shell can
|
||||
// retire the outgoing page's grants before the new document initializes.
|
||||
if (!isAllowedDocumentRequestUrl(Uri.parse(url))) return
|
||||
documentLoaded = false
|
||||
onLoadState(mapOf("state" to "loading"))
|
||||
}
|
||||
|
||||
override fun onPageFinished(view: WebView, url: String) {
|
||||
if (isAllowedDocumentUrl(Uri.parse(url))) {
|
||||
documentLoaded = true
|
||||
|
||||
@@ -566,6 +566,14 @@ final class MobileWebShellView: ExpoView, WKNavigationDelegate, WKUIDelegate,
|
||||
decisionHandler(allowed ? .allow : .cancel)
|
||||
}
|
||||
|
||||
func webView(_ webView: WKWebView, didStartProvisionalNavigation navigation: WKNavigation!) {
|
||||
// The page can replace its own document (the route error boundary reloads on a failed chunk),
|
||||
// and that is the only signal the shell gets. Every load start is reported so the shell can
|
||||
// retire the outgoing page's grants before the new document initializes.
|
||||
guard activeSessionId != nil else { return }
|
||||
onLoadState(["state": "loading"])
|
||||
}
|
||||
|
||||
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
|
||||
guard isAllowedDocumentUrl(webView.url) else { return }
|
||||
onLoadState(["state": "loaded"])
|
||||
|
||||
@@ -35,6 +35,7 @@ type MobileWebHybridShellPresentationProps = {
|
||||
onClearCache: () => void | Promise<void>
|
||||
onRecoveryFailure: () => void
|
||||
onBridgeMessage: (message: string) => void
|
||||
onDocumentLoadStarted: () => void
|
||||
onPageLoaded: () => void
|
||||
onLoadFailed: (reason: string | undefined) => void
|
||||
onNavigationBlocked: () => void
|
||||
@@ -57,6 +58,7 @@ export function MobileWebHybridShellPresentation({
|
||||
onClearCache,
|
||||
onRecoveryFailure,
|
||||
onBridgeMessage,
|
||||
onDocumentLoadStarted,
|
||||
onPageLoaded,
|
||||
onLoadFailed,
|
||||
onNavigationBlocked,
|
||||
@@ -141,6 +143,12 @@ export function MobileWebHybridShellPresentation({
|
||||
sessionId={hostedViewActive ? session.sessionId : null}
|
||||
onBridgeMessage={(event) => onBridgeMessage(event.nativeEvent.data)}
|
||||
onLoadState={(event) => {
|
||||
// A load only ever starts because the document is being replaced, and the outgoing
|
||||
// page's grants have to retire before the incoming one initializes.
|
||||
if (event.nativeEvent.state === 'loading') {
|
||||
onDocumentLoadStarted()
|
||||
return
|
||||
}
|
||||
if (event.nativeEvent.state === 'loaded') {
|
||||
onPageLoaded()
|
||||
return
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const ANDROID_VIEW = source(
|
||||
'../../packages/expo-mobile-web-shell/android/src/main/java/expo/modules/mobilewebshell/MobileWebShellView.kt'
|
||||
)
|
||||
const ANDROID_MODULE = source(
|
||||
'../../packages/expo-mobile-web-shell/android/src/main/java/expo/modules/mobilewebshell/ExpoMobileWebShellModule.kt'
|
||||
)
|
||||
const IOS_VIEW = source('../../packages/expo-mobile-web-shell/ios/MobileWebShellView.swift')
|
||||
|
||||
describe('hosted WebView document lifecycle', () => {
|
||||
// The shell owns the document lifecycle, but a page can replace its own document — the route
|
||||
// error boundary reloads in place — and the load start is the only signal that reaches the shell.
|
||||
it('reports a document load the page itself starts, on both platforms', () => {
|
||||
expect(block(ANDROID_VIEW, 'override fun onPageStarted')).toContain(
|
||||
'onLoadState(mapOf("state" to "loading"))'
|
||||
)
|
||||
expect(
|
||||
block(IOS_VIEW, 'func webView(_ webView: WKWebView, didStartProvisionalNavigation')
|
||||
).toContain('onLoadState(["state": "loading"])')
|
||||
})
|
||||
|
||||
// Deactivation parks the WebView for a reactivation in place, so only an unmount may destroy it.
|
||||
it('destroys the Android WebView on unmount and never on deactivation', () => {
|
||||
expect(block(ANDROID_MODULE, 'OnViewDestroys')).toContain('view.destroy()')
|
||||
expect(block(ANDROID_VIEW, 'fun destroy()')).toContain('webView.destroy()')
|
||||
expect(block(ANDROID_VIEW, 'fun deactivateSessionView()')).not.toContain('webView.destroy()')
|
||||
expect(block(ANDROID_VIEW, 'override fun onDetachedFromWindow()')).not.toContain(
|
||||
'webView.destroy()'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
function source(relativePath: string): string {
|
||||
return readFileSync(fileURLToPath(new URL(relativePath, import.meta.url)), 'utf8')
|
||||
}
|
||||
|
||||
/** The declaration through the brace that closes it, so a match cannot leak into the next member. */
|
||||
function block(text: string, header: string): string {
|
||||
const start = text.indexOf(header)
|
||||
expect(start, `missing ${header}`).toBeGreaterThanOrEqual(0)
|
||||
const indent = text.slice(0, start).split('\n').at(-1) ?? ''
|
||||
const end = text.indexOf(`\n${indent}}`, start)
|
||||
return text.slice(start, end === -1 ? undefined : end)
|
||||
}
|
||||
@@ -106,7 +106,7 @@ describe('mobile native shell route ownership', () => {
|
||||
const readyBranch = hybridShell.match(
|
||||
/if \(parsed\.value\.type === 'ready'\) \{([\s\S]*?)\} else if/
|
||||
)?.[1]
|
||||
expect(readyBranch).toContain('setPageReadySessionId(current.sessionId)')
|
||||
expect(readyBranch).toContain('setReadySessionId(current.sessionId)')
|
||||
expect(readyBranch).not.toContain('postInit')
|
||||
})
|
||||
|
||||
|
||||
@@ -0,0 +1,264 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { createElement, useRef, type FunctionComponent } from 'react'
|
||||
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
MOBILE_WEB_BRIDGE_PROTOCOL_VERSION,
|
||||
type MobileWebBridgePageMessage,
|
||||
type MobileWebBridgeShellMessage
|
||||
} from '../../../src/shared/mobile-web/bridge-contract'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import { MobileWebCapabilityBroker } from './mobile-web-capability-broker'
|
||||
import { MobileWebHealthDeadline } from './mobile-web-health-deadline'
|
||||
import { MobileWebHybridShellPresentation } from './MobileWebHybridShellPresentation'
|
||||
import { MobileWebNativeRouteHandoff } from './mobile-web-native-route-handoff'
|
||||
import { useMobileWebCapabilityBroker } from './use-mobile-web-capability-broker'
|
||||
import { useMobileWebPageDocument } from './use-mobile-web-page-document'
|
||||
|
||||
vi.mock('react-native', () => ({
|
||||
ActivityIndicator: 'ActivityIndicator',
|
||||
Pressable: 'Pressable',
|
||||
StyleSheet: { create: (styles: Record<string, unknown>) => styles },
|
||||
Text: 'Text',
|
||||
View: 'View'
|
||||
}))
|
||||
vi.mock('react-native-safe-area-context', () => ({
|
||||
useSafeAreaInsets: () => ({ top: 0, bottom: 0, left: 0, right: 0 })
|
||||
}))
|
||||
vi.mock('lucide-react-native', () => ({
|
||||
ChevronLeft: 'ChevronLeft',
|
||||
MonitorSmartphone: 'MonitorSmartphone'
|
||||
}))
|
||||
vi.mock('@orca/expo-mobile-web-shell', () => ({ MobileWebShellView: 'MobileWebShellView' }))
|
||||
|
||||
const noop = (): void => {}
|
||||
const SESSION_ID = 'S'.repeat(43)
|
||||
const BUILD_ID = 'a'.repeat(64)
|
||||
|
||||
// A native-route excursion, the route error boundary's reload and a re-attach all replace the
|
||||
// document while the shell session, its build and the view epoch stand still. The previous page's
|
||||
// broker used to survive that, and its records held every per-operation grant (one for workspace),
|
||||
// so the new document was refused with rate_limited for the life of the shell session.
|
||||
describe('hosted document replacement', () => {
|
||||
let renderer: ReactTestRenderer | null = null
|
||||
let harness: ReturnType<typeof createHarness>
|
||||
|
||||
beforeEach(() => {
|
||||
harness = createHarness()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
act(() => renderer?.unmount())
|
||||
renderer = null
|
||||
})
|
||||
|
||||
it('re-subscribes after a native-route excursion reloads the page', async () => {
|
||||
await mount()
|
||||
await loadDocument()
|
||||
await handle(subscribeRequest('A', 'Z'))
|
||||
expect(harness.subscribe).toHaveBeenCalledOnce()
|
||||
|
||||
// The excursion deactivates the view (about:blank) and reactivates it on return; the page is
|
||||
// never unmounted, so the shell only ever sees the reload the reactivation starts.
|
||||
await setHostedViewActive(false)
|
||||
await setHostedViewActive(true)
|
||||
await loadDocument()
|
||||
await handle(subscribeRequest('B', 'Y'))
|
||||
|
||||
expect(harness.unsubscribe).toHaveBeenCalledOnce()
|
||||
expect(harness.subscribe).toHaveBeenCalledTimes(2)
|
||||
expect(errorFor(harness.messages, 'B')).toEqual([])
|
||||
})
|
||||
|
||||
it('re-subscribes after the page reloads itself in place', async () => {
|
||||
await mount()
|
||||
await loadDocument()
|
||||
await handle(subscribeRequest('A', 'Z'))
|
||||
|
||||
// What the route error boundary's recovery button does: same view, same session, new document.
|
||||
// The duplicate `loading` is what the shell really posts — once for the reload it starts and
|
||||
// once for the navigation the page started — and the pair is still a single new page.
|
||||
await emitLoadState({ state: 'loading' })
|
||||
await emitLoadState({ state: 'loading' })
|
||||
await emitLoadState({ state: 'loaded' })
|
||||
await handle(subscribeRequest('B', 'Y'))
|
||||
|
||||
expect(harness.unsubscribe).toHaveBeenCalledOnce()
|
||||
expect(harness.created).toBe(2)
|
||||
expect(errorFor(harness.messages, 'B')).toEqual([])
|
||||
})
|
||||
|
||||
it('never retires the broker that is about to serve the first document', async () => {
|
||||
await mount()
|
||||
const live = harness.brokerRef.current
|
||||
|
||||
// The shell posts `loading` for the first load too, and more than once for the same load. A
|
||||
// page that has not loaded yet has nothing to retire, and retiring here would drop the grants
|
||||
// out from under the document that is loading.
|
||||
await emitLoadState({ state: 'loading' })
|
||||
await emitLoadState({ state: 'loading' })
|
||||
await emitLoadState({ state: 'loaded' })
|
||||
await handle(subscribeRequest('A', 'Z'))
|
||||
|
||||
expect(harness.unsubscribe).not.toHaveBeenCalled()
|
||||
expect(harness.created).toBe(1)
|
||||
expect(harness.brokerRef.current).toBe(live)
|
||||
expect(errorFor(harness.messages, 'A')).toEqual([])
|
||||
})
|
||||
|
||||
it('is the wiring the hosted screen uses', () => {
|
||||
// The harness above composes the two seams by hand; this pins the screen to the same pair.
|
||||
const source = readFileSync(
|
||||
fileURLToPath(new URL('../../app/hybrid.tsx', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
|
||||
expect(source).toContain('documentEpoch: pageDocument.epoch')
|
||||
expect(source).toContain('onDocumentLoadStarted={pageDocument.onLoadStart}')
|
||||
expect(source).toContain('pageDocument.onLoaded()')
|
||||
})
|
||||
|
||||
async function mount(): Promise<void> {
|
||||
await act(async () => {
|
||||
renderer = create(createElement(harness.Shell, { hostedViewActive: true }))
|
||||
})
|
||||
}
|
||||
|
||||
async function setHostedViewActive(hostedViewActive: boolean): Promise<void> {
|
||||
await act(async () => {
|
||||
renderer?.update(createElement(harness.Shell, { hostedViewActive }))
|
||||
})
|
||||
}
|
||||
|
||||
async function loadDocument(): Promise<void> {
|
||||
await emitLoadState({ state: 'loading' })
|
||||
await emitLoadState({ state: 'loaded' })
|
||||
}
|
||||
|
||||
async function emitLoadState(nativeEvent: { state: string; reason?: string }): Promise<void> {
|
||||
const shell = renderer!.root.findByType('MobileWebShellView' as never)
|
||||
await act(async () => {
|
||||
shell.props.onLoadState({ nativeEvent })
|
||||
})
|
||||
}
|
||||
|
||||
async function handle(message: MobileWebBridgePageMessage): Promise<void> {
|
||||
await act(async () => {
|
||||
await harness.brokerRef.current?.handle(message)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
function createHarness() {
|
||||
const messages: MobileWebBridgeShellMessage[] = []
|
||||
const unsubscribe = vi.fn()
|
||||
const subscribe = vi.fn<RpcClient['subscribe']>(() => unsubscribe)
|
||||
const client = {
|
||||
sendRequest: vi.fn<RpcClient['sendRequest']>(),
|
||||
subscribe,
|
||||
sendTerminalBinaryFrame: vi.fn(() => true)
|
||||
} as unknown as RpcClient
|
||||
const brokerRef: { current: MobileWebCapabilityBroker | null } = { current: null }
|
||||
// Stable like hybrid.tsx's useCallback: a fresh identity per render would retire the broker on
|
||||
// every render and hide whether the document epoch is doing the work.
|
||||
const createBroker = (page: { sessionId: string; buildId: string }) => {
|
||||
state.created += 1
|
||||
return new MobileWebCapabilityBroker({
|
||||
context: { shellSessionId: page.sessionId, buildId: page.buildId },
|
||||
getClient: () => client,
|
||||
isConnected: () => true,
|
||||
isActive: () => true,
|
||||
postMessage: (message) => void messages.push(message),
|
||||
nativeAuthority: {
|
||||
clipboardAvailability: vi.fn(),
|
||||
hapticFeedback: vi.fn(),
|
||||
clipboardWrite: vi.fn(),
|
||||
openExternal: vi.fn(),
|
||||
terminalPreferences: vi.fn(),
|
||||
terminalTextScaleUpdate: vi.fn()
|
||||
},
|
||||
terminalClientId: 'device-token',
|
||||
randomBytes: (length) => new Uint8Array(length).fill(1)
|
||||
})
|
||||
}
|
||||
const state = {
|
||||
messages,
|
||||
subscribe,
|
||||
unsubscribe,
|
||||
brokerRef,
|
||||
created: 0,
|
||||
Shell: undefined as unknown as FunctionComponent<{ hostedViewActive: boolean }>
|
||||
}
|
||||
state.Shell = ({ hostedViewActive }) => {
|
||||
const healthDeadlineRef = useRef(new MobileWebHealthDeadline(10_000))
|
||||
const routeHandoffRef = useRef(new MobileWebNativeRouteHandoff())
|
||||
const pageDocument = useMobileWebPageDocument({
|
||||
sessionId: SESSION_ID,
|
||||
viewEpoch: 0,
|
||||
healthDeadlineRef,
|
||||
routeHandoffRef
|
||||
})
|
||||
useMobileWebCapabilityBroker({
|
||||
brokerRef,
|
||||
sessionId: SESSION_ID,
|
||||
buildId: BUILD_ID,
|
||||
viewEpoch: 0,
|
||||
documentEpoch: pageDocument.epoch,
|
||||
createBroker,
|
||||
onBrokerReady: noop,
|
||||
onBrokerSessionChange: noop
|
||||
})
|
||||
return createElement(MobileWebHybridShellPresentation, {
|
||||
viewRef: { current: null },
|
||||
selectedHost: { id: 'host-1', name: 'Desk', publicKeyB64: 'k' } as never,
|
||||
session: { sessionId: SESSION_ID, buildId: BUILD_ID } as never,
|
||||
viewEpoch: 0,
|
||||
packageLoading: false,
|
||||
packageProgress: undefined,
|
||||
packageWarning: undefined,
|
||||
hostedViewActive,
|
||||
onBack: noop,
|
||||
onShowHosts: noop,
|
||||
onRetryRecovery: noop,
|
||||
onUsePrevious: noop,
|
||||
onClearCache: noop,
|
||||
onRecoveryFailure: noop,
|
||||
onBridgeMessage: noop,
|
||||
onDocumentLoadStarted: pageDocument.onLoadStart,
|
||||
onPageLoaded: pageDocument.onLoaded,
|
||||
onLoadFailed: noop,
|
||||
onNavigationBlocked: noop,
|
||||
onProcessTerminated: noop
|
||||
})
|
||||
}
|
||||
return state
|
||||
}
|
||||
|
||||
function subscribeRequest(
|
||||
requestId: string,
|
||||
subscriptionId: string
|
||||
): Extract<MobileWebBridgePageMessage, { type: 'request' }> {
|
||||
return {
|
||||
version: MOBILE_WEB_BRIDGE_PROTOCOL_VERSION,
|
||||
shellSessionId: SESSION_ID,
|
||||
buildId: BUILD_ID,
|
||||
type: 'request',
|
||||
mode: 'subscription',
|
||||
requestId: requestId.repeat(22),
|
||||
subscriptionId: subscriptionId.repeat(22),
|
||||
capability: 'workspace',
|
||||
operation: 'subscribe',
|
||||
payload: {}
|
||||
}
|
||||
}
|
||||
|
||||
function errorFor(messages: MobileWebBridgeShellMessage[], requestId: string) {
|
||||
return messages.flatMap((message) =>
|
||||
message.type === 'response' &&
|
||||
message.requestId === requestId.repeat(22) &&
|
||||
message.status === 'error'
|
||||
? [message.error]
|
||||
: []
|
||||
)
|
||||
}
|
||||
@@ -57,6 +57,7 @@ describe('hosted shell document load failures', () => {
|
||||
onClearCache: noop,
|
||||
onRecoveryFailure: noop,
|
||||
onBridgeMessage: noop,
|
||||
onDocumentLoadStarted: noop,
|
||||
onPageLoaded: loaded,
|
||||
onLoadFailed: (reason) => failures.push(reason),
|
||||
onNavigationBlocked: noop,
|
||||
|
||||
@@ -57,6 +57,20 @@ describe('useMobileWebCapabilityBroker', () => {
|
||||
expect(errorFor(harness.messages, 'B')).toEqual([])
|
||||
})
|
||||
|
||||
it('retires the previous page subscriptions when the document is replaced in place', async () => {
|
||||
await mount(0)
|
||||
await handle(subscribeRequest('A', 'Z'))
|
||||
|
||||
await act(async () => {
|
||||
renderer?.update(createElement(harness.Harness, { viewEpoch: 0, documentEpoch: 1 }))
|
||||
})
|
||||
await handle(subscribeRequest('B', 'Y'))
|
||||
|
||||
expect(harness.unsubscribe).toHaveBeenCalledOnce()
|
||||
expect(harness.subscribe).toHaveBeenCalledTimes(2)
|
||||
expect(errorFor(harness.messages, 'B')).toEqual([])
|
||||
})
|
||||
|
||||
it('retires the broker on demand so an in-place reload cannot inherit it', async () => {
|
||||
await mount(0)
|
||||
await handle(subscribeRequest('A', 'Z'))
|
||||
@@ -99,7 +113,10 @@ function createHarness() {
|
||||
created: 0,
|
||||
brokerSessionId: undefined as string | undefined,
|
||||
retireBroker: undefined as (() => void) | undefined,
|
||||
Harness: undefined as unknown as FunctionComponent<{ viewEpoch: number }>
|
||||
Harness: undefined as unknown as FunctionComponent<{
|
||||
viewEpoch: number
|
||||
documentEpoch?: number
|
||||
}>
|
||||
}
|
||||
const createBroker = (page: MobileWebBrokerPageIdentity): MobileWebCapabilityBroker => {
|
||||
state.created += 1
|
||||
@@ -124,12 +141,13 @@ function createHarness() {
|
||||
const onBrokerSessionChange = (sessionId: string | undefined): void => {
|
||||
state.brokerSessionId = sessionId
|
||||
}
|
||||
state.Harness = ({ viewEpoch }) => {
|
||||
state.Harness = ({ viewEpoch, documentEpoch = 0 }) => {
|
||||
const lifecycle = useMobileWebCapabilityBroker({
|
||||
brokerRef,
|
||||
sessionId: CONTEXT.shellSessionId,
|
||||
buildId: CONTEXT.buildId,
|
||||
viewEpoch,
|
||||
documentEpoch,
|
||||
createBroker,
|
||||
onBrokerReady,
|
||||
onBrokerSessionChange
|
||||
|
||||
@@ -5,12 +5,15 @@ export type MobileWebBrokerPageIdentity = { sessionId: string; buildId: string }
|
||||
|
||||
// A view-epoch bump loads a fresh document over the same shell session, so the previous page's
|
||||
// broker (subscriptions, terminal streams, speech authority, replay window, rate limiter) has to
|
||||
// be retired before the new document can post against it.
|
||||
// be retired before the new document can post against it. A document epoch is the same boundary
|
||||
// without a native remount: the shell replaces the document in place on a native-route return, an
|
||||
// in-page reload and a re-attach, and each of those pages is just as new.
|
||||
export function useMobileWebCapabilityBroker({
|
||||
brokerRef,
|
||||
sessionId,
|
||||
buildId,
|
||||
viewEpoch,
|
||||
documentEpoch,
|
||||
createBroker,
|
||||
onBrokerReady,
|
||||
onBrokerSessionChange
|
||||
@@ -19,6 +22,7 @@ export function useMobileWebCapabilityBroker({
|
||||
sessionId: string | undefined
|
||||
buildId: string | undefined
|
||||
viewEpoch: number
|
||||
documentEpoch: number
|
||||
createBroker: (page: MobileWebBrokerPageIdentity) => MobileWebCapabilityBroker | null
|
||||
onBrokerReady: () => void
|
||||
onBrokerSessionChange: (sessionId: string | undefined) => void
|
||||
@@ -52,6 +56,7 @@ export function useMobileWebCapabilityBroker({
|
||||
brokerRef,
|
||||
buildId,
|
||||
createBroker,
|
||||
documentEpoch,
|
||||
onBrokerReady,
|
||||
onBrokerSessionChange,
|
||||
retireBroker,
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import { useCallback, useEffect, useRef, useState, type MutableRefObject } from 'react'
|
||||
import type { MobileWebHealthDeadline } from './mobile-web-health-deadline'
|
||||
import type { MobileWebNativeRouteHandoff } from './mobile-web-native-route-handoff'
|
||||
|
||||
/**
|
||||
* The shell owns the document lifecycle, so it also owns the page-scoped state that dies with a
|
||||
* document: what was initialized, what reported ready, and the health deadline armed for it.
|
||||
*
|
||||
* A document is replaced without the shell session, its build or the view epoch moving at all — a
|
||||
* native-route excursion deactivates the view to about:blank and reloads on return, the route error
|
||||
* boundary reloads in place, and a re-attached view reloads its URL. Each of those mints a new page
|
||||
* with new subscription ids while the previous page's broker survives, and its records keep holding
|
||||
* every per-operation grant (one for workspace, account and source control), so the new document's
|
||||
* subscribes are refused with `rate_limited` for the life of the shell session. The document epoch
|
||||
* is that boundary: it retires the outgoing page's broker before the incoming one initializes.
|
||||
*/
|
||||
export function useMobileWebPageDocument({
|
||||
sessionId,
|
||||
viewEpoch,
|
||||
healthDeadlineRef,
|
||||
routeHandoffRef
|
||||
}: {
|
||||
sessionId: string | undefined
|
||||
viewEpoch: number
|
||||
healthDeadlineRef: MutableRefObject<MobileWebHealthDeadline>
|
||||
routeHandoffRef: MutableRefObject<MobileWebNativeRouteHandoff>
|
||||
}): {
|
||||
epoch: number
|
||||
initializedSessionRef: MutableRefObject<string | undefined>
|
||||
readySessionId: string | undefined
|
||||
setReadySessionId: (sessionId: string | undefined) => void
|
||||
onLoadStart: () => void
|
||||
onLoaded: () => void
|
||||
} {
|
||||
const initializedSessionRef = useRef<string | undefined>(undefined)
|
||||
const loadedRef = useRef(false)
|
||||
const [epoch, setEpoch] = useState(0)
|
||||
const [readySessionId, setReadySessionId] = useState<string>()
|
||||
|
||||
useEffect(() => {
|
||||
initializedSessionRef.current = undefined
|
||||
loadedRef.current = false
|
||||
routeHandoffRef.current.clear()
|
||||
setReadySessionId(undefined)
|
||||
healthDeadlineRef.current.clear()
|
||||
return () => healthDeadlineRef.current.clear()
|
||||
}, [epoch, healthDeadlineRef, routeHandoffRef, sessionId, viewEpoch])
|
||||
|
||||
const onLoadStart = useCallback(() => {
|
||||
// Only a load that displaces a document that finished loading is a replacement. The shell posts
|
||||
// `loading` for the first load too, and more than once per load, and neither is a new page.
|
||||
if (!loadedRef.current) {
|
||||
return
|
||||
}
|
||||
loadedRef.current = false
|
||||
setEpoch((current) => current + 1)
|
||||
}, [])
|
||||
|
||||
const onLoaded = useCallback(() => {
|
||||
loadedRef.current = true
|
||||
}, [])
|
||||
|
||||
return {
|
||||
epoch,
|
||||
initializedSessionRef,
|
||||
readySessionId,
|
||||
setReadySessionId,
|
||||
onLoadStart,
|
||||
onLoaded
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user