Skip to content

Close the PWA first-paint gap: ETag revalidation + inlined critical CSS - #83

Open
radiantnode wants to merge 2 commits into
mainfrom
claude/ios-pwa-loading-screen-k0q7iz
Open

Close the PWA first-paint gap: ETag revalidation + inlined critical CSS#83
radiantnode wants to merge 2 commits into
mainfrom
claude/ios-pwa-loading-screen-k0q7iz

Conversation

@radiantnode

Copy link
Copy Markdown
Owner

Summary

Reopening the installed PWA on a slow connection (e.g. LTE) showed a blank white screen between the OS splash screen and the app's own loading screen. Two contributing causes, both fixed:

  • No cheap revalidation for the HTML document. server/routes.py served every page as a plain HTMLResponse with no ETag/Last-Modified, and SecurityHeadersMiddleware force-sets Cache-Control: no-cache (deliberately, to stop a PWA running a version-skewed module graph). No validator + no-cache meant every relaunch at the PWA's start_url required a full re-fetch of the document, even when nothing had changed.
  • First paint of #loading depended on an extra network round trip. In prod, critical.css was served as a separate <link>, so the browser couldn't paint anything — including the custom loading screen — until that request resolved on top of the document fetch itself.

Changes

  • server/routes.py: all HTML page routes (/, /join, /signin, /welcome, /nearby, /games/{code}, /@{username}) now send an ETag (sha1 of the rendered body) and honor If-None-Match, returning a 304 on a repeat load instead of re-sending the whole document.
  • scripts/build_assets.mjs: critical.css is now inlined as a <style> directly into dist/index.html in prod, instead of a separate fingerprinted file — removing the round trip that gated first paint.
  • server/security.py: since the CSP is style-src 'self' with no unsafe-inline, the inlined block is allowed via a content-pinned 'sha256-...' source computed at build time (dist/csp.json) and read by the security middleware at startup — not a general inline-style exemption, only that exact byte sequence validates. Dev is unaffected; critical.css stays an external <link> there.
  • tests/assets_test.py: updated the build-pipeline invariants for the new contract (one stylesheet link instead of two, inline <style> present, its sha256 matches dist/csp.json).
  • docs/ASSET_PIPELINE.md: updated to describe the inlining + CSP-hash mechanism.

Intentionally out of scope for now: a service worker for app-shell caching (would help repeat loads further, but wasn't part of this pass).

Test plan

  • python tests/assets_test.py — 16/16 passing, including a new check that dist/csp.json's hash matches the inlined <style> content
  • Bare TestClient run (no DB/Redis) confirmed: prod CSP header carries the sha256- hash in style-src; first GET / returns 200 + ETag; repeat GET / with If-None-Match returns 304 with an empty body — verified in both prod (FRONTEND_DIST set) and dev modes
  • Playwright at the mobile viewport (390×844): loaded the built dist/ output, confirmed no CSP violations in the console; with the JS bundle deliberately blocked (to freeze on #loading), screenshotted the dice loader rendering correctly — fully styled by the inlined critical CSS

Generated by Claude Code

claude added 2 commits July 30, 2026 15:50
Reopening the installed PWA on a slow connection showed a blank white
screen between the OS splash and the loading screen. Two fixes:

- server/routes.py: every HTML page route now sends an ETag and honors
  If-None-Match, so a repeat load (e.g. reopening at the PWA's start_url)
  revalidates with a cheap 304 instead of re-fetching the whole document
  under the existing Cache-Control: no-cache.

- scripts/build_assets.mjs / server/security.py: critical.css is now
  inlined into dist/index.html in prod, removing the extra network round
  trip that previously gated first paint of the #loading screen. This
  stays CSP-compliant (no unsafe-inline) via a content-pinned CSP hash:
  the build writes the inlined content's sha256 to dist/csp.json, and
  the security middleware allows exactly that hash in style-src. Dev is
  unaffected — critical.css stays an external <link> there.

tests/assets_test.py and docs/ASSET_PIPELINE.md updated for the new
contract; verified with a real prod build (dist/), a bare TestClient
checking the CSP header + 304 behavior, and a Playwright screenshot at
the mobile viewport confirming the dice loader paints under the new CSP.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GiXTGVkJ1yut7i8DUGLwxi
…link>

Matching the exact <link> markup as a string was brittle: any incidental
attribute or formatting change to that tag would make the replace silently
no-op, leaving a dead reference to a critical.css file the build no longer
writes. Anchor on the preceding comment instead, consistent with the two
similar collapses right below it — with a lazy quantifier, since unlike
those two this one isn't the last `.css">` in the document (a greedy match
would swallow the non-critical block that follows).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GiXTGVkJ1yut7i8DUGLwxi
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.

2 participants