|
| 1 | +// Recovers from deploys rotating the content-hashed /build assets out from |
| 2 | +// under a page. Policy: |
| 3 | +// |
| 4 | +// 1. A working page is never touched just because a new build exists. Remix |
| 5 | +// loader fetches (?_data=) are stamped with X-Build-Id by the server; |
| 6 | +// when a navigation reveals a different build, the navigation is turned |
| 7 | +// into a full document load — the user lands on the new build without |
| 8 | +// ever seeing an incompatible state. |
| 9 | +// 2. Only real incompatibility (a /build stylesheet/script 404 or a failed |
| 10 | +// chunk import) triggers recovery: an overlay goes up immediately, the |
| 11 | +// script polls /build-version with backoff, and reloads once the server |
| 12 | +// reports a different build than the page was rendered with |
| 13 | +// (window.__remixManifest.version). If versions never diverge or the |
| 14 | +// reload budget (one per observed build, 2 total) is spent, the overlay |
| 15 | +// offers a manual reload instead of leaving a dead page. |
| 16 | +// 3. Before a recovery reload, form fields and scroll position are |
| 17 | +// snapshotted to sessionStorage and restored (best-effort) after the |
| 18 | +// reload. history.state is deliberately not restored — the Remix router |
| 19 | +// owns it, and reviving a stale one can desync the router. |
| 20 | +// |
| 21 | +// Must render before <Links /> so the listener precedes the stylesheet. |
| 22 | +const script = `(function () { |
| 23 | + var VKEY = "trigger:assetRecovery"; |
| 24 | + var SKEY = "trigger:recoverySnapshot"; |
| 25 | + var MAX_RELOADS = 2; |
| 26 | + var RESET_AFTER = 300000; |
| 27 | + var SNAPSHOT_TTL = 30000; |
| 28 | + var CHECK_DELAYS = [0, 2000, 4000, 8000, 15000, 30000]; |
| 29 | + var recovering = false; |
| 30 | + var navigated = false; |
| 31 | +
|
| 32 | + function ownVersion() { |
| 33 | + return window.__remixManifest && window.__remixManifest.version; |
| 34 | + } |
| 35 | +
|
| 36 | + function readJson(key) { |
| 37 | + try { |
| 38 | + return JSON.parse(sessionStorage.getItem(key) || "null"); |
| 39 | + } catch (e) { |
| 40 | + return undefined; |
| 41 | + } |
| 42 | + } |
| 43 | +
|
| 44 | + function writeJson(key, value) { |
| 45 | + try { |
| 46 | + sessionStorage.setItem(key, JSON.stringify(value)); |
| 47 | + return true; |
| 48 | + } catch (e) { |
| 49 | + return false; |
| 50 | + } |
| 51 | + } |
| 52 | +
|
| 53 | + // ---- form + scroll snapshot ------------------------------------------ |
| 54 | +
|
| 55 | + function takeSnapshot() { |
| 56 | + var fields = []; |
| 57 | + var els = document.querySelectorAll("input, textarea, select"); |
| 58 | + for (var i = 0; i < els.length; i++) { |
| 59 | + var el = els[i]; |
| 60 | + var type = (el.getAttribute("type") || "").toLowerCase(); |
| 61 | + if (type === "password" || type === "file" || type === "hidden") continue; |
| 62 | + fields.push({ |
| 63 | + i: i, |
| 64 | + id: el.id || null, |
| 65 | + name: el.name || null, |
| 66 | + tag: el.tagName, |
| 67 | + value: el.value, |
| 68 | + checked: el.checked === true, |
| 69 | + }); |
| 70 | + } |
| 71 | + writeJson(SKEY, { |
| 72 | + t: Date.now(), |
| 73 | + path: location.pathname, |
| 74 | + scrollY: window.scrollY, |
| 75 | + fields: fields, |
| 76 | + }); |
| 77 | + } |
| 78 | +
|
| 79 | + function setNativeValue(el, value) { |
| 80 | + // Go through the prototype setter so React's value tracking notices the |
| 81 | + // change when the input event fires. |
| 82 | + var proto = |
| 83 | + el.tagName === "TEXTAREA" |
| 84 | + ? window.HTMLTextAreaElement |
| 85 | + : el.tagName === "SELECT" |
| 86 | + ? window.HTMLSelectElement |
| 87 | + : window.HTMLInputElement; |
| 88 | + var descriptor = Object.getOwnPropertyDescriptor(proto.prototype, "value"); |
| 89 | + if (descriptor && descriptor.set) { |
| 90 | + descriptor.set.call(el, value); |
| 91 | + } else { |
| 92 | + el.value = value; |
| 93 | + } |
| 94 | + } |
| 95 | +
|
| 96 | + function restoreSnapshot() { |
| 97 | + var snapshot = readJson(SKEY); |
| 98 | + try { |
| 99 | + sessionStorage.removeItem(SKEY); |
| 100 | + } catch (e) {} |
| 101 | + if (!snapshot || snapshot.path !== location.pathname) return; |
| 102 | + if (Date.now() - (snapshot.t || 0) > SNAPSHOT_TTL) return; |
| 103 | + var els = document.querySelectorAll("input, textarea, select"); |
| 104 | + for (var i = 0; i < snapshot.fields.length; i++) { |
| 105 | + var field = snapshot.fields[i]; |
| 106 | + var el = (field.id && document.getElementById(field.id)) || els[field.i]; |
| 107 | + if (!el || el.tagName !== field.tag || (el.name || null) !== field.name) continue; |
| 108 | + if (el.type === "checkbox" || el.type === "radio") { |
| 109 | + // click() keeps React state in sync with the DOM |
| 110 | + if (el.checked !== field.checked) el.click(); |
| 111 | + } else if (field.value != null && el.value !== field.value) { |
| 112 | + setNativeValue(el, field.value); |
| 113 | + el.dispatchEvent(new Event("input", { bubbles: true })); |
| 114 | + el.dispatchEvent(new Event("change", { bubbles: true })); |
| 115 | + } |
| 116 | + } |
| 117 | + if (snapshot.scrollY) window.scrollTo(0, snapshot.scrollY); |
| 118 | + } |
| 119 | +
|
| 120 | + window.addEventListener("load", function () { |
| 121 | + setTimeout(restoreSnapshot, 100); |
| 122 | + }); |
| 123 | +
|
| 124 | + function doReload() { |
| 125 | + takeSnapshot(); |
| 126 | + location.reload(); |
| 127 | + } |
| 128 | +
|
| 129 | + // ---- scenario 1: working page, navigation as the update point -------- |
| 130 | +
|
| 131 | + var origFetch = window.fetch; |
| 132 | + window.fetch = function (input) { |
| 133 | + var result = origFetch.apply(this, arguments); |
| 134 | + try { |
| 135 | + var url = typeof input === "string" ? input : input && input.url; |
| 136 | + if (url && url.indexOf("_data=") !== -1) { |
| 137 | + result.then(function (response) { |
| 138 | + var server = response.headers.get("X-Build-Id"); |
| 139 | + var mine = ownVersion(); |
| 140 | + if (server && mine && server !== mine && !navigated && !recovering) { |
| 141 | + navigated = true; |
| 142 | + var target = new URL(url, location.origin); |
| 143 | + target.searchParams.delete("_data"); |
| 144 | + location.assign(target.toString()); |
| 145 | + } |
| 146 | + }, function () {}); |
| 147 | + } |
| 148 | + } catch (e) {} |
| 149 | + return result; |
| 150 | + }; |
| 151 | +
|
| 152 | + // ---- scenario 2: page is actually broken ------------------------------ |
| 153 | +
|
| 154 | + function showOverlay(final) { |
| 155 | + if (!document.body) { |
| 156 | + document.addEventListener("DOMContentLoaded", function () { |
| 157 | + showOverlay(final); |
| 158 | + }); |
| 159 | + return; |
| 160 | + } |
| 161 | + // ASCII only: documents are served without an explicit charset, so |
| 162 | + // non-ASCII here can render as mojibake on a broken page. |
| 163 | + var text = final ? "This page failed to load properly. Please reload." : "Loading..."; |
| 164 | + var existing = document.getElementById("stale-asset-overlay-text"); |
| 165 | + if (existing) { |
| 166 | + existing.textContent = text; |
| 167 | + if (final) document.getElementById("stale-asset-overlay-button").style.display = ""; |
| 168 | + return; |
| 169 | + } |
| 170 | + var overlay = document.createElement("div"); |
| 171 | + overlay.id = "stale-asset-overlay"; |
| 172 | + overlay.style.cssText = |
| 173 | + "position:fixed;inset:0;z-index:2147483647;display:flex;flex-direction:column;gap:16px;align-items:center;justify-content:center;background:#121317;color:#d7d9dd;font:15px/1.5 system-ui,sans-serif;text-align:center;padding:24px"; |
| 174 | + var message = document.createElement("p"); |
| 175 | + message.id = "stale-asset-overlay-text"; |
| 176 | + message.textContent = text; |
| 177 | + var button = document.createElement("button"); |
| 178 | + button.id = "stale-asset-overlay-button"; |
| 179 | + button.textContent = "Reload"; |
| 180 | + button.style.cssText = |
| 181 | + "border:0;border-radius:4px;padding:7px 18px;background:#6366f1;color:#fff;font:inherit;cursor:pointer" + |
| 182 | + (final ? "" : ";display:none"); |
| 183 | + button.onclick = doReload; |
| 184 | + overlay.appendChild(message); |
| 185 | + overlay.appendChild(button); |
| 186 | + document.body.appendChild(overlay); |
| 187 | + } |
| 188 | +
|
| 189 | + function reloadFor(serverVersion) { |
| 190 | + var state = readJson(VKEY); |
| 191 | + if (state === undefined) return showOverlay(true); |
| 192 | + state = state || {}; |
| 193 | + if (Date.now() - (state.t || 0) > RESET_AFTER) state = {}; |
| 194 | + // One reload per observed server version, MAX_RELOADS total: a page that |
| 195 | + // is still broken after reloading for this build gets the manual overlay |
| 196 | + // instead of reloading again. |
| 197 | + if (state.v === serverVersion || (state.reloads || 0) >= MAX_RELOADS) return showOverlay(true); |
| 198 | + if (!writeJson(VKEY, { v: serverVersion, reloads: (state.reloads || 0) + 1, t: Date.now() })) { |
| 199 | + return showOverlay(true); |
| 200 | + } |
| 201 | + doReload(); |
| 202 | + } |
| 203 | +
|
| 204 | + function check(attempt) { |
| 205 | + origFetch("/build-version", { cache: "no-store" }) |
| 206 | + .then(function (response) { |
| 207 | + return response.json(); |
| 208 | + }) |
| 209 | + .then(function (data) { |
| 210 | + var mine = ownVersion(); |
| 211 | + if (data && data.version && mine && data.version !== mine) { |
| 212 | + reloadFor(data.version); |
| 213 | + } else { |
| 214 | + scheduleNext(attempt); |
| 215 | + } |
| 216 | + }) |
| 217 | + .catch(function () { |
| 218 | + scheduleNext(attempt); |
| 219 | + }); |
| 220 | + } |
| 221 | +
|
| 222 | + function scheduleNext(attempt) { |
| 223 | + var next = attempt + 1; |
| 224 | + if (next >= CHECK_DELAYS.length) return showOverlay(true); |
| 225 | + setTimeout(function () { |
| 226 | + check(next); |
| 227 | + }, CHECK_DELAYS[next]); |
| 228 | + } |
| 229 | +
|
| 230 | + function recover() { |
| 231 | + if (recovering) return; |
| 232 | + recovering = true; |
| 233 | + showOverlay(false); |
| 234 | + // __remixManifest is set by an inline script near the end of body; wait |
| 235 | + // for the document to finish parsing before comparing versions. |
| 236 | + if (document.readyState === "loading") { |
| 237 | + document.addEventListener("DOMContentLoaded", function () { |
| 238 | + check(0); |
| 239 | + }); |
| 240 | + } else { |
| 241 | + check(0); |
| 242 | + } |
| 243 | + } |
| 244 | +
|
| 245 | + window.addEventListener( |
| 246 | + "error", |
| 247 | + function (event) { |
| 248 | + var el = event.target; |
| 249 | + if (!el || el === window) return; |
| 250 | + var url = el.tagName === "LINK" ? el.href : el.tagName === "SCRIPT" ? el.src : null; |
| 251 | + if (url && url.indexOf("/build/") !== -1) recover(); |
| 252 | + }, |
| 253 | + true |
| 254 | + ); |
| 255 | + window.addEventListener("unhandledrejection", function (event) { |
| 256 | + var message = event.reason && event.reason.message; |
| 257 | + if ( |
| 258 | + typeof message === "string" && |
| 259 | + /dynamically imported module|Importing a module script failed|ChunkLoadError/i.test(message) |
| 260 | + ) { |
| 261 | + recover(); |
| 262 | + } |
| 263 | + }); |
| 264 | +})();`; |
| 265 | + |
| 266 | +export function StaleAssetRecovery({ isProduction }: { isProduction: boolean }) { |
| 267 | + if (!isProduction) { |
| 268 | + return null; |
| 269 | + } |
| 270 | + |
| 271 | + return <script dangerouslySetInnerHTML={{ __html: script }} />; |
| 272 | +} |
0 commit comments