Skip to content

fix: honor session metro hints and expo dev-client bundle urls#1199

Merged
thymikee merged 4 commits into
mainfrom
fix/metro-session-hints
Jul 10, 2026
Merged

fix: honor session metro hints and expo dev-client bundle urls#1199
thymikee merged 4 commits into
mainfrom
fix/metro-session-hints

Conversation

@thymikee

Copy link
Copy Markdown
Member

Summary

Three metro-command gaps found live on 2026-07-10 while moving the RN example dev server from port 8081 to 8082.

1. metro reload ignored session hints. After metro prepare --port 8082 --public-base-url http://127.0.0.1:8082 bound a session, a plain metro reload --session <s> still hit http://localhost:8081/reload — reloading a different project's server on the default port. metro prepare/metro reload are local-only (they never round-trip through the daemon), so there was nowhere for session-scoped Metro coordinates to live. metro prepare now persists { metroHost, metroPort } to a small per-session file (<state-dir>/metro-sessions/<session>.json), and metro reload resolves against it when no explicit flag is given. Priority: explicit flag > session hint > default (localhost:8081).

2. metro prepare --metro-kind expo misbehaved for expo dev-client monorepos, reproduced live against react-navigation/example (expo dev-client, yarn workspaces):

  • (a) The prepared bundleUrl hint used .../index.bundle?..., which 404s against the expo server ("Unable to resolve module ./index from <monorepo root>"). The working URL is the virtual entry .../.expo/.virtual-metro-entry.bundle?.... Verified live on port 8083: index.bundle → 404, virtual-entry → 200 with an 11 MB bundle. Fixed by mapping kind=expo to the virtual-entry path.
  • (b) Verified live that the spawned server's cwd already equals --project-root (confirmed via lsof -p <pid>), so the monorepo-root module resolution is Expo's own workspace/watchFolders behavior, not an agent-device bug — documented in code and help text instead of "fixed".
  • (c) When node_modules is missing in a workspace using yarn workspace: protocol, dependency install failed with raw npm error EUNSUPPORTEDPROTOCOL because package-manager detection only checked the leaf project root. It now walks up from --project-root to find the nearest yarn.lock/pnpm-lock.yaml/bun.lockb/package-lock.json, and install failures now carry a hint pointing at --no-install-deps and the detected package manager.

3. open rejected --bundle-url/--metro-host/--metro-port with INVALID_ARGS, even though the daemon already accepts and persists a runtime hint object for open (used for a fresh session before its first reload). Chose to add these three flags (plus --launch-url) to open directly as session-hint setters — they fold into the same runtime object the daemon already handles, so no reload-first workaround is needed. This was the clean option: open's command definition already builds its client-call options from CLI flags in one place, so wiring these through was a small, well-contained change with no ordering blocker.

Test plan

  • pnpm build, pnpm lint, pnpm typecheck, pnpm format:check, pnpm check:layering all pass
  • npx fallow audit --base origin/main passes (0 issues in changed files; 1 pre-existing complexity finding correctly excluded as inherited)
  • New/updated unit tests pass: client-metro.test.ts, metro-session-hints.test.ts, commands/management/app.test.ts, cli-help-command-usage.test.ts
  • Broader touched-area tests pass: open/session-runtime daemon tests, contracts-schema-public, command-surface-metadata
  • Live-verified fix 2(a)/(b) against react-navigation/example on port 8083 (killed after); did not touch ports 8081/8082 or any simulator/device
  • 5 unrelated pre-existing flaky failures (android/ios process-spawn tests under contention) all pass in isolation — not caused by this change

@github-actions

github-actions Bot commented Jul 10, 2026

Copy link
Copy Markdown

Size Report

Metric Base Current Diff
JS raw 1.6 MB 1.6 MB +6.4 kB
JS gzip 516.9 kB 519.2 kB +2.3 kB
npm tarball 624.2 kB 626.7 kB +2.5 kB
npm unpacked 2.2 MB 2.2 MB +6.8 kB

