Compare commits

..
9 Commits
Author SHA1 Message Date
Bram Kragten 18f79dfc91 Bumped version to 20260826.6 2026-09-04 12:40:08 +02:00
Paul BotteinandBram Kragten 5861c7487c Only select ha-input-copy text on click when readonly (#53959) 2026-09-04 12:39:01 +02:00
WendelinandBram Kragten 9dbfa821d3 History: Fix "add targets" on empty state (#53953)
Fix history empty targets button only on closed filterbar
2026-09-04 12:38:59 +02:00
Bram KragtenandCopilot Autofix powered by AI 1821e8fd67 Check if failed import is from us, or from user, and check if chunck 404's (#53940)
Co-authored-by: Copilot Autofix powered by AI <[email protected]>
2026-09-04 12:38:58 +02:00
Paul BotteinandBram Kragten c8a3aa1d9a Hide empty labels row in more-info related view (#53939) 2026-09-04 12:38:57 +02:00
6c606a4968 Fix websocket hang in picture card (#53930)
Co-authored-by: Copilot Autofix powered by AI <[email protected]>
2026-09-04 12:38:56 +02:00
f59a4b4996 Clarify what uninstalling an app deletes (#53923)
* Clarify what uninstalling an app deletes

The uninstall dialog offered a single switch labeled "Also remove app
data". That named the wrong thing twice over: the switch sends
remove_config, which Supervisor applies to the app's configuration
folder, while the app's data folder is deleted on uninstall either way,
independent of the switch.

Relabel the switch after the folder it actually removes, and state in the
dialog that uninstalling permanently deletes the app's stored data, so
the unconditional part of the operation is no longer implied to be
optional.

Rename the local variable and the uninstallHassioAddon parameter to
removeConfig to match the API field they carry.

* Move the app name out of the uninstall dialog title

Dialog guidelines ask to keep user generated content out of titles, as
names can get long enough to be unreadable there. The name now carries
the sentence in the body, which already describes what is deleted.

* Scope the uninstall warning to the app's private data folder

Uninstalling deletes the app's data folder and, when the app maps it,
the public config folder. Files the app wrote through the share, media
or Home Assistant config mappings stay where they are, so promising
that all of its data goes away is wrong for those apps.

Name the folder the deletion is limited to, and say the configuration
folder is only deleted if the app uses one, matching how the Supervisor
API documents the field. Also switch the label to "delete" for
consistency with the sentence above it.

* Name what an app actually keeps in its data folder

Checked the 30 most installed apps for what they write there. It holds
databases (MariaDB, InfluxDB, Grafana, UniFi, Nginx Proxy Manager,
AdGuard), credentials and network identity (Matter fabrics, the Thread
network key, Tailscale node state, Z-Wave JS settings, Mosquitto
accounts) and internal state such as caches and SSH host keys.

"Any other files it created" reaches past all of that. ESPHome keeps
device configuration in the Home Assistant config folder, Node-RED sets
its user directory to /config, WireGuard writes its keys to /ssl, and
those survive an uninstall.

* Fix punctuation in uninstall dialog text

Co-authored-by: Copilot Autofix powered by AI <[email protected]>

---------

Co-authored-by: Copilot Autofix powered by AI <[email protected]>
2026-09-04 12:38:55 +02:00
Bram Kragten 4c08607dfe Bumped version to 20260826.4 2026-09-02 12:32:22 +02:00
f082816d9c Group the connectivity panels behind a Connectivity settings page (#53905)
* Group the connectivity panels behind a Connectivity settings page

Move the connectivity panels (Matter, Zigbee, Z-Wave, KNX, MQTT, Thread,
Bluetooth, serial, infrared, radio frequency, Insteon, and tags) behind a new
Connectivity page. It sits with Voice assistants in the second settings group,
where those panels used to be listed individually.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Y7Yy1BcbkMjCZEtkTEqyKm

* Update config panel e2e coverage for the Connectivity page

The connectivity panels moved off the settings root, so split the link smoke
cases and assert them on the Connectivity page instead. Add a route smoke case
for /config/connectivity.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Y7Yy1BcbkMjCZEtkTEqyKm

---------

Co-authored-by: Claude <[email protected]>
2026-09-02 12:30:56 +02:00
32 changed files with 1249 additions and 181 deletions
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "home-assistant-frontend"
version = "20260826.3"
version = "20260826.6"
license = "Apache-2.0"
license-files = ["LICENSE*"]
description = "The Home Assistant frontend"
+3
View File
@@ -113,6 +113,9 @@ export class HaInputCopy extends LitElement {
}
private _focusInput(ev: HASSDomCurrentTargetEvent<HaInput>) {
if (!this.readonly) {
return;
}
const inputElement = ev.currentTarget as HaInput;
inputElement.select();
}
+2 -2
View File
@@ -280,14 +280,14 @@ export const restartHassioAddon = async (
export const uninstallHassioAddon = async (
callWS: CallWS,
slug: string,
removeData: boolean
removeConfig: boolean
): Promise<void> => {
await callWS({
type: "supervisor/api",
endpoint: `/addons/${slug}/uninstall`,
method: "post",
timeout: null,
data: { remove_config: removeData },
data: { remove_config: removeConfig },
});
};
+4
View File
@@ -104,3 +104,7 @@ export const removeLocalMedia = async (
type: "media_source/local_source/remove",
media_content_id,
});
export const isStreamingMedia = (url: string): boolean =>
url.includes("/api/image_proxy_stream") ||
url.includes("/api/camera_proxy_stream");
+38 -35
View File
@@ -155,43 +155,46 @@ class HaMoreInfoRelated extends LitElement {
/>`,
});
}
contextEntries.push({
translationKey: "ui.dialogs.more_info_control.labels",
value:
labels.map(({ id, entry }) => entry?.name ?? id).join(", ") ||
this.hass.localize("ui.dialogs.more_info_control.no_labels"),
displayValue: labels.length
? html`<div class="labels">
${labels.map(
({ id, entry }) => html`
<ha-label
class="text-ellipsis"
.color=${entry?.color ?? undefined}
.description=${entry?.description ?? undefined}
>
${
entry?.icon
? html`<ha-icon
slot="icon"
.icon=${entry.icon}
></ha-icon>`
: nothing
}
${entry?.name ?? id}
</ha-label>
`
)}
</div>`
: undefined,
});
if (labels.length) {
contextEntries.push({
translationKey: "ui.dialogs.more_info_control.labels",
value: labels.map(({ id, entry }) => entry?.name ?? id).join(", "),
displayValue: html`<div class="labels">
${labels.map(
({ id, entry }) => html`
<ha-label
class="text-ellipsis"
.color=${entry?.color ?? undefined}
.description=${entry?.description ?? undefined}
>
${
entry?.icon
? html`<ha-icon slot="icon" .icon=${entry.icon}></ha-icon>`
: nothing
}
${entry?.name ?? id}
</ha-label>
`
)}
</div>`,
});
}
return html`
<div class="content">
<ha-grouped-list
.header=${this.hass.localize("ui.dialogs.more_info_control.context")}
>
${this._renderEntries(contextEntries)}
</ha-grouped-list>
${
contextEntries.length
? html`
<ha-grouped-list
.header=${this.hass.localize(
"ui.dialogs.more_info_control.context"
)}
>
${this._renderEntries(contextEntries)}
</ha-grouped-list>
`
: nothing
}
<ha-related-items
.hass=${this.hass}
.itemId=${this.params.entityId}
@@ -239,7 +242,7 @@ class HaMoreInfoRelated extends LitElement {
padding-bottom: max(var(--safe-area-inset-bottom), var(--ha-space-6));
}
ha-related-items {
ha-grouped-list + ha-related-items {
margin-top: var(--ha-space-6);
}
+171 -41
View File
@@ -9,23 +9,46 @@
* 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.
* Only a file the server no longer has means a stale index (see probeGone).
* On a 404 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. One
* reload per minute, tracked in sessionStorage: if it did not help, stop and
* show a message instead of reloading forever.
*
* Never transpiled, so it stays within the [legacy] browserslist floor
* (Chrome 59 / Safari 12): no object spread, Promise.finally, optional
* chaining or AbortController.
*/
(function () {
var BUST_PARAM = "ha_cache_bust";
const 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");
const HASHED_ENTRY = new RegExp(<%= JSON.stringify(staleBuildPatterns.hashedEntry) %>, "i");
const MODULE_ERROR = new RegExp(<%= JSON.stringify(staleBuildPatterns.moduleError) %>, "i");
// The same pattern anchored: a URL is ours only when it *is* an entry path,
// not when it merely contains one (/local/frontend_latest/x.abc12345.js).
const HASHED_PATH = new RegExp("^(?:" + <%= JSON.stringify(staleBuildPatterns.hashedEntry) %> + ")$", "i");
// Any file reference, to tell "a file we do not ship failed" apart from
// "the browser did not say which file failed".
const ANY_URL = new RegExp(<%= JSON.stringify(staleBuildPatterns.anyUrl) %>, "i");
const PROBE_TIMEOUT = 5000;
// Grace period before admitting defeat, so a modulepreload failure that
// the browser retries successfully does not flash the message.
const FATAL_DELAY = 10000;
// This build's own entry bundles, to probe when the browser did not name
// the file that failed (Safari's message names none).
const LATEST_ENTRY_JS = <%= JSON.stringify(latestEntryJS) %>;
const ES5_ENTRY_JS = <%= JSON.stringify(es5EntryJS) %>;
// Shared with the bundled recovery: one reload per minute between them.
const RELOAD_STORAGE_KEY = "haStaleBuildReload";
const RELOAD_COOLDOWN = 60000;
const probing = {};
let recovering = false;
function booted() {
// The launch screen is only removed once the app has taken over the
@@ -36,21 +59,62 @@
}
function showFatal() {
var box = document.getElementById("ha-launch-screen-info-box");
const box = document.getElementById("ha-launch-screen-info-box");
if (box) {
box.textContent =
"Could not load Home Assistant. Please refresh the page, and clear your browser cache if the problem persists.";
}
}
/*
* Claim a recovery reload: one per cooldown window, timed rather than
* counted. The bust param alone is not a
* durable marker: core.ts strips it on every successful connect, so a page
* that boots and then fails on the next load would reload again and again.
* When storage is unavailable we keep the old param-only behaviour rather
* than lose the recovery entirely.
*/
function claimReload() {
const now = Date.now();
let last = 0;
try {
// A missing or unreadable marker reads as 0, i.e. no recent reload; it
// is replaced below rather than handing out a reload on every boot.
last = Number(sessionStorage.getItem(RELOAD_STORAGE_KEY)) || 0;
} catch (_err) {
return true;
}
if (now - last <= RELOAD_COOLDOWN) {
return false;
}
try {
sessionStorage.setItem(RELOAD_STORAGE_KEY, String(now));
} catch (_err) {
// ignore
}
return true;
}
function recover() {
if (recovering) {
// core.js and app.js are confirmed gone moments apart; the first
// verdict is already navigating.
return;
}
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 =
if (!claimReload()) {
// Reloaded recently (possibly on the previous page load, before the
// param was stripped) and it did not help: stop asking.
showFatal();
return;
}
recovering = true;
const target =
location.pathname +
location.search +
(location.search ? "&" : "?") +
@@ -63,31 +127,21 @@
// 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 () {
const go = () => {
location.replace(target);
};
Promise.all([
navigator.serviceWorker
.getRegistrations()
.then(function (regs) {
return Promise.all(
regs.map(function (r) {
return r.unregister();
})
);
})
.catch(function () {}),
.then((regs) => Promise.all(regs.map((reg) => reg.unregister())))
.catch(() => {}),
self.caches
? caches
.keys()
.then(function (keys) {
return Promise.all(
keys.map(function (k) {
return caches.delete(k);
})
);
})
.catch(function () {})
.then((keys) =>
Promise.all(keys.map((key) => caches.delete(key)))
)
.catch(() => {})
: null,
]).then(go, go);
} else {
@@ -95,26 +149,102 @@
}
}
function maybeRecover(url, message) {
if (booted()) {
/*
* The file a failure is about: its absolute URL when this build ships it,
* "foreign" for anything else (a dashboard resource, an extra_module_url, a
* custom panel - those say nothing about the index being stale, and
* reloading over them never ends), or null when no file was named.
*/
function failedFile(text) {
const match = ANY_URL.exec(text);
if (!match) {
return null;
}
try {
const url = new URL(match[0], location.href);
if (url.origin === location.origin && HASHED_PATH.test(url.pathname)) {
return url.href;
}
} catch (_err) {
// Not a URL after all.
}
return "foreign";
}
function referenceUrl() {
const entries = window.latestJS ? LATEST_ENTRY_JS : ES5_ENTRY_JS;
for (const entry of entries) {
const url = new URL(entry, location.href);
if (url.origin === location.origin && HASHED_PATH.test(url.pathname)) {
return url.href;
}
}
return null;
}
/*
* A failed load looks identical whether the file was deleted by an upgrade
* or the request never reached the server, and only the first is fixed by
* reloading. HEAD is never intercepted by the service worker (workbox
* routes GET only), so the answer comes from the server itself. A server
* that refuses HEAD outright leaves us with no answer, and no answer means
* no recovery: a GET cannot substitute, because the frontend_latest route
* is CacheFirst with `ignoreSearch`, so a controlling worker can answer it
* from its own cache.
*/
function probeGone(url) {
// Status 0 stands for "no usable answer": unreachable, or too slow.
return Promise.race([
fetch(url, { method: "HEAD", cache: "no-store" }).then(
(response) => response.status,
() => 0
),
new Promise((resolve) => {
setTimeout(() => resolve(0), PROBE_TIMEOUT);
}),
]).then((status) => status === 404 || status === 410);
}
function maybeRecover(text) {
if (booted() || (!HASHED_ENTRY.test(text) && !MODULE_ERROR.test(text))) {
return;
}
if (
(url && HASHED_ENTRY.test(url)) ||
(message && (HASHED_ENTRY.test(message) || MODULE_ERROR.test(message)))
) {
recover();
const failed = failedFile(text);
if (failed === "foreign") {
return;
}
// Probe per URL: core.js and app.js fail as separate events, and one of
// them being served says nothing about the other.
const target = failed || referenceUrl();
if (!target || probing[target]) {
return;
}
probing[target] = true;
probeGone(target).then((gone) => {
delete probing[target];
if (gone) {
recover();
return;
}
// Nothing was deleted, so a reload cannot help - but the file that
// failed is not coming back either. Say so if the app never takes over,
// rather than leaving an empty launch screen up for good.
setTimeout(() => {
if (!booted() && !recovering) {
showFatal();
}
}, FATAL_DELAY);
});
}
// 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;
(ev) => {
const el = ev.target;
if (el && (el.tagName === "SCRIPT" || el.tagName === "LINK")) {
maybeRecover(el.href || el.src, null);
maybeRecover(el.href || el.src || "");
}
},
true
@@ -122,9 +252,9 @@
// 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)));
window.addEventListener("unhandledrejection", (ev) => {
const reason = ev && ev.reason;
maybeRecover((reason && (reason.message || String(reason))) || "");
});
})();
</script>
+4 -1
View File
@@ -155,7 +155,10 @@
<% if (obj.hassUrl === "") { %>
<script>
{%- for extra_module in extra_modules -%}
import("{{ extra_module }}");
// Caught, or the boot recovery guard reads it as a stale build.
import("{{ extra_module }}").catch(function (err) {
console.error("Failed to load extra module {{ extra_module }}", err);
});
{%- endfor -%}
</script>
<% } %>
+6 -2
View File
@@ -202,7 +202,7 @@ export class HassRouterPage extends ReactiveElement {
// the app stayed open) is recoverable: reload onto the current build
// (or prompt when there are unsaved edits) instead of dead-ending.
const message = err instanceof Error ? err.message : String(err ?? "");
const stale = recoverFromStaleBuild(message, this);
const recovery = recoverFromStaleBuild(message, this);
// Show error screen, offering a reload action for a stale build. Set
// `showReload` on the returned element rather than through
@@ -211,8 +211,12 @@ export class HassRouterPage extends ReactiveElement {
const errorScreen = this.createErrorScreen(
`Error while loading page ${newPage}.`
);
errorScreen.showReload = stale;
this.appendChild(errorScreen);
// That action drops the caches, so only offer it once the probe has
// confirmed the chunk is really gone.
void Promise.resolve(recovery).then((stale) => {
errorScreen.showReload = stale;
});
});
// If we don't show loading screen, just show the panel.
@@ -1462,29 +1462,34 @@ class SupervisorAppInfo extends MobileAwareMixin(LitElement) {
return;
}
let removeData = false;
const _removeDataToggled = (e: Event) => {
removeData = (e.target as HaSwitch).checked;
let removeConfig = false;
const _removeConfigToggled = (e: Event) => {
removeConfig = (e.target as HaSwitch).checked;
};
const confirmed = await showConfirmationDialog(this, {
title: this.i18n.localize(
"ui.panel.config.apps.dashboard.uninstall_dialog.title",
{
name: getAppDisplayName(addon.name, addon.stage),
}
"ui.panel.config.apps.dashboard.uninstall_dialog.title"
),
text: html`
<p>
${this.i18n.localize(
"ui.panel.config.apps.dashboard.uninstall_dialog.text",
{
name: getAppDisplayName(addon.name, addon.stage),
}
)}
</p>
<ha-formfield
.label=${html`<p>
${this.i18n.localize(
"ui.panel.config.apps.dashboard.uninstall_dialog.remove_data"
"ui.panel.config.apps.dashboard.uninstall_dialog.remove_config"
)}
</p>`}
>
<ha-switch
@change=${_removeDataToggled}
.checked=${removeData}
@change=${_removeConfigToggled}
.checked=${removeConfig}
haptic
></ha-switch>
</ha-formfield>
@@ -1503,7 +1508,7 @@ class SupervisorAppInfo extends MobileAwareMixin(LitElement) {
this._uninstalling = true;
this._error = undefined;
try {
await uninstallHassioAddon(this.api.callWS, addon.slug, removeData);
await uninstallHassioAddon(this.api.callWS, addon.slug, removeConfig);
const eventdata = {
success: true,
response: undefined,
+18 -7
View File
@@ -1,4 +1,5 @@
import {
mdiAccessPointNetwork,
mdiAccount,
mdiBackupRestore,
mdiBadgeAccountHorizontal,
@@ -92,13 +93,6 @@ export const configSections: Record<string, PageNavigation[]> = {
component: "lovelace",
adminOnly: true,
},
{
path: "/config/voice-assistants",
translationKey: "voice_assistants",
iconPath: mdiMicrophone,
iconColor: "#3263C3",
adminOnly: true,
},
],
dashboard_external_settings: [
{
@@ -109,6 +103,23 @@ export const configSections: Record<string, PageNavigation[]> = {
},
],
dashboard_2: [
{
path: "/config/connectivity",
translationKey: "connectivity",
iconPath: mdiAccessPointNetwork,
iconColor: "#00838F",
core: true,
adminOnly: true,
},
{
path: "/config/voice-assistants",
translationKey: "voice_assistants",
iconPath: mdiMicrophone,
iconColor: "#3263C3",
adminOnly: true,
},
],
connectivity: [
{
path: "/config/matter",
iconPath:
@@ -0,0 +1,80 @@
import type { CSSResultGroup, TemplateResult } from "lit";
import { css, html, LitElement } from "lit";
import { customElement, property } from "lit/decorators";
import "../../../components/ha-card";
import "../../../layouts/hass-subpage";
import { haStyle } from "../../../resources/styles";
import type { HomeAssistant } from "../../../types";
import { configSections } from "../config-sections";
import "../dashboard/ha-config-navigation";
import "../ha-config-section";
@customElement("ha-config-connectivity")
class HaConfigConnectivity extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ type: Boolean }) public narrow = false;
@property({ attribute: "is-wide", type: Boolean }) public isWide = false;
protected render(): TemplateResult {
const title = this.hass.localize(
"ui.panel.config.dashboard.connectivity.main"
);
return html`
<hass-subpage
.hass=${this.hass}
back-path="/config"
.header=${title}
.narrow=${this.narrow}
>
<ha-config-section .isWide=${this.isWide} full-width>
<ha-card outlined>
<ha-config-navigation
.hass=${this.hass}
.narrow=${this.narrow}
.pages=${configSections.connectivity}
.label=${title}
></ha-config-navigation>
</ha-card>
</ha-config-section>
</hass-subpage>
`;
}
static get styles(): CSSResultGroup {
return [
haStyle,
css`
ha-config-section {
margin: auto;
margin-top: -32px;
max-width: 600px;
}
ha-card {
overflow: hidden;
margin-bottom: max(24px, var(--safe-area-inset-bottom));
}
@media all and (max-width: 600px) {
ha-card {
border-width: 1px 0;
border-radius: var(--ha-border-radius-square);
box-shadow: unset;
}
ha-config-section {
margin-top: -42px;
}
}
`,
];
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-config-connectivity": HaConfigConnectivity;
}
}
@@ -24,6 +24,8 @@ class HaConfigNavigation extends LitElement {
@property({ attribute: false }) public pages!: PageNavigation[];
@property() public label?: string;
@state() private _visiblePages?: PageNavigation[];
private _hasBluetoothConfigEntries = false;
@@ -99,16 +101,15 @@ class HaConfigNavigation extends LitElement {
}
`,
}));
const label = this.label ?? this.hass.localize("panel.config");
return html`
<div class="visually-hidden" role="heading" aria-level="2">
${this.hass.localize("panel.config")}
</div>
<div class="visually-hidden" role="heading" aria-level="2">${label}</div>
<ha-config-navigation-list
has-secondary
.hass=${this.hass}
.narrow=${this.narrow}
.pages=${pages}
.label=${this.hass.localize("panel.config")}
.label=${label}
></ha-config-navigation-list>
`;
}
+4
View File
@@ -70,6 +70,10 @@ class HaPanelConfig extends HassRouterPage {
tag: "ha-config-cloud",
load: () => import("./cloud/ha-config-cloud"),
},
connectivity: {
tag: "ha-config-connectivity",
load: () => import("./connectivity/ha-config-connectivity"),
},
devices: {
tag: "ha-config-devices",
load: () => import("./devices/ha-config-devices"),
@@ -160,7 +160,7 @@ export class BluetoothConfigDashboard extends LitElement {
.hass=${this.hass}
.narrow=${this.narrow}
.header=${this.hass.localize("ui.panel.config.bluetooth.title")}
back-path="/config"
back-path="/config/connectivity"
>
<div class="container">
<ha-card class="content network-status">
@@ -52,7 +52,7 @@ export class InfraredConfigDashboard extends LitElement {
.hass=${this.hass}
.narrow=${this.narrow}
.header=${this.hass.localize("ui.panel.config.infrared.title")}
back-path="/config"
back-path="/config/connectivity"
>
<div class="container">
<ha-card class="content network-status">
@@ -83,7 +83,7 @@ export class MatterConfigDashboard extends LitElement {
.narrow=${this.narrow}
.hass=${this.hass}
header="Matter"
back-path="/config"
back-path="/config/connectivity"
has-fab
>
<div class="container">
@@ -43,7 +43,7 @@ export class RadioFrequencyConfigDashboard extends LitElement {
.hass=${this.hass}
.narrow=${this.narrow}
.header=${this.hass.localize("ui.panel.config.radio_frequency.title")}
back-path="/config"
back-path="/config/connectivity"
>
<div class="container">
<ha-card class="network-status">
@@ -409,7 +409,7 @@ export class SerialConfigDashboard extends LitElement {
.hass=${this.hass}
.narrow=${this.narrow}
.header=${this.hass.localize("ui.panel.config.serial.title")}
back-path="/config"
back-path="/config/connectivity"
>
<ha-icon-button
slot="toolbar-icon"
@@ -79,7 +79,7 @@ export class ThreadConfigPanel extends SubscribeMixin(LitElement) {
.narrow=${this.narrow}
.hass=${this.hass}
header="Thread"
back-path="/config"
back-path="/config/connectivity"
>
<ha-dropdown slot="toolbar-icon">
<ha-icon-button
@@ -101,7 +101,7 @@ class ZHAConfigDashboard extends LitElement {
.hass=${this.hass}
.narrow=${this.narrow}
.header=${this.hass.localize("ui.panel.config.zha.network.caption")}
back-path="/config"
back-path="/config/connectivity"
>
<div class="loading">
<ha-spinner></ha-spinner>
@@ -129,7 +129,7 @@ class ZHAConfigDashboard extends LitElement {
.hass=${this.hass}
.narrow=${this.narrow}
.header=${this.hass.localize("ui.panel.config.zha.network.caption")}
back-path="/config"
back-path="/config/connectivity"
has-fab
>
<div class="container">
@@ -148,7 +148,7 @@ class ZWaveJSConfigDashboard extends SubscribeMixin(LitElement) {
.header=${this.hass.localize(
"ui.panel.config.zwave_js.navigation.general"
)}
back-path="/config"
back-path="/config/connectivity"
has-fab
>
<ha-icon-button
+1 -1
View File
@@ -191,7 +191,7 @@ export class HaConfigTags extends SubscribeMixin(LitElement) {
<hass-tabs-subpage-data-table
.hass=${this.hass}
.narrow=${this.narrow}
back-path="/config"
back-path="/config/connectivity"
.route=${this.route}
.tabs=${configSections.tags}
.columns=${this._columns(this.hass.localize)}
+18 -12
View File
@@ -20,7 +20,6 @@ import type { HASSDomEvent } from "../../common/dom/fire_event";
import { computeDomain } from "../../common/entity/compute_domain";
import { navigate } from "../../common/navigate";
import { constructUrlCurrentPath } from "../../common/url/construct-url";
import { shallowEqual } from "../../common/util/shallow-equal";
import {
createHistoryLogbookUrl,
decodeHistoryLogbookQueryParams,
@@ -31,6 +30,7 @@ import {
extractSearchParamsObject,
removeSearchParam,
} from "../../common/url/search-params";
import { shallowEqual } from "../../common/util/shallow-equal";
import { MIN_TIME_BETWEEN_UPDATES } from "../../components/chart/ha-chart-base";
import "../../components/chart/state-history-charts";
import type { StateHistoryCharts } from "../../components/chart/state-history-charts";
@@ -40,20 +40,20 @@ import "../../components/ha-dropdown";
import type { HaDropdownSelectEvent } from "../../components/ha-dropdown";
import "../../components/ha-dropdown-item";
import "../../components/ha-empty-state";
import "../../components/ha-filter-pane-chip";
import "../../components/ha-filter-pane";
import "../../components/ha-filter-pane-chip";
import "../../components/ha-icon-button";
import type { SourceFilters } from "../../components/ha-sources-picker";
import {
applySourceFilters,
countSourceFilters,
countTargets,
} from "../../components/ha-sources-picker";
import type { SourceFilters } from "../../components/ha-sources-picker";
import { entityTypesNeedStates } from "../../data/entity/entity_type";
import "../../components/ha-spinner";
import "../../components/ha-top-app-bar-fixed";
import type { EntitySources } from "../../data/entity/entity_sources";
import { fetchEntitySourcesWithCache } from "../../data/entity/entity_sources";
import { entityTypesNeedStates } from "../../data/entity/entity_type";
import type { HistoryResult } from "../../data/history";
import {
computeHistory,
@@ -66,8 +66,8 @@ import { resolveEntityIDs } from "../../data/selector";
import { showAlertDialog } from "../../dialogs/generic/show-dialog-box";
import { haStyle, haStyleScrollbar } from "../../resources/styles";
import type { HomeAssistant } from "../../types";
import { csvDownload, csvSafeString } from "../../util/csv";
import { addEntitiesToLovelaceView } from "../lovelace/editor/add-entities-to-view";
import { csvSafeString, csvDownload } from "../../util/csv";
const EMPTY_STATES: HomeAssistant["states"] = {};
@@ -294,13 +294,19 @@ class HaPanelHistory extends LitElement {
: "ui.panel.history.start_search"
)}
>
<ha-button appearance="plain" @click=${this._openSources}>
${this.hass.localize(
hasTargets
? "ui.panel.history.change_sources"
: "ui.panel.history.add_targets"
)}
</ha-button>
${
!this._sourcesShown()
? html`
<ha-button appearance="plain" @click=${this._openSources}>
${this.hass.localize(
hasTargets
? "ui.panel.history.change_sources"
: "ui.panel.history.add_targets"
)}
</ha-button>
`
: nothing
}
</ha-empty-state>
`;
}
+28 -3
View File
@@ -1,8 +1,9 @@
import type { PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { customElement, property, query, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { ifDefined } from "lit/directives/if-defined";
import { live } from "lit/directives/live";
import { applyThemesOnElement } from "../../../common/dom/apply_themes_on_element";
import { computeDomain } from "../../../common/entity/compute_domain";
import "../../../components/ha-card";
@@ -21,6 +22,7 @@ import type { PersonEntity } from "../../../data/person";
import {
isMediaSourceContentId,
resolveMediaSource,
isStreamingMedia,
} from "../../../data/media_source";
@customElement("hui-picture-card")
@@ -43,6 +45,25 @@ export class HuiPictureCard extends LitElement implements LovelaceCard {
@state() private _resolvedImage?: string;
private _reconnectImg = false;
@query("img") private _img?: HTMLImageElement;
public disconnectedCallback() {
super.disconnectedCallback();
if (isStreamingMedia(this._img?.getAttribute("src") || "")) {
this._reconnectImg = true;
this._img?.removeAttribute("src");
}
}
public connectedCallback() {
super.connectedCallback();
if (this._reconnectImg) {
this.requestUpdate();
}
}
public getCardSize(): number {
return 5;
}
@@ -69,7 +90,8 @@ export class HuiPictureCard extends LitElement implements LovelaceCard {
if (
!this._config ||
hasConfigChanged(this, changedProps) ||
changedProps.has("_resolvedImage")
changedProps.has("_resolvedImage") ||
this._reconnectImg
) {
return true;
}
@@ -93,6 +115,9 @@ export class HuiPictureCard extends LitElement implements LovelaceCard {
if (!this._config || !this.hass) {
return;
}
if (this.isConnected) {
this._reconnectImg = false;
}
const firstHass =
changedProps.has("hass") && changedProps.get("hass") === undefined;
@@ -198,7 +223,7 @@ export class HuiPictureCard extends LitElement implements LovelaceCard {
alt=${ifDefined(
this._config.alt_text || stateObj?.attributes.friendly_name
)}
src=${this.hass.hassUrl(image)}
src=${this._reconnectImg ? nothing : live(this.hass.hassUrl(image))}
/>
</ha-card>
`;
+15 -5
View File
@@ -39,7 +39,7 @@ export const loggingMixin = <T extends Constructor<HassBaseEl>>(
target &&
(target.tagName === "SCRIPT" || target.tagName === "LINK")
) {
recoverFromStaleBuild(target.src || target.href, this);
void recoverFromStaleBuild(target.src || target.href, this);
}
},
true
@@ -47,9 +47,16 @@ export const loggingMixin = <T extends Constructor<HassBaseEl>>(
window.addEventListener("error", async (ev) => {
// A stale build can surface as a runtime error while evaluating a
// freshly (re)loaded chunk; recover rather than log it.
if (recoverFromStaleBuild(ev.error?.message || ev.message, this)) {
const recovery = recoverFromStaleBuild(
ev.error?.message || ev.message,
this
);
if (recovery) {
ev.preventDefault();
return;
// A chunk that is still on the server failed in transport: log it.
if (await recovery) {
return;
}
}
if (!this.hass?.connected) {
return;
@@ -94,9 +101,12 @@ export const loggingMixin = <T extends Constructor<HassBaseEl>>(
: typeof reason === "string"
? reason
: "";
if (recoverFromStaleBuild(reasonMessage, this)) {
const recovery = recoverFromStaleBuild(reasonMessage, this);
if (recovery) {
ev.preventDefault();
return;
if (await recovery) {
return;
}
}
if (!this.hass?.connected) {
return;
+8 -3
View File
@@ -1633,6 +1633,7 @@
"knx": "[%key:ui::panel::config::dashboard::knx::main%]",
"insteon": "[%key:ui::panel::config::dashboard::insteon::main%]",
"voice-assistants": "[%key:ui::panel::config::dashboard::voice_assistants::main%]",
"connectivity": "[%key:ui::panel::config::dashboard::connectivity::main%]",
"ai-tasks": "[%key:ui::panel::config::ai_tasks::caption%]"
}
},
@@ -1691,7 +1692,6 @@
"entity_id": "ID",
"copy_value": "Copy {label}: {value}",
"labels": "Labels",
"no_labels": "None",
"toggle_yaml_mode": "Toggle YAML mode",
"back_to_info": "Back to info",
"info": "Information",
@@ -2718,6 +2718,10 @@
"main": "Dashboards",
"secondary": "Organize how you interact with your home"
},
"connectivity": {
"main": "Connectivity",
"secondary": "Communication protocols and radios for your devices"
},
"voice_assistants": {
"main": "Voice assistants",
"secondary": "Manage your voice assistants"
@@ -3125,8 +3129,9 @@
"view_supervisor_logs": "View supervisor logs"
},
"uninstall_dialog": {
"title": "Uninstall {name}?",
"remove_data": "Also remove app data",
"title": "Uninstall app",
"text": "{name} and everything in its private data folder will be permanently deleted, including any databases, credentials, and other internal state it kept there.",
"remove_config": "Also delete the app's configuration folder (if used)",
"uninstall": "Uninstall"
},
"restart_dialog": {
+190 -27
View File
@@ -1,4 +1,5 @@
import { mainWindow } from "../common/dom/get_main_window";
import { promiseTimeout } from "../common/util/promise-timeout";
import { fireExternalBusMessage } from "../external_app/external_messaging";
import * as staleBuildPatterns from "./stale-build-patterns.json";
import { showToast } from "./toast";
@@ -8,9 +9,19 @@ import { showToast } from "./toast";
// (src/html/_bootstrap_recovery.html.template), which runs before any bundle
// loads and therefore cannot import from here.
const patterns = ((staleBuildPatterns as any).default ??
staleBuildPatterns) as { hashedEntry: string; moduleError: string };
staleBuildPatterns) as {
hashedEntry: string;
moduleError: string;
anyUrl: string;
};
const HASHED_ENTRY = new RegExp(patterns.hashedEntry, "i");
// The same pattern anchored: a URL is ours only when it *is* an entry path, not
// when it merely contains one (/local/frontend_latest/custom.abc12345.js).
const HASHED_PATH = new RegExp(`^(?:${patterns.hashedEntry})$`, "i");
const MODULE_ERROR = new RegExp(patterns.moduleError, "i");
// Any file reference, to tell "a file we do not ship failed" apart from "the
// browser did not say which file failed".
const ANY_URL = new RegExp(patterns.anyUrl, "i");
const RELOAD_STORAGE_KEY = "haStaleBuildReload";
const RELOAD_COOLDOWN = 60_000;
@@ -27,10 +38,131 @@ let toastShown = false;
* on the server after an upgrade. Accepts either a URL (from a resource
* `error` event) or an error message (from a rejected dynamic `import()`).
*/
export const isStaleBuildError = (urlOrMessage: string | undefined): boolean =>
export const isStaleBuildError = (
urlOrMessage: string | undefined
): urlOrMessage is string =>
!!urlOrMessage &&
(HASHED_ENTRY.test(urlOrMessage) || MODULE_ERROR.test(urlOrMessage));
type ChunkVerdict = "gone" | "present" | "unknown";
// How long a verdict other than "gone" stands, so a burst of failures for the
// same chunk asks the server once.
const PROBE_SUPPRESS = 10_000;
const PROBE_TIMEOUT = 5_000;
// Both keyed by chunk URL: during a deploy some chunks can be gone while others
// are still served, so one chunk's verdict must not answer for another.
const pendingProbes = new Map<string, Promise<boolean>>();
const probedNotGone = new Map<string, number>();
/**
* The file a failure is about: its absolute URL when this build ships it,
* `"foreign"` for anything else (a dashboard resource, an extra_module_url, a
* custom panel), or `undefined` when no file was named.
*/
const failedFile = (urlOrMessage: string): string | "foreign" | undefined => {
const match = ANY_URL.exec(urlOrMessage);
if (!match) {
return undefined;
}
try {
const url = new URL(match[0], location.href);
if (url.origin === location.origin && HASHED_PATH.test(url.pathname)) {
return url.href;
}
} catch (_err) {
// Not a URL after all.
}
return "foreign";
};
// Remember a chunk that was not confirmed gone — still served, or no usable
// answer at all — dropping verdicts that have expired.
const rememberNotGone = (url: string): void => {
const now = Date.now();
probedNotGone.forEach((until, key) => {
if (until <= now) {
probedNotGone.delete(key);
}
});
probedNotGone.set(url, now + PROBE_SUPPRESS);
};
/**
* A hashed file of the running build, to probe when the browser did not name
* the one that failed. They all live or die with the build.
*/
const referenceUrl = (): string | undefined => {
try {
const entries = performance.getEntriesByType("resource");
for (let i = entries.length - 1; i >= 0; i--) {
const { name } = entries[i];
const url = new URL(name, location.href);
if (url.origin === location.origin && HASHED_PATH.test(url.pathname)) {
return url.href;
}
}
} catch (_err) {
// ignore
}
return undefined;
};
/**
* Ask the server whether the chunk is really gone.
*
* A failed module load looks identical whether the file was deleted by an
* upgrade or the request never reached the server, and only the first is helped
* by dropping the caches and reloading on a transient failure that throws
* away the cache that could have served the chunk.
*
* HEAD is never intercepted by the service worker (workbox routes GET only), so
* the answer reflects what the server actually has. A server that refuses HEAD
* outright leaves us with no answer, and no answer means no recovery: a GET
* cannot substitute, because the frontend_latest route is CacheFirst with
* `ignoreSearch`, so a controlling worker can answer it from its own cache.
*/
const askServer = async (
url: string,
signal?: AbortSignal
): Promise<ChunkVerdict> => {
try {
const response = await fetch(url, {
method: "HEAD",
cache: "no-store",
signal,
});
if (response.status === 404 || response.status === 410) {
return "gone";
}
// A 5xx or a proxy error page says nothing about the file being deleted.
return response.ok ? "present" : "unknown";
} catch (_err) {
// Aborted, offline, connection reset: unreachable, not stale.
return "unknown";
}
};
const probeChunk = async (url: string): Promise<ChunkVerdict> => {
const controller =
typeof AbortController === "undefined" ? undefined : new AbortController();
try {
// Timed out rather than relying on the abort alone: without
// AbortController a stalled request would never settle, pinning the probe.
const verdict: ChunkVerdict = await promiseTimeout(
PROBE_TIMEOUT,
askServer(url, controller?.signal)
);
return verdict;
} catch (_err) {
if (controller) {
controller.abort();
}
return "unknown";
}
};
const dropCachesAndReload = async (bust: number): Promise<void> => {
// A service worker serves chunks CacheFirst (with `ignoreSearch`), so a
// cache-busting navigation alone would still be answered from the stale
@@ -66,8 +198,9 @@ const dropCachesAndReload = async (bust: number): Promise<void> => {
* guard blocked it so callers can fall back to logging / an error screen
* instead of silently swallowing a still-failing chunk.
*
* Guarded by a monotonic sessionStorage counter NOT the boot guard's URL
* param, which core.ts strips on every successful connect so a recovery that
* Guarded by the time of the last recovery reload in sessionStorage NOT the
* boot guard's URL param, which core.ts strips on every connect so a recovery
* that
* boots and then immediately re-triggers the same missing chunk (a deep-linked
* panel, more-info from the URL, or a preloaded route) cannot reload-loop.
*/
@@ -76,32 +209,22 @@ export const reloadFresh = (): boolean => {
return false;
}
const now = Date.now();
let attempts = 0;
let last = 0;
let storageOk = true;
try {
const stored = sessionStorage.getItem(RELOAD_STORAGE_KEY);
if (stored) {
const parsed = JSON.parse(stored);
attempts = Number(parsed.n) || 0;
last = Number(parsed.t) || 0;
}
// A missing or unreadable marker reads as 0, i.e. no recent reload; it is
// replaced below rather than standing in the way of every later recovery.
last = Number(sessionStorage.getItem(RELOAD_STORAGE_KEY)) || 0;
} catch (_err) {
storageOk = false;
}
if (now - last > RELOAD_COOLDOWN) {
attempts = 0;
}
if (attempts >= 1) {
// Already reloaded once recently and it still failed: stop, to avoid a
// reload loop on a genuinely broken (not merely stale) deploy.
if (now - last <= RELOAD_COOLDOWN) {
// Already reloaded within the cooldown and it still failed: stop, to avoid
// a reload loop on a genuinely broken (not merely stale) deploy.
return false;
}
try {
sessionStorage.setItem(
RELOAD_STORAGE_KEY,
JSON.stringify({ n: attempts + 1, t: now })
);
sessionStorage.setItem(RELOAD_STORAGE_KEY, String(now));
} catch (_err) {
storageOk = false;
}
@@ -175,19 +298,26 @@ export const reloadForUpdate = (rootEl?: HTMLElement): boolean => {
* Recover from a failed lazy load caused by a stale build (a content-hashed
* chunk that 404s after an upgrade while the app stayed open).
*
* The chunk is probed first: one that is still on the server failed in
* transport, which a cache-dropping reload cannot fix and would make worse, so
* that case is left to the caller to surface.
*
* On a confirmed stale build:
* - clean: reload onto the current build.
* - dirty (an editor has unsaved changes): show a non-dismissable toast and
* auto-reload once the user saves/discards, so unsaved work is never lost.
*
* Returns `true` when the error was a stale-build error and recovery was
* actually started, so callers can skip their own error UI / logging. Returns
* `false` for a non-stale error, or when the loop guard blocked the reload (so
* the caller still surfaces/logs the failure).
* Returns `false` when the error is not a chunk-load failure, names a file this
* build does not ship, or a recent probe did not find the chunk gone.
* Otherwise a promise shared across a burst resolving `true` when recovery
* started (the caller can skip its own error UI / logging) and `false` for
* everything else: the chunk was still served, the probe was inconclusive (a
* network error, a timeout, a 5xx), or the loop guard blocked the reload.
*/
export const recoverFromStaleBuild = (
urlOrMessage: string | undefined,
rootEl?: HTMLElement
): boolean => {
): false | Promise<boolean> => {
// In dev/demo the entry files are unhashed and rebuild churn can throw
// transient import errors; never auto-reload there.
if (__DEV__ || __DEMO__) {
@@ -196,5 +326,38 @@ export const recoverFromStaleBuild = (
if (!isStaleBuildError(urlOrMessage)) {
return false;
}
return reloadForUpdate(rootEl);
const failed = failedFile(urlOrMessage);
if (failed === "foreign") {
// Those fail on their own schedule and say nothing about the build being
// stale, so reloading over them never ends.
return false;
}
// Nothing named (Safari's message names no file): ask about the build itself.
const url = failed ?? referenceUrl();
if (!url) {
// No evidence at all — never drop caches on a guess.
return false;
}
const now = Date.now();
const suppressed = probedNotGone.get(url);
if (suppressed !== undefined && suppressed > now) {
return false;
}
const pending = pendingProbes.get(url);
if (pending) {
return pending;
}
const probe = probeChunk(url)
.then((verdict) => {
if (verdict !== "gone") {
rememberNotGone(url);
return false;
}
return reloadForUpdate(rootEl);
})
.finally(() => {
pendingProbes.delete(url);
});
pendingProbes.set(url, probe);
return probe;
};
+2 -1
View File
@@ -1,4 +1,5 @@
{
"hashedEntry": "/frontend_(?:latest|es5)/[^/?#]+\\.[0-9a-f]{8,}\\.js",
"moduleError": "dynamically imported module|Importing a module script failed|error loading dynamically imported|ChunkLoadError"
"moduleError": "dynamically imported module|Importing a module script failed|error loading dynamically imported|ChunkLoadError",
"anyUrl": "[a-z]+://[^\\s\"'()]+|/[^\\s\"'()]+\\.(?:m?js|css)\\b"
}
+17
View File
@@ -26,6 +26,7 @@ import {
import {
appRouteSmokeGroups,
configLinks,
connectivityLinks,
moreInfoViewElements,
} from "./app/src/smoke";
@@ -591,4 +592,20 @@ test.describe("Config panel", () => {
configLinks,
getDashboard
);
// Reached by clicking through from the dashboard: the e2e harness only
// resolves config panel translations once the dashboard has mounted.
const getConnectivity = async (page) => {
const dashboard = await getDashboard(page);
await dashboard.getByRole("link", { name: /^Connectivity\b/ }).click();
const connectivity = page.locator("ha-config-connectivity");
await expect(connectivity).toBeAttached({ timeout: PANEL_TIMEOUT });
return connectivity;
};
defineLinkSmokeTests(
"connectivity links point to expected pages",
connectivityLinks,
getConnectivity
);
});
+9 -4
View File
@@ -20,7 +20,15 @@ export const configLinks: LinkSmokeCase[] = [
{ href: "/config/areas", label: "Areas, labels & zones" },
{ href: "/config/apps", label: "Apps" },
{ href: "/config/lovelace/dashboards", label: "Dashboards" },
{ href: "/config/connectivity", label: "Connectivity" },
{ href: "/config/voice-assistants", label: "Voice assistants" },
{ href: "/config/person", label: "People" },
{ href: "/config/system", label: "System" },
{ href: "/config/tools", label: "Tools" },
{ href: "/config/info", label: "About" },
];
export const connectivityLinks: LinkSmokeCase[] = [
{ href: "/config/matter", label: "Matter" },
{ href: "/config/zha", label: "Zigbee" },
{ href: "/config/zwave_js", label: "Z-Wave" },
@@ -31,10 +39,6 @@ export const configLinks: LinkSmokeCase[] = [
{ href: "/config/radio-frequency", label: "Radio frequency" },
{ href: "/insteon", label: "Insteon" },
{ href: "/config/tags", label: "Tags" },
{ href: "/config/person", label: "People" },
{ href: "/config/system", label: "System" },
{ href: "/config/tools", label: "Tools" },
{ href: "/config/info", label: "About" },
];
// ── More-info dialog views ───────────────────────────────────────────────────
@@ -198,6 +202,7 @@ const CONFIG_ROUTES = routeCases([
["/config/script", "ha-config-script"],
["/config/blueprint", "ha-config-blueprint"],
["/config/cloud", "ha-config-cloud"],
["/config/connectivity", "ha-config-connectivity"],
["/config/energy", "ha-config-energy"],
["/config/hardware", "ha-config-hardware"],
["/config/labs", "ha-config-labs"],
+372
View File
@@ -0,0 +1,372 @@
/**
* @vitest-environment node
*/
import { readFileSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import template from "lodash.template";
import { beforeEach, describe, expect, it, vi } from "vitest";
import patterns from "../../src/util/stale-build-patterns.json";
const repoRoot = path.resolve(
path.dirname(fileURLToPath(import.meta.url)),
"../.."
);
const ORIGIN = "http://ha.local";
const CORE_ENTRY = "/frontend_latest/core.b1c2d3e4f5.js";
const APP_ENTRY = "/frontend_latest/app.a1b2c3d4e5.js";
const CHUNK = `${ORIGIN}/frontend_latest/45995.dce03284ae03.js`;
// The boot guard is inline in index.html: it runs before any bundle loads, so
// it is neither type-checked nor covered by the bundled recovery tests. Here it
// is rendered exactly as the build renders it, then evaluated with every global
// it touches passed in — which isolates it from the real environment and makes
// the reload observable without navigating.
const renderGuard = template(
readFileSync(
path.join(repoRoot, "src/html/_bootstrap_recovery.html.template"),
"utf-8"
)
);
const guardSource = (latestEntryJS) => {
const rendered = renderGuard({
staleBuildPatterns: patterns,
latestEntryJS,
es5EntryJS: ["/frontend_es5/app.f6a7b8c9d0.js"],
});
// The template is a single script block; take what is between the tags.
return rendered.slice(
rendered.indexOf("\n"),
rendered.lastIndexOf("</script>")
);
};
const importFailure = (url) =>
`error loading dynamically imported module: ${url}`;
describe("index.html boot recovery guard", () => {
/** @type {{ method: string, url: string, cache: string }[]} */
let probes;
/** Status per requested URL: a number, "error" or "hang". */
let statusFor;
/** Pending timeout callbacks, so the probe timeout is deterministic. */
let timeouts;
/** Resolvers of requests that never answer on their own. */
let hanging;
let location;
let infoBox;
let booted;
let storage;
const fakeStorage = () => {
const entries = new Map();
return {
getItem: (key) => entries.get(key) ?? null,
setItem: (key, value) => entries.set(key, String(value)),
removeItem: (key) => entries.delete(key),
};
};
const runGuard = (latestEntryJS = [CORE_ENTRY, APP_ENTRY]) => {
const env = {
window: Object.assign(new EventTarget(), { latestJS: true }),
document: {
getElementById: (id) => {
if (id === "ha-launch-screen") {
return booted ? null : {};
}
return id === "ha-launch-screen-info-box" ? infoBox : null;
},
},
location,
// No controlling service worker: recover() navigates straight away.
navigator: { serviceWorker: { controller: null } },
self: { caches: undefined },
caches: undefined,
sessionStorage: storage,
setTimeout: (fn) => {
timeouts.push(fn);
return timeouts.length;
},
fetch: (url, options) => {
probes.push({ method: options.method, url, cache: options.cache });
const status = statusFor(url);
if (status === "error") {
return Promise.reject(new TypeError("Failed to fetch"));
}
if (status === "hang") {
// Never settles on its own; the probe's timeout decides.
return new Promise((resolve) => {
hanging.push(resolve);
});
}
return Promise.resolve({ status });
},
};
// eslint-disable-next-line no-new-func
new Function(...Object.keys(env), guardSource(latestEntryJS))(
...Object.values(env)
);
return env.window;
};
const failImport = (win, message) => {
const event = new Event("unhandledrejection");
event.reason = new Error(message);
win.dispatchEvent(event);
return new Promise((resolve) => {
setTimeout(resolve, 0);
});
};
beforeEach(() => {
probes = [];
statusFor = () => 404;
timeouts = [];
hanging = [];
booted = false;
infoBox = { textContent: "" };
storage = fakeStorage();
location = {
origin: ORIGIN,
href: `${ORIGIN}/lovelace/0`,
pathname: "/lovelace/0",
search: "",
hash: "",
replace: vi.fn(),
};
});
it("reloads with a cache-busting param when the chunk is gone", async () => {
const win = runGuard();
await failImport(win, importFailure(CHUNK));
expect(probes).toEqual([{ method: "HEAD", url: CHUNK, cache: "no-store" }]);
expect(location.replace).toHaveBeenCalledOnce();
expect(location.replace.mock.calls[0][0]).toMatch(
/^\/lovelace\/0\?ha_cache_bust=\d+$/
);
});
it.each([
["the chunk is still on the server", 200],
["the server is unreachable", "error"],
["the server errors without deleting anything", 502],
])("does not reload when %s", async (_case, status) => {
statusFor = () => status;
const win = runGuard();
await failImport(win, importFailure(CHUNK));
// Probed, then left alone: a reload cannot fix a transport failure.
expect(probes).toHaveLength(1);
expect(location.replace).not.toHaveBeenCalled();
});
it("does not reload when the probe does not answer in time", async () => {
statusFor = () => "hang";
const win = runGuard();
await failImport(win, importFailure(CHUNK));
timeouts.forEach((fn) => fn());
await new Promise((resolve) => {
setTimeout(resolve, 0);
});
expect(location.replace).not.toHaveBeenCalled();
});
it.each([
"Failed to fetch dynamically imported module: /local/layout-card.js",
`error loading dynamically imported module: ${ORIGIN}/hacsfiles/silam/forecast.js`,
`error loading dynamically imported module: ${ORIGIN}/[/hacsfiles/card-mod/card-mod.js?hacstag=1]`,
// Our path, someone else's host: still not a file we ship.
"error loading dynamically imported module: https://cdn.example/frontend_latest/app.a1b2c3d4e5.js",
// Our path nested under theirs: also not a file we ship.
`error loading dynamically imported module: ${ORIGIN}/local/frontend_latest/custom.a1b2c3d4e5.js`,
])(
"ignores a failure of a file this build does not ship: %s",
async (message) => {
const win = runGuard();
// A foreign module failure must not be mistaken for a stale build.
await failImport(win, message);
expect(probes).toHaveLength(0);
expect(location.replace).not.toHaveBeenCalled();
expect(storage.getItem("haStaleBuildReload")).toBeNull();
}
);
it("probes each failing entry, so a served one cannot hide a deleted one", async () => {
const gone = `${ORIGIN}${APP_ENTRY}`;
statusFor = (url) => (url === gone ? 404 : 200);
const win = runGuard();
await Promise.all([
failImport(win, importFailure(`${ORIGIN}${CORE_ENTRY}`)),
failImport(win, importFailure(gone)),
]);
expect(probes.map((probe) => probe.url)).toEqual([
`${ORIGIN}${CORE_ENTRY}`,
gone,
]);
expect(location.replace).toHaveBeenCalledOnce();
});
it("does not show the fatal message while a recovery is navigating", async () => {
// Both entries are gone, so both probes confirm it moments apart.
const win = runGuard();
await Promise.all([
failImport(win, importFailure(`${ORIGIN}${CORE_ENTRY}`)),
failImport(win, importFailure(`${ORIGIN}${APP_ENTRY}`)),
]);
expect(probes).toHaveLength(2);
expect(location.replace).toHaveBeenCalledOnce();
expect(infoBox.textContent).toBe("");
});
it("asks about the same URL only once while a probe is in flight", async () => {
statusFor = () => "hang";
const win = runGuard();
await Promise.all([
failImport(win, importFailure(CHUNK)),
failImport(win, importFailure(CHUNK)),
]);
expect(probes).toHaveLength(1);
});
it("probes this build's entry when the message names no file", async () => {
const win = runGuard();
// Safari's message names nothing, so the entry bundle stands in.
await failImport(win, "Importing a module script failed.");
expect(probes[0].url).toBe(`${ORIGIN}${CORE_ENTRY}`);
expect(location.replace).toHaveBeenCalledOnce();
});
it("does not reload on a guess when there is nothing to probe", async () => {
const win = runGuard([]);
await failImport(win, "Importing a module script failed.");
expect(probes).toHaveLength(0);
expect(location.replace).not.toHaveBeenCalled();
});
it("says so when the app never takes over and a reload would not help", async () => {
statusFor = () => 200;
const win = runGuard();
await failImport(win, importFailure(CHUNK));
timeouts.forEach((fn) => fn());
await new Promise((resolve) => {
setTimeout(resolve, 0);
});
expect(location.replace).not.toHaveBeenCalled();
expect(infoBox.textContent).toContain("Could not load Home Assistant");
});
it("stays quiet when the app takes over after all", async () => {
statusFor = () => 200;
const win = runGuard();
await failImport(win, importFailure(CHUNK));
// The browser retried the failed modulepreload and the app came up.
booted = true;
timeouts.forEach((fn) => fn());
await new Promise((resolve) => {
setTimeout(resolve, 0);
});
expect(infoBox.textContent).toBe("");
});
it("stays inert once the app has booted", async () => {
booted = true;
const win = runGuard();
await failImport(win, importFailure(CHUNK));
// Post-boot failures belong to the bundled recovery, not this guard.
expect(probes).toHaveLength(0);
expect(location.replace).not.toHaveBeenCalled();
});
it("claims the shared reload budget when it recovers", async () => {
const win = runGuard();
await failImport(win, importFailure(CHUNK));
expect(location.replace).toHaveBeenCalledOnce();
expect(Number(storage.getItem("haStaleBuildReload"))).toBeGreaterThan(0);
});
it("does not reload again after a recent reload, even without the param", async () => {
// core.ts strips ha_cache_bust from the URL on every successful connect, so
// the param cannot bound the loop across page loads — the marker does.
storage.setItem("haStaleBuildReload", String(Date.now()));
const win = runGuard();
await failImport(win, importFailure(CHUNK));
expect(location.replace).not.toHaveBeenCalled();
expect(infoBox.textContent).toContain("Could not load Home Assistant");
});
it("reloads again once the cooldown has passed", async () => {
storage.setItem("haStaleBuildReload", String(Date.now() - 61_000));
const win = runGuard();
await failImport(win, importFailure(CHUNK));
expect(location.replace).toHaveBeenCalledOnce();
});
it("replaces a marker it cannot read", async () => {
// Also covers a marker written by an older build, which was JSON.
storage.setItem("haStaleBuildReload", '{"n":1,"t":1788243737299}');
const win = runGuard();
await failImport(win, importFailure(CHUNK));
// Recovered once, and left a marker that bounds the next boot — an
// unreadable one must not hand out a reload on every page load.
expect(location.replace).toHaveBeenCalledOnce();
expect(Number(storage.getItem("haStaleBuildReload"))).toBeGreaterThan(0);
});
it("still recovers when session storage is unavailable", async () => {
// Private mode / blocked storage: fall back to the bust param alone rather
// than losing the stale-index recovery altogether.
storage.getItem = () => {
throw new Error("denied");
};
const win = runGuard();
await failImport(win, importFailure(CHUNK));
expect(location.replace).toHaveBeenCalledOnce();
});
it("shows a message instead of reloading twice", async () => {
location.search = "?ha_cache_bust=123";
const win = runGuard();
await failImport(win, importFailure(CHUNK));
expect(location.replace).not.toHaveBeenCalled();
expect(infoBox.textContent).toContain("Could not load Home Assistant");
});
});
+228 -12
View File
@@ -10,7 +10,7 @@ interface RecoverModule {
recoverFromStaleBuild: typeof recoverFromStaleBuild;
}
const STALE_URL = "/frontend_latest/core.abc12345.js";
const STALE_URL = `${location.origin}/frontend_latest/core.abc12345.js`;
// Set by reloadFresh() right before it navigates; used here to observe that
// the reload path ran without having to mock window.location (jsdom forbids
// redefining it).
@@ -22,6 +22,14 @@ describe("recover-stale-build", () => {
let notifications: ShowToastParams[];
let serviceWorkerDescriptor: PropertyDescriptor | undefined;
let cachesDescriptor: PropertyDescriptor | undefined;
let fetchMock: ReturnType<typeof vi.fn>;
/** Resolvers of requests that never answer on their own. */
let stalled: ((value: unknown) => void)[];
const httpResponse = (status: number) => ({
status,
ok: status >= 200 && status < 300,
});
const latestNotification = () => notifications[notifications.length - 1];
const reloadMarker = () => sessionStorage.getItem(RELOAD_KEY);
@@ -44,6 +52,11 @@ describe("recover-stale-build", () => {
value: { controller: null },
});
// The staleness probe; by default the chunk really is gone.
stalled = [];
fetchMock = vi.fn().mockResolvedValue(httpResponse(404));
vi.stubGlobal("fetch", fetchMock);
// Capture toasts fired via showToast (a "hass-notification" event).
notifications = [];
root = document.createElement("home-assistant");
@@ -58,6 +71,10 @@ describe("recover-stale-build", () => {
});
afterEach(() => {
vi.unstubAllGlobals();
// spyOn is not covered by unstubAllGlobals, and a leaked
// performance.getEntriesByType would silently steer later tests.
vi.restoreAllMocks();
root.remove();
if (serviceWorkerDescriptor) {
Object.defineProperty(
@@ -123,12 +140,20 @@ describe("recover-stale-build", () => {
expect(mod.recoverFromStaleBuild("TypeError: boom", root)).toBe(false);
expect(reloadMarker()).toBeNull();
expect(notifications).toHaveLength(0);
expect(fetchMock).not.toHaveBeenCalled();
});
it("reloads onto the current build when clean", () => {
expect(mod.recoverFromStaleBuild(STALE_URL, root)).toBe(true);
it("reloads onto the current build when the chunk is gone", async () => {
await expect(mod.recoverFromStaleBuild(STALE_URL, root)).resolves.toBe(
true
);
// reloadFresh() ran (marker written before navigating) and did not toast.
// Asked the server about the chunk, bypassing every cache…
expect(fetchMock).toHaveBeenCalledWith(
STALE_URL,
expect.objectContaining({ method: "HEAD", cache: "no-store" })
);
// …then reloadFresh() ran (marker written before navigating), no toast.
expect(reloadMarker()).not.toBeNull();
expect(notifications).toHaveLength(0);
});
@@ -151,13 +176,15 @@ describe("recover-stale-build", () => {
},
});
expect(mod.recoverFromStaleBuild(STALE_URL, root)).toBe(true);
await expect(mod.recoverFromStaleBuild(STALE_URL, root)).resolves.toBe(
true
);
await vi.waitFor(() => expect(unregister).toHaveBeenCalledOnce());
expect(cacheDelete).toHaveBeenCalledTimes(2);
});
it("uses the companion-app command when the WebKit bridge is present", () => {
it("uses the companion-app command when the WebKit bridge is present", async () => {
const postMessage = vi.fn();
(
window as unknown as {
@@ -169,7 +196,9 @@ describe("recover-stale-build", () => {
}
).webkit = { messageHandlers: { externalBus: { postMessage } } };
expect(mod.recoverFromStaleBuild(STALE_URL, root)).toBe(true);
await expect(mod.recoverFromStaleBuild(STALE_URL, root)).resolves.toBe(
true
);
// Asks the native app to purge its cache and reload instead of the
// browser path.
@@ -180,10 +209,12 @@ describe("recover-stale-build", () => {
expect(notifications).toHaveLength(0);
});
it("defers with a toast instead of reloading when dirty", () => {
it("defers with a toast instead of reloading when dirty", async () => {
window.isDirtyState = true;
expect(mod.recoverFromStaleBuild(STALE_URL, root)).toBe(true);
await expect(mod.recoverFromStaleBuild(STALE_URL, root)).resolves.toBe(
true
);
// Took the toast branch, not the reload branch, and the toast has no
// immediate-reload action that could discard unsaved work.
@@ -199,8 +230,193 @@ describe("recover-stale-build", () => {
expect(latestNotification().action).toBeUndefined();
});
describe("staleness probe", () => {
const FAILED_CHUNK = `${location.origin}/frontend_latest/23792.c1214a5d.js`;
const IMPORT_FAILURE = `error loading dynamically imported module: ${FAILED_CHUNK}`;
it("does not reload when the chunk is still on the server", async () => {
fetchMock.mockResolvedValue(httpResponse(200));
// Dropping the caches cannot fix a transport failure.
await expect(mod.recoverFromStaleBuild(STALE_URL, root)).resolves.toBe(
false
);
expect(reloadMarker()).toBeNull();
expect(notifications).toHaveLength(0);
});
it("does not reload when the server is unreachable", async () => {
fetchMock.mockRejectedValue(
new TypeError("NetworkError when attempting to fetch resource.")
);
await expect(
mod.recoverFromStaleBuild(IMPORT_FAILURE, root)
).resolves.toBe(false);
expect(reloadMarker()).toBeNull();
});
it("does not reload on a server error other than 404/410", async () => {
fetchMock.mockResolvedValue(httpResponse(502));
await expect(mod.recoverFromStaleBuild(STALE_URL, root)).resolves.toBe(
false
);
expect(reloadMarker()).toBeNull();
});
it("probes the URL taken from the import failure message", async () => {
await expect(
mod.recoverFromStaleBuild(IMPORT_FAILURE, root)
).resolves.toBe(true);
expect(fetchMock).toHaveBeenCalledWith(
FAILED_CHUNK,
expect.objectContaining({ method: "HEAD" })
);
});
it("asks about the same chunk only once while a probe is in flight", async () => {
fetchMock.mockResolvedValue(httpResponse(200));
const verdicts = await Promise.all(
Array.from({ length: 5 }, () =>
mod.recoverFromStaleBuild(STALE_URL, root)
)
);
expect(verdicts).toEqual([false, false, false, false, false]);
expect(fetchMock).toHaveBeenCalledOnce();
});
it("probes each chunk, so a served one cannot hide a deleted one", async () => {
// A deploy can have replaced some chunks and not others.
const gone = `${location.origin}/frontend_latest/gone.abcdef12.js`;
fetchMock.mockImplementation((url: string) =>
Promise.resolve(httpResponse(url === gone ? 404 : 200))
);
await expect(mod.recoverFromStaleBuild(STALE_URL, root)).resolves.toBe(
false
);
await expect(mod.recoverFromStaleBuild(gone, root)).resolves.toBe(true);
expect(fetchMock).toHaveBeenCalledTimes(2);
expect(reloadMarker()).not.toBeNull();
});
it("suppresses further probes for a chunk found intact", async () => {
fetchMock.mockResolvedValue(httpResponse(200));
await expect(mod.recoverFromStaleBuild(STALE_URL, root)).resolves.toBe(
false
);
// Answered synchronously from the previous verdict: no second probe.
expect(mod.recoverFromStaleBuild(STALE_URL, root)).toBe(false);
expect(fetchMock).toHaveBeenCalledOnce();
});
it("gives up on a stalled request without AbortController", async () => {
const abortController = globalThis.AbortController;
// @ts-expect-error — emulate a browser at the legacy floor
delete globalThis.AbortController;
// Never settles on its own; the probe's timeout has to end it.
fetchMock.mockReturnValue(
new Promise((resolve) => {
stalled.push(resolve);
})
);
vi.useFakeTimers();
try {
const recovery = mod.recoverFromStaleBuild(STALE_URL, root);
await vi.advanceTimersByTimeAsync(5_000);
await expect(recovery).resolves.toBe(false);
expect(reloadMarker()).toBeNull();
// The probe was released, so a later failure is not swallowed.
fetchMock.mockResolvedValue(httpResponse(404));
vi.setSystemTime(Date.now() + 11_000);
await expect(
mod.recoverFromStaleBuild(STALE_URL, root)
).resolves.toBe(true);
} finally {
vi.useRealTimers();
globalThis.AbortController = abortController;
}
});
it.each([
// Foreign dynamic-import failures say nothing about whether this build's
// chunks remain available.
`error loading dynamically imported module: ${location.origin}/hacsfiles/silam/forecast.js`,
// A resource URL with literal brackets from a broken configuration.
`error loading dynamically imported module: ${location.origin}/[/hacsfiles/card-mod/card-mod.js?hacstag=1]`,
// Our path, someone else's host: still not a file we ship.
"error loading dynamically imported module: " +
"https://cdn.example/frontend_latest/app.abc12345.js",
// Our path nested under theirs: also not a file we ship.
`error loading dynamically imported module: ${location.origin}/local/frontend_latest/custom.abc12345.js`,
])(
"ignores a failure of a file this build does not ship: %s",
(message) => {
expect(mod.recoverFromStaleBuild(message, root)).toBe(false);
expect(fetchMock).not.toHaveBeenCalled();
expect(reloadMarker()).toBeNull();
}
);
it("probes the running build when the message names no file", async () => {
// Safari's message names nothing, so a hashed file of this build
// stands in for the question.
performance.clearResourceTimings?.();
vi.spyOn(performance, "getEntriesByType").mockReturnValue([
{ name: `${location.origin}/static/translations/en-abc12345.json` },
{ name: "https://cdn.example/frontend_latest/other.abcdef12.js" },
// A host that merely starts with ours is a different origin.
{ name: `${location.origin}.evil/frontend_latest/evil.abcdef12.js` },
{ name: `${location.origin}/frontend_latest/app.abc12345.js` },
] as unknown as PerformanceEntryList);
await expect(
mod.recoverFromStaleBuild("Importing a module script failed.", root)
).resolves.toBe(true);
expect(fetchMock).toHaveBeenCalledWith(
`${location.origin}/frontend_latest/app.abc12345.js`,
expect.objectContaining({ method: "HEAD" })
);
expect(reloadMarker()).not.toBeNull();
});
it("does nothing when there is no evidence to go on", async () => {
vi.spyOn(performance, "getEntriesByType").mockReturnValue([]);
expect(
mod.recoverFromStaleBuild("Importing a module script failed.", root)
).toBe(false);
expect(fetchMock).not.toHaveBeenCalled();
expect(reloadMarker()).toBeNull();
});
});
it("replaces a cooldown marker it cannot read", async () => {
// Also covers a marker written by an older build, which was JSON.
sessionStorage.setItem(RELOAD_KEY, '{"n":1,"t":1788243737299}');
// An unreadable marker must not read as "no storage", which would fail
// closed and leave recovery disabled for the rest of the session.
await expect(mod.recoverFromStaleBuild(STALE_URL, root)).resolves.toBe(
true
);
expect(Number(reloadMarker())).toBeGreaterThan(0);
});
it("does not reload again while the cooldown marker is set (loop guard)", async () => {
expect(mod.recoverFromStaleBuild(STALE_URL, root)).toBe(true);
await expect(mod.recoverFromStaleBuild(STALE_URL, root)).resolves.toBe(
true
);
const firstMarker = reloadMarker();
expect(firstMarker).not.toBeNull();
@@ -211,9 +427,9 @@ describe("recover-stale-build", () => {
await import("../../src/util/recover-stale-build");
// Blocked by the cooldown → returns false so the caller still surfaces it.
expect(
await expect(
reloaded.recoverFromStaleBuild("/frontend_latest/app.def67890.js", root)
).toBe(false);
).resolves.toBe(false);
expect(reloadMarker()).toBe(firstMarker);
});
});