Skip to content

fix(mobile): clear derived data when the app or update changes, and catch render errors at the root - #1667

Merged
janicduplessis merged 4 commits into
mainfrom
fix/1660-ota-launch-crash
Sep 27, 2026
Merged

janicduplessis merged 4 commits into
mainfrom
fix/1660-ota-launch-crash

Conversation

@janicduplessis

@janicduplessis janicduplessis commented Sep 27, 2026 •

Copy link
Copy Markdown
Collaborator

Description

The production app (TestFlight build 13) aborts at launch after the OTAs built from e3da74c and fad82c4. This PR clears data the app derives and caches whenever the running JS changes (new build, OTA or rollback), instead of adding compatibility code for old stored shapes. It also adds a root error boundary so a render or effect throw no longer becomes a hard abort.

The crash reports resolve against build 13's binary to expo-updates' ErrorRecovery.crash(), which re-raises a JS fatal that error recovery couldn't fall back from. The confirmed throw is in fad82c4, at oversight.ts:627: previous?.workspaces[env.path]. Build 13's JS stored the notify state in MMKV (stim.notifications/state) under the same status:<id> keys, but in an older shape with no workspaces. The new JS reads it back unchecked and throws once a live status arrives with notifications on. No test fed a previous version's persisted bytes to the new code.

Solution

  • src/storage.ts owns the MMKV instances. On import, it compares a marker of the running JS (expoConfig.version, Updates.runtimeVersion, Updates.updateId) with the one stored in stim.app. On a mismatch, it clears the derived keys.
  • The hooks take their MMKV instances from that module, so module order guarantees the clear runs before any module-scope read (createMachineStore reading the status cache, pushedMacs).
  • A new build, an OTA, and a rollback to embedded all change updateId.
Store Cleared on change Kept
MMKV stim.status every key (the status cache)
MMKV stim.notifications state prefs, pushToken, pushed:*, handledResponse
SecureStore pairings and device tokens, appearance and video settings, home filters and view, recents

pushed:* stays because the Mac keeps the registration across an app update; clearing it would make the phone repeat what the Mac pushes until it registers again. handledResponse stays so a relaunch after an update doesn't re-open the last tapped notification.

This replaces the notify state shape check from #1662 (parseNotifyState and its test): with the clear, no code reads another version's shapes, so there is nothing to parse defensively.

A root ErrorBoundary, exported from app/_layout.tsx, turns a render or effect throw into an error screen with Try again, instead of a fatal that expo-updates converts into an abort. Throws outside React, such as in the WebSocket message handler, are still fatal.

Limitations:

  • A rollback clears only once the JS it rolls back to has this code. Build 13's embedded JS doesn't, so a rollback to it today doesn't clear. It does from the next native build onward.
  • The fix adds no dependency, and the runtime fingerprint is unchanged (npx expo-updates runtimeversion:resolve --platform ios prints 87cad17f256996c2023667c721a6115c4dcd0015 before and after), so it can ship as an OTA onto build 13.

Not yet confirmed

