fix(web): reload a stale app on return, and restart the inbox follow

Merged
DA
6d ago
+720-52

What this asks for

The installed iPhone web app kept showing an empty account-wide Inbox for hours after #4013 shipped. The likely cause is that a standalone PWA keeps its old code and CSP until it is fully restarted. This PR does two things:

  1. It reloads a stale tab when it comes back to the front.
  2. It restarts the account Inbox follow on that return, and when the browser comes back online, so the follow doesn't wait out Realtime's backoff.

Reload a stale app on return (web only, apps/web/src/app/)

  • The source of the release. The build writes the deployment's commit (VERCEL_GIT_COMMIT_SHA) into the app's page as <meta name="luke-release">, through scripts/release-meta.ts. The tab already carries the same commit as its Sentry release. No endpoint or header was serving it, so this was the smallest check: a same-origin GET /app (cache: "no-store") of the static public page. It needs no new route, rewrite or CSP source, since connect-src 'self' already covers it. Nothing of the account is sent.
  • When it checks. It runs on visibilitychange to visible, or on pageshow with persisted, throttled to once a minute (Stream.throttle with enforce).
  • What it does. If the release served differs from the tab's own, the tab reloads through the leave guard. If unsavedWork stands (#4038), it shows a persistent "A new version of Luke is ready" toast with a Reload action instead, once per release. The press still goes through the leave guard.
  • A build that names no release, such as a dev server or tests, never checks.
  • To support this, PageLeave gains holdsUnsavedWork(), and @luke/panel/toast exports showToast so the shell can raise a toast from outside the React tree.

Restart the Inbox follow on return

  • The host gets a new optional seam, PageReturns (Stream<void>). Only the web provides it: shown again, restored from the back-forward cache, or online. The desktop provides none and is unchanged.
  • account-inbox-follow.ts now tracks each part's Realtime status. On a return, if any part isn't live, or no subscription is standing (for example while it waits out LIVE_RESTART), the current attempt is interrupted and started again at once. A follow whose parts are all live is left alone.
  • Limit: a socket iOS silently half-closed that still reads live can't be detected this way.

Tests

  • apps/web/tests/stale-release.test.ts (it.effect, TestClock):
    • a newer release reloads;
    • the same release doesn't;
    • unsaved work offers the reload instead of taking it, once per release, and reloads once the work is gone;
    • the throttle holds within the minute;
    • a build with no release never asks.
  • compose-coding-agents.test.ts:
    • a return while a part is reconnecting restarts at once;
    • a return while every part is live leaves the follow alone;
    • a return while a stopped subscription waits to restart restarts it at once.
  • release-meta.test.ts: only the app's page names the commit. page-leave.test.ts: holdsUnsavedWork.
  • I mutated the code to confirm that the throttle, unsaved-work and restart tests each fail without their change.

Checks

  • Locally passing: oxlint, biome, knip, discarded-effect, and typecheck for host, panel and web, each run alone because of the sandbox's memory.
  • In check:affected, one host test failed: open-on-mac.test.ts > a worktree folder deleted by hand is made again. It timed out at 113 s under memory pressure. I didn't touch that code, and it is a known flaky test.

Coordination

Agent a2f34c76 is also changing account-inbox-follow.ts: it gives openLive a heard callback. Whichever PR merges second rebases. The conflicts should be small.

Made with Luke