Skip to content

Make the web app open offline from one visit, update cleanly, and keep what it stores - #108

Merged
seenu-k merged 4 commits into
mainfrom
web-platform-hardening
Sep 17, 2026
Merged

seenu-k merged 4 commits into
mainfrom
web-platform-hardening

Conversation

@seenu-k

@seenu-k seenu-k commented Sep 17, 2026 •

Copy link
Copy Markdown
Contributor

An audit of the web build against decisions 0012 and 0013 found several browser
behaviours worked around where they needed designing for. This fixes them, and
records the design as
decision 0014.

What changes

The service worker is generated by Workbox and precaches the build. The
hand-written worker only began caching once it controlled the page, after
Flutter had already fetched its entry files, so an app visited once did not open
offline; and it served the unhashed entry points cache-first, so the
update-required reload came back to the old build. Now a deploy installs beside
the running build and takes over when the app asks, so a tab never mixes two
builds. run_worker_first and the worker's navigateFallbackDenylist are held
together by a scaffold test.

Drift's web runtime ships with eigen_flutter as web-only package assets,
so no app or template carries a copy of sqlite3.wasm and drift_worker.js.

Storage is chosen, then locked, then opened. The tab lock was taken when
SharedWorker was missing, but drift also falls back to per-tab IndexedDB when
a shared worker exists and fails, so the unsafe case could open unlocked. A tab
refused the database now opens by itself once the tab holding it closes.

A sync request is answered by a pass that began after it. The lock was taken
before joining a pass in flight, so on the web a trigger arriving mid-pass
queued a second full pass. A burst of triggers now costs one pass, across tabs
too.

Coming back is onShow. Flutter's web engine reports every window focus as
a resume, so alt-tabbing, or closing the sign-in popup, ran a sync pass and
reconnected the open game's socket.

Connectivity starts from the current state. connectivity_plus on the web
reports only changes, so an app opened offline believed itself online and did
not sync when the network returned.

Sign-in outcomes are modelled. Dismissing Google's sign-in is no longer an
error, and no longer discards a guest's data mid-switch; a blocked popup says to
allow pop-ups. Web stays on the popup, with no redirect fallback.

Two drift defects are worked around, tracked in docs/blockers.md. On
IndexedDB storage, drift 2.35.0 saves neither a transaction nor the schema
version until some later unrelated write, so a tab closing in between lost the
write, or left every later open failing to create tables that exist. Fixed
upstream in simolus3/drift#3865, unreleased.

A replica that stops answering says so. A browser may terminate the worker
holding the database — Chrome for Android documents this for a backgrounded app
— and nothing tells the page, so statements never answer and the app looks
frozen. Statements are timed; one unanswered for 20 seconds raises a banner
offering a reload, which opens everything again. Nothing recovers automatically,
and every committed write is already stored.

The replay budget is app configuration (ReplicaConfig.keptReplays), and
the browser is asked to keep local games behind an explanation, once, rather
than silently as a game is created.

Verification

./tool/check.sh passes for dart, flutter, shell, docs, server,
web, and scaffold web, which now builds a scaffolded game and its service
worker end to end.

Browser behaviour is not in CI and was checked in headless Chrome 153:

  • an offline cold start after a single online visit, with the origin down and
    every other host unresolvable, plus an offline /game/:id link;
  • the update handshake: a deploy waits, the reload takes over, the new build is
    served;
  • two tabs over shared-worker storage, a refused tab taking over when the first
    closes, and reopening after the only tab closed;
  • the drift durability defect, reproduced against drift as released and gone
    through the replica host.

Follow-ups

  • Whether Chrome for Android really does terminate the shared worker needs a
    device; the reload offer above is what the app does if it happens.
  • Remove the drift workaround when the release carrying #3865 lands, replacing
    the web assets in the same change.
  • accounts.google.com/gsi/client still loads on web, pulled in by the Android
    sign-in dependency and unused there. Left as is deliberately: removing it
    would mean dropping google_sign_in and moving Android off its native
    account picker.

🤖 Generated with Claude Code

seenu-k and others added 4 commits September 17, 2026 16:12
- Removed sqlite3.wasm from the project as it is now handled by eigen_flutter.
- Updated GitHub Actions workflow to install root dependencies before building the web app.
- Modified README to reflect changes in service worker generation and offline capabilities.
- Updated package.json to include workbox-cli for service worker generation.
- Added workbox-config.cjs for service worker configuration to precache assets.
- Updated scaffold tests to verify service worker and asset handling.
- Improved app lifecycle handling to refresh when coming back into view.
- Enhanced update mechanism to allow service worker to take over before reloading.
- Updated documentation to clarify web app behavior regarding offline play and updates.
…y budget

A browser may clear a site's storage, and a game played on the device that has
not uploaded yet is the one thing a sync cannot bring back. Asking the browser
to keep it makes some browsers ask the player, so the app now explains first:
the home screen offers it while such a game exists, once, and never on a device.
Creating a local game no longer asks silently at a moment that explains nothing.

`ReplicaHost` reports persistence as a state, so the offer is made only where
the browser has neither granted nor refused it.

How many ended online games keep their replay is now `ReplicaConfig.keptReplays`
on `AppConfig`, which answers an open question of decision 0013. It matters most
in a browser, where drift holds the whole replica in memory.

Also drops the storage chooser's rule that kept an existing database in the
storage it was created in: that is compatibility for data no player has yet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… fixes it

A browser may terminate the worker hosting the replica, which Chrome for Android
documents for a backgrounded app, and nothing tells the page: drift's channel
cannot observe a worker going away, so a statement never answers, lists keep
what they last read, and writes wait forever. The app looked frozen with nothing
to act on, and only a reload recovered it.

There is no signal to listen for, so statements are timed. One unanswered for 20
seconds reports the replica as not answering, and any answer reports it back;
opening is not timed, because loading a large replica out of IndexedDB is slow
rather than broken. Nothing recovers automatically: a banner says the app
stopped responding and offers a reload, which opens everything again. Every
committed write is already stored, so that is all it costs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@seenu-k
seenu-k merged commit 201e66a into main Sep 17, 2026
11 checks passed
@seenu-k
seenu-k deleted the web-platform-hardening branch September 17, 2026 12:38
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.

1 participant