Nami Smart LLC

A long-open SPA asks for a file that no longer exists

CloudflareCloudflare PagesViteSPA

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.

  • _v bypasses the service worker. cache: 'no-store' skips the HTTP cache but does not reach a service worker. If the Workbox precache answers with an old index.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.
  • _r is for WKWebView, where location.reload() returns the same index.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.

Contact us