fix: reload every app through the Metro websocket - #685
Conversation
e7dc459 to
0816778
Compare
5270482 to
a61f4c4
Compare
|
Fresh review at a61f4c4 found a compatibility blocker: bare Android reload now always uses Reproduced against the real package's 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. |
57c0381 to
820fd8b
Compare
1e56a82 to
8c0b0d3
Compare
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.
8c0b0d3 to
a81dbc0
Compare
Description
stim reload androidkills 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'smain: 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
/messagewebsocket. 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 onapp, because only iOS sendsrole(RCTPackagerConnection.mm, and it sends nothing else) and only Android sendsapp(JSPackagerClient.kt, alongsidedeviceandclientid). Every matching peer is addressed, andtargetsreports 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, whererole=iosseparates 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 iosreloads 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-apiand 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-apianswersgetpeersout ofotherWs.upgradeReq.url, whichwsremoved in 3.0, and its own dependency isws@^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.
getpeersonly 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 againstcli-server-api@20.2.0on its ownws@6.2.6:noPeerok, broadcastok, broadcastThe third row is why
strategyismetro-broadcastrather thanmetro-websocket, and why both the plain output and thestrategycontract inguide factssay that the reload went to every app on the port and that Stim cannot confirmappIdwas 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.deepLinkUrlis 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:parseWorkspaceLaunchRecordvalidates 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 —
ReconnectingWebSocketretries every 2 seconds, which is also this probe's own timeout — so the remedy asks for one morestim reloadfirst. 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 bridgelessRCTInstanceresolvesDevSettingsonly in_loadJSBundle's success callback andRCTDevSettings.initializeis what both opens the/messagesocket 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 runstim reload iosafter a failed first bundle, which worked onmainonly 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 reloadcannot reach that app either.STIM_RELOAD_FAILEDnow 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 andstim doctorinstead of sending the agent to the device.Test plan
Peer identification runs against a real
WebSocketServerreplaying the shapes the two dev servers actually produce, object and raw query string, for both platforms:rolepeer and leaves a simultaneously connected Android peer alone; Android matches its package peer and leaves iOS alone.null, which@expo/cliproduces for a query-less connection URL, is counted but never matched.getpeerswith an error is reloaded by broadcast, for theupgradeReqTypeError 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.0on its ownws@6.2.6, with a client connected exactly asJSPackagerClient.ktconnects:On
mainthat same app receives nothing, because the adb broadcast is what reached it andgetpeersthrows.Command behavior:
--relaunch, and names the iOS first-bundle cause only on iOS.stim iosroutes 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.metro-broadcastand says in the plain output that every app on that Metro reloaded; a targeted reload says neither.reloadcannot reach: it refuses anything that is not this workspace's owned local simulator or emulator.guide factsnames every value ofReloadFacts['strategy'], asserted against the source union.deepLinkUrlwas dropped still parses and is still a reload target.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 sameApp react context shouldn't be created before.stack inlogcat -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 loggedAndroid Bundled 120ms.Not verified:
stim reloadfrom 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