mirror of
https://github.com/stablyai/orca.git
synced 2026-11-04 02:57:36 +00:00
fix(mobile): show the update screen instead of native screens when the desktop has no page (#29956)
* fix(mobile): wall hosts that cannot serve the page instead of opening native screens * refactor(mobile): drop the native-route state and the shell fallback prop; unknown routes say unavailable * test(mobile): read the nullable page route refusal in its policy test
This commit is contained in:
@@ -1,14 +1,14 @@
|
||||
/**
|
||||
* The screens this desktop asks a phone's shell to render from the app bundle instead of natively.
|
||||
*
|
||||
* One entry per route proved on the web, and the list is deliberately short: a route that is not
|
||||
* here renders the native screen, which is the state every phone is already in. Adding one is a
|
||||
* product decision with a device proof behind it, not a consequence of the bundle happening to
|
||||
* contain the module.
|
||||
* One entry per route proved on the web. A route that is not here is not served: older phone builds
|
||||
* render their native screen, and current ones show the update screen. Adding one is a product
|
||||
* decision with a device proof behind it, not a consequence of the bundle happening to contain the
|
||||
* module; dropping one walls current phones on that screen.
|
||||
*
|
||||
* `grants` names what the screen needs the shell to do for it. A shell that implements fewer than
|
||||
* an entry names renders the native screen for that route, so writing a grant here before the app
|
||||
* that implements it ships costs nothing and breaks nothing.
|
||||
* an entry names does not serve that route (older builds go native, current ones ask for an app
|
||||
* update), so write a grant here only once the app that implements it ships.
|
||||
*
|
||||
* Declared here rather than in src/shared because the builder is the only thing that reads it: the
|
||||
* shape it must satisfy is MobileWebBundleRouteSchema, which the manifest write is checked against.
|
||||
@@ -19,7 +19,7 @@
|
||||
* on a route that imports the seam and declares nothing.
|
||||
*
|
||||
* `optionalGrants` names what a screen is better with and complete without (ruling 37). A shell that
|
||||
* implements fewer than an entry's `grants` renders the native screen; a shell that implements fewer
|
||||
* implements fewer than an entry's `grants` does not serve the route; a shell that implements fewer
|
||||
* than its `optionalGrants` renders the page and the page hides that one affordance. So the two
|
||||
* lanes are a product decision about the screen: a capability the screen cannot be shown without
|
||||
* goes above, and one an author can point at a complete screen without goes below.
|
||||
|
||||
@@ -14,8 +14,7 @@ import { firstParam } from '../../../../src/navigation/route-param-reader'
|
||||
* Agent session history, from the desktop's bundle or from this app.
|
||||
*
|
||||
* The switch is `index.tsx`'s, for its reasons: the shell renders the page only for a route the
|
||||
* bundle lists with grants this app implements, and `fallback` is what a negotiation that said no
|
||||
* falls back to.
|
||||
* bundle lists with grants this app implements, and walls the host otherwise.
|
||||
*
|
||||
* Two dynamic segments rather than one, so both are encoded: `useLocalSearchParams` answers the
|
||||
* decoded value, and a worktree id or a deep-linked host id carrying `/`, `?`, `#` or whitespace
|
||||
@@ -70,11 +69,6 @@ export default withWorkspaceRoute(function MobileAgentSessionHistoryScreen() {
|
||||
// page has left. The key is what makes the change a remount, which disposes that bridge in the
|
||||
// commit, and the new session starts with no grants until its own `init`.
|
||||
return (
|
||||
<MobileWebShellScreen
|
||||
key={decision.route.pathname}
|
||||
hostId={hostId}
|
||||
route={decision.route}
|
||||
fallback={panel}
|
||||
/>
|
||||
<MobileWebShellScreen key={decision.route.pathname} hostId={hostId} route={decision.route} />
|
||||
)
|
||||
})
|
||||
|
||||
@@ -17,7 +17,7 @@ import { shellSwitchDecision } from '../../../../src/mobile-web-shell/shell-swit
|
||||
* The file explorer, from the desktop's bundle or from this app.
|
||||
*
|
||||
* The shell decides, not this switch: it renders the page only for a route the bundle lists with
|
||||
* grants this app implements, and answers `native-route` otherwise, which is what `fallback` is.
|
||||
* grants this app implements, and walls the host otherwise.
|
||||
*
|
||||
* Encoded, not interpolated raw, for the reason `web.tsx` states: an id carrying `?`, `#` or
|
||||
* whitespace would build a pathname the page refuses and mount nothing.
|
||||
@@ -67,7 +67,6 @@ export default withWorkspaceRoute(function MobileFileExplorerScreen() {
|
||||
key={shellScreenRouteKey(decision.route)}
|
||||
hostId={hostId}
|
||||
route={decision.route}
|
||||
fallback={native}
|
||||
/>
|
||||
)
|
||||
})
|
||||
|
||||
@@ -78,7 +78,6 @@ export default withWorkspaceRoute(function MobileFilePreviewRoute() {
|
||||
key={shellScreenRouteKey(decision.route)}
|
||||
hostId={route.params.hostId}
|
||||
route={decision.route}
|
||||
fallback={native}
|
||||
/>
|
||||
)
|
||||
})
|
||||
|
||||
@@ -11,9 +11,8 @@ import { shellSwitchDecision } from '../../../src/mobile-web-shell/shell-switch-
|
||||
* The worktree list, from the desktop's bundle or from this app.
|
||||
*
|
||||
* The shell decides, not this switch: it renders the page only for a route the bundle lists with
|
||||
* grants this app implements, and answers `native-route` otherwise, which is what `fallback` is.
|
||||
* So the two ways to stay native are a build without the shell and a negotiation that said no, and
|
||||
* the second one covers every host whose desktop is older than the page.
|
||||
* grants this app implements, and walls the host otherwise. So the one way to stay native is a build
|
||||
* without the shell.
|
||||
*
|
||||
* Encoded, not interpolated raw, for the reason `web.tsx` states: a deep-linked host id carrying
|
||||
* `?`, `#` or whitespace would build a pathname the page refuses, and a refusal here is a failure
|
||||
@@ -50,7 +49,6 @@ function HostListScreen() {
|
||||
key={hostId}
|
||||
hostId={hostId}
|
||||
route={decision.route}
|
||||
fallback={native}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ import { shellSwitchDecision } from '../../../../src/mobile-web-shell/shell-swit
|
||||
* The files switch's shape. What is different here is that the native screen is a component rather
|
||||
* than the body of this file: `MobileDiffReviewRouteScreen` calls the review controller, and a
|
||||
* controller at this file's top level would subscribe 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.
|
||||
* As an element it is built and not mounted, and only a native-shell build mounts it.
|
||||
*
|
||||
* `matchesRoutePattern` is segment-count exact with literal equality on the static segments, so
|
||||
* these five segments collide with nothing: the explorer's five carry `files`.
|
||||
@@ -73,7 +73,6 @@ export default withWorkspaceRoute(function MobileDiffReviewScreen() {
|
||||
key={shellScreenRouteKey(decision.route)}
|
||||
hostId={hostId}
|
||||
route={decision.route}
|
||||
fallback={native}
|
||||
/>
|
||||
)
|
||||
})
|
||||
|
||||
@@ -20,7 +20,7 @@ import { shellSwitchDecision } from '../../../../src/mobile-web-shell/shell-swit
|
||||
* The review switch's shape, for its reasons: the native screen is `MobileSessionRouteScreen`
|
||||
* rather than the body of this file, because `useMobileSessionController` at this file's top level
|
||||
* 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.
|
||||
* As an element it is built and not mounted, and only a native-shell build mounts it.
|
||||
*
|
||||
* 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
|
||||
@@ -103,7 +103,6 @@ export default withWorkspaceRoute(function MobileSessionScreen() {
|
||||
key={shellScreenRouteKey({ pathname: decision.route.pathname, params: identity })}
|
||||
hostId={hostId}
|
||||
route={decision.route}
|
||||
fallback={native}
|
||||
onRouteParamClear={erasePaneKey}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -17,8 +17,8 @@ import { shellSwitchDecision } from '../../../../src/mobile-web-shell/shell-swit
|
||||
/**
|
||||
* The source-control hub, from the desktop's bundle or from this app.
|
||||
*
|
||||
* The files switch's shape, for its reasons: the shell answers `native-route` for a route the
|
||||
* bundle does not list or this app's grants do not cover, and `fallback` is what that renders.
|
||||
* The files switch's shape, for its reasons: the shell walls a route the bundle does not list or
|
||||
* this app's grants do not cover.
|
||||
*
|
||||
* `pr` and `history` are not switched and never will be. Both are `Redirect`s into this route, and
|
||||
* a redirect inside the page would leave the session bound to a pathname the page has left; left
|
||||
@@ -83,7 +83,6 @@ export default withWorkspaceRoute(function MobileSourceControlScreen() {
|
||||
key={shellScreenRouteKey(decision.route)}
|
||||
hostId={hostId}
|
||||
route={decision.route}
|
||||
fallback={native}
|
||||
/>
|
||||
)
|
||||
})
|
||||
|
||||
@@ -43,7 +43,6 @@ export default function MobileTasksRoute() {
|
||||
key={hostId}
|
||||
hostId={hostId}
|
||||
route={decision.route}
|
||||
fallback={native}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -25,10 +25,6 @@ export default function MobileWebShellRoute() {
|
||||
}
|
||||
// The screen the page stands in for. The document is served at `/`, which matches no route in
|
||||
// the tree the page carries, so this is the only thing that tells it which one to open.
|
||||
//
|
||||
// The fallback is a redirect rather than the native screen: this route exists only to open the
|
||||
// page deliberately, so a bundle that does not list the worktree list has nothing to show here
|
||||
// and the host route is where the list actually lives.
|
||||
return (
|
||||
<MobileWebShellScreen
|
||||
// Same reason as the agent-history route: a host holds the grants its session opened with,
|
||||
@@ -36,7 +32,6 @@ export default function MobileWebShellRoute() {
|
||||
key={hostId}
|
||||
hostId={hostId}
|
||||
route={decision.route}
|
||||
fallback={<Redirect href={`/h/${hostId}`} />}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -101,13 +101,22 @@ describe('ProtocolBlockScreen', () => {
|
||||
expect(desktop).toContain('Open GitHub Releases')
|
||||
})
|
||||
|
||||
it('sends a host without a bundle to the desktop update', () => {
|
||||
const output = render({ kind: 'blocked', reason: 'bundle-unavailable' })
|
||||
expect(output).toContain('Update Orca on your computer')
|
||||
expect(output).toContain(
|
||||
'This paired desktop app does not include the mobile workspace yet. Update Orca on your computer, then try this host again.'
|
||||
)
|
||||
expect(primaryActionUrl()).toBe(RELEASES_URL)
|
||||
it('tells a host without a bundle, or without this screen, the desktop is too old', () => {
|
||||
for (const reason of ['bundle-unavailable', 'page-route-unlisted'] as const) {
|
||||
const output = render({ kind: 'blocked', reason })
|
||||
expect(output).toContain('Update Orca on your computer')
|
||||
expect(output).toContain(
|
||||
'This paired desktop app is too old for your current Orca Mobile app. Update Orca on your computer, then try this host again.'
|
||||
)
|
||||
expect(output).not.toContain('does not include the mobile workspace')
|
||||
expect(primaryActionUrl()).toBe(RELEASES_URL)
|
||||
}
|
||||
})
|
||||
|
||||
it('sends a screen that needs a grant this app lacks to the mobile update', () => {
|
||||
const output = render({ kind: 'blocked', reason: 'page-route-grant-missing' })
|
||||
expect(output).toContain('Update Orca Mobile')
|
||||
expect(output).toContain("This desktop's mobile workspace needs a newer Orca Mobile app.")
|
||||
})
|
||||
|
||||
it('sends an unknown manifest schema to the mobile update', () => {
|
||||
|
||||
@@ -5,15 +5,18 @@ import { Platform, Pressable, StyleSheet, Text, View } from 'react-native'
|
||||
import { colors, radii, spacing, typography } from '../theme/mobile-theme'
|
||||
import type { CompatVerdict } from '../transport/protocol-compat'
|
||||
import type { MobileWebBundleCompatVerdict } from '../transport/mobile-web-bundle-compat'
|
||||
import type { PageRouteRefusalVerdict } from '../mobile-web-shell/page-route-policy'
|
||||
|
||||
const RELEASES_URL = 'https://github.com/stablyai/orca/releases'
|
||||
const IOS_APP_STORE_URL = 'itms-apps://apps.apple.com/app/orca-ide/id6766130217'
|
||||
|
||||
/** Every wall this screen renders: the protocol one and the bundle one. Both are terminal — there
|
||||
* is no native workspace to fall back to, so the only way out is updating one of the two apps. */
|
||||
/** Every wall this screen renders: the protocol one, the bundle one and the page route one. All are
|
||||
* terminal — there is no native workspace to fall back to, so the only way out is updating one of
|
||||
* the two apps. */
|
||||
export type BlockedVerdict =
|
||||
| Extract<CompatVerdict, { kind: 'blocked' }>
|
||||
| Extract<MobileWebBundleCompatVerdict, { kind: 'blocked' }>
|
||||
| PageRouteRefusalVerdict
|
||||
|
||||
type Props = {
|
||||
verdict: BlockedVerdict
|
||||
@@ -30,9 +33,11 @@ function blockRemedy(verdict: BlockedVerdict): BlockRemedy {
|
||||
switch (verdict.reason) {
|
||||
case 'mobile-too-old':
|
||||
case 'bundle-shell-too-old':
|
||||
case 'page-route-grant-missing':
|
||||
return 'update-mobile'
|
||||
case 'desktop-too-old':
|
||||
case 'bundle-unavailable':
|
||||
case 'page-route-unlisted':
|
||||
return 'update-desktop'
|
||||
case 'bundle-incompatible':
|
||||
return verdict.side === 'desktop' ? 'update-desktop' : 'refresh-bundle'
|
||||
@@ -57,9 +62,6 @@ function blockBody(verdict: BlockedVerdict, remedy: BlockRemedy, storeName: stri
|
||||
if (verdict.reason === 'mobile-too-old') {
|
||||
return `This desktop needs a newer Orca Mobile app. Update Orca Mobile from ${storeName}, then try this host again.`
|
||||
}
|
||||
if (verdict.reason === 'bundle-unavailable') {
|
||||
return 'This paired desktop app does not include the mobile workspace yet. Update Orca on your computer, then try this host again.'
|
||||
}
|
||||
if (remedy === 'update-mobile') {
|
||||
return `This desktop's mobile workspace needs a newer Orca Mobile app. Update Orca Mobile from ${storeName}, then try this host again.`
|
||||
}
|
||||
|
||||
@@ -41,7 +41,9 @@ const WALL_COPY: Record<MobileWebShellUpdateFailureWall, string> = {
|
||||
'bundle-unavailable': 'the host has no workspace bundle',
|
||||
'bundle-shell-too-old': 'this app is too old for the saved bundle',
|
||||
'host-too-old-for-bundle': 'the host is too old for the saved bundle',
|
||||
'bundle-too-old-for-host': 'the saved bundle is too old for the host'
|
||||
'bundle-too-old-for-host': 'the saved bundle is too old for the host',
|
||||
'page-route-unlisted': "the host's workspace does not include this screen",
|
||||
'page-route-grant-missing': 'this screen needs a newer app'
|
||||
}
|
||||
|
||||
function generation(buildId: string): string {
|
||||
@@ -61,6 +63,8 @@ function outcomeCopy(failure: MobileWebShellUpdateFailure): string {
|
||||
: `Blocked: ${WALL_COPY[failure.wall]}.`
|
||||
case 'native-route':
|
||||
return 'Showed the native screen.'
|
||||
case 'route-unavailable':
|
||||
return 'Showed that the screen is not available.'
|
||||
case 'failed':
|
||||
return 'Showed the failure screen.'
|
||||
case 'waiting':
|
||||
|
||||
@@ -30,7 +30,6 @@ import { bridgeId, clientFrame, createFakeRpcClient } from './bridge-host-test-f
|
||||
import {
|
||||
byName,
|
||||
trackRenderedScreen,
|
||||
NativeFallback,
|
||||
SCREEN_BUILD_ID as BUILD_ID,
|
||||
SCREEN_DIRECTORY as DIRECTORY,
|
||||
hostParentOf,
|
||||
@@ -282,8 +281,7 @@ describe('the hybrid shell screen', () => {
|
||||
const element = (next: Record<string, string>) =>
|
||||
createElement(MobileWebShellScreen, {
|
||||
hostId: 'host-1',
|
||||
route: { pathname: '/h/host-1', params: next },
|
||||
fallback: createElement(NativeFallback)
|
||||
route: { pathname: '/h/host-1', params: next }
|
||||
})
|
||||
dependencies.state = readyState('session-one')
|
||||
const rendered: { tree: ReactTestRenderer | null } = { tree: null }
|
||||
@@ -528,10 +526,10 @@ describe('the hybrid shell screen', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('the route the shell was not asked to render', () => {
|
||||
it('hands the screen back to the caller rather than painting anything of its own', async () => {
|
||||
const tree = await renderScreen({ kind: 'native-route' })
|
||||
expect(tree.root.findAllByType(NativeFallback)).toHaveLength(1)
|
||||
describe('a route the page does not serve and no side can be blamed for', () => {
|
||||
it('paints the unavailable screen itself, with no view and no spinner', async () => {
|
||||
const tree = await renderScreen({ kind: 'route-unavailable' })
|
||||
expect(textOf(tree)).toContain('This workspace screen is not available on this host.')
|
||||
expect(byName(tree, 'ShellViewProbe')).toEqual([])
|
||||
expect(byName(tree, 'ActivityIndicator')).toEqual([])
|
||||
})
|
||||
|
||||
@@ -33,6 +33,7 @@ import { useMobileWebShellSession } from './use-mobile-web-shell-session'
|
||||
import { usePageHostSnapshot } from './use-page-host-snapshot'
|
||||
import { useReportedHostAreaServing } from './host-area-serving'
|
||||
import { isHostRouteOf } from './page-route-policy'
|
||||
import { PageRouteUnavailableScreen } from './PageRouteUnavailableScreen'
|
||||
import { useResponsiveLayout } from '../layout/responsive-layout'
|
||||
import { storageReadParamsSchema, type BridgeNativeVerb } from './bridge/bridge-native-verbs'
|
||||
import { SHELL_OPENING_LABEL, ShellPageCover, ShellWaitingFrame } from './ShellWaitingFrame'
|
||||
@@ -134,12 +135,8 @@ export type MobileWebShellScreenProps = {
|
||||
hostId: string
|
||||
/** The screen this shell stands in for, which the page cannot derive from a document served at `/`. */
|
||||
route: BridgeInitRoute
|
||||
/**
|
||||
* What to render when the bundle does not list this route, or lists it needing a grant this app
|
||||
* does not implement. Required, because every caller has a native screen behind it: that is what
|
||||
* the negotiation falls back to, and a shell with nothing behind it would paint a blank instead.
|
||||
*/
|
||||
fallback: ReactNode
|
||||
/** False for the catch-all, whose pathnames have no screen in this app. */
|
||||
appNamesRoute?: boolean
|
||||
/**
|
||||
* The page applied a one-shot route param and asks for it to be erased (ruling 34), naming what
|
||||
* it applied. Only a caller that put one on the route ever hears this, and the comparison is
|
||||
@@ -159,7 +156,7 @@ export type MobileWebShellScreenProps = {
|
||||
export function MobileWebShellScreen({
|
||||
hostId,
|
||||
route,
|
||||
fallback,
|
||||
appNamesRoute,
|
||||
onRouteParamClear,
|
||||
runtime
|
||||
}: MobileWebShellScreenProps) {
|
||||
@@ -190,6 +187,7 @@ export function MobileWebShellScreen({
|
||||
hostId,
|
||||
routePathname: route.pathname,
|
||||
wide: isWideLayout,
|
||||
appNamesRoute,
|
||||
runtime
|
||||
})
|
||||
// Full screen from the download on, as on a phone; while checking, nobody knows the page owns it.
|
||||
@@ -352,8 +350,8 @@ export function MobileWebShellScreen({
|
||||
}
|
||||
}, [hostId, unreadable])
|
||||
|
||||
if (state.kind === 'native-route') {
|
||||
return fallback
|
||||
if (state.kind === 'route-unavailable') {
|
||||
return <PageRouteUnavailableScreen hostId={hostId} />
|
||||
}
|
||||
if (state.kind === 'wall') {
|
||||
return <ProtocolBlockScreen verdict={state.verdict} />
|
||||
|
||||
@@ -6,9 +6,9 @@ import { colors, radii, spacing, typography } from '../theme/mobile-theme'
|
||||
/**
|
||||
* What a host-scoped pathname nothing can paint lands on.
|
||||
*
|
||||
* The catch-all route is the one switch with no native screen behind it, so its `fallback` cannot
|
||||
* be a panel: a pathname the bundle does not list, or lists needing a grant this app lacks, has no
|
||||
* renderer on either side. This replaces expo-router's Unmatched there, which says "Unmatched
|
||||
* The catch-all route is the one switch with no native screen behind it, so its refusal cannot be
|
||||
* a panel: a pathname the bundle does not serve has no renderer on either side. The shell shows it
|
||||
* too when it cannot say which app is behind (`route-unavailable`). This replaces expo-router's Unmatched there, which says "Unmatched
|
||||
* Route" and offers a sitemap link that means nothing to someone holding a stale app.
|
||||
*
|
||||
* `replace` and not `push`: this screen is a dead end, so leaving it must not leave it behind to
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
*
|
||||
* `catch-all-page-route.test.tsx` mocks both and reads what the switch hands over; this drives the
|
||||
* other half — the session reducer is the only thing stubbed, so the screen the user sees for
|
||||
* `native-route`, `offline`, `checking` and the wall is the one this app ships.
|
||||
* `route-unavailable`, `offline`, `checking` and the wall is the one this app ships.
|
||||
*/
|
||||
import { createElement } from 'react'
|
||||
import { act, create, type ReactTestInstance, type ReactTestRenderer } from 'react-test-renderer'
|
||||
@@ -26,7 +26,7 @@ const SNAPSHOT = vi.hoisted(() => ({
|
||||
const dependencies = vi.hoisted((): Dependencies => {
|
||||
Object.assign(globalThis, { __DEV__: true })
|
||||
return {
|
||||
state: { kind: 'native-route' },
|
||||
state: { kind: 'route-unavailable' },
|
||||
params: {},
|
||||
replace: vi.fn(),
|
||||
push: vi.fn()
|
||||
@@ -192,16 +192,22 @@ afterEach(() => {
|
||||
})
|
||||
|
||||
describe('the screen the catch-all paints for each shell state', () => {
|
||||
it('renders the refusal through the shell fallback, not beside it', async () => {
|
||||
// `native-route` is the only state that reaches `fallback`, so this is what pins that binding:
|
||||
// with `fallback={null}` the shell paints nothing and the control below is gone.
|
||||
const tree = await renderRoute({ kind: 'native-route' })
|
||||
it('shows the refusal when the shell finds the page does not serve the pathname', async () => {
|
||||
const tree = await renderRoute({ kind: 'route-unavailable' })
|
||||
expect(textOf(tree)).toContain('This workspace screen is not available on this host.')
|
||||
expect(textOf(tree)).not.toContain('too old')
|
||||
expect(backControl(tree)).toBeDefined()
|
||||
})
|
||||
|
||||
it('shows the same refusal on a native-shell build, with no shell mounted', async () => {
|
||||
vi.stubEnv('EXPO_PUBLIC_MOBILE_SHELL', '')
|
||||
const tree = await renderRoute({ kind: 'checking' })
|
||||
expect(textOf(tree)).toContain('This workspace screen is not available on this host.')
|
||||
expect(backControl(tree)).toBeDefined()
|
||||
})
|
||||
|
||||
it('leaves the dead end rather than stacking it, on the encoded host route', async () => {
|
||||
const tree = await renderRoute({ kind: 'native-route' })
|
||||
const tree = await renderRoute({ kind: 'route-unavailable' })
|
||||
await act(async () => {
|
||||
backControl(tree).props.onPress()
|
||||
})
|
||||
@@ -219,7 +225,7 @@ describe('the screen the catch-all paints for each shell state', () => {
|
||||
for (const hostId of ['a?b', 'a#b', 'a b', 'a/b', 'a\\b']) {
|
||||
dependencies.params = { hostId, page: ['settings'] }
|
||||
dependencies.replace.mockClear()
|
||||
const tree = await renderRoute({ kind: 'native-route' })
|
||||
const tree = await renderRoute({ kind: 'route-unavailable' })
|
||||
await act(async () => {
|
||||
backControl(tree).props.onPress()
|
||||
})
|
||||
@@ -240,7 +246,7 @@ describe('the screen the catch-all paints for each shell state', () => {
|
||||
*/
|
||||
it('goes to the app root when there is no host to go back to', async () => {
|
||||
dependencies.params = { page: ['settings'] }
|
||||
const tree = await renderRoute({ kind: 'native-route' })
|
||||
const tree = await renderRoute({ kind: 'route-unavailable' })
|
||||
await act(async () => {
|
||||
backControl(tree, ROOT_LABEL).props.onPress()
|
||||
})
|
||||
|
||||
@@ -50,10 +50,17 @@ vi.mock('./MobileWebShellScreen', () => ({
|
||||
}))
|
||||
|
||||
import { BRIDGE_MAX_ROUTE_PARAMS, BRIDGE_ROUTE_PATHNAME_PATTERN } from './bridge/bridge-caps'
|
||||
import { grantsForRoute, pageRendersRoute, routeViewOf } from './page-route-policy'
|
||||
import { grantsForRoute, matchesRoutePattern, routeViewOf } from './page-route-policy'
|
||||
import { shellScreenRouteKey } from './shell-screen-route'
|
||||
import MobileWebPageCatchAllScreen from './catch-all-page-route'
|
||||
|
||||
/** Whether a narrow session on `pathname` would be served the page under `routes`. */
|
||||
function servesRoute(routes: Parameters<typeof routeViewOf>[0], pathname: string): boolean {
|
||||
return routeViewOf(routes, pathname).pageRoutes.some((pattern) =>
|
||||
matchesRoutePattern(pathname, pattern)
|
||||
)
|
||||
}
|
||||
|
||||
async function render(): Promise<void> {
|
||||
await act(async () => {
|
||||
create(createElement(MobileWebPageCatchAllScreen))
|
||||
@@ -209,8 +216,8 @@ const FUTURE_MANIFEST = [
|
||||
|
||||
describe('the gate a catch-all pathname meets', () => {
|
||||
it('renders a manifest route this app has no route file for', () => {
|
||||
expect(pageRendersRoute(FUTURE_MANIFEST, '/h/H1/insights')).toBe(true)
|
||||
expect(pageRendersRoute(FUTURE_MANIFEST, '/h/H1/insights/W1')).toBe(true)
|
||||
expect(servesRoute(FUTURE_MANIFEST, '/h/H1/insights')).toBe(true)
|
||||
expect(servesRoute(FUTURE_MANIFEST, '/h/H1/insights/W1')).toBe(true)
|
||||
expect(grantsForRoute(FUTURE_MANIFEST, '/h/H1/insights/W1')).toEqual([
|
||||
'navigate',
|
||||
'storage',
|
||||
@@ -220,12 +227,12 @@ describe('the gate a catch-all pathname meets', () => {
|
||||
})
|
||||
|
||||
it('refuses a pathname the manifest does not list', () => {
|
||||
expect(pageRendersRoute(FUTURE_MANIFEST, '/h/H1/settings')).toBe(false)
|
||||
expect(servesRoute(FUTURE_MANIFEST, '/h/H1/settings')).toBe(false)
|
||||
expect(grantsForRoute(FUTURE_MANIFEST, '/h/H1/settings')).toEqual([])
|
||||
})
|
||||
|
||||
it('refuses a listed route needing a grant this app does not implement', () => {
|
||||
expect(pageRendersRoute(FUTURE_MANIFEST, '/h/H1/future')).toBe(false)
|
||||
expect(servesRoute(FUTURE_MANIFEST, '/h/H1/future')).toBe(false)
|
||||
expect(routeViewOf(FUTURE_MANIFEST, '/h/H1/future').pageRoutes).toEqual([
|
||||
'/h/[hostId]/insights',
|
||||
'/h/[hostId]/insights/[worktreeId]'
|
||||
|
||||
@@ -9,9 +9,10 @@ import { shellSwitchDecision } from './shell-switch-decision'
|
||||
* Any host-scoped pathname this app has no route file for, handed to the shell.
|
||||
*
|
||||
* The only switch with no native screen behind it: a screen registered after this app shipped is in
|
||||
* the desktop's bundle and nowhere else, so `fallback` is a refusal rather than a panel. The
|
||||
* manifest still decides — the session reducer answers `native-route` for a pathname the bundle
|
||||
* does not list, and that answer lands on the same refusal.
|
||||
* the desktop's bundle and nowhere else, so a native-shell build gets a refusal rather than a panel.
|
||||
* `appNamesRoute={false}` gives a pathname the bundle does not serve that same refusal from the
|
||||
* shell: with no screen for it here, nothing says the desktop is the one behind (a stale deep link
|
||||
* looks the same).
|
||||
*
|
||||
* Least specific of everything in its directory, so it takes only what expo-router would otherwise
|
||||
* send to Unmatched; every route that has a file keeps it, on both platforms.
|
||||
@@ -69,18 +70,16 @@ export default function MobileWebPageCatchAllScreen() {
|
||||
// with, so a screen reused across a route change would authorise frames under grants the page has
|
||||
// left behind.
|
||||
//
|
||||
// `fallback` is the refusal and only the refusal. `native-route` is the shell saying this build
|
||||
// cannot serve this pathname, which is the one question the refusal answers; the states it does
|
||||
// not reach answer different ones, and each is true for a screen only the page has. Offline means
|
||||
// the bundle that would serve it cannot be fetched, `checking` means the answer is not in yet,
|
||||
// and the wall means no page can be served at all. Absorbing those would tell someone with no
|
||||
// connection that the screen does not exist. `catch-all-page-route-states.test.tsx` drives them.
|
||||
// The shell's own states, not the refusal: offline means the bundle that would serve it cannot be
|
||||
// fetched, `checking` means the answer is not in yet, and a wall means no page can be served at
|
||||
// all. Absorbing those would tell someone with no connection that the screen does not exist.
|
||||
// `catch-all-page-route-states.test.tsx` drives them.
|
||||
return (
|
||||
<MobileWebShellScreen
|
||||
key={shellScreenRouteKey(decision.route)}
|
||||
hostId={hostId}
|
||||
route={decision.route}
|
||||
fallback={refusal}
|
||||
appNamesRoute={false}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
*
|
||||
* Off a wide layout a detail route is served as before; on one it is served only by a page that
|
||||
* declares `canOwnHostArea`, because an older page would draw its own sidebar beside the native
|
||||
* one. The session rebuilds on the change, so the page's in-page stack does not survive it.
|
||||
* one, so an older one is walled. The session rebuilds on the change, so the page's in-page stack
|
||||
* does not survive it.
|
||||
*/
|
||||
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
@@ -156,9 +157,9 @@ describe('a detail session across a layout-class change', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('goes native on a wide layout under a page without the declaration, and back', async () => {
|
||||
it('walls on a wide layout under a page without the declaration, and serves again after', async () => {
|
||||
expect(await settledKinds(false, [false, true, false])).toEqual({
|
||||
kinds: ['ready session-1', 'native-route', 'ready session-2'],
|
||||
kinds: ['ready session-1', 'wall', 'ready session-2'],
|
||||
manifestReads: 3
|
||||
})
|
||||
})
|
||||
|
||||
@@ -89,8 +89,7 @@ async function hopFromSession(viewport: { width: number; height: number }, href:
|
||||
mounted.tree = create(
|
||||
createElement(MobileWebShellScreen, {
|
||||
hostId: 'host-1',
|
||||
route: { pathname: DETAIL },
|
||||
fallback: null
|
||||
route: { pathname: DETAIL }
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
@@ -131,11 +131,16 @@ describe('the wide host-area session', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('stays native for no page and for a page without the declaration, host route or detail', () => {
|
||||
it('walls the desktop for no page and for a page without the declaration, host route or detail', () => {
|
||||
for (const pathname of [HOST_ROUTE, '/h/host-1/session/wt-1']) {
|
||||
for (const routes of [null, desktopRoutes(false)]) {
|
||||
expect(wideStateFor(routes, pathname).state.kind).toBe('native-route')
|
||||
}
|
||||
expect(wideStateFor(null, pathname).state).toEqual({
|
||||
kind: 'wall',
|
||||
verdict: { kind: 'blocked', reason: 'bundle-unavailable' }
|
||||
})
|
||||
expect(wideStateFor(desktopRoutes(false), pathname).state).toEqual({
|
||||
kind: 'wall',
|
||||
verdict: { kind: 'blocked', reason: 'page-route-unlisted' }
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -118,7 +118,7 @@ vi.mock('../navigation/host-stack', async () => {
|
||||
return (
|
||||
<serving.HostAreaServingContext.Provider value={recorded}>
|
||||
{env.hostSession === null ? null : (
|
||||
<MobileWebShellScreen hostId="host-1" route={{ pathname: HOST }} fallback={null} />
|
||||
<MobileWebShellScreen hostId="host-1" route={{ pathname: HOST }} />
|
||||
)}
|
||||
</serving.HostAreaServingContext.Provider>
|
||||
)
|
||||
|
||||
@@ -84,11 +84,7 @@ vi.mock('../navigation/host-stack', async () => {
|
||||
HostStack: () =>
|
||||
stack.hosts.map((hostId, index) => (
|
||||
<stack.Focused.Provider key={hostId} value={index === stack.hosts.length - 1}>
|
||||
<MobileWebShellScreen
|
||||
hostId={hostId}
|
||||
route={{ pathname: `/h/${hostId}` }}
|
||||
fallback={null}
|
||||
/>
|
||||
<MobileWebShellScreen hostId={hostId} route={{ pathname: `/h/${hostId}` }} />
|
||||
</stack.Focused.Provider>
|
||||
))
|
||||
}
|
||||
|
||||
@@ -2,9 +2,9 @@ import type {
|
||||
CachedGeneration,
|
||||
MobileWebShellBlockedVerdict,
|
||||
MobileWebShellSession,
|
||||
MobileWebShellSessionState,
|
||||
MobileWebShellStep
|
||||
} from './mobile-web-shell-session-contract'
|
||||
import { NATIVE_ROUTE } from './mobile-web-shell-gates'
|
||||
import { matchesRoutePattern, routeViewOf } from './page-route-policy'
|
||||
import { step } from './mobile-web-shell-session-step'
|
||||
|
||||
@@ -34,32 +34,32 @@ export function openCached(
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens a generation already on disk under its own route list, or leaves the route native when that
|
||||
* list does not carry it. The only judge available when the newer manifest is absent or refused:
|
||||
* Opens a generation already on disk under its own route list, or answers `unclaimed` when that list
|
||||
* does not carry it. The only judge available when the newer manifest is absent or refused:
|
||||
* opening under a bundle this shell is not running would grant the page what other bytes declared.
|
||||
*
|
||||
* The route question comes first and `wall` is asked only on the served branch, the order
|
||||
* `onManifestRead` takes: a route this bundle never claimed is not a screen to refuse, and a
|
||||
* generation cached before routes were listed claims none at all. `patch` belongs to either answer;
|
||||
* `served` is what only an opened page gets, so the native one carries no notice about an update
|
||||
* for a screen it is not showing.
|
||||
* `unclaimed` is the caller's because these bytes say nothing about what the host serves now: the
|
||||
* screen the newer manifest would have answered with, never a version wall. `wall` is asked only on
|
||||
* the served branch, since it judges bytes that are about to be shown. `patch` belongs to either
|
||||
* answer; `served` is what only an opened page gets.
|
||||
*/
|
||||
export function openByOwnRoutes(
|
||||
session: MobileWebShellSession,
|
||||
generation: CachedGeneration,
|
||||
options: {
|
||||
unclaimed: MobileWebShellSessionState
|
||||
patch?: Partial<MobileWebShellSession>
|
||||
served?: Partial<MobileWebShellSession>
|
||||
wall?: MobileWebShellBlockedVerdict | null
|
||||
} = {}
|
||||
}
|
||||
): MobileWebShellStep {
|
||||
const { patch = {}, served = {}, wall = null } = options
|
||||
const { unclaimed, patch = {}, served = {}, wall = null } = options
|
||||
const view = {
|
||||
...routeViewOf(generation.routes, session.routePathname, session.wide),
|
||||
routes: generation.routes
|
||||
}
|
||||
if (!rendersRoute(view.pageRoutes, session.routePathname)) {
|
||||
return step(session, { ...patch, ...view, state: NATIVE_ROUTE })
|
||||
return step(session, { ...patch, ...view, state: unclaimed })
|
||||
}
|
||||
if (wall !== null) {
|
||||
return step(session, { ...patch, ...view, state: { kind: 'wall', verdict: wall } })
|
||||
|
||||
@@ -11,7 +11,7 @@ import type {
|
||||
/** Shared because several transitions reach each of them, and two objects that are equal but not
|
||||
* identical are two renders where the reducer meant one. */
|
||||
export const CHECKING: MobileWebShellSessionState = { kind: 'checking' }
|
||||
export const NATIVE_ROUTE: MobileWebShellSessionState = { kind: 'native-route' }
|
||||
export const ROUTE_UNAVAILABLE: MobileWebShellSessionState = { kind: 'route-unavailable' }
|
||||
|
||||
/**
|
||||
* Whether a gates change may start or restart the flow.
|
||||
@@ -47,9 +47,6 @@ export type MobileWebShellGateVerdict =
|
||||
| { readonly kind: 'pending' }
|
||||
| { readonly kind: 'offline' }
|
||||
| { readonly kind: 'status-unreadable' }
|
||||
/** The desktop ships no bundle, so it lists no page route and this one is the native screen's.
|
||||
* Not a wall: a wall says the workspace cannot be opened, and here there is none to open. */
|
||||
| { readonly kind: 'native-route' }
|
||||
| { readonly kind: 'wall'; readonly verdict: MobileWebShellBlockedVerdict }
|
||||
| { readonly kind: 'open' }
|
||||
|
||||
@@ -75,14 +72,7 @@ export function gateVerdict(gates: MobileWebShellGates): MobileWebShellGateVerdi
|
||||
hostStatus: gates.hostStatus,
|
||||
manifest: null
|
||||
})
|
||||
if (verdict.kind !== 'blocked') {
|
||||
return { kind: 'open' }
|
||||
}
|
||||
// `bundle-unavailable` is the only verdict reachable with no manifest, and it is the one block
|
||||
// that is not a wall: a desktop with no bundle declares no page route, so every route is native.
|
||||
return verdict.reason === 'bundle-unavailable'
|
||||
? { kind: 'native-route' }
|
||||
: { kind: 'wall', verdict }
|
||||
return verdict.kind === 'blocked' ? { kind: 'wall', verdict } : { kind: 'open' }
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -101,8 +91,6 @@ export function gateState(
|
||||
retriedOnce: boolean
|
||||
): MobileWebShellSessionState | null {
|
||||
switch (verdict.kind) {
|
||||
case 'native-route':
|
||||
return NATIVE_ROUTE
|
||||
case 'wall':
|
||||
return { kind: 'wall', verdict: verdict.verdict }
|
||||
case 'status-unreadable':
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
MANIFEST,
|
||||
afterCacheRead,
|
||||
createMobileWebShellSession,
|
||||
gates,
|
||||
run
|
||||
} from './mobile-web-shell-session-test-fixtures'
|
||||
|
||||
const UNLISTED_WALL = { kind: 'wall', verdict: { kind: 'blocked', reason: 'page-route-unlisted' } }
|
||||
|
||||
describe('a manifest whose page does not serve this route walls instead of going native', () => {
|
||||
it('walls the desktop as too old for a route the bundle does not list', () => {
|
||||
const step = run(afterCacheRead(null).session, {
|
||||
type: 'manifest-read',
|
||||
manifest: { ...MANIFEST, routes: [{ pathname: '/h/[hostId]/tasks', grants: [] }] }
|
||||
})
|
||||
expect(step.session.state).toEqual(UNLISTED_WALL)
|
||||
expect(step.effects).toEqual([])
|
||||
})
|
||||
|
||||
it('walls this app as too old for a route listed with a grant it does not implement', () => {
|
||||
const step = run(afterCacheRead(null).session, {
|
||||
type: 'manifest-read',
|
||||
manifest: {
|
||||
...MANIFEST,
|
||||
routes: [{ pathname: '/h/[hostId]', grants: ['navigate', 'teleport'] }]
|
||||
}
|
||||
})
|
||||
expect(step.session.state).toEqual({
|
||||
kind: 'wall',
|
||||
verdict: { kind: 'blocked', reason: 'page-route-grant-missing' }
|
||||
})
|
||||
expect(step.effects).toEqual([])
|
||||
})
|
||||
|
||||
it('walls the desktop as too old when it predates the routes field itself', () => {
|
||||
const step = run(afterCacheRead(null).session, {
|
||||
type: 'manifest-read',
|
||||
manifest: { ...MANIFEST, routes: undefined }
|
||||
})
|
||||
expect(step.session.state).toEqual(UNLISTED_WALL)
|
||||
})
|
||||
|
||||
it('answers the compat wall before the route, since an unreadable schema hides the route list', () => {
|
||||
const step = run(afterCacheRead(null).session, {
|
||||
type: 'manifest-read',
|
||||
manifest: { ...MANIFEST, schemaVersion: 99, routes: [] }
|
||||
})
|
||||
expect(step.session.state).toEqual({
|
||||
kind: 'wall',
|
||||
verdict: { kind: 'blocked', reason: 'bundle-shell-too-old', schemaVersion: 99 }
|
||||
})
|
||||
})
|
||||
|
||||
it('walls a page below the floor as it is, whatever routes it lists', () => {
|
||||
const step = run(afterCacheRead(null).session, {
|
||||
type: 'manifest-read',
|
||||
manifest: { ...MANIFEST, pageVersion: 0, routes: [] }
|
||||
})
|
||||
expect(step.session.state).toMatchObject({
|
||||
kind: 'wall',
|
||||
verdict: { reason: 'bundle-incompatible', side: 'desktop', pageVersion: 0 }
|
||||
})
|
||||
})
|
||||
|
||||
it('says the screen is unavailable, not the desktop too old, for a pathname this app has no screen for', () => {
|
||||
const opened = run(
|
||||
createMobileWebShellSession('/h/host-1/insights', false, false),
|
||||
{ type: 'gates-changed', gates: gates() },
|
||||
{ type: 'cache-read', generation: null }
|
||||
)
|
||||
const step = run(opened.session, { type: 'manifest-read', manifest: MANIFEST })
|
||||
expect(step.session.state).toEqual({ kind: 'route-unavailable' })
|
||||
})
|
||||
|
||||
it('says the screen is unavailable when the manifest carries a pattern this shell cannot read', () => {
|
||||
const step = run(afterCacheRead(null).session, {
|
||||
type: 'manifest-read',
|
||||
manifest: { ...MANIFEST, routes: [{ pathname: '/h/[...a]/[...b]', grants: [] }] }
|
||||
})
|
||||
expect(step.session.state).toEqual({ kind: 'route-unavailable' })
|
||||
})
|
||||
|
||||
it('keeps that answer across a layout flip, which rebuilds the session', () => {
|
||||
const opened = run(
|
||||
createMobileWebShellSession('/h/host-1/insights', false, false),
|
||||
{ type: 'gates-changed', gates: gates() },
|
||||
{ type: 'cache-read', generation: null },
|
||||
{ type: 'manifest-read', manifest: MANIFEST }
|
||||
)
|
||||
expect(run(opened.session, { type: 'layout-changed', wide: true }).session.appNamesRoute).toBe(
|
||||
false
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -11,6 +11,8 @@ import {
|
||||
withoutRecord
|
||||
} from './mobile-web-shell-session-test-fixtures'
|
||||
|
||||
const DOWNLOAD_FAILED = { kind: 'failed', reason: 'download-failed', retriedOnce: false }
|
||||
|
||||
/**
|
||||
* The host is up, it serves a generation this shell could not take, and one that works is on disk.
|
||||
*
|
||||
@@ -126,21 +128,20 @@ describe('an update this shell refused falls back to the generation that already
|
||||
expect(refused().session.updateNotice).toBe('update-failed')
|
||||
})
|
||||
|
||||
it('leaves the route native when the cached bundle never carried it, before any wall', () => {
|
||||
// The order `onManifestRead` takes, and the one the compat wall broke: a route this bundle
|
||||
// does not serve is not a screen to refuse. Below the host's floor as well, so the only thing
|
||||
// that can produce `native-route` here is the route question being asked first.
|
||||
it('shows the download failure when the cached bundle never carried the route, before any wall', () => {
|
||||
// The newer manifest claimed the route, so the cache lacking it is this download's failure.
|
||||
// Below the host's floor as well: the wall judges bytes about to be shown, and these are not.
|
||||
const fetching = run(afterCacheRead({ ...CACHED_BELOW_HOST_FLOOR, routes: [] }).session, {
|
||||
type: 'manifest-read',
|
||||
manifest: NEWER
|
||||
})
|
||||
const step = run(fetching.session, { type: 'download-failed', cause: BUNDLE_REFUSED })
|
||||
expect(step.session.state).toEqual({ kind: 'native-route' })
|
||||
expect(step.session.state).toEqual(DOWNLOAD_FAILED)
|
||||
expect(step.session.updateNotice).toBeNull()
|
||||
expect(withoutRecord(step.effects)).toEqual([])
|
||||
})
|
||||
|
||||
it('leaves it native for a generation from before routes were listed at all', () => {
|
||||
it('shows the download failure for a generation from before routes were listed at all', () => {
|
||||
// `routes: undefined` is every generation cached by a desktop older than the field. It claims
|
||||
// no route, so it cannot claim this one, and it must not be walled for that.
|
||||
const fetching = run(
|
||||
@@ -148,7 +149,7 @@ describe('an update this shell refused falls back to the generation that already
|
||||
{ type: 'manifest-read', manifest: NEWER }
|
||||
)
|
||||
const step = run(fetching.session, { type: 'download-failed', cause: BUNDLE_REFUSED })
|
||||
expect(step.session.state).toEqual({ kind: 'native-route' })
|
||||
expect(step.session.state).toEqual(DOWNLOAD_FAILED)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -171,11 +172,13 @@ describe('the fallback answers each gate verdict the way the rest of the shell d
|
||||
return run(moved.session, { type: 'download-failed', cause: BUNDLE_REFUSED })
|
||||
}
|
||||
|
||||
it('answers native-route when the host has stopped serving a bundle at all', () => {
|
||||
// Not a wall and not the cached page: a desktop that ships no bundle declares no page route,
|
||||
// so this route is the native screen's, which is what `gateVerdict` answers everywhere else.
|
||||
it('walls the host when it has stopped serving a bundle at all', () => {
|
||||
// Not the cached page: the gate's own answer, which is what `gateVerdict` answers elsewhere.
|
||||
const step = refusedUnder({ hostCapabilities: [] })
|
||||
expect(step.session.state).toEqual({ kind: 'native-route' })
|
||||
expect(step.session.state).toEqual({
|
||||
kind: 'wall',
|
||||
verdict: { kind: 'blocked', reason: 'bundle-unavailable' }
|
||||
})
|
||||
expect(step.session.updateNotice).toBeNull()
|
||||
expect(withoutRecord(step.effects)).toEqual([])
|
||||
})
|
||||
|
||||
@@ -156,11 +156,6 @@ export function resetScreenDependencies(dependencies: ScreenDependencies): void
|
||||
dependencies.backSends = 0
|
||||
}
|
||||
|
||||
/** The caller's native screen, as a component so `findAllByType` can name it without a host string. */
|
||||
export function NativeFallback(): null {
|
||||
return null
|
||||
}
|
||||
|
||||
export function readyState(sessionId: string): MobileWebShellSessionState {
|
||||
return {
|
||||
kind: 'ready',
|
||||
@@ -196,14 +191,12 @@ export function unmountRenderedScreens(): void {
|
||||
export type ScreenComponent = ComponentType<{
|
||||
hostId: string
|
||||
route: { pathname: string }
|
||||
fallback: ReactElement
|
||||
}>
|
||||
|
||||
function element(Screen: ScreenComponent): ReactElement {
|
||||
return createElement(Screen, {
|
||||
hostId: 'host-1',
|
||||
route: { pathname: '/h/host-1' },
|
||||
fallback: createElement(NativeFallback)
|
||||
route: { pathname: '/h/host-1' }
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { MobileWebShellFailureReason } from '../../modules/orca-mobile-web-shell/src/load-state'
|
||||
import type { MobileWebPageRoute } from './page-route-policy'
|
||||
import type { MobileWebPageRoute, PageRouteRefusalVerdict } from './page-route-policy'
|
||||
import type {
|
||||
MobileWebBundleCompatManifest,
|
||||
MobileWebBundleCompatVerdict,
|
||||
@@ -39,7 +39,7 @@ export type MobileWebShellManifestFacts = MobileWebBundleCompatManifest & {
|
||||
readonly buildId: string
|
||||
readonly totalBytes: number
|
||||
readonly totalAssets: number
|
||||
/** Undefined for a desktop older than the field, which is every route staying native. */
|
||||
/** Undefined for a desktop older than the field, which serves no route. */
|
||||
readonly routes: readonly MobileWebPageRoute[] | undefined
|
||||
}
|
||||
|
||||
@@ -57,10 +57,9 @@ export type CachedGeneration = {
|
||||
readonly compat: MobileWebBundleCompatManifest
|
||||
}
|
||||
|
||||
export type MobileWebShellBlockedVerdict = Extract<
|
||||
MobileWebBundleCompatVerdict,
|
||||
{ kind: 'blocked' }
|
||||
>
|
||||
export type MobileWebShellBlockedVerdict =
|
||||
| Extract<MobileWebBundleCompatVerdict, { kind: 'blocked' }>
|
||||
| PageRouteRefusalVerdict
|
||||
|
||||
/** The shell's own failures plus the one the view cannot report: a download or a cache write that
|
||||
* never produced a generation to hand it. */
|
||||
@@ -105,14 +104,9 @@ export type MobileWebShellSessionState =
|
||||
readonly totalBytes: number
|
||||
readonly elapsedMs: number
|
||||
}
|
||||
/**
|
||||
* This route is the native screen's, and the caller renders it.
|
||||
*
|
||||
* Either the bundle does not list the route, or it lists it needing a grant this shell does not
|
||||
* implement, or the desktop ships no bundle at all. Not a failure and not a wall: every route
|
||||
* starts native, and the negotiation saying no leaves it where it was.
|
||||
*/
|
||||
| { readonly kind: 'native-route' }
|
||||
/** The page does not serve this screen and neither app can be named as the one to update: a
|
||||
* pathname this app has no screen for, or a manifest pattern this shell cannot read. */
|
||||
| { readonly kind: 'route-unavailable' }
|
||||
| { readonly kind: 'wall'; readonly verdict: MobileWebShellBlockedVerdict }
|
||||
| {
|
||||
readonly kind: 'failed'
|
||||
@@ -224,6 +218,9 @@ export type MobileWebShellSessionEvent =
|
||||
export type MobileWebShellSession = {
|
||||
/** The concrete route this session was opened for, matched against what the bundle lists. */
|
||||
readonly routePathname: string
|
||||
/** False for the catch-all's pathnames: with no screen of its own here, an unserved one is not
|
||||
* evidence of an older desktop. */
|
||||
readonly appNamesRoute: boolean
|
||||
/** A wide layout: served only under `canOwnHostArea`; the host route gets every route's grants. */
|
||||
readonly wide: boolean
|
||||
/** The route list the view below was built from, so a layout-class change can rebuild it. */
|
||||
|
||||
@@ -16,6 +16,8 @@ import {
|
||||
} from './mobile-web-shell-session-test-fixtures'
|
||||
import { shellPageFrame } from './shell-page-frame'
|
||||
|
||||
const NO_BUNDLE_WALL = { kind: 'wall', verdict: { kind: 'blocked', reason: 'bundle-unavailable' } }
|
||||
|
||||
describe('the gates decide whether a step is taken at all', () => {
|
||||
it('waits while a connection is still being made', () => {
|
||||
const step = started({ reachability: 'connecting' })
|
||||
@@ -46,11 +48,9 @@ describe('the gates decide whether a step is taken at all', () => {
|
||||
expect(step.effects).toEqual([{ kind: 'open-cache' }])
|
||||
})
|
||||
|
||||
it('leaves the route native for a readable host that serves no bundle', () => {
|
||||
// Not a wall: a wall says the workspace cannot be opened, and a desktop with no bundle declares
|
||||
// no page route, so there is nothing to open and the native screen is where this already was.
|
||||
it('walls a readable host that serves no bundle rather than falling back to a native screen', () => {
|
||||
const step = started({ hostCapabilities: [] })
|
||||
expect(step.session.state).toEqual({ kind: 'native-route' })
|
||||
expect(step.session.state).toEqual(NO_BUNDLE_WALL)
|
||||
expect(step.effects).toEqual([])
|
||||
})
|
||||
|
||||
@@ -64,13 +64,13 @@ describe('the gates decide whether a step is taken at all', () => {
|
||||
})
|
||||
|
||||
describe('the offline rule', () => {
|
||||
it('leaves an unreachable host native when its cached bundle lists no such route', () => {
|
||||
it("shows offline, not a wall, when an unreachable host's cached bundle lists no such route", () => {
|
||||
const offline = started({ reachability: 'unreachable' })
|
||||
const step = run(offline.session, {
|
||||
type: 'cache-read',
|
||||
generation: { ...CACHED, routes: [] }
|
||||
})
|
||||
expect(step.session.state).toEqual({ kind: 'native-route' })
|
||||
expect(step.session.state).toEqual({ kind: 'offline' })
|
||||
expect(step.effects).toEqual([])
|
||||
})
|
||||
|
||||
@@ -171,45 +171,6 @@ describe('the connected flow', () => {
|
||||
expect(step.effects).toEqual([{ kind: 'download' }])
|
||||
})
|
||||
|
||||
it('downloads nothing for a route the bundle does not list', () => {
|
||||
const step = run(afterCacheRead(null).session, {
|
||||
type: 'manifest-read',
|
||||
manifest: { ...MANIFEST, routes: [{ pathname: '/h/[hostId]/tasks', grants: [] }] }
|
||||
})
|
||||
expect(step.session.state).toEqual({ kind: 'native-route' })
|
||||
expect(step.effects).toEqual([])
|
||||
})
|
||||
|
||||
it('downloads nothing for a route listed with a grant this shell does not implement', () => {
|
||||
const step = run(afterCacheRead(null).session, {
|
||||
type: 'manifest-read',
|
||||
manifest: {
|
||||
...MANIFEST,
|
||||
routes: [{ pathname: '/h/[hostId]', grants: ['navigate', 'teleport'] }]
|
||||
}
|
||||
})
|
||||
expect(step.session.state).toEqual({ kind: 'native-route' })
|
||||
expect(step.effects).toEqual([])
|
||||
})
|
||||
|
||||
it('leaves the route native for a desktop older than the field itself', () => {
|
||||
const step = run(afterCacheRead(null).session, {
|
||||
type: 'manifest-read',
|
||||
manifest: { ...MANIFEST, routes: undefined }
|
||||
})
|
||||
expect(step.session.state).toEqual({ kind: 'native-route' })
|
||||
})
|
||||
|
||||
it('answers the route before it answers the wall, since a native route has none to show', () => {
|
||||
// A bundle this shell cannot open is not a reason to refuse a screen it was never going to
|
||||
// open: the wall belongs to the page, and this route is the native screen's.
|
||||
const step = run(afterCacheRead(null).session, {
|
||||
type: 'manifest-read',
|
||||
manifest: { ...MANIFEST, schemaVersion: 99, routes: [] }
|
||||
})
|
||||
expect(step.session.state).toEqual({ kind: 'native-route' })
|
||||
})
|
||||
|
||||
it('still walls a listed route whose bundle this shell cannot read', () => {
|
||||
const step = run(afterCacheRead(null).session, {
|
||||
type: 'manifest-read',
|
||||
@@ -408,13 +369,13 @@ describe('recovery follows the shell view contract', () => {
|
||||
expect(rearmed.effects).toEqual([{ kind: 'open-cache' }])
|
||||
})
|
||||
|
||||
it('hands a recovery back to the native screen when the host serves no bundle', () => {
|
||||
it('walls a recovery when the host no longer serves a bundle', () => {
|
||||
const stale = run(readySession().session, {
|
||||
type: 'gates-changed',
|
||||
gates: gates({ hostCapabilities: [] })
|
||||
})
|
||||
const step = run(stale.session, { type: 'shell-failed', reason: 'document-load-failed' })
|
||||
expect(step.session.state).toEqual({ kind: 'native-route' })
|
||||
expect(step.session.state).toEqual(NO_BUNDLE_WALL)
|
||||
// The cache still goes: the bytes that failed are suspect whatever screen follows them.
|
||||
expect(step.effects).toEqual([{ kind: 'delete-cache' }])
|
||||
})
|
||||
@@ -520,10 +481,10 @@ describe('try again', () => {
|
||||
expect(again.effects).toEqual([{ kind: 'delete-cache' }, { kind: 'open-cache' }])
|
||||
})
|
||||
|
||||
it('stays native rather than looping when the host still serves no bundle', () => {
|
||||
const native = started({ hostCapabilities: [] })
|
||||
const retried = run(native.session, { type: 'retry-pressed' })
|
||||
expect(retried.session.state).toEqual({ kind: 'native-route' })
|
||||
it('stays walled rather than looping when the host still serves no bundle', () => {
|
||||
const walled = started({ hostCapabilities: [] })
|
||||
const retried = run(walled.session, { type: 'retry-pressed' })
|
||||
expect(retried.session.state).toEqual(NO_BUNDLE_WALL)
|
||||
expect(retried.effects).toEqual([])
|
||||
})
|
||||
|
||||
@@ -684,13 +645,13 @@ describe('a gates change that says nothing new starts nothing', () => {
|
||||
expect(settled.effects).toEqual([{ kind: 'open-cache' }])
|
||||
})
|
||||
|
||||
it('drops a check in flight to the native screen when the host stops serving a bundle', () => {
|
||||
it('walls a check in flight when the host stops serving a bundle', () => {
|
||||
const checking = started()
|
||||
const step = run(checking.session, {
|
||||
type: 'gates-changed',
|
||||
gates: gates({ hostCapabilities: [] })
|
||||
})
|
||||
expect(step.session.state).toEqual({ kind: 'native-route' })
|
||||
expect(step.session.state).toEqual(NO_BUNDLE_WALL)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -22,9 +22,9 @@ import {
|
||||
gateKey,
|
||||
gateState,
|
||||
gateVerdict,
|
||||
NATIVE_ROUTE
|
||||
ROUTE_UNAVAILABLE
|
||||
} from './mobile-web-shell-gates'
|
||||
import { routeViewOf, sameRouteView } from './page-route-policy'
|
||||
import { pageRouteRefusalVerdict, routeViewOf, sameRouteView } from './page-route-policy'
|
||||
import { CLEAR_PAGE_DOCUMENT_STATE, pageDocumentStatePatch } from './page-document-state'
|
||||
import { openByOwnRoutes, openCached, rendersRoute } from './mobile-web-shell-cached-generation'
|
||||
import { step } from './mobile-web-shell-session-step'
|
||||
@@ -32,10 +32,12 @@ import { AWAIT_DOCUMENT_LOAD, reducePageDocumentLifecycle } from './page-documen
|
||||
|
||||
export function createMobileWebShellSession(
|
||||
routePathname: string,
|
||||
wide = false
|
||||
wide = false,
|
||||
appNamesRoute = true
|
||||
): MobileWebShellSession {
|
||||
return {
|
||||
routePathname,
|
||||
appNamesRoute,
|
||||
wide,
|
||||
pageRoutes: [],
|
||||
pageRouteGrants: [],
|
||||
@@ -105,8 +107,13 @@ function onCacheRead(
|
||||
if (generation === null) {
|
||||
return step(session, { cached: null, state: { kind: 'offline' } })
|
||||
}
|
||||
// The cached bundle's own list, which is the only one an unreachable host can be judged by.
|
||||
return openByOwnRoutes(session, generation, { patch: { cached: generation } })
|
||||
// The cached bundle's own list, which is the only one an unreachable host can be judged by. A
|
||||
// route it lacks is the offline screen, as with nothing cached: an unreachable host cannot be
|
||||
// told too old, and connecting is what fetches the bundle that may carry the route.
|
||||
return openByOwnRoutes(session, generation, {
|
||||
unclaimed: { kind: 'offline' },
|
||||
patch: { cached: generation }
|
||||
})
|
||||
}
|
||||
return step(session, { cached: generation, state: CHECKING }, [{ kind: 'read-manifest' }])
|
||||
}
|
||||
@@ -119,15 +126,8 @@ function onManifestRead(
|
||||
if (gates === null) {
|
||||
return step(session, {})
|
||||
}
|
||||
// Before the compat verdict, because a route that stays native has nothing to wall about: a
|
||||
// bundle this shell could not open is not a reason to refuse a screen it was never going to open.
|
||||
const view = {
|
||||
...routeViewOf(manifest.routes, session.routePathname, session.wide),
|
||||
routes: manifest.routes
|
||||
}
|
||||
if (!rendersRoute(view.pageRoutes, session.routePathname)) {
|
||||
return step(session, { ...view, state: NATIVE_ROUTE })
|
||||
}
|
||||
// Before the route question: a page below the floor is a desktop too old whatever it lists, and
|
||||
// a schema this shell cannot read leaves the route list unreadable too.
|
||||
const verdict = evaluateMobileWebBundleCompat({
|
||||
hostCapabilities: gates.hostCapabilities,
|
||||
hostStatus: gates.hostStatus,
|
||||
@@ -136,6 +136,19 @@ function onManifestRead(
|
||||
if (verdict.kind === 'blocked') {
|
||||
return step(session, { state: { kind: 'wall', verdict } })
|
||||
}
|
||||
const view = {
|
||||
...routeViewOf(manifest.routes, session.routePathname, session.wide),
|
||||
routes: manifest.routes
|
||||
}
|
||||
if (!rendersRoute(view.pageRoutes, session.routePathname)) {
|
||||
// The view is kept so a layout flip that would serve the route restarts the session.
|
||||
const refusal = session.appNamesRoute
|
||||
? pageRouteRefusalVerdict(manifest.routes, session.routePathname, session.wide)
|
||||
: null
|
||||
const state =
|
||||
refusal === null ? ROUTE_UNAVAILABLE : ({ kind: 'wall', verdict: refusal } as const)
|
||||
return step(session, { ...view, state })
|
||||
}
|
||||
// Same build id is the same bytes (the id is their digest), so the generation on disk opens as is.
|
||||
const cached = session.cached
|
||||
if (cached?.buildId === manifest.buildId) {
|
||||
@@ -253,6 +266,9 @@ function decideDownloadFailed(
|
||||
return step(session, { state: gated })
|
||||
}
|
||||
return openByOwnRoutes(session, cached, {
|
||||
// The newer manifest claimed this route or never arrived, so the cache lacking it is this
|
||||
// download's failure, with its retry.
|
||||
unclaimed: { kind: 'failed', reason: 'download-failed', retriedOnce: session.retriedOnce },
|
||||
served: { updateNotice: readFailureSide(cause.reason) === 'bundle' ? 'update-failed' : null },
|
||||
wall: cachedGenerationWall(verdict, gates, cached.compat)
|
||||
})
|
||||
@@ -266,7 +282,10 @@ function onLayoutChanged(session: MobileWebShellSession, wide: boolean): MobileW
|
||||
return step(session, { wide })
|
||||
}
|
||||
// Still counting flows, so nothing the old session has in flight lands on this one.
|
||||
const fresh = { ...createMobileWebShellSession(session.routePathname, wide), flow: session.flow }
|
||||
const fresh = {
|
||||
...createMobileWebShellSession(session.routePathname, wide, session.appNamesRoute),
|
||||
flow: session.flow
|
||||
}
|
||||
return startFlow(fresh, session.gates)
|
||||
}
|
||||
|
||||
|
||||
@@ -12,8 +12,8 @@ export function updateFailureOutcomeOf(
|
||||
case 'activating':
|
||||
case 'ready':
|
||||
return { outcome: 'opened-cached', wall: null }
|
||||
case 'native-route':
|
||||
return { outcome: 'native-route', wall: null }
|
||||
case 'route-unavailable':
|
||||
return { outcome: 'route-unavailable', wall: null }
|
||||
case 'wall':
|
||||
return { outcome: 'wall', wall: updateFailureWallOf(state.verdict) }
|
||||
case 'failed':
|
||||
|
||||
@@ -106,10 +106,10 @@ describe('every exit from a failed update read leaves one record', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('a cached generation that never carried this route leaves it native', () => {
|
||||
it('a cached generation that never carried this route shows the download failure', () => {
|
||||
const step = refusedUnder({}, { ...CACHED, routes: [] })
|
||||
expect(step.session.state).toEqual({ kind: 'native-route' })
|
||||
expect(recorded(step)).toMatchObject([{ outcome: 'native-route', wall: null }])
|
||||
expect(step.session.state).toMatchObject({ kind: 'failed', reason: 'download-failed' })
|
||||
expect(recorded(step)).toMatchObject([{ outcome: 'failed', wall: null }])
|
||||
})
|
||||
|
||||
it('gates gone offline under the fetch open the cache unjudged', () => {
|
||||
@@ -124,10 +124,10 @@ describe('every exit from a failed update read leaves one record', () => {
|
||||
expect(recorded(step)).toMatchObject([{ outcome: 'failed' }])
|
||||
})
|
||||
|
||||
it('gates that dropped the bundle capability leave the route native', () => {
|
||||
it('gates that dropped the bundle capability wall the host', () => {
|
||||
const step = refusedUnder({ hostCapabilities: [] })
|
||||
expect(step.session.state).toEqual({ kind: 'native-route' })
|
||||
expect(recorded(step)).toMatchObject([{ outcome: 'native-route' }])
|
||||
expect(step.session.state).toMatchObject({ kind: 'wall' })
|
||||
expect(recorded(step)).toMatchObject([{ outcome: 'wall', wall: 'bundle-unavailable' }])
|
||||
})
|
||||
|
||||
it('gates still dialling or pending leave the session waiting', () => {
|
||||
|
||||
@@ -3,7 +3,7 @@ import {
|
||||
isMobileWebBundleTransportFailure,
|
||||
readMobileWebBundleErrorCode
|
||||
} from '../transport/mobile-web-bundle-operations'
|
||||
import type { MobileWebBundleCompatVerdict } from '../transport/mobile-web-bundle-compat'
|
||||
import type { MobileWebShellBlockedVerdict } from './mobile-web-shell-session-contract'
|
||||
import {
|
||||
MOBILE_WEB_BUNDLE_FETCH_REFUSALS,
|
||||
MobileWebBundleFetchError
|
||||
@@ -69,7 +69,9 @@ export function updateFailureCauseOf(error: unknown): MobileWebShellUpdateFailur
|
||||
/** What the shell put on screen instead of the generation it could not take. */
|
||||
export const MOBILE_WEB_SHELL_UPDATE_FAILURE_OUTCOMES = [
|
||||
'opened-cached',
|
||||
// Written by builds that still fell back to native screens; kept so their records still read.
|
||||
'native-route',
|
||||
'route-unavailable',
|
||||
'wall',
|
||||
'failed',
|
||||
'waiting'
|
||||
@@ -83,13 +85,15 @@ export const MOBILE_WEB_SHELL_UPDATE_FAILURE_WALLS = [
|
||||
'bundle-unavailable',
|
||||
'bundle-shell-too-old',
|
||||
'host-too-old-for-bundle',
|
||||
'bundle-too-old-for-host'
|
||||
'bundle-too-old-for-host',
|
||||
'page-route-unlisted',
|
||||
'page-route-grant-missing'
|
||||
] as const
|
||||
|
||||
export type MobileWebShellUpdateFailureWall = (typeof MOBILE_WEB_SHELL_UPDATE_FAILURE_WALLS)[number]
|
||||
|
||||
export function updateFailureWallOf(
|
||||
verdict: Extract<MobileWebBundleCompatVerdict, { kind: 'blocked' }>
|
||||
verdict: MobileWebShellBlockedVerdict
|
||||
): MobileWebShellUpdateFailureWall {
|
||||
if (verdict.reason !== 'bundle-incompatible') {
|
||||
return verdict.reason
|
||||
|
||||
@@ -3,9 +3,9 @@ import { z } from 'zod'
|
||||
import { MobileWebBundleRouteSchema } from '../../../src/shared/mobile-web-bundle/manifest-contract'
|
||||
import {
|
||||
matchesRoutePattern,
|
||||
pageRendersRoute,
|
||||
MOBILE_WEB_SHELL_GRANTS,
|
||||
grantsForRoute,
|
||||
pageRouteRefusalVerdict,
|
||||
routePatternRefusal,
|
||||
routeViewOf
|
||||
} from './page-route-policy'
|
||||
@@ -19,6 +19,13 @@ import {
|
||||
} from './bridge/bridge-native-verbs'
|
||||
import { BRIDGE_SCREENCAST_BINARY_GRANT } from './bridge/bridge-screencast-grant'
|
||||
|
||||
/** Whether a narrow session on `pathname` would be served the page under `routes`. */
|
||||
function servesRoute(routes: Parameters<typeof routeViewOf>[0], pathname: string): boolean {
|
||||
return routeViewOf(routes, pathname).pageRoutes.some((pattern) =>
|
||||
matchesRoutePattern(pathname, pattern)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The patterns a session would be told it may keep, read through the view the reducer builds.
|
||||
*
|
||||
@@ -126,7 +133,7 @@ describe('a pattern with a rest segment', () => {
|
||||
|
||||
it('serves such a route and grants it what it declared', () => {
|
||||
const routes = [{ pathname: PATTERN, grants: ['navigate', 'storage'] }]
|
||||
expect(pageRendersRoute(routes, '/h/a/insights/deep/er')).toBe(true)
|
||||
expect(servesRoute(routes, '/h/a/insights/deep/er')).toBe(true)
|
||||
expect(grantsForRoute(routes, '/h/a/insights/deep/er')).toEqual(['navigate', 'storage'])
|
||||
})
|
||||
|
||||
@@ -170,14 +177,14 @@ describe('the routes this shell will render from the page', () => {
|
||||
|
||||
it('answers nothing for a desktop older than the field', () => {
|
||||
expect(pageRoutesOf(undefined)).toEqual([])
|
||||
expect(pageRendersRoute(undefined, '/h/host-1')).toBe(false)
|
||||
expect(servesRoute(undefined, '/h/host-1')).toBe(false)
|
||||
})
|
||||
|
||||
it('answers the two halves of the negotiation together', () => {
|
||||
const routes = [{ pathname: '/h/[hostId]', grants: ['navigate'] }]
|
||||
expect(pageRendersRoute(routes, '/h/host-1')).toBe(true)
|
||||
expect(pageRendersRoute(routes, '/h/host-1/tasks')).toBe(false)
|
||||
expect(pageRendersRoute([], '/h/host-1')).toBe(false)
|
||||
expect(servesRoute(routes, '/h/host-1')).toBe(true)
|
||||
expect(servesRoute(routes, '/h/host-1/tasks')).toBe(false)
|
||||
expect(servesRoute([], '/h/host-1')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -592,3 +599,16 @@ describe('a shell whose policy has never heard of the optional lane', () => {
|
||||
expect(grantsForRoute(written, opened)).toEqual(['navigate', 'storage', 'screencastBinary'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('why the page does not serve a route', () => {
|
||||
const reasonFor = (...args: Parameters<typeof pageRouteRefusalVerdict>) =>
|
||||
pageRouteRefusalVerdict(...args)?.reason
|
||||
|
||||
it('blames the desktop on a wide layout when its page cannot own the host area', () => {
|
||||
const routes = [{ pathname: '/h/[hostId]', grants: [] }]
|
||||
expect(reasonFor(routes, '/h/a', true)).toBe('page-route-unlisted')
|
||||
expect(
|
||||
reasonFor([{ ...routes[0]!, grants: ['teleport'], canOwnHostArea: true }], '/h/a', true)
|
||||
).toBe('page-route-grant-missing')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -11,9 +11,8 @@ export type MobileWebPageRoute = NonNullable<MobileWebBundleManifestRead['routes
|
||||
* What this app build does on a page's behalf.
|
||||
*
|
||||
* Every name here is a capability the shell implements and will honour over the bridge, and it is
|
||||
* the same list `init.grants.native` tells the page it has. A bundle naming a grant absent from
|
||||
* here renders its native screen instead: an old app against a new bundle lands on a screen that
|
||||
* works rather than on a tap that does nothing.
|
||||
* the same list `init.grants.native` tells the page it has. A route naming a grant absent from
|
||||
* here is not served: this app asks to be updated rather than landing on a tap that does nothing.
|
||||
*/
|
||||
export const MOBILE_WEB_SHELL_GRANTS = [
|
||||
'navigate',
|
||||
@@ -65,7 +64,7 @@ export type RoutePatternRefusal = (typeof ROUTE_PATTERN_REFUSALS)[number]
|
||||
* Whether a manifest pattern is one this matcher can read, and what is wrong with it if not.
|
||||
*
|
||||
* One rest segment, trailing, is the shape expo-router's own file system can produce and the only
|
||||
* one this reads. Anything else is refused by name and matches nothing, so its route stays native
|
||||
* one this reads. Anything else is refused by name and matches nothing, so its route is not served
|
||||
* — the same answer a phone too old to have heard of rest segments gives, and the answer a shell
|
||||
* should give for a pattern whose tail it cannot say it understood.
|
||||
*
|
||||
@@ -124,8 +123,8 @@ export function matchesRoutePattern(pathname: string, pattern: string): boolean
|
||||
/**
|
||||
* The routes this shell will render from the page: listed, and needing nothing it lacks.
|
||||
*
|
||||
* `every` and not `some`: one grant this build lacks takes the whole route native, so a token every
|
||||
* page route declares couples the whole set to a shell that carries it — `haptics` is the first,
|
||||
* `every` and not `some`: one grant this build lacks leaves the whole route unserved, so a token
|
||||
* every page route declares couples the whole set to a shell that carries it — `haptics` is the first,
|
||||
* and against a shell without it no page route is served at all.
|
||||
*/
|
||||
function implementedPageRouteEntries(
|
||||
@@ -151,25 +150,6 @@ function effectiveRouteGrants(route: MobileWebPageRoute): string[] {
|
||||
return [...route.grants, ...(route.optionalGrants ?? [])].filter(implementsGrant)
|
||||
}
|
||||
|
||||
/** The patterns alone, for the readers in this module that only name routes. */
|
||||
function implementedPageRoutes(routes: readonly MobileWebPageRoute[] | undefined): string[] {
|
||||
return implementedPageRouteEntries(routes).map((route) => route.pathname)
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the page renders this route, rather than the native screen.
|
||||
*
|
||||
* Both halves are the negotiation: the desktop lists what it has proved on the web, and the shell
|
||||
* answers for what it can do. Either side saying no leaves the route native, which is where every
|
||||
* route starts and what every phone already ships.
|
||||
*/
|
||||
export function pageRendersRoute(
|
||||
routes: readonly MobileWebPageRoute[] | undefined,
|
||||
pathname: string
|
||||
): boolean {
|
||||
return implementedPageRoutes(routes).some((pattern) => matchesRoutePattern(pathname, pattern))
|
||||
}
|
||||
|
||||
/**
|
||||
* The grants one page session gets: what this shell implements, narrowed to what the route it was
|
||||
* opened for declared.
|
||||
@@ -220,8 +200,7 @@ export function routeViewOf(
|
||||
) {
|
||||
const entries = implementedPageRouteEntries(routes)
|
||||
const root = hostAreaRoot(pathname)
|
||||
// Native unless the page declares it: an older page draws its own sidebar beside the native one.
|
||||
if (wide && (root === null || !pageCanOwnHostArea(entries, root))) {
|
||||
if (!servesWideLayout(entries, pathname, wide)) {
|
||||
return { pageRoutes: [], pageRouteGrants: [], routeGrants: [], ownsHostArea: false }
|
||||
}
|
||||
const ownsHostArea = wide && pathname === root
|
||||
@@ -281,6 +260,46 @@ function pageCanOwnHostArea(entries: readonly MobileWebPageRoute[], root: string
|
||||
)
|
||||
}
|
||||
|
||||
/** Wide sessions need a page that draws the host area: an older page draws its own sidebar beside
|
||||
* the native one. */
|
||||
function servesWideLayout(
|
||||
entries: readonly MobileWebPageRoute[],
|
||||
pathname: string,
|
||||
wide: boolean
|
||||
): boolean {
|
||||
const root = hostAreaRoot(pathname)
|
||||
return !wide || (root !== null && pageCanOwnHostArea(entries, root))
|
||||
}
|
||||
|
||||
/**
|
||||
* Why the page will not serve a route `routeViewOf` left out, as a wall the shell shows, or null
|
||||
* when a declared pattern this shell cannot read might have covered it and no side can be blamed.
|
||||
*
|
||||
* Split on whether a shell implementing every grant would serve it: if so the page is ahead of this
|
||||
* app, and otherwise the desktop's page predates the screen.
|
||||
*/
|
||||
export type PageRouteRefusalVerdict =
|
||||
| { readonly kind: 'blocked'; readonly reason: 'page-route-unlisted' }
|
||||
| { readonly kind: 'blocked'; readonly reason: 'page-route-grant-missing' }
|
||||
|
||||
export function pageRouteRefusalVerdict(
|
||||
routes: readonly MobileWebPageRoute[] | undefined,
|
||||
pathname: string,
|
||||
wide: boolean
|
||||
): PageRouteRefusalVerdict | null {
|
||||
const declared = routes ?? []
|
||||
if (declared.some((route) => routePatternRefusal(route.pathname) !== null)) {
|
||||
return null
|
||||
}
|
||||
const servedWithEveryGrant =
|
||||
servesWideLayout(declared, pathname, wide) &&
|
||||
declared.some((route) => matchesRoutePattern(pathname, route.pathname))
|
||||
return {
|
||||
kind: 'blocked',
|
||||
reason: servedWithEveryGrant ? 'page-route-grant-missing' : 'page-route-unlisted'
|
||||
}
|
||||
}
|
||||
|
||||
/** Every served route's grants, so `route-handoff.web.ts` keeps each hop in the page, plus the read
|
||||
* of a workspace key that page opens in-page. */
|
||||
function hostAreaGrants(entries: readonly MobileWebPageRoute[]): string[] {
|
||||
|
||||
@@ -141,12 +141,14 @@ describe('shell lifecycle log lines', () => {
|
||||
expect(JSON.stringify(lines)).not.toContain('/Users/someone')
|
||||
})
|
||||
|
||||
it('logs nothing for a route that stays native, the normal case for native screens', () => {
|
||||
it('logs the wall a host with no page earns', () => {
|
||||
const { session, lines } = replay([
|
||||
{ type: 'gates-changed', gates: gates({ hostCapabilities: [] }) }
|
||||
])
|
||||
expect(session.state.kind).toBe('native-route')
|
||||
expect(lines).toEqual([])
|
||||
expect(session.state.kind).toBe('wall')
|
||||
expect(summary(lines)).toEqual([
|
||||
'web-shell-flow Web shell: blocked by version check | reason=bundle-unavailable'
|
||||
])
|
||||
})
|
||||
|
||||
it('logs a missed ready deadline, the failure it causes and the recovery flow', () => {
|
||||
|
||||
@@ -127,7 +127,7 @@ export function shellStallLine(
|
||||
`Web shell: page has not drawn after ${seconds}`,
|
||||
`build=${buildPrefix(state.buildId)} page ready=${yesNo(session.pageReady)} readyAsks=${trace.readyAsks} documentLoaded=${yesNo(trace.documentLoaded)}`
|
||||
)
|
||||
case 'native-route':
|
||||
case 'route-unavailable':
|
||||
case 'wall':
|
||||
case 'failed':
|
||||
case 'offline':
|
||||
@@ -228,9 +228,10 @@ function transitionLine(
|
||||
? flowLine('info', 'Web shell: activating app', 'source=download')
|
||||
: null
|
||||
case 'ready':
|
||||
case 'native-route':
|
||||
// `ready` is logged at paint; native is every native screen's normal outcome.
|
||||
// Logged at paint.
|
||||
return null
|
||||
case 'route-unavailable':
|
||||
return flowLine('warn', 'Web shell: page does not serve this route')
|
||||
case 'wall': {
|
||||
const side = 'side' in after.verdict ? ` side=${after.verdict.side}` : ''
|
||||
return flowLine(
|
||||
|
||||
@@ -21,7 +21,7 @@ describe('how long the shell keeps its own frame up', () => {
|
||||
{ kind: 'checking' },
|
||||
{ kind: 'activating', source: 'download' },
|
||||
{ kind: 'offline' },
|
||||
{ kind: 'native-route' }
|
||||
{ kind: 'route-unavailable' }
|
||||
] as const satisfies readonly MobileWebShellSessionState[]) {
|
||||
expect(frame({ state }), state.kind).toBe('pending')
|
||||
}
|
||||
|
||||
@@ -593,8 +593,11 @@ describe('the wait for the page to speak', () => {
|
||||
})
|
||||
await flush()
|
||||
// Pinned, not merely "moved on": `/h/host-1/tasks` is not the route the bundle lists, so a
|
||||
// re-armed session settles on the native screen. A failure would also leave `checking`.
|
||||
expect(seen.at(-1)?.kind).toBe('native-route')
|
||||
// re-armed session settles on the desktop wall. A failure would also leave `checking`.
|
||||
expect(seen.at(-1)).toEqual({
|
||||
kind: 'wall',
|
||||
verdict: { kind: 'blocked', reason: 'page-route-unlisted' }
|
||||
})
|
||||
await act(async () => {
|
||||
tree.unmount()
|
||||
})
|
||||
|
||||
@@ -84,10 +84,13 @@ export function useMobileWebShellSession(args: {
|
||||
routePathname: string
|
||||
/** A wide layout; a change restarts the session only where it changes the answer. */
|
||||
wide?: boolean
|
||||
/** False when this app has no screen of its own for the route. */
|
||||
appNamesRoute?: boolean
|
||||
runtime?: MobileWebShellRuntime
|
||||
}): MobileWebShellSessionView {
|
||||
const { hostId, routePathname } = args
|
||||
const wide = args.wide === true
|
||||
const appNamesRoute = args.appNamesRoute !== false
|
||||
const gates = useHostProtocolGates()
|
||||
const { client, state: connState } = useHostClient(hostId)
|
||||
|
||||
@@ -97,7 +100,7 @@ export function useMobileWebShellSession(args: {
|
||||
const storeRef = useRef<GenerationStore | null>(null)
|
||||
storeRef.current ??= runtime.createStore()
|
||||
|
||||
const sessionRef = useRef(createMobileWebShellSession(routePathname, wide))
|
||||
const sessionRef = useRef(createMobileWebShellSession(routePathname, wide, appNamesRoute))
|
||||
const [state, setState] = useState(sessionRef.current.state)
|
||||
const [pageReady, setPageReady] = useState(sessionRef.current.pageReady)
|
||||
const [pageFrame, setPageFrame] = useState(() => shellPageFrame(sessionRef.current))
|
||||
@@ -235,13 +238,13 @@ export function useMobileWebShellSession(args: {
|
||||
invalidate()
|
||||
hostIdRef.current = hostId
|
||||
logTraceRef.current = EMPTY_SHELL_LOG_TRACE
|
||||
sessionRef.current = createMobileWebShellSession(routePathname, wide)
|
||||
sessionRef.current = createMobileWebShellSession(routePathname, wide, appNamesRoute)
|
||||
startedAtRef.current = runtime.now()
|
||||
setState(sessionRef.current.state)
|
||||
setPageReady(sessionRef.current.pageReady)
|
||||
setPageFrame(shellPageFrame(sessionRef.current))
|
||||
return invalidate
|
||||
}, [hostId, invalidate, routePathname, runtime])
|
||||
}, [appNamesRoute, hostId, invalidate, routePathname, runtime])
|
||||
|
||||
// Log-only: one warning per parked state object, cancelled by any state, paint, host or route change.
|
||||
// `CHECKING` is one shared object, so checking-to-checking steps keep the first open's timer.
|
||||
|
||||
@@ -14,8 +14,8 @@ import { useWorkspaceRouteHandoff } from '../navigation/workspace-route-handoff'
|
||||
*
|
||||
* A route file that both reads the flag and calls `useMobileDiffReviewController` runs the whole
|
||||
* controller — its client subscriptions included — behind the page, because hooks cannot be
|
||||
* conditional. As an element passed for `fallback` it is created and not mounted, so the
|
||||
* controller runs only when the shell answers `native-route`. Same reason the explorer switch
|
||||
* conditional. As an element it is created and not mounted, so the controller runs only when a
|
||||
* native-shell build renders it. Same reason the explorer switch
|
||||
* builds its panel as an element.
|
||||
*
|
||||
* The params are read here rather than handed down, so this is the route body and the two route
|
||||
|
||||
@@ -28,9 +28,9 @@ import {
|
||||
const MAX_DATA_BASE64_LENGTH = Math.ceil(MOBILE_WEB_BUNDLE_CHUNK_BYTES / 3) * 4 + 8
|
||||
|
||||
/** A screen the desktop asks this shell to render from the bundle. Optional, because a desktop
|
||||
* older than the field sends none and every route then stays native, which is where they all
|
||||
* start. Loose for the same reason the manifest is: a grant name this build does not know is not a
|
||||
* reason to refuse a bundle, it is a reason to leave that one route native.
|
||||
* older than the field sends none, and then no route is served. Loose for the same reason the
|
||||
* manifest is: a grant name this build does not know is not a reason to refuse a bundle, only that
|
||||
* one route.
|
||||
*
|
||||
* `optionalGrants` is read so this build can honour it, and typed here only — the host's own
|
||||
* schema is where its grammar and the ceiling over the union live. A shell without this line still
|
||||
|
||||
Reference in New Issue
Block a user