Files
Jinwoo Hong 5d51bcaf6e feat(mobile): serve any manifest route through a [...page] catch-all (OTA phase C, C8) (#21950)
* feat(mobile): add the page-route-unavailable refusal screen

The catch-all route landing next has no native screen behind it, so its
fallback cannot be a panel. Nothing imports this yet.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* refactor(mobile): move firstParam out of the source-control tree

Every caller is a route file under app/h/, and the import dragged
mobile-git-status.ts and the screen-state module into the closure of any
route that reads a param: 1989 modules (3 local) for a one-line helper,
against 1 from src/navigation/route-param-reader.ts. Pure move.

The three shell route suites drop their lucide-react-native mocks with
it; that barrel was only ever reached through the old home.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* feat(mobile): serve any manifest route through a [...page] catch-all

Every page screen needs a route file under app/h/[hostId]/ today, so a
screen the desktop registers after a store build has nowhere to mount and
expo-router paints Unmatched. This adds one catch-all that hands any
host-scoped pathname to the shell; the manifest still decides, through
the same routeViewOf the other switches reach.

Measured with expo-router's own matcher on both platforms: every route
that has a file keeps it, index and the four .web.tsx siblings included;
only pathnames that reached Unmatched move.

The body lives under src/ because expo-router 55 reads a file's platform
from the first dot of its stripped name: [...page].web.tsx under app/
parses as platform '' and registers a second route rather than overriding
the first. Under src/ the stem is plain and Metro and the page builder
both resolve the sibling. getRoutes shows exactly one [...page] key on
each platform.

Registers no manifest entry, grant, hop row or PAGE_SERVED_SCREENS row.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): the page refuses an unrouted host path instead of Unmatched

The catch-all owns every /h/<id>/... pathname with no module, on the page
as well as in the app, so the render check's unmatched case can no longer
pass: measured in headless Chromium, /h/<id>/not-a-route paints the
refusal with no page or console error.

bridge-caps.ts records that C8 closes the C1.7 class for the host
subtree, and its dot-segment note is rewritten to the measured mechanism:
getStateFromPath normalizes the href through new URL(href, 'file:') in
getUrlWithReactNavigationConcessions before cleanPath sees it, so
/h/..?x lands on the app's root screen rather than on a host screen with
hostId '..'. Refusing it stays correct.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): keep the web-overrides allowlist in append order

The catch-all entry was added with a whole-file sort, which rewrote 116
lines for one addition and buried it. The test compares sorted sets, so
the order on disk is free; append order is what makes the diff readable.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): leave the refusal on the encoded host route

The Back href was a raw template, so a host id carrying a slash built
/h/a/b — two segments, which the catch-all that rendered the refusal
matches with hostId now "a". The control looped back into the screen it
exists to leave. hostStackHostRoute already encodes it and is what the
notification path pushes through.

hostId is a string: firstParam returns one, so the undefined arm and its
?? '' were unreachable.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): pin the catch-all's fallback binding and its way out

catch-all-page-route.test.tsx mocks both the shell and the refusal, so
fallback={refusal} was unpinned: mutating it to null left that file at
10 passed. This drives the other half with the real shell screen and the
real refusal under it, stubbing only what the session reducer settled on.

Four cases, each measured against a mutant: fallback={null} reds three,
push instead of replace reds two, a raw /h/${hostId} template reds the
five-shape encoding case.

The checking case is the presence precondition the rest need: before the
flag read settles the switch returns the refusal on its own, with the
same text and the same control, so an assertion on the refusal alone
would pass against a screen no shell ever rendered.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): make the route-body follower resolve every shape or fail

The follower read only `export { default } from 'x'`. A route file doing
`import X from 'x'; export default X` and mounting the shell without
shellScreenRoute left the census at 5 passed: the body was never opened,
so the file read as "not a switch" — the one answer a census must never
give by default.

Both shapes are followed now, and an unresolvable one is named rather
than skipped. Measured against four mutants: a shellScreenRoute call
dropped from the re-exported body reds the rule; an import-then-export
route mounting the shell reds two cases naming the file; a default from
a package specifier, a file with no default, and a re-export with no
module each red the new resolution case with the reason.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): type the host-node lookup in the catch-all state test

The tests-typecheck ratchet reds on findAllByType with a host string:
react-native is mocked to strings here, which is not an ElementType. A
findAll predicate on node.type is the same lookup and checks.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* docs(mobile): say why the refusal does not absorb the other shell states

The refusal answers one question — this build cannot serve this route —
and offline, checking and the protocol wall answer different ones that
are each true for a screen only the page has. Absorbing them would tell
someone with no connection that the screen does not exist.

Written where fallback is bound, and driven: an offline session through
the catch-all paints the connect message. Mutating the shell to return
fallback for offline reds that case.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): name the type the shell-view probe stands in for

The anti-slop gate refuses a broad `object` parameter, and it is right
here: the probe forwards every prop to its host node, so the type it
accepts is the view's own. Type-only import, so the module's
requireNativeViewManager call is still never evaluated.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): send the refusal to the app root when it has no host

firstParam answers an absent hostId as '', so Back called
hostStackHostRoute('') and landed on /h/ — which expo-router's own
matcher resolves to the h layout with no child, a press that paints
nothing and leaves the dead end in place. The app root lists hosts and is
where ProtocolBlockScreen sends the same gesture from the same position;
the label follows the target rather than outliving it.

Also through useRouteHandoff rather than useRouter, which is the same
defect on the other side: the page renders this screen through the
catch-all's .web.tsx sibling, and there a bare replace navigates inside
the WebView to a route the page does not carry instead of leaving it.
ProtocolBlockScreen already uses the seam; the router-seam censuses cover
src/session, src/files and src/source-control, not src/mobile-web-shell,
so nothing caught it.

Costs one module in the page closure (host-stack-navigation.ts): every
module the seam reaches is already in the layout's closure.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): repoint C4.4's source-control web sibling at the moved reader

The merge brought in a route file that imports firstParam from the
source-control screen state, which this branch emptied. Git merged both
sides cleanly because neither touched the other's lines; tsc is what
catches it.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): mock dictation's device half in the catch-all state test

The merge put the audio verbs in the shell screen's closure, so this file
reaches @orca/expo-two-way-audio, whose module touches the Expo global at
import. Same two mocks MobileWebShellScreen.test.tsx carries for the same
reason; what each verb does is bridge-audio-verbs.test.ts.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): census the router seam under mobile-web-shell

The three landed censuses walk src/session, src/files and
src/source-control, which is how PageRouteUnavailableScreen shipped with
a bare useRouter and nothing caught it until CodeRabbit.

This tree needs a shape of its own because it holds both halves: the rule
cannot be "no router" when MobileWebShellScreen and useShellStackPop are
the app end the page's navigate and navigate-back notifies arrive at.
Both are named with the reason, and neither has a .web.* sibling, so
neither runs inside the page.

Red first: with the bare useRouter put back, two of the four rules fail
naming the file —
  + "PageRouteUnavailableScreen.tsx (useRouter)"
  - "PageRouteUnavailableScreen.tsx"

The walk covers 72 product modules, asserted above 60, so the empty
finding list is over a non-empty walk.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-21 07:26:18 -04:00
..