Skip to content

fix: reload every app through the Metro websocket - #685

Merged
janicduplessis merged 1 commit into
mainfrom
@janic/android-reload-metro-first
Sep 11, 2026
Merged

fix: reload every app through the Metro websocket#685
janicduplessis merged 1 commit into
mainfrom
@janic/android-reload-metro-first

Conversation

@janicduplessis

@janicduplessis janicduplessis commented Sep 11, 2026

Copy link
Copy Markdown
Collaborator

Description

stim reload android kills any Expo dev-client app that is already running. The process dies, the reload never happens, and the command reports success because it dispatches without observing completion.

Reopening a recorded development-client URL is not a reload. It restarts the app. On Android that recreates MainActivity, and expo-dev-launcher asserts at that point that no React context exists yet (DevLauncherAppLoader.kt:49, require(appHost.currentReactContext == null)), so a healthy app trips a fatal activity-start failure. The assertion is still on expo's main: expo/expo#35385 was auto-closed 77 seconds after filing for lacking a reproduction, and expo/expo#48085 diagnoses this case but was closed unmerged with no maintainer review.

#363 exercised the Android dev-client path against an app handed an unreachable startup URL. No context existed there, so the assertion passed. The crash only appears on the healthy-app path, which is what agents hit after stim start --reset-cache.

Solution

One transport on both platforms: the Metro /message websocket. The deep-link and adb-broadcast paths are removed, so three mechanisms become one and the platform branch disappears.

Targeting, where Metro can enumerate peers. iOS peers match on role, Android peers on app, because only iOS sends role (RCTPackagerConnection.mm, and it sends nothing else) and only Android sends app (JSPackagerClient.kt, alongside device and clientid). Every matching peer is addressed, and targets reports how many. A workspace Metro serves one app, so several matching peers are that app on several devices, not several apps — reloading all of them is reloading the app everywhere it is running. That matters most on iOS, where role=ios separates an iOS client from an Android one but cannot single out one iOS app, so an iPhone and a simulator on one port are indistinguishable and there is nothing to choose between. Targeting still buys precision over a blanket broadcast: with two iOS devices and an Android device attached, reload ios reloads the two iPhones and leaves Android alone.

This also fixes a latent bug in the path that already existed — Metro reports peer metadata as a parsed object under @react-native-community/cli-server-api and as a raw query string under @expo/cli, and the old code only handled the object, so the iOS peer path silently matched nothing under an Expo-hosted dev server.

Broadcasting, where it cannot. The bare React Native dev server cannot enumerate peers at all. cli-server-api answers getpeers out of otherWs.upgradeReq.url, which ws removed in 3.0, and its own dependency is ws@^6. This is not version drift: 20.2.0, the current latest, still does it, and nothing upstream tracks it. Its broadcast path does not touch that property, so a Metro that cannot enumerate gets an untargeted reload instead.

Without this, removing the adb broadcast would have left bare React Native Android with no working reload at all, since the targeted path can never find a peer there.

What the broadcast cannot promise, and how that is reported. getpeers only touches the broken property when some other client exists, so the throw proves a client is connected but not which one. Three cases, all confirmed against cli-server-api@20.2.0 on its own ws@6.2.6:

connected result reached
nothing noPeer nothing, correctly
the target app ok, broadcast the target app
some other app ok, broadcast that app, not the target

The third row is why strategy is metro-broadcast rather than metro-websocket, and why both the plain output and the strategy contract in guide facts say that the reload went to every app on the port and that Stim cannot confirm appId was among them, with the app's own error screen as the fallback. Failing closed there instead would take the second row down with it, which is the case this exists for.

A miss also falls back to the broadcast. Matching is best-effort — a peer can carry no query at all, or a shape these matchers do not read — so when Metro names its clients and none match, Stim still broadcasts before giving up. It reports the miss either way, because nothing there proves the app received it; the remedy just tells the agent to check the screen before spending a retry.

Contract note. WorkspaceLaunchRecord.deepLinkUrl is removed. The deep-link branch was its only reader, so it became dead state written by both launch paths on every run. Dropping it is safe for records already on disk: parseWorkspaceLaunchRecord validates the fields it needs and casts, so an existing record carrying the field still parses and the extra key is ignored. An older stim would reject records written by this one, which is a downgrade-only concern.

