mirror of
https://github.com/home-assistant/frontend.git
synced 2026-09-08 12:35:29 +00:00
Compare commits
9
Commits
20260826.3
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
18f79dfc91 | ||
|
|
5861c7487c | ||
|
|
9dbfa821d3 | ||
|
|
1821e8fd67 | ||
|
|
c8a3aa1d9a | ||
|
|
6c606a4968 | ||
|
|
f59a4b4996 | ||
|
|
4c08607dfe | ||
|
|
f082816d9c |
+1
-1
@@ -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"
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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 },
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
<% } %>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -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"),
|
||||
|
||||
+1
-1
@@ -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">
|
||||
|
||||
+1
-1
@@ -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">
|
||||
|
||||
+1
-1
@@ -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">
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
`;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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"],
|
||||
|
||||
Vendored
+372
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user