Close the PWA first-paint gap: ETag revalidation + inlined critical CSS - #83
Open
radiantnode wants to merge 2 commits into
Open
Close the PWA first-paint gap: ETag revalidation + inlined critical CSS#83radiantnode wants to merge 2 commits into
radiantnode wants to merge 2 commits into
Conversation
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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:
server/routes.pyserved every page as a plainHTMLResponsewith noETag/Last-Modified, andSecurityHeadersMiddlewareforce-setsCache-Control: no-cache(deliberately, to stop a PWA running a version-skewed module graph). No validator +no-cachemeant every relaunch at the PWA'sstart_urlrequired a full re-fetch of the document, even when nothing had changed.#loadingdepended on an extra network round trip. In prod,critical.csswas 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 anETag(sha1 of the rendered body) and honorIf-None-Match, returning a304on a repeat load instead of re-sending the whole document.scripts/build_assets.mjs:critical.cssis now inlined as a<style>directly intodist/index.htmlin prod, instead of a separate fingerprinted file — removing the round trip that gated first paint.server/security.py: since the CSP isstyle-src 'self'with nounsafe-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.cssstays 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 matchesdist/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 thatdist/csp.json's hash matches the inlined<style>contentTestClientrun (no DB/Redis) confirmed: prod CSP header carries thesha256-hash instyle-src; firstGET /returns200+ETag; repeatGET /withIf-None-Matchreturns304with an empty body — verified in both prod (FRONTEND_DISTset) and dev modesdist/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 CSSGenerated by Claude Code