Behavior traded away. An app that Metro has a peer map for but no peer in cannot be reached by any websocket reload, and is no longer restarted through its dev-client URL. A missing peer is not proof the app never connected — ReconnectingWebSocket retries every 2 seconds, which is also this probe's own timeout — so the remedy asks for one more stim reload first. On Android, staying unreachable is largely theoretical: an app whose first bundle fails still receives Metro reload commands, confirmed in the test plan of react/react-native#58352. On iOS the app genuinely is absent, because bridgeless RCTInstance resolves DevSettings only in _loadJSBundle's success callback and RCTDevSettings.initialize is what both opens the /message socket and registers the reload handler; a failed first bundle skips both, so that cause is named only on iOS, and named as one no retry will fix. The remedy then routes to the device's own reload controls in the agent's existing automation session — the error screen's Reload button, or the dev menu — with a relaunch offered only when neither is reachable and marked as losing in-memory state. Reopening the URL was never the right answer there either, since it restarts the app for the same loss.

That same iOS fact makes stim ios's own FATAL remedy wrong as of this change. It told the agent to run stim reload ios after a failed first bundle, which worked on main only because the dev-client URL restarted the app. It now routes to the error screen's Reload button instead, on simulators and phones alike — agent-device metro reload cannot reach that app either.

STIM_RELOAD_FAILED now carries two distinguishable shapes with different remedies, and the result type spells out all four outcomes so an impossible one does not typecheck. A Metro that does not answer at all says nothing about the app, so that case asks for a retry and stim doctor instead of sending the agent to the device.

Test plan

Peer identification runs against a real WebSocketServer replaying the shapes the two dev servers actually produce, object and raw query string, for both platforms:

  • iOS matches its role peer and leaves a simultaneously connected Android peer alone; Android matches its package peer and leaves iOS alone.
  • An Android peer for a different package reports no peer rather than reloading it.
  • Neither matcher falls back to the other's key when a bundle id and a package name are the same string.
  • A peer whose metadata is null, which @expo/cli produces for a query-less connection URL, is counted but never matched.
  • An app that never connected reports no peer, which is the shape an iOS first-bundle failure produces. Multiple matching peers refuse without sending a reload.
  • A Metro that answers getpeers with an error is reloaded by broadcast, for the upgradeReq TypeError and for any other enumeration error.

The broadcast fallback is also verified against the real dependency rather than a replay. Running @react-native-community/cli-server-api@20.2.0 on its own ws@6.2.6, with a client connected exactly as JSPackagerClient.kt connects:

result:      { ok: true, broadcast: true }
appReceived: [ { version: 2, method: 'reload' } ]

On main that same app receives nothing, because the adb broadcast is what reached it and getpeers throws.

Command behavior:

  • Metro is addressed with the target platform and app id on both platforms. With the URL field gone there is no second path a reload can take, so this is the guard against the regression rather than a URL-specific assertion.
  • An app Metro cannot see gets the automation remedy, not a restart. It says a broadcast went out and to check the screen first, then asks for one retry before sending the agent to the device, routes to the Reload button and dev menu ahead of --relaunch, and names the iOS first-bundle cause only on iOS.
  • Several matching peers all reload, and both the facts and the plain output say how many devices that was. A cross-platform miss is never addressed as a match, only swept up by the last-resort broadcast.
  • A Metro that does not answer, by timeout or socket error, asks for a retry and names no device command at all. Both shapes are asserted at the engine boundary against a real socket, not only through a faked result.
  • stim ios routes a failed first bundle to the error screen rather than to any Metro reload, on the simulator and on a phone. Its counterpart still holds: a bundle that loaded and then hit a runtime error is a peer, so that case still recommends a reload.
  • A broadcast reload reports metro-broadcast and says in the plain output that every app on that Metro reloaded; a targeted reload says neither.
  • The remedy never mentions Local Network, a phone-only cause reload cannot reach: it refuses anything that is not this workspace's owned local simulator or emulator.
  • guide facts names every value of ReloadFacts['strategy'], asserted against the source union.
  • A launch record written before deepLinkUrl was dropped still parses and is still a reload target.
  • Release, ambiguity, ownership and Metro-identity refusals still fire before any reload is attempted.

Real-device evidence from diagnosing this on an Expo SDK 57 app (io.tlon.groups, expo-dev-launcher 57.0.7, emulator-5556, Metro 8082): reopening the dev-client URL on a healthy app killed the process four times out of four, each leaving the same App react context shouldn't be created before. stack in logcat -b crash, with a focused text input present for two of them and absent for the other two. A version-2 Metro websocket reload against the same app left the process at the same pid across 30 seconds and Metro logged Android Bundled 120ms.

