mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
fix(mobile): recover host profile reads without discarding cached bundles (#34806)
* fix(mobile): retry unavailable host profiles without discarding cache * fix(mobile): cancel startup work on terminal shell failures via flow advance Merging main pushed use-mobile-web-shell-session.ts over max-lines; move the host-profile cancellation into the reducer so the flow-change cancel path from #34780 handles it. --------- Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
This commit is contained in:
@@ -297,7 +297,7 @@ describe('the hybrid shell screen', () => {
|
||||
const logged = () => connectionLogStore.get('host-1').map((entry) => entry.message)
|
||||
const before = logged().length
|
||||
await renderScreen(readyState('session-one'))
|
||||
expect(dependencies.reportShellFailure.mock.calls).toEqual([['document-load-failed']])
|
||||
expect(dependencies.reportShellFailure.mock.calls).toEqual([['host-profile-unreadable']])
|
||||
expect(logged().slice(before)).toEqual(['Web shell: host profile unreadable'])
|
||||
warned.mockRestore()
|
||||
})
|
||||
|
||||
@@ -111,8 +111,15 @@ export function MobileWebShellScreen({
|
||||
// Edge-to-edge disables adjustResize and zeroes WebView IME insets, so visualViewport never shrinks; publish native keyboard height to the page.
|
||||
const keyboardInset = Math.max(0, useKeyboardOcclusion())
|
||||
const pageInsets = pageSafeAreaInsets({ insets, topCovered: noticeShown })
|
||||
const { snapshot, unreadable, readStorage, refreshStorage, writeStorage, readWorkspaceKey } =
|
||||
usePageHostSnapshot(hostId, route.pathname, ownsHostArea)
|
||||
const {
|
||||
snapshot,
|
||||
unreadable,
|
||||
retry: retryHostSnapshot,
|
||||
readStorage,
|
||||
refreshStorage,
|
||||
writeStorage,
|
||||
readWorkspaceKey
|
||||
} = usePageHostSnapshot(hostId, route.pathname, ownsHostArea)
|
||||
// Create the media registry before the bridge; its staged files must belong to this page session.
|
||||
const serveDeviceVerb = useNativeDeviceVerbs(state.kind === 'ready' ? state.sessionId : null)
|
||||
const servePdfView = useNativePdfViewer(hostId, state.kind === 'ready' ? state.sessionId : null)
|
||||
@@ -232,11 +239,11 @@ export function MobileWebShellScreen({
|
||||
})
|
||||
useReportedShellOverlayLoad(onOverlayLoad, state, pageFrame, downloadProgress)
|
||||
|
||||
// A rejected profile read creates no host; fail the document instead of leaving ready forever. Recovery refetches once, then exposes Try again.
|
||||
// Profile failures preserve verified bundle bytes and retry the device store.
|
||||
useEffect(() => {
|
||||
if (unreadable) {
|
||||
console.warn('[web-shell] this host could not be read from the app store')
|
||||
reportShellFailure('document-load-failed')
|
||||
reportShellFailure('host-profile-unreadable')
|
||||
}
|
||||
}, [reportShellFailure, unreadable])
|
||||
useEffect(() => {
|
||||
@@ -253,7 +260,12 @@ export function MobileWebShellScreen({
|
||||
<ShellStateScreen
|
||||
hostId={hostId}
|
||||
state={state}
|
||||
onRetry={retry}
|
||||
onRetry={() => {
|
||||
if (unreadable) {
|
||||
retryHostSnapshot()
|
||||
}
|
||||
retry()
|
||||
}}
|
||||
downloadProgress={downloadProgress}
|
||||
host={snapshot?.host ?? null}
|
||||
/>
|
||||
|
||||
@@ -21,6 +21,8 @@ function failureMessage(reason: MobileWebShellFailureCause): string {
|
||||
return "This device's WebView is too old to open the workspace safely."
|
||||
case 'download-failed':
|
||||
return 'The latest app could not be downloaded from this host.'
|
||||
case 'host-profile-unreadable':
|
||||
return 'Could not read this host on your device. Try again.'
|
||||
case 'status-unreadable':
|
||||
return "Could not read this host's status. Go back and reopen it."
|
||||
case 'render-process-gone':
|
||||
|
||||
@@ -0,0 +1,272 @@
|
||||
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
|
||||
|
||||
const harness = await vi.hoisted(async () => await import('./mobile-web-shell-screen-test-harness'))
|
||||
const dependencies = vi.hoisted(() => harness.createScreenDependencies())
|
||||
|
||||
const { screenModuleMocks } = await vi.hoisted(
|
||||
async () => await import('./mobile-web-shell-screen-test-mocks')
|
||||
)
|
||||
const mocks = vi.hoisted(() => screenModuleMocks(dependencies))
|
||||
vi.mock('react-native', () => ({
|
||||
...mocks['react-native'](),
|
||||
useWindowDimensions: () => ({ width: profile.wide ? 1200 : 390, height: 844 })
|
||||
}))
|
||||
vi.mock('expo-clipboard', mocks['expo-clipboard'])
|
||||
vi.mock('expo-haptics', mocks['expo-haptics'])
|
||||
vi.mock('expo-document-picker', mocks['expo-document-picker'])
|
||||
vi.mock('@orca/expo-two-way-audio', mocks['@orca/expo-two-way-audio'])
|
||||
vi.mock('expo-keep-awake', mocks['expo-keep-awake'])
|
||||
vi.mock('expo-image-picker', mocks['expo-image-picker'])
|
||||
vi.mock('expo-file-system', mocks['expo-file-system'])
|
||||
vi.mock('lucide-react-native', mocks['lucide-react-native'])
|
||||
vi.mock('react-native-safe-area-context', mocks['react-native-safe-area-context'])
|
||||
vi.mock('expo-router', mocks['expo-router'])
|
||||
vi.mock('../../modules/orca-mobile-web-shell/src', mocks['../../modules/orca-mobile-web-shell/src'])
|
||||
vi.mock('../app-update/use-wall-app-update', mocks['../app-update/use-wall-app-update'])
|
||||
vi.mock('../transport/host-client-hooks', () => ({
|
||||
useHostClient: () => ({ client: dependencies.client, state: 'connected' })
|
||||
}))
|
||||
|
||||
const profile = vi.hoisted(() => ({
|
||||
fails: true,
|
||||
reads: 0,
|
||||
downloads: 0,
|
||||
wide: false,
|
||||
hold: false,
|
||||
canOwnHostArea: false,
|
||||
release: (): void => {}
|
||||
}))
|
||||
vi.mock('../transport/host-store', () => ({
|
||||
loadHosts: async () => {
|
||||
profile.reads += 1
|
||||
if (profile.hold) {
|
||||
await new Promise<void>((resolve) => {
|
||||
profile.release = resolve
|
||||
})
|
||||
}
|
||||
if (profile.fails) {
|
||||
throw new Error('the keychain is temporarily unavailable')
|
||||
}
|
||||
return [{ id: 'host-1', name: 'Host One', endpoint: 'ws://host-1', lastConnected: 3 }]
|
||||
}
|
||||
}))
|
||||
vi.mock('@react-native-async-storage/async-storage', () => ({
|
||||
default: {
|
||||
multiGet: async () => [],
|
||||
getItem: async () => null,
|
||||
setItem: async () => undefined,
|
||||
removeItem: async () => undefined
|
||||
}
|
||||
}))
|
||||
vi.mock('../components/host-protocol-gates-context', () => ({
|
||||
useHostProtocolGates: () => ({
|
||||
statusPending: false,
|
||||
statusReadable: true,
|
||||
hostCapabilities: [MOBILE_WEB_BUNDLE_CAPABILITY],
|
||||
hostProtocolWindow: { protocolVersion: 10, minCompatibleMobileVersion: 1 }
|
||||
})
|
||||
}))
|
||||
vi.mock('../transport/rpc-operation', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof RpcOperationModule>()),
|
||||
runRpcOperation: async () => ({ manifest: currentManifest() })
|
||||
}))
|
||||
vi.mock('../transport/mobile-web-bundle-fetch', () => ({
|
||||
fetchMobileWebBundle: async () => {
|
||||
profile.downloads += 1
|
||||
return {
|
||||
manifest: currentManifest(),
|
||||
assets: new Map(),
|
||||
reused: [],
|
||||
totalBytes: 4096,
|
||||
elapsedMs: 0
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
import type * as RpcOperationModule from '../transport/rpc-operation'
|
||||
import { createElement } from 'react'
|
||||
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
|
||||
import { MOBILE_WEB_BUNDLE_CAPABILITY } from '../../../src/shared/mobile-web-bundle/mobile-web-bundle-capability'
|
||||
import { MobileWebShellScreen } from './MobileWebShellScreen'
|
||||
import { clientFrame, createFakeRpcClient } from './bridge-host-test-fakes'
|
||||
import { byName, trackRenderedScreen, textOf } from './mobile-web-shell-screen-test-harness'
|
||||
import { MANIFEST_WIRE } from './mobile-web-shell-session-test-fixtures'
|
||||
import { readBridgeHostMessage } from './bridge/bridge-envelope'
|
||||
import type { ActiveGeneration, GenerationStore } from './generation-store'
|
||||
|
||||
afterEach(harness.unmountRenderedScreens)
|
||||
beforeEach(() => {
|
||||
harness.resetScreenDependencies(dependencies)
|
||||
profile.fails = true
|
||||
profile.reads = 0
|
||||
profile.downloads = 0
|
||||
profile.wide = false
|
||||
profile.hold = false
|
||||
profile.canOwnHostArea = false
|
||||
})
|
||||
|
||||
function currentManifest() {
|
||||
return profile.canOwnHostArea
|
||||
? {
|
||||
...MANIFEST_WIRE,
|
||||
routes: [{ pathname: '/h/[hostId]', grants: ['navigate'], canOwnHostArea: true }]
|
||||
}
|
||||
: MANIFEST_WIRE
|
||||
}
|
||||
|
||||
function cachedStore() {
|
||||
const active = {
|
||||
buildId: MANIFEST_WIRE.buildId,
|
||||
directory: 'file:///cache/generation',
|
||||
manifest: currentManifest()
|
||||
}
|
||||
let cached: ActiveGeneration | null = active
|
||||
const heldReads: (() => void)[] = []
|
||||
const cache = {
|
||||
holdReads: false,
|
||||
releaseReads: () => {
|
||||
for (const release of heldReads.splice(0)) {
|
||||
release()
|
||||
}
|
||||
},
|
||||
deletes: vi.fn(async () => {
|
||||
cached = null
|
||||
})
|
||||
}
|
||||
const store: GenerationStore = {
|
||||
readActiveGeneration: async () => {
|
||||
const answer = cached
|
||||
if (cache.holdReads) {
|
||||
await new Promise<void>((resolve) => heldReads.push(resolve))
|
||||
}
|
||||
return answer
|
||||
},
|
||||
openReusableAssets: async () => ({ has: () => false, check: async () => false }),
|
||||
stageGeneration: async () => ({ ...active, hostKey: 'host-key' }),
|
||||
commitGeneration: async () => {
|
||||
cached = active
|
||||
return active
|
||||
},
|
||||
abortStagedGeneration: async () => undefined,
|
||||
sweepStagedGenerations: async () => undefined,
|
||||
deleteHostCache: cache.deletes,
|
||||
recordUpdateFailure: async () => undefined,
|
||||
readUpdateFailures: async () => [],
|
||||
forgetHostUpdateFailures: async () => undefined
|
||||
}
|
||||
return Object.assign(cache, { store })
|
||||
}
|
||||
|
||||
async function mount(cache: ReturnType<typeof cachedStore>) {
|
||||
dependencies.client = createFakeRpcClient()
|
||||
const timers: { run: () => void; cancelled: boolean }[] = []
|
||||
const rendered: { tree: ReactTestRenderer | null } = { tree: null }
|
||||
await act(async () => {
|
||||
rendered.tree = create(
|
||||
createElement(MobileWebShellScreen, {
|
||||
hostId: 'host-1',
|
||||
route: { pathname: '/h/host-1' },
|
||||
runtime: {
|
||||
createStore: () => cache.store,
|
||||
mintSessionId: () => 'session-one',
|
||||
now: () => 0,
|
||||
setTimer: (run) => {
|
||||
const timer = { run, cancelled: false }
|
||||
timers.push(timer)
|
||||
return () => {
|
||||
timer.cancelled = true
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
)
|
||||
trackRenderedScreen(rendered.tree)
|
||||
})
|
||||
const tree = rendered.tree
|
||||
if (tree === null) {
|
||||
throw new Error('the screen did not mount')
|
||||
}
|
||||
return { tree, timers }
|
||||
}
|
||||
|
||||
async function retryWithReadableProfile(tree: ReactTestRenderer): Promise<void> {
|
||||
profile.fails = false
|
||||
const retry = tree.root.find(
|
||||
(node) => node.props.testID === 'mobile-web-shell-retry' && String(node.type) === 'Pressable'
|
||||
)
|
||||
await act(async () => {
|
||||
retry.props.onPress()
|
||||
})
|
||||
expect(profile.reads).toBe(2)
|
||||
await act(async () => {
|
||||
byName(tree, 'ShellViewProbe')[0].props.onBridgeMessage({
|
||||
nativeEvent: { json: clientFrame({ type: 'ready' }) }
|
||||
})
|
||||
})
|
||||
const inits = dependencies.posted.flatMap((json) => {
|
||||
const frame = readBridgeHostMessage(json)
|
||||
return frame.ok && frame.message.type === 'init' ? [frame.message] : []
|
||||
})
|
||||
expect(inits).toHaveLength(1)
|
||||
expect(inits[0]?.host?.id).toBe('host-1')
|
||||
expect(inits[0]?.ownsHostArea).toBeUndefined()
|
||||
}
|
||||
|
||||
it('retries an unavailable device profile without deleting or downloading verified bytes', async () => {
|
||||
const cache = cachedStore()
|
||||
const { tree, timers } = await mount(cache)
|
||||
expect(cache.deletes).not.toHaveBeenCalled()
|
||||
expect(profile.downloads).toBe(0)
|
||||
expect(textOf(tree)).toContain('Could not read this host on your device')
|
||||
expect(byName(tree, 'ShellViewProbe')).toHaveLength(0)
|
||||
expect(timers.every((timer) => timer.cancelled)).toBe(true)
|
||||
await retryWithReadableProfile(tree)
|
||||
expect(cache.deletes).not.toHaveBeenCalled()
|
||||
expect(profile.downloads).toBe(0)
|
||||
})
|
||||
|
||||
it('ends checking on profile failure and ignores cache work completed after that failure', async () => {
|
||||
const cache = cachedStore()
|
||||
cache.holdReads = true
|
||||
const { tree } = await mount(cache)
|
||||
expect(textOf(tree)).toContain('Could not read this host on your device')
|
||||
await act(async () => {
|
||||
cache.releaseReads()
|
||||
})
|
||||
expect(textOf(tree)).toContain('Could not read this host on your device')
|
||||
expect(byName(tree, 'ShellViewProbe')).toHaveLength(0)
|
||||
cache.holdReads = false
|
||||
await retryWithReadableProfile(tree)
|
||||
expect(profile.downloads).toBe(0)
|
||||
})
|
||||
|
||||
it('keeps profile failure parked across layout changes and retries with the latest layout', async () => {
|
||||
profile.wide = true
|
||||
profile.hold = true
|
||||
profile.canOwnHostArea = true
|
||||
const cache = cachedStore()
|
||||
const { tree } = await mount(cache)
|
||||
expect(byName(tree, 'ShellViewProbe')).toHaveLength(1)
|
||||
await act(async () => {
|
||||
profile.release()
|
||||
})
|
||||
expect(textOf(tree)).toContain('Could not read this host on your device')
|
||||
for (const wide of [false, true, false]) {
|
||||
profile.wide = wide
|
||||
await act(async () => {
|
||||
tree.update(
|
||||
createElement(MobileWebShellScreen, {
|
||||
hostId: 'host-1',
|
||||
route: { pathname: '/h/host-1' }
|
||||
})
|
||||
)
|
||||
})
|
||||
expect(textOf(tree)).toContain('Could not read this host on your device')
|
||||
expect(byName(tree, 'ShellViewProbe')).toHaveLength(0)
|
||||
expect(profile.reads).toBe(1)
|
||||
}
|
||||
profile.hold = false
|
||||
await retryWithReadableProfile(tree)
|
||||
expect(cache.deletes).not.toHaveBeenCalled()
|
||||
expect(profile.downloads).toBe(0)
|
||||
})
|
||||
@@ -169,6 +169,7 @@ export function screenModuleMocks(dependencies: ScreenDependencies) {
|
||||
// Keep preference identity stable like useState; fresh objects would rebuild the bridge on every render and settle pending requests.
|
||||
snapshot: SCREEN_SNAPSHOT,
|
||||
unreadable: dependencies.snapshotUnreadable,
|
||||
retry: () => {},
|
||||
readStorage: () => ({ storage: {}, storageOversize: [] }),
|
||||
refreshStorage: () => {
|
||||
dependencies.storageRefreshes += 1
|
||||
|
||||
@@ -43,8 +43,10 @@ export type MobileWebShellBlockedVerdict =
|
||||
| Extract<MobileWebBundleCompatVerdict, { kind: 'blocked' }>
|
||||
| PageRouteRefusalVerdict
|
||||
|
||||
export type MobileWebShellReportedFailure = MobileWebShellFailureReason | 'host-profile-unreadable'
|
||||
|
||||
export type MobileWebShellFailureCause =
|
||||
| MobileWebShellFailureReason
|
||||
| MobileWebShellReportedFailure
|
||||
| 'download-failed'
|
||||
| 'status-unreadable'
|
||||
|
||||
@@ -138,7 +140,7 @@ export type MobileWebShellSessionEvent =
|
||||
readonly flow: number
|
||||
readonly cause: MobileWebShellUpdateFailureCause
|
||||
}
|
||||
| { readonly type: 'shell-failed'; readonly reason: MobileWebShellFailureReason }
|
||||
| { readonly type: 'shell-failed'; readonly reason: MobileWebShellReportedFailure }
|
||||
| { readonly type: 'retry-pressed' }
|
||||
| { readonly type: 'document-started' }
|
||||
| { readonly type: 'document-loaded' }
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import type { MobileWebShellFailureReason } from '../../modules/orca-mobile-web-shell/src/load-state'
|
||||
import { evaluateMobileWebBundleCompat } from '../transport/mobile-web-bundle-compat'
|
||||
import type {
|
||||
CachedGeneration,
|
||||
MobileWebShellGates,
|
||||
MobileWebShellManifestFacts,
|
||||
MobileWebShellReportedFailure as ShellFailure,
|
||||
MobileWebShellSession,
|
||||
MobileWebShellSessionEffect,
|
||||
MobileWebShellSessionEvent,
|
||||
@@ -158,17 +158,15 @@ function onManifestRead(
|
||||
}
|
||||
|
||||
// Retry suspect disk bytes once via delete/refetch; renderer loss gets one remount without deletion; unavailable isolation is terminal.
|
||||
// Ignore reports outside ready: retired-view batches must not interrupt recovery already in flight.
|
||||
function onShellFailed(
|
||||
session: MobileWebShellSession,
|
||||
reason: MobileWebShellFailureReason
|
||||
): MobileWebShellStep {
|
||||
if (session.state.kind !== 'ready') {
|
||||
// Retired document reports cannot interrupt recovery; profile failures can end startup before ready.
|
||||
function onShellFailed(session: MobileWebShellSession, reason: ShellFailure): MobileWebShellStep {
|
||||
if (session.state.kind !== 'ready' && reason !== 'host-profile-unreadable') {
|
||||
return step(session, {})
|
||||
}
|
||||
const failed = { kind: 'failed', reason, retriedOnce: session.retriedOnce } as const
|
||||
if (reason === 'isolation-unavailable') {
|
||||
return step(session, { state: failed })
|
||||
if (reason === 'isolation-unavailable' || reason === 'host-profile-unreadable') {
|
||||
// Terminal failures advance flow so pending startup work is cancelled and its completions rejected.
|
||||
return step(session, { state: failed, flow: session.flow + 1 })
|
||||
}
|
||||
if (reason === 'render-process-gone') {
|
||||
return session.remountedOnce
|
||||
@@ -227,6 +225,9 @@ function decideDownloadFailed(
|
||||
}
|
||||
|
||||
function onLayoutChanged(session: MobileWebShellSession, wide: boolean): MobileWebShellStep {
|
||||
if (session.state.kind === 'failed' && session.state.reason === 'host-profile-unreadable') {
|
||||
return step(session, { wide })
|
||||
}
|
||||
const view = routeViewOf(session.routes, session.routePathname, wide)
|
||||
if (session.gates === null || sameRouteView(view, session)) {
|
||||
return step(session, { wide })
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import type { MobileWebShellFailureReason } from '../../modules/orca-mobile-web-shell/src/load-state'
|
||||
import { useHostProtocolGates } from '../components/host-protocol-gates-context'
|
||||
import { useHostClient } from '../transport/host-client-hooks'
|
||||
import {
|
||||
@@ -32,6 +31,7 @@ import {
|
||||
reduceMobileWebShellSession
|
||||
} from './mobile-web-shell-session'
|
||||
import type {
|
||||
MobileWebShellReportedFailure,
|
||||
MobileWebShellSessionEffect,
|
||||
MobileWebShellSessionEvent,
|
||||
MobileWebShellSessionState,
|
||||
@@ -49,7 +49,7 @@ export type MobileWebShellSessionView = {
|
||||
// Explain fallback beside the running page without replacing it.
|
||||
readonly updateNotice: MobileWebShellUpdateNotice | null
|
||||
readonly retry: () => void
|
||||
readonly reportShellFailure: (reason: MobileWebShellFailureReason) => void
|
||||
readonly reportShellFailure: (reason: MobileWebShellReportedFailure) => void
|
||||
readonly reportDocumentStarted: () => void
|
||||
readonly reportDocumentLoaded: () => void
|
||||
readonly reportPageReady: () => void
|
||||
@@ -281,7 +281,7 @@ export function useMobileWebShellSession(args: {
|
||||
}, [dispatch, invalidate, runtime])
|
||||
|
||||
const reportShellFailure = useCallback(
|
||||
(reason: MobileWebShellFailureReason) => {
|
||||
(reason: MobileWebShellReportedFailure) => {
|
||||
dispatch(epochRef.current, { type: 'shell-failed', reason })
|
||||
},
|
||||
[dispatch]
|
||||
|
||||
@@ -23,6 +23,7 @@ export type PageHostSnapshotView = {
|
||||
snapshot: PageHostSnapshot | null
|
||||
// Report rejected profile reads; no host can answer ready and nothing retries this read automatically.
|
||||
unreadable: boolean
|
||||
retry: () => void
|
||||
// Keep init synchronous from a mirror updated by native writers and refreshed from the store.
|
||||
readStorage: () => PageStorageForInit
|
||||
refreshStorage: () => Promise<void>
|
||||
@@ -38,6 +39,7 @@ export function usePageHostSnapshot(
|
||||
): PageHostSnapshotView {
|
||||
const [snapshot, setSnapshot] = useState<PageHostSnapshot | null>(null)
|
||||
const [unreadable, setUnreadable] = useState(false)
|
||||
const [readAttempt, setReadAttempt] = useState(0)
|
||||
|
||||
// Apply the shell allowlist on every read/refresh; unrelated mirrored app state must never cross.
|
||||
const refreshStorage = useCallback(
|
||||
@@ -86,7 +88,7 @@ export function usePageHostSnapshot(
|
||||
return () => {
|
||||
stale = true
|
||||
}
|
||||
}, [hostId, refreshStorage])
|
||||
}, [hostId, readAttempt, refreshStorage])
|
||||
|
||||
const writeStorage = useCallback(
|
||||
(key: string, value: string | null): void => {
|
||||
@@ -113,6 +115,7 @@ export function usePageHostSnapshot(
|
||||
return {
|
||||
snapshot,
|
||||
unreadable,
|
||||
retry: useCallback(() => setReadAttempt((attempt) => attempt + 1), []),
|
||||
readStorage: useCallback(() => {
|
||||
// Bound values here or one oversized preference rejects all of init; report omissions so defaulted preferences are diagnosable.
|
||||
const held = readMirroredStorage(pageStorageKeysForRoute(hostId, routePathname))
|
||||
|
||||
Reference in New Issue
Block a user