fix(web): reload a stale app on return, and restart the inbox follow
Merged
DA
6d ago+720+720+012345678901234567890123456789-52-52-01234567890123456789
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:
- It reloads a stale tab when it comes back to the front.
- 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">, throughscripts/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-originGET /app(cache: "no-store") of the static public page. It needs no new route, rewrite or CSP source, sinceconnect-src 'self'already covers it. Nothing of the account is sent. - When it checks. It runs on
visibilitychangeto visible, or onpageshowwithpersisted, throttled to once a minute (Stream.throttlewithenforce). - What it does. If the release served differs from the tab's own, the tab reloads through the leave guard. If
unsavedWorkstands (#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,
PageLeavegainsholdsUnsavedWork(), and@luke/panel/toastexportsshowToastso 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, oronline. The desktop provides none and is unchanged. account-inbox-follow.tsnow tracks each part's Realtime status. On a return, if any part isn'tlive, or no subscription is standing (for example while it waits outLIVE_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
livecan'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