Skip to content

Cache-bust the runtime loader, and say something when it stalls - #8

Merged
mgravell merged 2 commits into
mainfrom
framework-cache-key
Aug 24, 2026
Merged

mgravell merged 2 commits into
mainfrom
framework-cache-key

Conversation

@mgravell

@mgravell mgravell commented Aug 24, 2026 •

Copy link
Copy Markdown
Member

Fixes the site being stuck on the loading overlay after #7 for anyone who had visited before. A fresh browser was always fine, which is why CI and a clean incognito window both looked healthy.

What happened

Every file the .NET runtime loads is content-hashed by the SDK — except dotnet.js, which is the file that names all the hashed ones. Pages serves it with max-age=14400 and offers no per-path control, so a returning visitor booted a four-hour-old dotnet.js, asked for an assembly hash that the deploy had replaced, and took a 404 partway through boot:

live dotnet.js names:  ProtoGen.Wasm.2r47gf035o.wasm
previous builds:       ProtoGen.Wasm.axtv8rkyag.wasm  404
                       ProtoGen.Wasm.ftv3s8zkok.wasm  404
                       ProtoGen.Wasm.u3x0vkyezd.wasm  404

The hash only moves when the .NET assembly does, so this hid through three web-only deploys and surfaced on the first one to touch C#. It has been latent since the site was written; #7 was the trigger, not the cause.

The fix

vite.config.ts hashes dotnet.js into __FRAMEWORK_ID__, and wasm.ts loads it as dotnet.js?v=<id>. Hashing that particular file is the exact invalidation key — the hashed names are embedded in it, so the id changes when, and only when, something it loads does. The query is a cache key, not a parameter: everything dotnet.js goes on to fetch resolves relative to itself, without the query, and is content-hashed already.

Needed @types/node, since the config now reads a file.

Verification

Boot checked in headless Chrome, three runs on each of vite preview, a plain static server, and the dev server — a plain static server being what Pages is:

vite preview  booted 3/3
http-server   booted 3/3
vite dev      booted 3/3   (warm; see below)

Worth recording: an early single run made it look like the query had broken the boot, and it had not — a cold first load can exceed the headless time budget and leave the overlay up, which looks identical to a failure unless you check which overlay text is showing. A real failure renders "Could not load the WebAssembly engine", because main.ts catches it. Any smoke test worth having has to assert on that distinction and tolerate a cold start, or it will lie in both directions.

Also

dotnet publish copies into its output without clearing it, so every rebuild that changed the engine left the previous build's hashed files beside the current ones — and they shipped. Three copies of ProtoGen.Wasm had accumulated locally. Harmless bytes rather than a fault, since the boot manifest names exactly one, but it grows without limit and makes the publish output a poor guide to what is actually loaded. build-wasm.mjs clears the directory first now.


And a fallback for when it goes wrong anyway

A second commit adds a line under the spinner after ten seconds — added, not substituted, since the spinner may still be telling the truth.

Worth recording why the existing try/catch never fired: serving a build with the main assembly deleted produces no error at all. The loader waits for a file that will never arrive, nothing rejects, and no catch anywhere runs. That is the failure mode that shipped, and it was unreachable by the handling already in place.

The line says the engine is large and compiles on first visit, and that if it never finishes this browser may be holding an out-of-date copy — with a link that fetches a fresh one. It is deliberately not a diagnosis: from inside the page a slow connection and a stalled loader are indistinguishable, and telling them apart would mean interposing on fetch and deciding which failed requests matter, for a message that is identical either way.

healthy build              booted=1  hint-shown=0
main assembly removed      booted=0  hint-shown=1

Merging #7 left the site stuck on the loading overlay for anyone who had visited
it before. The deploy was fine and a fresh browser loaded it without complaint;
the fault was four hours old and sitting in everyone else's cache.

Every file the .NET runtime loads is content-hashed by the SDK except dotnet.js
itself, and dotnet.js is the file that names all the hashed ones. Pages serves
it with max-age=14400 and has no way to say otherwise for one path, so a
returning visitor booted a four-hour-old dotnet.js, asked for an assembly hash
that deploy had replaced, took a 404 partway through boot and never got the
overlay removed.

It only bites when the .NET side changes, which is why it hid through three
web-only deploys and surfaced on the first one to touch C#. It has been latent
since the site was written.

vite.config.ts now hashes dotnet.js into __FRAMEWORK_ID__ and wasm.ts loads it
as dotnet.js?v=<id>. Hashing that particular file is the exact invalidation key:
the hashed names are embedded in it, so the id changes when, and only when,
something it loads does. The query is a cache key rather than a parameter -
everything dotnet.js goes on to fetch resolves relative to itself, without the
query, and is content-hashed already. Verified booting three times each under
vite preview, a plain static server and the dev server, since a plain static
server is what Pages is.

That needed @types/node, because the config now reads a file.

Separately: dotnet publish copies into its output without clearing it first, so
every rebuild that changed the engine left the previous build's hashed files
beside the current ones, and they shipped. Three copies of ProtoGen.Wasm had
accumulated locally. Harmless bytes rather than a fault - the boot manifest names
exactly one of them - but it grows without limit and makes the output a poor
guide to what is actually loaded. build-wasm.mjs clears the publish directory
first now.
The overlay had one thing to say and said it regardless: still loading, failed,
and never coming all looked identical from the outside. The middle one is already
handled - main.ts has always caught a rejected boot - but the third is not, and
the third is what shipped last week. A missing runtime file does not reject: the
loader waits for something that will never arrive, so no catch anywhere runs.

Confirmed by serving a build with the main assembly deleted. The page produced no
error at all and sat on the spinner until the browser gave up.

So after ten seconds a line is added under the spinner - added, not substituted,
because the spinner may still be telling the truth. It says the engine is large
and compiles on the first visit, and that if it never finishes this browser may
be holding an out-of-date copy, with a link that fetches a fresh one.

The link carries a cache-busting query rather than calling reload(), which is
free to serve back the same cached files that caused the problem; the argument
that used to force otherwise has not done anything for years. A fresh index.html
is enough on its own, because it names the current bundle, which asks for the
current runtime.

Deliberately not a diagnosis. From inside the page a slow connection and a loader
waiting on a file that will never arrive are indistinguishable, and telling them
apart would mean interposing on fetch and deciding which failed requests matter -
for a message that is the same either way. If it is only a slow connection,
nothing has been taken off the screen and the load carries on.

Verified in headless Chrome against a healthy build and one with the main
assembly removed: the first boots without ever showing the line, the second shows
it.
@mgravell mgravell changed the title Cache-bust the one runtime file the SDK does not hash Cache-bust the runtime loader, and say something when it stalls Aug 24, 2026
@mgravell
mgravell merged commit 00c089c into main Aug 24, 2026
2 checks passed
@mgravell
mgravell deleted the framework-cache-key branch August 25, 2026 09:15
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.

1 participant