Startup median (7 runs, lower is better):

Scenario Base Current Diff
CLI --version 21.2 ms 21.6 ms +0.4 ms
CLI --help 44.0 ms 44.5 ms +0.5 ms

Top changed chunks:

Chunk Raw diff Gzip diff
dist/src/client.js +3.6 kB +1.2 kB
dist/src/registry.js +2.8 kB +1.0 kB
dist/src/session.js +57 B +16 B
dist/src/internal/daemon.js -124 B -9 B
dist/src/cli.js 0 B +1 B

@thymikee thymikee left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Independent review — needs changes

Reviewed in a scratch worktree off origin/fix/metro-session-hints (commit 94ada3191), not the checked-out branches on this machine. All gates run there; live probes only on port 8083 (killed after each), ports 8081/8082 and all devices untouched.

Blocking findings

1. open's new --metro-host/--metro-port/--bundle-url/--launch-url are not actually consumed by a later metro reload — the help text promises behavior the code doesn't deliver.

The open help text (src/commands/management/app.ts:123) says: "a later plain reload in the same session reuses whichever of these were set." That's not what happens. open's hint flags fold into SessionRuntimeHints and get written to the daemon's session store via setSessionRuntimeHintsForOpen (src/daemon/handlers/session-runtime.ts, called from session-open.ts:328) — applied to the device's native dev-server prefs (Android ReactNativeDevPrefs.xml / iOS simulator plist via src/daemon/runtime-hints.ts). metro reload is local-only and never touches the daemon; its hint resolution (client.metro.reload() in src/client/client.ts:289-296) only reads resolveMetroSessionHints(config), which reads <state-dir>/metro-sessions/<session>.json — a file written exclusively by metro prepare (persistMetroSessionHints, client.ts:286,456-469). These are two disjoint storage layers that never sync.

Live repro (fresh client instances, matching real CLI invocation-per-process):

open MyApp --metro-host 10.0.0.5 --metro-port 8090
  -> daemon request runtime payload: { metroHost: '10.0.0.5', metroPort: 8090, ... }  ✓ (as designed)

metro reload   (new client instance, same session/state-dir, no flags)
  -> resolveMetroReloadUrl(...) => http://localhost:8081/reload   ✗ (falls back to the hard default,
     ignoring what `open` just set)

