A long-open SPA asks for a file that no longer exists
After a deployment to staging, one page went blank the moment a link was tapped. It was the
page that had been left open for several days in the LINE in-app browser on iOS. The error was
'text/html' is not a valid JavaScript MIME type. Reopening the URL on the same device did
not reproduce it.
What happens
Three facts have to line up.
1. While a page stays open, the browser never fetches the HTML again. A Vite build puts a
content hash in every JavaScript filename, and index.html holds those names. A page loaded
three days ago keeps the filenames from three days ago. Navigation inside an SPA goes through
history.pushState, so no HTTP request is made. If the target screen is loaded with
import(), that is the moment the three-day-old filename is requested.
2. Cloudflare Pages stops serving the assets of older deployments once a new one goes out. The requested filename is no longer there.
3. The SPA catch-all redirect answers a missing file with HTML. _redirects contains
/* /index.html 200, so a GET for /assets/Events-BqK3f1.js returns index.html with
Content-Type: text/html instead of a 404. Cloudflare Pages _redirects has no syntax for
returning a 404.
When the response to an ES module request does not carry a JavaScript MIME type, the browser
refuses to execute it without reading the body. That is the error above, and React.lazy
receives the failure and renders nothing. Reopening the URL fetches index.html again, which
asks for the current filenames, so nothing fails.
It surfaced in the LINE in-app browser because WKWebView keeps a backgrounded page loaded for days. Any browser with a tab left open reaches the same state.
The fix
One JavaScript file for the member app
Members arrive from a LINE chat, and installing to the home screen is not available there.
Splitting by screen saves a few tens of KB on first load, and in exchange every “fetched
later” file becomes material for this failure. Vite’s
build.rollupOptions.output.inlineDynamicImports produces a single file. JavaScript is now
one bundle of 908 KB (246 KB gzipped), and the first load grows from 60 KB to 246 KB gzipped.
There is no file left to fetch later, so this failure path no longer exists.
One entry point for dynamic imports in the admin app
The admin app has a public login page, so that page should stay small. A file that only
re-exports the 13 post-login screens, src/admin-bundle.ts, is now the single place where
import() appears. The login page loads two files statically, and both are written into
index.html as <script> and modulepreload, so their versions always match the HTML. The
JavaScript file count went from 47 to 5.
Routing pages/admin/ into one chunk with manualChunks does not work. The shared
react-router and Relay move into the admin chunk, the entry then imports it statically, and
the admin chunk appears in the modulepreload list of dist/index.html. The login page grows
from 147 KB to 303 KB and the lazy loading has no effect. The check is that
grep modulepreload dist/index.html shows no deferred chunk.
Compare against the served HTML on resume
As long as any splitting remains, and for the case where splitting is reintroduced, the page
verifies its own version. When a page that was hidden for more than 10 minutes comes back
(visibilitychange and pageshow), it fetches index.html and looks for the URL of its own
entry file. If the URL is absent, it reloads.
const res = await fetch(`/?_v=${Date.now()}`, { cache: 'no-store' })
const html = await res.text()
if (!html.includes(new URL(import.meta.url).pathname)) {
const url = new URL(location.href)
url.searchParams.set('_r', String(Date.now()))
location.replace(url.toString())
}
The two query parameters are there for different reasons.
_vbypasses the service worker.cache: 'no-store'skips the HTTP cache but does not reach a service worker. If the Workbox precache answers with an oldindex.html, this comparison always reports that the page is current. The keys Workbox tries for/are/and/index.html, so adding a query parameter matches neither and the request goes to the network._ris for WKWebView, wherelocation.reload()returns the sameindex.html. Changing the URL makes it a new resource to fetch.
If the fetch fails, or if the HTML that comes back contains no reference to an entry file, the code does nothing. Deleting the service worker and Cache Storage while the device is merely offline would throw away the precache that lets the app open without a connection.
What was not done
Putting Cache-Control: no-store on index.html was rejected. This failure happens while the
page is open, and during that time no request for the HTML is made at all, so no
Cache-Control value reaches it. no-store also disables the Chrome bfcache.
The service worker in the member app was removed. WKWebView does not provide service workers
unless the site declares App-Bound Domains, so members coming through LINE never had a
precache to begin with. selfDestroying: true in vite-plugin-pwa emits an sw.js that
unregisters itself and deletes every cache. The plugin itself must stay. If /sw.js
disappears, the catch-all above returns index.html, the response is not a 404, the service
worker update does not succeed, and the old service worker stays installed.
One note about _headers
While working on this, staging turned out not to be serving Strict-Transport-Security,
X-Frame-Options, Permissions-Policy or Content-Security-Policy-Report-Only. Production
returned all four. The difference was the number of /* blocks in _headers: production had
one, staging had one for security headers, one for CSP, and a third that the deploy script
appends for X-Robots-Tag. Merging them into a single block brought all four back.
When the contents of _headers are not what Pages expects, it does not return a 500. The
deployment succeeds with some rules not applied. Counting the headers with curl -sI after
each deployment is the reliable check.
Nami Smart LLC
Fast, cost-efficient, high-quality app development. If you would like to talk about a project, please get in touch.