From b2419a58ade30f90b8f69fc3765512dd4674e8d3 Mon Sep 17 00:00:00 2001
From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com>
Date: Sat, 17 Oct 2026 02:01:20 -0700
Subject: [PATCH] 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
---
config/scripts/mobile-web-page-routes.mjs | 14 +--
.../h/[hostId]/agent-history/[worktreeId].tsx | 10 +-
mobile/app/h/[hostId]/files/[worktreeId].tsx | 3 +-
.../h/[hostId]/files/preview/[worktreeId].tsx | 1 -
mobile/app/h/[hostId]/index.tsx | 6 +-
mobile/app/h/[hostId]/review/[worktreeId].tsx | 3 +-
.../app/h/[hostId]/session/[worktreeId].tsx | 3 +-
.../[hostId]/source-control/[worktreeId].tsx | 5 +-
mobile/app/h/[hostId]/tasks.tsx | 1 -
mobile/app/h/[hostId]/web.tsx | 5 -
.../components/ProtocolBlockScreen.test.ts | 23 +++--
mobile/src/components/ProtocolBlockScreen.tsx | 12 ++-
.../mobile-web-shell-update-failure-copy.ts | 6 +-
.../MobileWebShellScreen.test.tsx | 12 +--
.../mobile-web-shell/MobileWebShellScreen.tsx | 16 ++--
.../PageRouteUnavailableScreen.tsx | 6 +-
.../catch-all-page-route-states.test.tsx | 24 +++--
.../catch-all-page-route.test.tsx | 17 +++-
.../mobile-web-shell/catch-all-page-route.tsx | 19 ++--
.../host-area-layout-class-change.test.tsx | 7 +-
.../host-area-leave-session.test.tsx | 3 +-
.../host-area-session.test.ts | 13 ++-
.../host-area-sidebar-matrix.test.tsx | 2 +-
.../host-area-two-hosts.test.tsx | 6 +-
.../mobile-web-shell-cached-generation.ts | 22 ++---
.../mobile-web-shell-gates.ts | 16 +---
.../mobile-web-shell-page-route-wall.test.ts | 96 +++++++++++++++++++
...-web-shell-refused-update-decision.test.ts | 25 ++---
.../mobile-web-shell-screen-test-harness.tsx | 9 +-
.../mobile-web-shell-session-contract.ts | 25 +++--
.../mobile-web-shell-session.test.ts | 67 +++----------
.../mobile-web-shell-session.ts | 49 +++++++---
...mobile-web-shell-update-failure-outcome.ts | 4 +-
...le-web-shell-update-failure-record.test.ts | 12 +--
.../mobile-web-shell-update-failure.ts | 10 +-
.../page-route-policy.test.ts | 32 +++++--
.../src/mobile-web-shell/page-route-policy.ts | 73 ++++++++------
.../shell-lifecycle-log.test.ts | 8 +-
.../mobile-web-shell/shell-lifecycle-log.ts | 7 +-
.../mobile-web-shell/shell-page-frame.test.ts | 2 +-
.../use-mobile-web-shell-session.test.ts | 7 +-
.../use-mobile-web-shell-session.ts | 9 +-
.../session/MobileDiffReviewRouteScreen.tsx | 4 +-
.../mobile-web-bundle-reply-schemas.ts | 6 +-
44 files changed, 407 insertions(+), 293 deletions(-)
create mode 100644 mobile/src/mobile-web-shell/mobile-web-shell-page-route-wall.test.ts
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