Test plan

  • src/lib/derived-data.test.ts:
    • On a marker mismatch (no marker, or another update's), a store holding build 13's exact notify state shape, a status cache entry, prefs, pushToken and pushed:* keeps only prefs, pushToken and pushed:*, and localNotifications on a live status no longer throws. The test fails when the state clear is removed.
    • The same marker leaves everything in place.
  • src/app-boot.test.tsx now seeds another release's marker along with its old-shape notify state and cached status. It asserts the launch clears the cache and the state before reading them, keeps the pairing and the preferences, and reports no error. With the clear disabled, it fails on the old-shape throw.
  • Simulator, a Release build of 76f2bd5 (the same runtime as build 13). OTAs were served to it by a local expo-updates server that replays the published 36826e03 manifest with this branch's bundle as the launch asset. Status came from a scratch stim-server built from main.
    • With MMKV restored to what build 13's JS wrote (notifications on), the fixed JS launches and clears. Offline, home shows no cached rows, where fad82c4 showed "Last seen" rows from the same data. The pairing and the saved home view stay.
    • Online, it runs with a live status, the Web tile (driven over CDP) and a ready workspace.
    • Relaunching the same update offline keeps the cached rows.
    • A new update id clears them again.
    • A bundle that throws in LocalNotifier's effect shows the error screen, and the process stays alive.

Fixes #1660

@janicduplessis janicduplessis left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fresh review of #1667 against #1660. No [bug] findings. pnpm test (162 passed), typecheck and lint in apps/mobile are green on this branch.

What I checked and found correct:

  • Clear order. src/storage.ts is the only createMMKV caller left. hooks/mac-connection.tsx and hooks/notifications.tsx import it, so ES module evaluation runs the clear before createMachineStore reads the status cache and before pushedMacs is read at module scope. notifications.tsx reaches storage through mac-connection first, and that order is also fine.
  • Marker. A new build, an OTA and a rollback to embedded each change Updates.updateId. The embedded update has its own id when updates are enabled. In dev builds with updates off, updateId is null, so the marker stays the same and nothing is cleared across Metro reloads. That is acceptable for dev. A crash after the marker is written, followed by an expo-updates rollback, clears again on the next launch, which is correct.
  • ErrorBoundary. expo-router's fromImport (node_modules/expo-router/build/useScreens.js:210-221) wraps the root layout's default export in <Try catch={ErrorBoundary}>. That puts every provider inside the boundary, including LocalNotifier's effect where the #1660 throw happens. React boundaries also catch effect errors. The fallback renders outside SettingsProvider and ThemeProvider, but EmptyState, Button, Text and Touch only use Unistyles, so it renders without them.
  • Fingerprint and dependencies. expo-updates, expo-constants and react-native-mmkv were already dependencies. The diff adds no package and touches no native config.
  • Test. It feeds build 13's persisted notify shape to the real localNotifications, so it fails when the state clear is removed, and it checks the same-marker no-op. That matches the repo's test policy.

Findings:

  1. [should-fix] apps/mobile/src/lib/derived-data.ts:29 clears pushed:*, and doing so can cause duplicate notifications. pushed:<id> is a boolean that records server-side state: that Mac holds this phone's push registration. It has no JS-defined shape that a new bundle could misread. The Mac keeps its registration across an app update. The doc comment on pushedMacs (hooks/notifications.tsx:133) says those keys exist "so a relaunch does not notify what they push". After an update, pushedMacs starts empty, so LocalNotifier runs the status rules with the phone's categories until push.register resolves (notifications.tsx:300-305). Two cases produce duplicates:

    • Within that window, any event is notified by the phone and also pushed by the Mac. The window is short, but it is real.
    • If push.register fails with a non-RequestError, for example because the socket drops mid-request, pushed stays false for that connection while the Mac keeps pushing. Duplicates then continue until a later registration succeeds.

    The first LocalNotifier run after the clear is a baseline (oversee with previous === null), so the launch itself is quiet; the risk is limited to the cases above. Suggest keeping pushed:* and clearing only state in stim.notifications. The test's expected keys would become ['prefs', 'pushToken', 'pushed:mac']. The PR body's table and the clearDerivedDataOnChange doc comment would need the same change.

  2. [should-fix] apps/mobile/README.md:30-34 says a cold launch shows each machine's last status, dimmed with "Last seen". That is no longer true on the first launch after an app update or OTA, when the saved status is cleared. Add one sentence, for example: "An app update clears the saved status, so the first launch after one shows no rows until each machine connects." It may also help to note in the notifications section (around line 322) that notification state resets on an update.

  3. [nit] apps/mobile/src/app/_layout.tsx:21-29: Try again re-renders the same tree against the same MMKV contents. A throw caused by persisted data under the current marker loops back to the error screen. This does not block the PR, because the marker clear covers the crash in #1660. If a persisted payload can still throw, one option is to have retry also clear the derived keys.

  4. [nit] apps/mobile/src/lib/derived-data.test.ts:29 stores 'pushed:mac' as the string 'true'. The app writes the boolean true (notifications.tsx:303). This is harmless because the code only checks key names, but matching the real value keeps the fixture faithful to what build 13 wrote.

  5. [nit] apps/mobile/src/lib/derived-data.ts:26: only the test reads the boolean return value of clearDerivedDataOnChange. The test could assert on store contents, which it already does, and the function could return void. This is minor.

@janicduplessis

Copy link
Copy Markdown
Collaborator Author

Review addressed in 1e6f354:

  • pushed:* (should-fix 1): now kept. Only the notify state and the status cache are cleared. The test, doc comment and PR table are updated to match.
  • README (should-fix 2): it now says the first launch of a new build, update or rollback drops the saved statuses and the notification state, and keeps pairings and settings.
  • Return value (nit 5): clearDerivedDataOnChange returns void.
  • Not changed:
    • Nit 3: data stored under the current marker was written by the current JS, so a retry that also clears isn't needed for this failure.
    • Nit 4: harmless, as the review says.

@janicduplessis
janicduplessis marked this pull request as ready for review September 27, 2026 10:41
@janicduplessis
janicduplessis added this pull request to stack #1668 September 27, 2026 10:42
…atch render errors at the root

The OTA built from fad82c4 read the notify state that build 13's JS
stored under the same MMKV keys in an older shape, and oversee() threw
on it. At startup the app now compares a marker of the running JS (app
version, runtime, update id) with the one it stored, and on a mismatch
clears the status cache, the notify state and the push acceptances
before any module reads them. Notification settings, the push token and
everything in SecureStore stay.

A root ErrorBoundary shows the error with a retry instead of a fatal
exception that expo-updates turns into an abort.

Fixes #1660
@janicduplessis
janicduplessis force-pushed the fix/1660-ota-launch-crash branch from 1e6f354 to 5cb83d7 Compare September 27, 2026 10:51

@janicduplessis janicduplessis left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fresh review of 5cb83d7 on the rebased branch. I found no bugs and nothing should-fix. There are two nits.

What I checked:

  • Clear before read. src/storage.ts is the only createMMKV caller in the app. mac-connection.tsx (the module-scope createMachineStore status cache read) and notifications.tsx (readState, pushedMacs) take their instances from it, so the clear runs on import before either can read. In the boot test, the react-native-mmkv mock is loaded lazily: storage.ts is first required during renderRouter, after seedPreviousInstall(). The test proves this: the seeded marker is replaced and the seeded status:mac1 entry is gone.
  • Upgrade path. I disabled the clearDerivedDataOnChange(...) call in src/storage.ts and ran npx jest src/app-boot. The test fails at expect(errors).toEqual([]). The captured error is TypeError: Cannot read properties of undefined (reading '/Users/dev/Developer/stim') at oversee (src/lib/oversight.ts:627), which is the production throw. I then restored the file.
  • parseNotifyState. No references remain anywhere in the repo (git grep). readState is back to the exact pre-#1662 body.
  • Checks. npm run lint, npm run typecheck and npm test (22 suites, 163 tests) all pass.
  • README. The statements match the code. The rollback limitation from the PR body (build 13's embedded JS does not clear) is not in the README. That is fine, because the README describes this code's behavior.

Nits:

  1. [nit] apps/mobile/src/app-boot.test.tsx:241: With the clear disabled, Jest does not report the TypeError above. It reports Couldn't find a LinkingContext context. The new root ErrorBoundary makes React log Caught error: with an errorInfo object, and Jest's diff throws while pretty-printing that object. The test still fails, but the message points away from the cause. Mapping the captured args to strings before the assertion would surface the real error, for example errors.map((args) => args.map((a) => (a instanceof Error ? a.message : typeof a === 'string' ? a : typeof a))).

  2. [nit] apps/mobile/src/app-boot.test.tsx:244 and apps/mobile/README.md:647: The test name and the README say the launch clears the cached status "before reading" it. The assertion only checks that the MMKV key is gone at the end, which would also pass if the clear ran after createMachineStore had loaded the old entry into memory. The notify state ordering is proven, because the old-shape throw would fire. The status cache ordering is guaranteed by the import structure, not by this test. Two ways to close the gap: assert that no cached "Last seen" row renders before the socket opens, or word the README and test name to match what is asserted.

@janicduplessis

Copy link
Copy Markdown
Collaborator Author

Both re-review nits are addressed in e3c9125:

  • The boot test now records captured console errors as text, so a missing clear shows the TypeError from oversight.ts:627.
  • The test name and the README no longer say "before reading". Import order guarantees that for the status cache; the test doesn't prove it.

@janicduplessis
janicduplessis merged commit ca95def into main Sep 27, 2026
5 checks passed
@janicduplessis
janicduplessis deleted the fix/1660-ota-launch-crash branch September 27, 2026 11:02
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.

mobile: OTA from e3da74ca6/fad82c429 crashes the production app at launch

1 participant