Either fix the wiring (have open's hint-setters also call writeMetroSessionHints) or correct the help text/PR description — right now item 3 of the PR ("no reload-first workaround is needed") doesn't hold for the CLI's actual metro reload path.

2. Stale session hints are never cleared — a same-name session reuse can make metro reload silently hit the wrong (or dead) dev server.

clearMetroSessionHints (src/metro/metro-session-hints.ts:56) is defined and unit-tested in isolation (metro-session-hints.test.ts:78) but has zero production call sites — confirmed by grep across src/. handleCloseCommand (src/daemon/handlers/session-close.ts:109-115) clears the daemon's SessionRuntimeHints on close via clearRuntimeHintsFromApp, but never touches the metro-session-hints file. Nothing else (idle-reap, daemon shutdown) touches <state-dir>/metro-sessions/ either — it's write-once-per-prepare, read-forever.

Live repro:

metro prepare --port 8083 ...      # session "proj-foo" -> hints = {host: 127.0.0.1, port: 8083}
close --session proj-foo           # hint file untouched
<project A's dev server on 8083 stops>
<an unrelated project B later starts its own dev server, happens to grab 8083>
metro reload --session proj-foo    # NEW session, same name, no flags
  -> resolves to http://127.0.0.1:8083/reload
  -> hits PROJECT B's server, gets 200 OK, body confirms it's a different app entirely

This is exactly the failure class the PR sets out to fix, just moved to a new trigger (session-name reuse instead of "never prepared"), and it's silent — the caller gets a 200 and no signal anything is wrong. This is the "bug's original shape inverted" scenario called out for this review.

Should-fix (non-blocking)

3. Bun's current default lockfile (bun.lock, text format, default since Bun 1.2) isn't in LOCKFILE_PACKAGE_MANAGERS (src/metro/client-metro.ts:185-190) — only the older binary bun.lockb is recognized. A Bun-workspace project on the current lockfile format falls through to npm detection and can hit the same EUNSUPPORTEDPROTOCOL-class failure this PR exists to fix, just for Bun users.

4. findLockfilePackageManager's walk-up has no boundary (stops only at filesystem root, src/metro/client-metro.ts:192-204). No stop at .git or similar repo marker, so a project with no lockfile of its own could walk into an unrelated ancestor directory and silently pick the wrong package manager. Low real-world odds, but zero test coverage of the boundary case. "Nearest wins" ordering (pnpm > yarn > bun > npm per directory level) is otherwise a sound, defensible heuristic for the nested-repo case.

Notes / low severity

  • Hint-file writes aren't atomic (plain writeFileSync, no temp+rename) — low risk since readMetroSessionHints already treats a corrupt/partial file as "no hint" and falls back gracefully rather than crashing.
  • Pre-existing (not introduced by this PR): live-verified that metro reload's /reload call against a real Expo dev server returns 200 but with the SPA index.html body, not a real Metro reload trigger — Expo's dev server doesn't appear to expose /reload the way plain Metro does. The reload-path logic is unchanged by this diff, but since this PR is the first to officially wire up kind=expo, worth a follow-up so callers don't believe reload worked when it didn't.
  • fallow audit flags resolveMetroPrepareSettings (client-metro.ts:788, cyclomatic 14) as high-complexity, but the gate itself confirms this is inherited/pre-existing, not introduced by this diff.

Verified sound

  • Priority resolution (explicit flag > session hint > default) is correctly implemented and tested per-field, including mixed cases (explicit port + hinted host) — resolveReloadMetroHost/resolveReloadMetroPort (client-metro.ts:398-422) and client-metro.test.ts.
  • Expo virtual-entry bundle fix is real and live-verified against react-navigation/example (expo dev-client, yarn workspaces) on port 8083: index.bundle → 404 UnableToResolveError, .expo/.virtual-metro-entry.bundle → 200 with an 11 MB real bundle.
  • Expo-kind detection keys only on package.json's expo dependency (src/utils/project-runtime.ts:17-28), not on a .expo/ directory's presence — a bare-RN app with a stale .expo/ dir from a past expo run will not misdetect.
  • platform query param is correctly plumbed for both android/ios through the shared buildBundleUrl for both the new expo entry path and the existing index.bundle path.
  • Session-name sanitization for the hint-file path (safeSessionName) is correct and tested against unsafe/path-traversal-shaped session names.
  • Exposing the four new flags unconditionally on open for every platform (macOS/web/tvOS included) matches existing convention — there's no per-flag platform gate anywhere in this codebase (only command-level capability, from #1137); precedent flags like activity/deviceHub are likewise accepted everywhere and silently no-op off-platform, and applyRuntimeHintsToApp (src/daemon/runtime-hints.ts:42-61) does the same for the new metro/launch hints on macOS/web/tvOS/physical iOS.
  • Architecture claim holds: metro prepare/metro reload never call the daemon transport (explicitly asserted and tested — a throwing fake transport in client-metro.test.ts's session-hints test passes).

Gates (scratch worktree, isolated)

  • pnpm build / pnpm lint / pnpm format:check / pnpm typecheck / pnpm check:layering — all clean.
  • fallow audit --base origin/main — 0 new issues in the 10 changed files; 1 pre-existing complexity finding correctly excluded as inherited.
  • PR-relevant unit tests run in isolation to avoid known process-spawn-under-contention flakiness: client-metro.test.ts (14), metro-session-hints.test.ts (6), commands/management/app.test.ts (4), cli-help-command-usage.test.ts (38) — all 62 pass.
  • gh pr checks 1199 — 21/21 green (Coverage, Layering Guard, Lint & Format, Typecheck, Fallow, Smoke Tests ×4, CodeQL, etc.).

Verdict

Needs changes. The Expo virtual-entry fix and the package-manager walk-up (yarn-workspace case) are solid, live-verified wins — keep those. But findings #1 and #2 mean two of the PR's three stated fixes don't fully hold: the open hint-setters don't feed the reload path they're documented to feed, and the core "don't silently reload the wrong project" guarantee this PR exists to deliver has a real gap on session-name reuse. Recommend wiring open's hint-setters into writeMetroSessionHints (or at minimum correcting the help text to stop promising that), and clearing the hint file on session close the same way clearRuntimeHintsFromApp already does for the daemon-side hints.

thymikee added a commit that referenced this pull request Jul 10, 2026
Review follow-up (PR #1199):

- open's --metro-host/--metro-port/--bundle-url now also record the
  session's dev-server binding in the metro-sessions file (the one
  local store metro reload resolves against), so a later plain reload
  actually reuses what open set. The daemon runtime-hints write stays
  for device-native dev-server prefs.
- The binding now carries bundleUrl (prepare persists the local-flow
  bundle URL; bridge runtimes are excluded).
- clearMetroSessionHints is wired: session close drops the binding
  (teardown intent — even when the daemon call fails), and a hintless
  open that creates the session clears a leftover same-name binding.
  The open result carries sessionReused so the client can tell fresh
  from reused sessions. Regression test pins prepare -> close ->
  flagless reload resolving to the default, never the stale port.
- Package-manager detection recognizes bun.lock (text lockfile,
  default since Bun 1.2) and bounds the lockfile walk-up at the
  nearest .git entry.
- Comment audit per maintainer directive: multi-line acceptability
  arguments trimmed to one-line constraint statements; the store's
  lifecycle is documented once on MetroSessionHints.
@thymikee

Copy link
Copy Markdown
Member Author

Review response — all findings addressed in 740d99a

Point-by-point:

Blocker 1 (open's hint flags never fed metro reload) — fixed by making the metro-sessions file the single local store. open's client-side path now records its metro hints (metroHost/metroPort/bundleUrl) into the same per-session file metro prepare writes, using the same write helper (recordMetroSessionHintsAfterOpen in src/client/client.ts). The daemon SessionRuntimeHints write is kept — it has a different job (device-native dev-server prefs via applyRuntimeHintsToApp) — but metro reload's resolution reads only the one local store. The file now also persists bundleUrl: prepare stores the local-flow bundle URL (bridge/proxy runtimes are excluded since their URL paths belong to the remote origin, not the local server the status URL points at), so reload derives the correct /…/reload path prefix from a prepared or open-set bundle URL. Your exact repro is now a client-level test (open with hints → file contents asserted → daemon request still carries the runtime object), and I live-verified the CLI flow end-to-end on port 8084 against the react-navigation example (killed after): metro prepare → file has host/port/virtual-entry bundleUrl → flagless metro reloadhttp://127.0.0.1:8084/reload, 200. Help text for both open and metro now describes the actual mechanism.

Blocker 2 (stale hints never cleared) — clearMetroSessionHints is wired into both lifecycle points.

  • (a) sessions.close clears the binding in a finally — teardown intent drops the binding even when the daemon call fails (e.g. SESSION_NOT_FOUND after a crash or prepare-without-open); the failure direction is "fall to default", never "keep pointing at a stranger's server". Live-verified: close on a session the daemon never knew about still removes the file.
  • (b) A hintless open that creates the session clears any leftover same-name binding. The daemon open result now carries sessionReused (naming precedent: the Android snapshot-helper metadata field), derived from the handler's existingSession, so the client can tell fresh from reused: fresh + no hints → clear; reused + no hints → keep (mid-session open/--relaunch must not drop a binding set by prepare or an earlier open); any hints → rebind. Both daemon paths are pinned (request-router-open.test.ts asserts sessionReused: false on create; session-open-existing.test.ts asserts true on reuse), and the client behavior has dedicated tests for all three cases.
  • Your repro is a regression test: prepare on a free port → flagless reload hits it → sessions.close → hints file gone → flagless resolution pinned to http://localhost:8081/reload (the default), asserted via the pure resolver rather than a live fetch so the test never sends a real /reload to port 8081.

Should-fix 3 (bun.lock) — added ahead of bun.lockb in LOCKFILE_PACKAGE_MANAGERS; tested via the install-failure path (bun.lock → detected PM bun in the error details).

Should-fix 4 (unbounded walk-up) — the lockfile walk now stops at the nearest .git entry (directory or worktree file). Tested: a lockfile above the repo root is not adopted and detection falls back to npm.

Notes acknowledged:

  • Non-atomic hint writes: left as-is per your low-risk assessment (corrupt/partial reads already degrade to "no hint").
  • Expo /reload returning the SPA index.html instead of triggering a reload: agreed it deserves a follow-up; it's pre-existing reload-path behavior and out of scope here.
  • resolveMetroPrepareSettings complexity: inherited, unchanged.

Additionally (maintainer directive): audited the whole diff for paragraph-length acceptability arguments — the expo monorepo-root cwd note is now one line ("cwd is --project-root; monorepo-root module resolution beyond that is Expo's own behavior"), and every other comment is a one-to-two-line constraint statement. The store's lifecycle is documented exactly once, on the MetroSessionHints type. The design needs no sync argument anymore because there's nothing to sync: the file is the only store reload reads; the daemon hints only drive device prefs.

Gates (all green): build, lint, format:check, typecheck, layering, MCP metadata, fallow audit --base origin/main (0 issues in the 15 changed files, 1 inherited finding excluded), 166 tests across the 13 touched/adjacent test files. Live probes used ports 8084 (metro) and an isolated --state-dir; the probe daemon and dev server were killed and 8081/8082 and all devices were untouched.

@thymikee

Copy link
Copy Markdown
Member Author

Two remaining issues on current head 740d99a1:

  1. Path-prefixed Expo reload URLs lose their prefix. resolveMetroReloadPath() preserves a prefix only when the bundle path ends in /index.bundle. The new Expo entry is /.expo/.virtual-metro-entry.bundle, so http://host/base/.expo/.virtual-metro-entry.bundle?... resolves to http://host/reload instead of http://host/base/reload. Strip either known bundle entry suffix before appending /reload, and cover both --bundle-url and metro prepare --kind expo --public-base-url .../base.

  2. Public docs still describe the old resolution order. website/docs/docs/commands.md mentions remote-runtime hints or localhost only; it should document the new per-session binding written by prepare/open and its close/fresh-open clearing behavior.

CI is green, but live evidence still does not prove that an Expo app actually reloads through this path; the current HTTP 200 probe can also be an Expo HTML response.

thymikee added a commit that referenced this pull request Jul 10, 2026
Three exported-and-unit-tested-but-unreferenced-in-production incidents
this week (#1166 getNearestCommandNames, #1167 buildSettleTail, #1199
clearMetroSessionHints) — the first two were caught by fallow's dead-code
check because they had zero importers anywhere; #1199 was missed because a
test file imports the export, and fallow's default reachability graph
counts a test import as "used".

Adds a second, stricter pass reusing fallow's own --production mode
(entry.exclude test/story/dev files) via scripts/test-only-exports/check.ts:
an export alive in fallow's default graph but dead in its production graph,
with no other reference anywhere in its own file, has no production call
site — exactly the #1199 shape. Ratchets against a checked-in baseline
(scripts/test-only-exports-baseline.json, 77 entries); new findings fail
`pnpm check:test-only-exports` (wired into CI's Fallow job and
check:tooling). A `// test-seam: <reason>` comment above an export is the
escape hatch for intentional test seams.

Also extends .fallowrc.json's ignoreExports for seven daemon route handlers
(src/daemon/handlers/*.ts) that are genuinely production-reachable through
request-handler-chain.ts's `typeof import()` lazy-load pattern, which
fallow's static import graph can't trace as a named-export consumer —
without this they were false positives in the production-mode pass.
thymikee added a commit that referenced this pull request Jul 10, 2026
* ci: ratchet against test-only exports

Three exported-and-unit-tested-but-unreferenced-in-production incidents
this week (#1166 getNearestCommandNames, #1167 buildSettleTail, #1199
clearMetroSessionHints) — the first two were caught by fallow's dead-code
check because they had zero importers anywhere; #1199 was missed because a
test file imports the export, and fallow's default reachability graph
counts a test import as "used".

Adds a second, stricter pass reusing fallow's own --production mode
(entry.exclude test/story/dev files) via scripts/test-only-exports/check.ts:
an export alive in fallow's default graph but dead in its production graph,
with no other reference anywhere in its own file, has no production call
site — exactly the #1199 shape. Ratchets against a checked-in baseline
(scripts/test-only-exports-baseline.json, 77 entries); new findings fail
`pnpm check:test-only-exports` (wired into CI's Fallow job and
check:tooling). A `// test-seam: <reason>` comment above an export is the
escape hatch for intentional test seams.

Also extends .fallowrc.json's ignoreExports for seven daemon route handlers
(src/daemon/handlers/*.ts) that are genuinely production-reachable through
request-handler-chain.ts's `typeof import()` lazy-load pattern, which
fallow's static import graph can't trace as a named-export consumer —
without this they were false positives in the production-mode pass.

* fix: harden test-only-exports ratchet per review

Addresses the two should-fixes and all five minors from the independent
review of #1202:

- Replace the regex own-file occurrence count with an oxc-parser AST walk
  (typescript@7 ships no JS scanner API, so the review's fallback tool
  suggestion is the primary): identifiers are counted as AST nodes deduped
  by source span, so mentions in JSDoc/block comments, strings, and
  template-literal text no longer masquerade as call sites (review finding
  1, both constructed cases re-verified fixed), and a `//` inside a string
  no longer hides real usages (finding 6). Span dedupe keeps barrel
  re-exports (`export { x } from`) counting once. The sharper count
  surfaced one organic false negative on main: `selector` in
  src/commands/index.ts was previously exempted because the regex matched
  "selector" inside the './...selector-read.ts' import path string; it is
  now baselined alongside its sibling `ref` (same re-export line).
- Make the baseline shrink-only (finding 2): --update-baseline refuses new
  findings with the same wire/delete/annotate message, so the `// test-seam:`
  annotation in the reviewed source diff is the only acceptance path;
  CONTRIBUTING no longer documents baseline regeneration as an acceptance
  option and now describes baseline growth as a deliberate manual edit.
- Stale baseline entries now emit a `::warning` CI annotation (finding 3).
- Commit a re-runnable fixture test (finding 4): check.test.ts mirrors
  scripts/layering/model.test.ts, builds a synthetic package with a
  clearMetroSessionHints-shaped export (JSDoc self-mention included),
  asserts it is flagged, and asserts the annotated twin passes; wired
  before the check in pnpm check:test-only-exports.
- Mark the unreadable/unparseable-file fallbacks CONSERVATIVE: per
  CONTRIBUTING's convention (finding 5).
- Document the dynamic property access (obj[name]) blind spot in the
  script header and CONTRIBUTING (finding 7).

* fix: harden test-only export ratchet

* refactor: use native Fallow export gate

* chore: refresh production export baseline
thymikee added 3 commits July 10, 2026 18:15
- metro reload now resolves against the dev server the session's last
  metro prepare bound (via a per-session hint file), instead of
  silently defaulting to localhost:8081 and reloading an unrelated
  project. Explicit --metro-host/--metro-port/--bundle-url still win.
- metro prepare --metro-kind expo (detected or forced) now hints the
  virtual-metro-entry bundle URL instead of index.bundle, which 404s
  against Expo dev servers in monorepos (live-verified against
  react-navigation's example app). Package-manager detection for
  --install-deps now walks up from --project-root to the nearest
  lockfile so Yarn/pnpm workspace monorepos don't wrongly fall back to
  npm and hit EUNSUPPORTEDPROTOCOL on workspace: deps; install
  failures now hint at --no-install-deps and the detected PM.
- open now accepts --metro-host/--metro-port/--bundle-url/--launch-url
  as session-hint setters (folded into the same runtime object the
  daemon already persists for open), so a fresh session doesn't need a
  throwaway reload-first call just to seed hints.

Updates help text for metro and open to match.
Review follow-up (PR #1199):

- open's --metro-host/--metro-port/--bundle-url now also record the
  session's dev-server binding in the metro-sessions file (the one
  local store metro reload resolves against), so a later plain reload
  actually reuses what open set. The daemon runtime-hints write stays
  for device-native dev-server prefs.
- The binding now carries bundleUrl (prepare persists the local-flow
  bundle URL; bridge runtimes are excluded).
- clearMetroSessionHints is wired: session close drops the binding
  (teardown intent — even when the daemon call fails), and a hintless
  open that creates the session clears a leftover same-name binding.
  The open result carries sessionReused so the client can tell fresh
  from reused sessions. Regression test pins prepare -> close ->
  flagless reload resolving to the default, never the stale port.
- Package-manager detection recognizes bun.lock (text lockfile,
  default since Bun 1.2) and bounds the lockfile walk-up at the
  nearest .git entry.
- Comment audit per maintainer directive: multi-line acceptability
  arguments trimmed to one-line constraint statements; the store's
  lifecycle is documented once on MetroSessionHints.
… /message

Maintainer review follow-ups (PR #1199):

- Reload/message endpoint URLs keep the bound bundle URL's mount prefix
  (e.g. /tenant-42/index.bundle -> /tenant-42/reload) instead of
  collapsing to the host root. The Expo virtual entry
  (.expo/.virtual-metro-entry.bundle) is an entry-module path, not a
  server mount, so it maps to the server-root endpoints (verified
  live: Expo serves /message at the root).
- When the dev server has no HTTP /reload route and answers with the
  app page (Expo), metro reload now broadcasts
  {"version":2,"method":"reload"} over the server's /message websocket
  (the channel dev-server CLIs use for the r key) instead of reporting
  the app-page 200 as a successful reload. The result carries a
  transport field (http | message-socket). Live-verified: a flagless
  metro reload against the running Expo server made the app re-fetch
  its JS bundle.
- Endpoint resolution moved to src/metro/metro-reload-endpoints.ts so
  the seam tests use is production-imported (keeps the new
  production-unused-exports ratchet clean).
- Docs reconciled: help metro and website commands.md describe the
  single session binding store, open's hint flags, prefix
  preservation, and the message-socket fallback.
@thymikee thymikee force-pushed the fix/metro-session-hints branch from 740d99a to 5e34471 Compare July 10, 2026 16:20
@thymikee

Copy link
Copy Markdown
Member Author

Maintainer review response — all three blockers addressed in 5e34471 (rebased onto 0.19.3 main)

Blocker 1 (path-prefixed reload drops the prefix) — fixed, with one correction discovered live. The old derivation only recognized /index.bundle and collapsed everything else to the bare host root. Endpoint resolution (now in src/metro/metro-reload-endpoints.ts, production-imported by client-metro.ts so the new production-unused-exports ratchet stays clean) keeps the bound bundle URL's mount prefix for both dev-server endpoints: /tenant-42/index.bundle/tenant-42/reload + ws://…/tenant-42/message, httpswss. The correction: the Expo virtual entry (.expo/.virtual-metro-entry.bundle) is an entry-module path, not a server mount — verified live against the 8082 server that /message is served at the root and /.expo/reload is just the SPA fallback — so that suffix maps to the server-root endpoints, while a real mount prefix in front of it (/tenant-42/.expo/.virtual-metro-entry.bundle/tenant-42/reload) is preserved. Unit tests pin all four cases (root virtual entry, mounted virtual entry, mounted index.bundle, root index.bundle) via resolveMetroReloadEndpoints.

While proving this live I found the deeper issue behind it: Expo's dev server has no HTTP reload route at all — any /reload* GET returns the app page with a 200, so the old code reported success while the app never reloaded (the "SPA index.html" follow-up from the independent review). metro reload now detects the app-page fallback (2xx + HTML document body) and broadcasts {"version":2,"method":"reload"} over the server's /message websocket — the exact channel @expo/cli and community-cli-plugin use for the r key (loopback clients without a mismatched Origin are trusted for the reload broadcast per createMessageSocket.js). The result now carries transport: 'http' | 'message-socket', and an HTML 200 is never reported as a successful HTTP reload. Unit test spins up an Expo-style fake (HTML on /reload, real websocket upgrade on /message) and asserts the exact broadcast frame.

Blocker 2 (stale docs) — reconciled. help metro and help open (command help descriptions) plus website/docs/docs/commands.md (Metro reload section + multi-worktree recipe) now all describe the shipped reality: single per-session binding store written by metro prepare/open hint flags, cleared on close and hintless fresh opens; flag > hint > default priority; mount-prefix preservation; and the /message websocket fallback. No other doc surface mentions the old behavior (checked CONTRIBUTING/README/AGENTS/docs/skills/website).

Blocker 3 (live expo reload proof) — done, app actually reloaded. Against the running Metro on 8082 (react-navigation example, expo dev-client) with the playground on the free iPhone 17 sim (PagerView sim untouched), own session/state-dir:

$ agent-device open org.reactnavigation.playground --platform ios --udid D74E… \
    --metro-host 127.0.0.1 --metro-port 8082 \
    --bundle-url "http://127.0.0.1:8082/.expo/.virtual-metro-entry.bundle?platform=ios&dev=true&minify=false" \
    --session live-proof --state-dir /tmp/ad-expo-reload-proof
# session binding written by open (client-side):
$ cat /tmp/ad-expo-reload-proof/metro-sessions/live-proof.json
{ "metroHost": "127.0.0.1", "metroPort": 8082,
  "bundleUrl": "http://127.0.0.1:8082/.expo/.virtual-metro-entry.bundle?platform=ios&dev=true&minify=false" }

$ agent-device metro reload --session live-proof --state-dir /tmp/ad-expo-reload-proof   # NO flags
{ "reloaded": true, "reloadUrl": "ws://127.0.0.1:8082/message",
  "status": 200, "body": "", "transport": "message-socket" }

App-side proof it actually reloaded (session logs start capture, immediately after the flagless reload):

18:14:27.462 ReactNavigationExample (React) [RCTMultipartDataTask]
  GET http://127.0.0.1:8082/.expo/.virtual-metro-entry.bundle?platform=ios&dev=true&lazy=true&minify=false&…&app=org.reactnavigation.playground

The app re-fetched its JS bundle from 8082 with the virtual-entry path in direct response to the plain metro reload — the snapshot alone was inconclusive only because the example restores persisted navigation state after reload. (Also demonstrated the prepare-bound variant earlier: metro prepare --port 8082 on a session followed by a flagless reload resolved to 8082, never the 8081 default.) Session closed, probe daemon killed, state dir removed; sim freed for the queued agents; ports 8081/8082 untouched.

Gates (rebased onto fb7dbfe6f / 0.19.3): build, lint, format:check, typecheck, layering, MCP metadata, check:production-exports (new ratchet — clean), fallow audit --base origin/main (0 issues in 18 changed files, 1 inherited excluded), 150 tests across the 9 touched test files including the new prefix-matrix, ws-fallback, and expo-session tests.

@github-actions

github-actions Bot commented Jul 10, 2026

Copy link
Copy Markdown
PR Preview Action v1.8.1
Preview removed because the pull request was closed.
2026-07-10 17:09 UTC

@thymikee thymikee merged commit d968a27 into main Jul 10, 2026
23 checks passed
@thymikee thymikee deleted the fix/metro-session-hints branch July 10, 2026 17:09
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant