diff --git a/config/scripts/mobile-web-page-routes.mjs b/config/scripts/mobile-web-page-routes.mjs index 9d2f5a0a0d0..fb37c534cc1 100644 --- a/config/scripts/mobile-web-page-routes.mjs +++ b/config/scripts/mobile-web-page-routes.mjs @@ -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. diff --git a/mobile/app/h/[hostId]/agent-history/[worktreeId].tsx b/mobile/app/h/[hostId]/agent-history/[worktreeId].tsx index 0288cbb393c..fe36c18f846 100644 --- a/mobile/app/h/[hostId]/agent-history/[worktreeId].tsx +++ b/mobile/app/h/[hostId]/agent-history/[worktreeId].tsx @@ -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 ( - + ) }) diff --git a/mobile/app/h/[hostId]/files/[worktreeId].tsx b/mobile/app/h/[hostId]/files/[worktreeId].tsx index e4746dd6cc4..1bf2ded62dd 100644 --- a/mobile/app/h/[hostId]/files/[worktreeId].tsx +++ b/mobile/app/h/[hostId]/files/[worktreeId].tsx @@ -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} /> ) }) diff --git a/mobile/app/h/[hostId]/files/preview/[worktreeId].tsx b/mobile/app/h/[hostId]/files/preview/[worktreeId].tsx index 8b3b6a8e872..5d1c804879d 100644 --- a/mobile/app/h/[hostId]/files/preview/[worktreeId].tsx +++ b/mobile/app/h/[hostId]/files/preview/[worktreeId].tsx @@ -78,7 +78,6 @@ export default withWorkspaceRoute(function MobileFilePreviewRoute() { key={shellScreenRouteKey(decision.route)} hostId={route.params.hostId} route={decision.route} - fallback={native} /> ) }) diff --git a/mobile/app/h/[hostId]/index.tsx b/mobile/app/h/[hostId]/index.tsx index fa480192c72..1c47796a408 100644 --- a/mobile/app/h/[hostId]/index.tsx +++ b/mobile/app/h/[hostId]/index.tsx @@ -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} /> ) } diff --git a/mobile/app/h/[hostId]/review/[worktreeId].tsx b/mobile/app/h/[hostId]/review/[worktreeId].tsx index 4372653e770..55c8cd8897d 100644 --- a/mobile/app/h/[hostId]/review/[worktreeId].tsx +++ b/mobile/app/h/[hostId]/review/[worktreeId].tsx @@ -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} /> ) }) diff --git a/mobile/app/h/[hostId]/session/[worktreeId].tsx b/mobile/app/h/[hostId]/session/[worktreeId].tsx index 9d22d087519..903636e1550 100644 --- a/mobile/app/h/[hostId]/session/[worktreeId].tsx +++ b/mobile/app/h/[hostId]/session/[worktreeId].tsx @@ -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} /> ) diff --git a/mobile/app/h/[hostId]/source-control/[worktreeId].tsx b/mobile/app/h/[hostId]/source-control/[worktreeId].tsx index c8575a56b41..dca50eae101 100644 --- a/mobile/app/h/[hostId]/source-control/[worktreeId].tsx +++ b/mobile/app/h/[hostId]/source-control/[worktreeId].tsx @@ -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} /> ) }) diff --git a/mobile/app/h/[hostId]/tasks.tsx b/mobile/app/h/[hostId]/tasks.tsx index ce63e24405e..84ca55f8f8d 100644 --- a/mobile/app/h/[hostId]/tasks.tsx +++ b/mobile/app/h/[hostId]/tasks.tsx @@ -43,7 +43,6 @@ export default function MobileTasksRoute() { key={hostId} hostId={hostId} route={decision.route} - fallback={native} /> ) } diff --git a/mobile/app/h/[hostId]/web.tsx b/mobile/app/h/[hostId]/web.tsx index c805b823d0d..3e319ef3e50 100644 --- a/mobile/app/h/[hostId]/web.tsx +++ b/mobile/app/h/[hostId]/web.tsx @@ -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 ( } /> ) } diff --git a/mobile/src/components/ProtocolBlockScreen.test.ts b/mobile/src/components/ProtocolBlockScreen.test.ts index 7a33bfa28c8..6c88b085f32 100644 --- a/mobile/src/components/ProtocolBlockScreen.test.ts +++ b/mobile/src/components/ProtocolBlockScreen.test.ts @@ -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', () => { diff --git a/mobile/src/components/ProtocolBlockScreen.tsx b/mobile/src/components/ProtocolBlockScreen.tsx index c6d7eba1e22..0de1bec3786 100644 --- a/mobile/src/components/ProtocolBlockScreen.tsx +++ b/mobile/src/components/ProtocolBlockScreen.tsx @@ -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 | Extract + | 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.` } diff --git a/mobile/src/diagnostics/mobile-web-shell-update-failure-copy.ts b/mobile/src/diagnostics/mobile-web-shell-update-failure-copy.ts index 8894727013b..44d0f3d1a30 100644 --- a/mobile/src/diagnostics/mobile-web-shell-update-failure-copy.ts +++ b/mobile/src/diagnostics/mobile-web-shell-update-failure-copy.ts @@ -41,7 +41,9 @@ const WALL_COPY: Record = { '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': diff --git a/mobile/src/mobile-web-shell/MobileWebShellScreen.test.tsx b/mobile/src/mobile-web-shell/MobileWebShellScreen.test.tsx index 69fa1bbaceb..1b7976259ed 100644 --- a/mobile/src/mobile-web-shell/MobileWebShellScreen.test.tsx +++ b/mobile/src/mobile-web-shell/MobileWebShellScreen.test.tsx @@ -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) => 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([]) }) diff --git a/mobile/src/mobile-web-shell/MobileWebShellScreen.tsx b/mobile/src/mobile-web-shell/MobileWebShellScreen.tsx index ea58f9876e3..c5e6d1209d2 100644 --- a/mobile/src/mobile-web-shell/MobileWebShellScreen.tsx +++ b/mobile/src/mobile-web-shell/MobileWebShellScreen.tsx @@ -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 } if (state.kind === 'wall') { return diff --git a/mobile/src/mobile-web-shell/PageRouteUnavailableScreen.tsx b/mobile/src/mobile-web-shell/PageRouteUnavailableScreen.tsx index 615435ae3e1..e42ad777b00 100644 --- a/mobile/src/mobile-web-shell/PageRouteUnavailableScreen.tsx +++ b/mobile/src/mobile-web-shell/PageRouteUnavailableScreen.tsx @@ -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 diff --git a/mobile/src/mobile-web-shell/catch-all-page-route-states.test.tsx b/mobile/src/mobile-web-shell/catch-all-page-route-states.test.tsx index 91d3add4383..f0d2bc967e2 100644 --- a/mobile/src/mobile-web-shell/catch-all-page-route-states.test.tsx +++ b/mobile/src/mobile-web-shell/catch-all-page-route-states.test.tsx @@ -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() }) diff --git a/mobile/src/mobile-web-shell/catch-all-page-route.test.tsx b/mobile/src/mobile-web-shell/catch-all-page-route.test.tsx index 773d6784031..7354fff0f30 100644 --- a/mobile/src/mobile-web-shell/catch-all-page-route.test.tsx +++ b/mobile/src/mobile-web-shell/catch-all-page-route.test.tsx @@ -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[0], pathname: string): boolean { + return routeViewOf(routes, pathname).pageRoutes.some((pattern) => + matchesRoutePattern(pathname, pattern) + ) +} + async function render(): Promise { 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]' diff --git a/mobile/src/mobile-web-shell/catch-all-page-route.tsx b/mobile/src/mobile-web-shell/catch-all-page-route.tsx index bfc56eee68a..8118f1aa3c1 100644 --- a/mobile/src/mobile-web-shell/catch-all-page-route.tsx +++ b/mobile/src/mobile-web-shell/catch-all-page-route.tsx @@ -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 ( ) } diff --git a/mobile/src/mobile-web-shell/host-area-layout-class-change.test.tsx b/mobile/src/mobile-web-shell/host-area-layout-class-change.test.tsx index ab2ad9c8021..964a06d88c5 100644 --- a/mobile/src/mobile-web-shell/host-area-layout-class-change.test.tsx +++ b/mobile/src/mobile-web-shell/host-area-layout-class-change.test.tsx @@ -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 }) }) diff --git a/mobile/src/mobile-web-shell/host-area-leave-session.test.tsx b/mobile/src/mobile-web-shell/host-area-leave-session.test.tsx index 06b0814025a..eddf863c588 100644 --- a/mobile/src/mobile-web-shell/host-area-leave-session.test.tsx +++ b/mobile/src/mobile-web-shell/host-area-leave-session.test.tsx @@ -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 } }) ) }) diff --git a/mobile/src/mobile-web-shell/host-area-session.test.ts b/mobile/src/mobile-web-shell/host-area-session.test.ts index 34d613a93dc..c4a8a08e465 100644 --- a/mobile/src/mobile-web-shell/host-area-session.test.ts +++ b/mobile/src/mobile-web-shell/host-area-session.test.ts @@ -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' } + }) } }) diff --git a/mobile/src/mobile-web-shell/host-area-sidebar-matrix.test.tsx b/mobile/src/mobile-web-shell/host-area-sidebar-matrix.test.tsx index 77c0eb6573f..ef3211e5889 100644 --- a/mobile/src/mobile-web-shell/host-area-sidebar-matrix.test.tsx +++ b/mobile/src/mobile-web-shell/host-area-sidebar-matrix.test.tsx @@ -118,7 +118,7 @@ vi.mock('../navigation/host-stack', async () => { return ( {env.hostSession === null ? null : ( - + )} ) diff --git a/mobile/src/mobile-web-shell/host-area-two-hosts.test.tsx b/mobile/src/mobile-web-shell/host-area-two-hosts.test.tsx index b2a281153eb..ab3b64de09e 100644 --- a/mobile/src/mobile-web-shell/host-area-two-hosts.test.tsx +++ b/mobile/src/mobile-web-shell/host-area-two-hosts.test.tsx @@ -84,11 +84,7 @@ vi.mock('../navigation/host-stack', async () => { HostStack: () => stack.hosts.map((hostId, index) => ( - + )) } diff --git a/mobile/src/mobile-web-shell/mobile-web-shell-cached-generation.ts b/mobile/src/mobile-web-shell/mobile-web-shell-cached-generation.ts index 1902f594d91..bde3485b6c8 100644 --- a/mobile/src/mobile-web-shell/mobile-web-shell-cached-generation.ts +++ b/mobile/src/mobile-web-shell/mobile-web-shell-cached-generation.ts @@ -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 served?: Partial 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 } }) diff --git a/mobile/src/mobile-web-shell/mobile-web-shell-gates.ts b/mobile/src/mobile-web-shell/mobile-web-shell-gates.ts index 26e50574309..63a90d4d939 100644 --- a/mobile/src/mobile-web-shell/mobile-web-shell-gates.ts +++ b/mobile/src/mobile-web-shell/mobile-web-shell-gates.ts @@ -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': diff --git a/mobile/src/mobile-web-shell/mobile-web-shell-page-route-wall.test.ts b/mobile/src/mobile-web-shell/mobile-web-shell-page-route-wall.test.ts new file mode 100644 index 00000000000..0dcbc016aac --- /dev/null +++ b/mobile/src/mobile-web-shell/mobile-web-shell-page-route-wall.test.ts @@ -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 + ) + }) +}) diff --git a/mobile/src/mobile-web-shell/mobile-web-shell-refused-update-decision.test.ts b/mobile/src/mobile-web-shell/mobile-web-shell-refused-update-decision.test.ts index fddcb2962ac..46787c52261 100644 --- a/mobile/src/mobile-web-shell/mobile-web-shell-refused-update-decision.test.ts +++ b/mobile/src/mobile-web-shell/mobile-web-shell-refused-update-decision.test.ts @@ -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([]) }) diff --git a/mobile/src/mobile-web-shell/mobile-web-shell-screen-test-harness.tsx b/mobile/src/mobile-web-shell/mobile-web-shell-screen-test-harness.tsx index a1b81f1bdc4..49eb01840a6 100644 --- a/mobile/src/mobile-web-shell/mobile-web-shell-screen-test-harness.tsx +++ b/mobile/src/mobile-web-shell/mobile-web-shell-screen-test-harness.tsx @@ -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' } }) } diff --git a/mobile/src/mobile-web-shell/mobile-web-shell-session-contract.ts b/mobile/src/mobile-web-shell/mobile-web-shell-session-contract.ts index c70bdb866d2..a39fd7361b8 100644 --- a/mobile/src/mobile-web-shell/mobile-web-shell-session-contract.ts +++ b/mobile/src/mobile-web-shell/mobile-web-shell-session-contract.ts @@ -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 + | 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. */ diff --git a/mobile/src/mobile-web-shell/mobile-web-shell-session.test.ts b/mobile/src/mobile-web-shell/mobile-web-shell-session.test.ts index efc2192559e..4cb3a63d12e 100644 --- a/mobile/src/mobile-web-shell/mobile-web-shell-session.test.ts +++ b/mobile/src/mobile-web-shell/mobile-web-shell-session.test.ts @@ -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) }) }) diff --git a/mobile/src/mobile-web-shell/mobile-web-shell-session.ts b/mobile/src/mobile-web-shell/mobile-web-shell-session.ts index c14c04e36e2..31ad7612946 100644 --- a/mobile/src/mobile-web-shell/mobile-web-shell-session.ts +++ b/mobile/src/mobile-web-shell/mobile-web-shell-session.ts @@ -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) } diff --git a/mobile/src/mobile-web-shell/mobile-web-shell-update-failure-outcome.ts b/mobile/src/mobile-web-shell/mobile-web-shell-update-failure-outcome.ts index dd3a89e4daf..7a552806349 100644 --- a/mobile/src/mobile-web-shell/mobile-web-shell-update-failure-outcome.ts +++ b/mobile/src/mobile-web-shell/mobile-web-shell-update-failure-outcome.ts @@ -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': diff --git a/mobile/src/mobile-web-shell/mobile-web-shell-update-failure-record.test.ts b/mobile/src/mobile-web-shell/mobile-web-shell-update-failure-record.test.ts index 0843e7ab553..f545267e785 100644 --- a/mobile/src/mobile-web-shell/mobile-web-shell-update-failure-record.test.ts +++ b/mobile/src/mobile-web-shell/mobile-web-shell-update-failure-record.test.ts @@ -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', () => { diff --git a/mobile/src/mobile-web-shell/mobile-web-shell-update-failure.ts b/mobile/src/mobile-web-shell/mobile-web-shell-update-failure.ts index c34ca181efe..79e7fa96595 100644 --- a/mobile/src/mobile-web-shell/mobile-web-shell-update-failure.ts +++ b/mobile/src/mobile-web-shell/mobile-web-shell-update-failure.ts @@ -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 + verdict: MobileWebShellBlockedVerdict ): MobileWebShellUpdateFailureWall { if (verdict.reason !== 'bundle-incompatible') { return verdict.reason diff --git a/mobile/src/mobile-web-shell/page-route-policy.test.ts b/mobile/src/mobile-web-shell/page-route-policy.test.ts index 4215a855bb9..10fbeba14e2 100644 --- a/mobile/src/mobile-web-shell/page-route-policy.test.ts +++ b/mobile/src/mobile-web-shell/page-route-policy.test.ts @@ -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[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) => + 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') + }) +}) diff --git a/mobile/src/mobile-web-shell/page-route-policy.ts b/mobile/src/mobile-web-shell/page-route-policy.ts index 6aeeed92ace..3de345d1bd5 100644 --- a/mobile/src/mobile-web-shell/page-route-policy.ts +++ b/mobile/src/mobile-web-shell/page-route-policy.ts @@ -11,9 +11,8 @@ export type MobileWebPageRoute = NonNullable 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[] { diff --git a/mobile/src/mobile-web-shell/shell-lifecycle-log.test.ts b/mobile/src/mobile-web-shell/shell-lifecycle-log.test.ts index c0bf3e52ffd..f533e345443 100644 --- a/mobile/src/mobile-web-shell/shell-lifecycle-log.test.ts +++ b/mobile/src/mobile-web-shell/shell-lifecycle-log.test.ts @@ -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', () => { diff --git a/mobile/src/mobile-web-shell/shell-lifecycle-log.ts b/mobile/src/mobile-web-shell/shell-lifecycle-log.ts index 718c997a3b7..0b92e7ff10d 100644 --- a/mobile/src/mobile-web-shell/shell-lifecycle-log.ts +++ b/mobile/src/mobile-web-shell/shell-lifecycle-log.ts @@ -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( diff --git a/mobile/src/mobile-web-shell/shell-page-frame.test.ts b/mobile/src/mobile-web-shell/shell-page-frame.test.ts index 8c4d7b5b6d8..f682aea0e04 100644 --- a/mobile/src/mobile-web-shell/shell-page-frame.test.ts +++ b/mobile/src/mobile-web-shell/shell-page-frame.test.ts @@ -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') } diff --git a/mobile/src/mobile-web-shell/use-mobile-web-shell-session.test.ts b/mobile/src/mobile-web-shell/use-mobile-web-shell-session.test.ts index a2be9c8b0d8..2e915d7f9d2 100644 --- a/mobile/src/mobile-web-shell/use-mobile-web-shell-session.test.ts +++ b/mobile/src/mobile-web-shell/use-mobile-web-shell-session.test.ts @@ -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() }) diff --git a/mobile/src/mobile-web-shell/use-mobile-web-shell-session.ts b/mobile/src/mobile-web-shell/use-mobile-web-shell-session.ts index f511a46c9eb..05cfba5dd53 100644 --- a/mobile/src/mobile-web-shell/use-mobile-web-shell-session.ts +++ b/mobile/src/mobile-web-shell/use-mobile-web-shell-session.ts @@ -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(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. diff --git a/mobile/src/session/MobileDiffReviewRouteScreen.tsx b/mobile/src/session/MobileDiffReviewRouteScreen.tsx index a2424003912..819c3b0e31f 100644 --- a/mobile/src/session/MobileDiffReviewRouteScreen.tsx +++ b/mobile/src/session/MobileDiffReviewRouteScreen.tsx @@ -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 diff --git a/mobile/src/transport/mobile-web-bundle-reply-schemas.ts b/mobile/src/transport/mobile-web-bundle-reply-schemas.ts index 9f028c57967..6944d441db9 100644 --- a/mobile/src/transport/mobile-web-bundle-reply-schemas.ts +++ b/mobile/src/transport/mobile-web-bundle-reply-schemas.ts @@ -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