Compare commits

...

2 Commits

Author SHA1 Message Date
Bram Kragten 049a22d408 Share stale-build recovery patterns via a single JSON source
Move the boot guard's chunk-detection regexes into
stale-build-patterns.json and inject them into the inline guard at build
time (entry-html.js), so they stay in sync with the bundled recovery util
(util/recover-stale-build.ts, in the follow-up post-boot recovery) which
reads the same file — no more manually kept-in-sync copies.

Part of home-assistant/epics#113.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-29 15:28:00 +02:00
Bram Kragten 31396fdf14 Recover from a stale index.html at boot
A cached, stale index.html imports the previous build's content-hashed
entry bundles (core.<hash>.js / app.<hash>.js). After an upgrade those
files 404, app.js never runs, <home-assistant> is never defined, and the
launch screen never clears. No bundled JS can recover this, because the
bundle itself failed to load.

Add a tiny, prod-only inline guard in index.html that catches the failed
entry load (capture-phase resource error + unhandledrejection) and does a
single, loop-guarded cache-busting reload, dropping the service worker and
caches on https first. core.ts strips the cache-bust param after a
successful boot.

Part of home-assistant/epics#113.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-29 14:32:55 +02:00
5 changed files with 159 additions and 1 deletions
+12
View File
@@ -58,6 +58,12 @@ const getCommonTemplateVars = () => {
return {
modernRegex: compileRegex(browserRegexes.concat(haMacOSRegex)).toString(),
hassUrl: process.env.HASS_URL || "",
// Single source for the stale-build recovery patterns, shared with the
// bundled src/util/recover-stale-build.ts and injected into the inline
// boot guard (_bootstrap_recovery.html.template).
staleBuildPatterns: fs.readJsonSync(
resolve(paths.root_dir, "src/util/stale-build-patterns.json")
),
};
};
@@ -107,6 +113,9 @@ const genPagesDevTask =
resolve(inputRoot, inputSub, `${page}.template`),
{
...commonVars,
// Dev entries are unhashed, so the stale-index recovery guard has
// nothing to key off and rebuild churn could cause spurious reloads.
useCacheRecovery: false,
latestEntryJS: entries.map(
(entry) => `${publicRoot}/frontend_latest/${entry}.js`
),
@@ -146,6 +155,9 @@ const genPagesProdTask =
resolve(inputRoot, inputSub, `${page}.template`),
{
...commonVars,
// Recover from a stale index.html that pins deleted hashed entry
// bundles (see _bootstrap_recovery.html.template).
useCacheRecovery: true,
latestEntryJS: entries.map((entry) => latestManifest[`${entry}.js`]),
es5EntryJS: entries.map((entry) => es5Manifest[`${entry}.js`]),
latestCustomPanelJS: latestManifest["custom-panel.js"],
+12 -1
View File
@@ -37,15 +37,26 @@ declare global {
}
const clearUrlParams = () => {
const searchParams = new URLSearchParams(location.search);
let changed = false;
// Clear auth data from url if we have been able to establish a connection
if (location.search.includes("auth_callback=1")) {
const searchParams = new URLSearchParams(location.search);
// https://github.com/home-assistant/home-assistant-js-websocket/blob/master/lib/auth.ts
// Remove all data from QueryCallbackData type
searchParams.delete("auth_callback");
searchParams.delete("code");
searchParams.delete("state");
searchParams.delete("storeToken");
changed = true;
}
// Remove the cache-busting param added by the stale-index recovery guard in
// index.html once we have booted successfully, so it doesn't linger in the
// URL (and stops acting as the guard's one-shot loop marker).
if (searchParams.has("ha_cache_bust")) {
searchParams.delete("ha_cache_bust");
changed = true;
}
if (changed) {
const search = searchParams.toString();
history.replaceState(
null,
+130
View File
@@ -0,0 +1,130 @@
<script>
/*
* Boot-time recovery from a stale index.html.
*
* index.html pins the content-hashed entry bundles (core.<hash>.js /
* app.<hash>.js). After a frontend release those files are deleted from
* disk, so a cached (stale) index.html imports URLs that now 404 and the
* app never boots - the launch screen stays up forever. No bundled JS can
* recover this, because the bundle itself failed to load, so this guard has
* to live inline in the HTML document.
*
* On a failed entry load we navigate once to the same URL with a
* cache-busting query param (and, on https, after dropping the service
* worker + its caches) so the browser fetches a fresh index.html that points
* at the current hashes. The param doubles as a one-shot loop guard: if the
* freshly fetched index still fails we stop and show a message instead of
* reloading forever. core.ts strips the param again after a successful boot.
*/
(function () {
var BUST_PARAM = "ha_cache_bust";
// Only production entry bundles carry a content hash. Requiring the hash
// keeps the guard inert in development (unhashed core.js / app.js) and
// scoped to the entry chunks whose 404 is fatal to boot - not translations
// or lazily loaded panels, which fail non-fatally and are handled elsewhere.
// Injected from stale-build-patterns.json (the single source shared with
// the bundled util/recover-stale-build.ts) so the two never drift.
var HASHED_ENTRY = new RegExp(<%= JSON.stringify(staleBuildPatterns.hashedEntry) %>, "i");
var MODULE_ERROR = new RegExp(<%= JSON.stringify(staleBuildPatterns.moduleError) %>, "i");
function booted() {
// The launch screen is only removed once the app has taken over the
// page. If it is gone, any later chunk failure is post-boot (a lazy
// panel/dialog) and is not the stale-index boot failure this guard
// targets, so we leave it alone.
return !document.getElementById("ha-launch-screen");
}
function showFatal() {
var box = document.getElementById("ha-launch-screen-info-box");
if (box) {
box.innerText =
"Could not load Home Assistant. Please refresh the page, and clear your browser cache if the problem persists.";
}
}
function recover() {
if (location.search.indexOf(BUST_PARAM + "=") !== -1) {
// We already reloaded once with a fresh index and it still failed;
// stop to avoid a reload loop.
showFatal();
return;
}
var target =
location.pathname +
location.search +
(location.search ? "&" : "?") +
BUST_PARAM +
"=" +
Date.now() +
location.hash;
// On https the service worker "/" route ignores the query string
// (ignoreSearch), so a bust param alone would still be answered with the
// stale cached index. Drop the worker and its caches first so the reload
// is served fresh from the network.
if ("serviceWorker" in navigator && navigator.serviceWorker.controller) {
var go = function () {
location.replace(target);
};
Promise.all([
navigator.serviceWorker
.getRegistrations()
.then(function (regs) {
return Promise.all(
regs.map(function (r) {
return r.unregister();
})
);
})
.catch(function () {}),
self.caches
? caches
.keys()
.then(function (keys) {
return Promise.all(
keys.map(function (k) {
return caches.delete(k);
})
);
})
.catch(function () {})
: null,
]).then(go, go);
} else {
location.replace(target);
}
}
function maybeRecover(url, message) {
if (booted()) {
return;
}
if (
(url && HASHED_ENTRY.test(url)) ||
(message && (HASHED_ENTRY.test(message) || MODULE_ERROR.test(message)))
) {
recover();
}
}
// Resource-load errors (<script>, modulepreload <link>) do not bubble, so
// they are only observable in the capture phase at the window.
window.addEventListener(
"error",
function (ev) {
var el = ev.target;
if (el && (el.tagName === "SCRIPT" || el.tagName === "LINK")) {
maybeRecover(el.href || el.src, null);
}
},
true
);
// A failed dynamic import() rejects; the inline entry imports do not catch
// it, so the rejection surfaces here.
window.addEventListener("unhandledrejection", function (ev) {
var reason = ev && ev.reason;
maybeRecover(null, reason && (reason.message || String(reason)));
});
})();
</script>
+1
View File
@@ -2,6 +2,7 @@
<html>
<head>
<title>Home Assistant</title>
<% if (useCacheRecovery) { %><%= renderTemplate("_bootstrap_recovery.html.template") %><% } %>
<%= renderTemplate("_header.html.template") %>
<link rel="mask-icon" href="/static/icons/mask-icon.svg" color="#18bcf2" />
<link
+4
View File
@@ -0,0 +1,4 @@
{
"hashedEntry": "/frontend_(?:latest|es5)/[^/?#]+\\.[0-9a-f]{8,}\\.js",
"moduleError": "dynamically imported module|Importing a module script failed|error loading dynamically imported|ChunkLoadError"
}