From 444d8a900161dcc9f607064ea9739109a765b566 Mon Sep 17 00:00:00 2001 From: mattshax Date: Tue, 8 Sep 2026 03:44:55 +0000 Subject: [PATCH] fix(mobile): the shell is pinned to the visual viewport, and a deploy is never cached Two faults, and the first hid the second. Static files were all served with max-age=0, so a phone could hold a stale shell after a deploy while two megabytes of fingerprinted JavaScript were revalidated on every load. The plugin writes that header itself after any setHeaders hook, so its own handling is now off: a file under assets carries its content hash and is cached for a year and never revalidated, and the HTML shell that names those files is never cached, so a deploy lands on the next load with nothing to clear. The phone layout asked a chain of percentage heights to agree with a viewport that changes size as iOS slides its toolbars, and it did not: the bottom bar left the screen and the thread ran under the browser chrome. The app is now a fixed element pinned to the visual viewport, which is the one arrangement iOS keeps correct through that transition. Out of flow, the page has nothing to scroll, and each view scrolls inside itself. --- server/src/main.ts | 24 +++++++++++++++++++++++- web/src/styles.css | 9 +++++++-- 2 files changed, 30 insertions(+), 3 deletions(-) diff --git a/server/src/main.ts b/server/src/main.ts index 96c569b..869c88f 100644 --- a/server/src/main.ts +++ b/server/src/main.ts @@ -43,9 +43,31 @@ const webDist = path.join(PROJECT_ROOT, 'web', 'dist') if (fs.existsSync(webDist)) { // wildcard:true serves from disk per request, so a web rebuild with new // hashed asset names does not require a server restart. - await app.register(fastifyStatic, { root: webDist, wildcard: true }) + // + // The two kinds of file want opposite caching, and serving both as + // max-age=0 got both wrong: the shell could still be held stale by a + // phone browser after a deploy, while two megabytes of fingerprinted + // JavaScript were revalidated on every single load. A file under + // /assets carries its content hash in its name, so its bytes can never + // change and it is cached for a year and never revalidated; the HTML + // shell that names those files is never cached, so a deploy is picked + // up on the next load without anyone clearing anything. + await app.register(fastifyStatic, { + root: webDist, + wildcard: true, + // The plugin writes its own Cache-Control (max-age=0) after + // setHeaders runs, so its handling has to be off for these to stand. + cacheControl: false, + setHeaders: (res, filePath) => { + const immutable = /[\\/]assets[\\/]/.test(filePath) && /-[A-Za-z0-9_-]{8,}\.[a-z0-9]+$/.test(filePath) + res.setHeader('Cache-Control', immutable + ? 'public, max-age=31536000, immutable' + : 'no-store, must-revalidate') + }, + }) app.setNotFoundHandler((req, reply) => { if (req.url.startsWith('/api/')) return reply.status(404).send({ error: 'not found' }) + reply.header('Cache-Control', 'no-store, must-revalidate') return reply.sendFile('index.html') }) } diff --git a/web/src/styles.css b/web/src/styles.css index 421868a..3cfda57 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -1640,8 +1640,13 @@ code, pre, kbd, .text-body, .viewer-path, .sql-box, [data-streamdown='code-block page-level scroll keeps the bar on the bottom edge; the views scroll inside their own containers, which is where scrolling belongs. */ - html, body, #root { height: 100dvh; overflow: hidden; } - .app { height: 100dvh; } + html, body, #root { height: 100%; overflow: hidden; } + /* Pinned to the visual viewport rather than sized by a chain of + percentages: a fixed element with dvh is the one arrangement iOS + keeps correct while its toolbars slide in and out. Out of flow, so + the page itself has nothing to scroll and the bottom bar cannot be + carried off-screen; the views scroll inside themselves. */ + .app { position: fixed; inset: 0; height: 100dvh; } body { overflow-x: hidden; } /* Clear of the home indicator, on the phones that have one. */ .sidenav, .sidenav.collapsed { padding-bottom: max(2px, env(safe-area-inset-bottom)); }