|
| 1 | +// Recovers from a rolling deploy rotating the content-hashed /build assets out from |
| 2 | +// under a page. Each image serves only its own build and hard-404s unknown hashes, so |
| 3 | +// a client can request a hash the serving replica doesn't have and get missing styles |
| 4 | +// or a failed asset load. On such a /build load failure we do a bounded full document |
| 5 | +// reload: the fresh document (and, under sticky routing, all of its assets) lands on a |
| 6 | +// single live build, so the asset resolves. Bounded via sessionStorage so it can never |
| 7 | +// loop; when the budget is spent it stops rather than reloading forever. |
| 8 | +// |
| 9 | +// Deliberately minimal — no fetch interception, no build-version polling, no server |
| 10 | +// build-id contract, no form snapshot, no blocking overlay. |
| 11 | + |
| 12 | +// The recovery logic runs as an inline <script> injected before <Links /> (see the |
| 13 | +// component below), so it must execute before the app bundle and before the stylesheet |
| 14 | +// can fail to load. It is authored as a normal, type-checked and lint-checked function |
| 15 | +// and serialized with .toString() at render time — NOT hand-written into a string — so |
| 16 | +// the logic is real code the compiler and linter can see. Because it is serialized, it |
| 17 | +// must stay fully self-contained: no imports, no references to module scope, and plain |
| 18 | +// ES that the bundler won't rewrite to reach a hoisted helper. It returns its `recover` |
| 19 | +// closure purely so the unit test can drive the logic directly (the inline IIFE that |
| 20 | +// runs in the browser ignores the return value). |
| 21 | +export function staleAssetRecoveryScript() { |
| 22 | + var KEY = "trigger:assetReload"; |
| 23 | + var MAX_RELOADS = 2; |
| 24 | + var WINDOW_MS = 300000; |
| 25 | + var recovering = false; |
| 26 | + |
| 27 | + function budgetAllows() { |
| 28 | + try { |
| 29 | + var raw = sessionStorage.getItem(KEY); |
| 30 | + var state = raw ? (JSON.parse(raw) as { n: number; t: number }) : { n: 0, t: 0 }; |
| 31 | + if (Date.now() - state.t > WINDOW_MS) state = { n: 0, t: 0 }; |
| 32 | + if (state.n >= MAX_RELOADS) return false; |
| 33 | + sessionStorage.setItem(KEY, JSON.stringify({ n: state.n + 1, t: Date.now() })); |
| 34 | + return true; |
| 35 | + } catch { |
| 36 | + // Storage blocked (private mode / quota): can't bound reloads, so don't auto-reload. |
| 37 | + return false; |
| 38 | + } |
| 39 | + } |
| 40 | + |
| 41 | + function recover() { |
| 42 | + // One recovery per page: a broken load fails several /build assets at once and each |
| 43 | + // fires its own error event before location.reload() commits — without this guard a |
| 44 | + // single incident would burn the entire reload budget. |
| 45 | + if (recovering) return; |
| 46 | + recovering = true; |
| 47 | + // Don't reload into the browser's offline error page. |
| 48 | + if (navigator.onLine === false) return; |
| 49 | + if (budgetAllows()) location.reload(); |
| 50 | + } |
| 51 | + |
| 52 | + // Non-bubbling resource load failures (stylesheet, modulepreload, entry <script>) at |
| 53 | + // document load — the failure class nothing else covers. Capture phase is required. |
| 54 | + window.addEventListener( |
| 55 | + "error", |
| 56 | + function (event) { |
| 57 | + var el = event.target as Element | null; |
| 58 | + if (!el || typeof el.tagName !== "string") return; // window/global errors have no tagName |
| 59 | + var url = |
| 60 | + el.tagName === "LINK" |
| 61 | + ? (el as HTMLLinkElement).href |
| 62 | + : el.tagName === "SCRIPT" |
| 63 | + ? (el as HTMLScriptElement).src |
| 64 | + : null; |
| 65 | + if (url && url.indexOf("/build/") !== -1) recover(); |
| 66 | + }, |
| 67 | + true |
| 68 | + ); |
| 69 | + |
| 70 | + // Raw dynamic import() failures in app code. (Remix reloads its own route chunks, so |
| 71 | + // that path rarely reaches here.) The message URL isn't reliable cross-browser, so |
| 72 | + // match the chunk-load error shape; the once-guard + bounded budget make a rare stray |
| 73 | + // reload harmless. |
| 74 | + window.addEventListener("unhandledrejection", function (event) { |
| 75 | + var message = (event.reason && event.reason.message) || ""; |
| 76 | + if ( |
| 77 | + /dynamically imported module|Importing a module script failed|ChunkLoadError/i.test(message) |
| 78 | + ) { |
| 79 | + recover(); |
| 80 | + } |
| 81 | + }); |
| 82 | + |
| 83 | + return { recover }; |
| 84 | +} |
| 85 | + |
| 86 | +export function StaleAssetRecovery({ isProduction }: { isProduction: boolean }) { |
| 87 | + if (!isProduction) { |
| 88 | + return null; |
| 89 | + } |
| 90 | + |
| 91 | + return ( |
| 92 | + <script dangerouslySetInnerHTML={{ __html: `(${staleAssetRecoveryScript.toString()})()` }} /> |
| 93 | + ); |
| 94 | +} |
0 commit comments