Not verified: stim reload from this branch driving a real device end to end. The manual websocket reload above was untargeted, so it exercised the transport but not this branch's peer identification. The iOS first-bundle-failure case is covered at the peer-identification boundary rather than on a device; reproducing it live needs an app with a deliberately broken bundle. react/react-native#58352 has landed but is not in a release yet, so every released RN still behaves this way and this path stays load-bearing.

Fixes #683

@janicduplessis
janicduplessis force-pushed the @janic/android-reload-metro-first branch from e7dc459 to 0816778 Compare September 11, 2026 02:48
@janicduplessis janicduplessis changed the title fix: reload connected Android apps through Metro fix: reload every app through a targeted Metro message Sep 11, 2026
@janicduplessis
janicduplessis force-pushed the @janic/android-reload-metro-first branch 3 times, most recently from 5270482 to a61f4c4 Compare September 11, 2026 03:18
@janicduplessis

Copy link
Copy Markdown
Collaborator Author

Fresh review at a61f4c4 found a compatibility blocker: bare Android reload now always uses getpeers, but the released @react-native-community/cli-server-api 20.2.0 endpoint fails that request with its ws 6.2.6 dependency. The removed package/serial-scoped adb route previously avoided this path.

Reproduced against the real package's createMessageSocketEndpoint, with a connected WebSocket carrying Android's device, app, and clientid query. reloadThroughMetro() returned failed: true with “Metro could not identify the connected Android app”; only getpeers was sent and the app received zero messages. The endpoint reads otherWs.upgradeReq.url, but upgradeReq is absent. This is an actual server compatibility failure, not a mocked response.

The latest branch's 61 focused reload and guide tests pass, but they do not catch this regression. Preserve a working bare Android route or resolve compatibility and verify against the real endpoint before merging. The body also correctly records that this branch has not yet had end-to-end native-device validation.

@janicduplessis
janicduplessis force-pushed the @janic/android-reload-metro-first branch 3 times, most recently from 57c0381 to 820fd8b Compare September 11, 2026 04:00
@janicduplessis janicduplessis changed the title fix: reload every app through a targeted Metro message fix: reload every app through the Metro websocket Sep 11, 2026
@janicduplessis
janicduplessis force-pushed the @janic/android-reload-metro-first branch 5 times, most recently from 1e56a82 to 8c0b0d3 Compare September 11, 2026 09:10
Reopening a development-client URL restarts the app rather than reloading its
JavaScript, and on Android that recreates MainActivity while expo-dev-launcher
asserts no React context exists yet, killing any app that was already running.

Reload now has one transport on both platforms. Where Metro can enumerate its
peers, the reload is a targeted message to this app's own peer: iOS peers are
matched by role and Android peers by package, because only iOS sends role and
only Android sends app. Peer metadata is read from both shapes Metro reports it
in, an object from @react-native-community/cli-server-api and a raw query
string from @expo/cli; the previous iOS-only path silently matched nothing
under the latter.

The bare dev server cannot enumerate peers at all: cli-server-api answers
getpeers out of otherWs.upgradeReq.url, a ws property removed in ws 3.0, so
every published version throws there. Broadcasting does not touch it, so a
Metro that cannot enumerate is reloaded by broadcast and the facts report
metro-broadcast instead of metro-websocket. That keeps bare Android working
after the adb broadcast is removed, and fixes bare iOS, which could not be
reloaded at all before.

The deep-link and adb-broadcast paths are removed, along with the launch
record's deepLinkUrl field, whose only reader was the deep-link branch.

An app Metro has a peer map for but no peer in cannot be reached by any
websocket reload. That now reports the automation remedy, which routes to the
dev menu or the error screen's Reload button before offering a relaunch,
rather than restarting the app itself. A Metro that does not answer at all says
nothing about the app, so that case asks for a retry instead.
@janicduplessis
janicduplessis force-pushed the @janic/android-reload-metro-first branch from 8c0b0d3 to a81dbc0 Compare September 11, 2026 09:16
@janicduplessis
janicduplessis marked this pull request as ready for review September 11, 2026 09:20
@janicduplessis
janicduplessis merged commit 35c6bab into main Sep 11, 2026
5 checks passed
@janicduplessis
janicduplessis deleted the @janic/android-reload-metro-first branch September 11, 2026 09:20
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.

stim reload android kills a healthy dev-client app by reopening its deep link

1 participant