Compare commits

..
Author SHA1 Message Date
Petar PetrovandSimon Lamon 898072e3d8 Keep the frame minimum when a gap marker shares the frame
The chart data modules push a null y value to break the line where an
entity was unavailable. downSampleLineData read it with Number(), and
Number(null) is 0, which is not NaN, so the isNaN guard did not fire.
The marker then competed as a real value of 0 and won its frame's
minimum slot whenever the readings were positive, discarding the
frame's actual minimum and widening the rendered gap.

Keep markers out of the min/max comparisons entirely and hold at most
one per frame in its own slot. It is emitted, after the frame's values,
only when no kept value follows it: a marker followed by a value in its
own frame is a gap that closed within one frame, which is about one
device pixel wide and too narrow to show. That check runs per frame at
emit time, so the per-point path stays as it was. Keeping every marker
instead would blow up the output on series that are mostly null, such
as the climate heating dataset, which went from 823 to 14525 points
before this was bounded.

Skipping markers before the numeric work also makes gapped series
faster: 16% on a series with a few gaps, 27% on one that is mostly
gaps. Both now have benchmark coverage, which the gap path lacked.

Mean mode no longer averages markers in as zero.
2026-08-11 08:48:31 +02:00
90 changed files with 678 additions and 1441 deletions
+1 -31
View File
@@ -17,9 +17,6 @@ const rspackConfigPath = fileURLToPath(
new URL("./rspack.config.cjs", import.meta.url)
);
// Applies everywhere, including the files exempted from the history rule below.
const restrictedSyntax = ["LabeledStatement", "WithStatement"];
export default tseslint.config(
js.configs.recommended,
eslintConfigPrettier,
@@ -114,16 +111,7 @@ export default tseslint.config(
"no-bitwise": "error",
"no-console": "error",
"no-restricted-globals": [2, "event"],
"no-restricted-syntax": [
"error",
...restrictedSyntax,
{
selector:
"CallExpression[callee.property.name=/^(push|replace)State$/]",
message:
"Use navigate(), updateHistoryState() or replaceCurrentUrl() from common/navigate. History entries carry the app's own bookkeeping, which a raw pushState/replaceState drops.",
},
],
"no-restricted-syntax": ["error", "LabeledStatement", "WithStatement"],
"wc/no-self-class": "off",
// import-x rules
@@ -234,24 +222,6 @@ export default tseslint.config(
],
},
},
{
// These own history entries themselves: the navigation helpers, the dialog
// stack, the boot paths that run before the app has any state to keep, and
// the tests that fabricate entries to simulate a document load.
files: [
"src/common/navigate.ts",
"src/dialogs/make-dialog-manager.ts",
"src/state/url-sync-mixin.ts",
"src/panels/config/automation/add-automation-element-dialog.ts",
"src/entrypoints/core.ts",
"src/onboarding/**/*.ts",
"cast/**/*.ts",
"test/**/*.ts",
],
rules: {
"no-restricted-syntax": ["error", ...restrictedSyntax],
},
},
{
files: ["src/util/recorder-worklet.js"],
languageOptions: {
@@ -37,16 +37,6 @@ title: Button
<ha-button size="s"> small </ha-button>
```
### Icons in the `xs` size
Avoid icons in `xs` buttons. At 24px the label carries the meaning on its own, and a
16px glyph next to it adds visual noise without adding information.
Use an icon only when the button needs to be recognized at a glance in a dense layout,
and only when the glyph is a common one users can identify from its silhouette alone,
such as close, add, or settings. A detailed or unfamiliar glyph is unreadable at this
size and should be replaced by the label alone.
### API
This component is based on the webawesome button component.
-13
View File
@@ -56,19 +56,6 @@ export class DemoHaButton extends LitElement {
`
)}
</div>
<div>
${appearances.map(
(appearance) => html`
<ha-button
.appearance=${appearance}
.variant=${variant}
size="xs"
>
${titleCase(`${variant} ${appearance}`)}
</ha-button>
`
)}
</div>
<div>
${appearances.map(
(appearance) => html`
+2 -4
View File
@@ -1,9 +1,8 @@
import type { ReactiveElement } from "lit";
import { getHistoryState, updateHistoryState } from "../navigate";
import { throttle } from "../util/throttle";
const throttleReplaceState = throttle((value) => {
updateHistoryState({ scrollPosition: value });
history.replaceState({ scrollPosition: value }, "");
}, 300);
export function restoreScroll(selector: string) {
@@ -40,8 +39,7 @@ export function restoreScroll(selector: string) {
newDescriptor = {
get(this: ReactiveElement) {
return (
this[`__${String(propertyKey)}`] ||
getHistoryState()?.scrollPosition
this[`__${String(propertyKey)}`] || history.state?.scrollPosition
);
},
set(this: ReactiveElement, value) {
+41 -78
View File
@@ -1,7 +1,6 @@
import { closeAllDialogs } from "../dialogs/make-dialog-manager";
import { fireEvent } from "./dom/fire_event";
import { mainWindow } from "./dom/get_main_window";
import { currentPath } from "./url/current-path";
declare global {
// for fire event
@@ -12,38 +11,12 @@ declare global {
export interface NavigateOptions {
replace?: boolean;
data?: Record<string, unknown>;
data?: any;
}
// max time to wait for dialogs to close before navigating
const DIALOG_WAIT_TIMEOUT = 500;
/**
* State of the current history entry. Always read through this, the app writes
* to the main window and a panel running in an iframe has its own history.
*/
export const getHistoryState = (): any => mainWindow.history.state;
/**
* Merge into the current history entry's state, keeping what is already there.
* Entries carry the app's own bookkeeping (`from`, `root`, dialog state), so
* they must never be replaced wholesale.
*/
export const updateHistoryState = (patch: Record<string, unknown>) => {
mainWindow.history.replaceState(
{ ...mainWindow.history.state, ...patch },
""
);
};
/**
* Rewrite the URL of the current history entry without navigating and without
* touching its state. For query parameter cleanup.
*/
export const replaceCurrentUrl = (url: string) => {
mainWindow.history.replaceState(mainWindow.history.state, "", url);
};
/**
* Stash a destination URL in the current history entry's state. If the page
* is refreshed while a dialog is open, urlSyncMixin will navigate to this URL
@@ -51,7 +24,10 @@ export const replaceCurrentUrl = (url: string) => {
* The current URL is not changed.
*/
export const setRefreshUrl = (path: string) => {
updateHistoryState({ refreshUrl: path });
mainWindow.history.replaceState(
{ ...mainWindow.history.state, refreshUrl: path },
""
);
};
/**
@@ -80,17 +56,6 @@ const ensureDialogsClosed = async (timestamp: number): Promise<boolean> => {
return ensureDialogsClosed(timestamp);
};
const buildHistoryState = (
data: Record<string, unknown> | undefined,
from?: string
) => {
const state = typeof data === "object" ? data : undefined;
if (from === undefined) {
return state ?? null;
}
return { ...state, from };
};
export const navigate = async (path: string, options?: NavigateOptions) => {
const canProceed = await ensureDialogsClosed(Date.now());
if (!canProceed) {
@@ -98,32 +63,37 @@ export const navigate = async (path: string, options?: NavigateOptions) => {
}
const replace = options?.replace || false;
if (__DEMO__ && !path.includes("#")) {
// The demo routes with the hash instead of the pathname. Resolve the
// path like the browser would do for pushState, and keep the query
// parameters in the URL query instead of inside the hash.
const url = new URL(
path,
`${mainWindow.location.origin}${mainWindow.location.hash.substring(1)}`
);
path = `${mainWindow.location.pathname}${url.search}#${url.pathname}`;
}
const { history } = mainWindow;
if (replace) {
// A replaced entry keeps its predecessor, so it keeps `from`.
const { root, from } = history.state ?? {};
const data = root ? { root: true } : options?.data;
history.replaceState(buildHistoryState(data, from), "", path);
} else {
history.pushState(
buildHistoryState(options?.data, currentPath()),
if (__DEMO__) {
if (!path.includes("#")) {
// The demo routes with the hash instead of the pathname. Resolve the
// path like the browser would do for pushState, and keep the query
// parameters in the URL query instead of inside the hash.
const url = new URL(
path,
`${mainWindow.location.origin}${mainWindow.location.hash.substring(1)}`
);
path = `${mainWindow.location.pathname}${url.search}#${url.pathname}`;
}
if (replace) {
mainWindow.history.replaceState(
mainWindow.history.state?.root
? { root: true }
: (options?.data ?? null),
"",
path
);
} else {
mainWindow.history.pushState(options?.data ?? null, "", path);
}
} else if (replace) {
mainWindow.history.replaceState(
mainWindow.history.state?.root ? { root: true } : (options?.data ?? null),
"",
path
);
} else {
mainWindow.history.pushState(options?.data ?? null, "", path);
}
fireEvent(mainWindow, "location-changed", {
replace,
});
@@ -131,17 +101,8 @@ export const navigate = async (path: string, options?: NavigateOptions) => {
};
/**
* Whether the previous history entry is a page this app navigated away from.
* `history.length` cannot answer this: a login redirect goes through
* `location.assign`, which leaves /auth/authorize right behind the requested
* page, and going back there would bounce the user out of the app.
*/
export const canGoBack = (): boolean =>
mainWindow.history.state?.from !== undefined;
/**
* Navigate back to the page we came from, falling back to a path when the
* previous entry is not ours (deep link, login redirect, fresh tab).
* Navigate back in history, with fallback to a default path if no history exists.
* This prevents a user from getting stuck when they navigate directly to a page with no history.
*/
export const goBack = async (fallbackPath?: string): Promise<void> => {
const canProceed = await ensureDialogsClosed(Date.now());
@@ -149,12 +110,14 @@ export const goBack = async (fallbackPath?: string): Promise<void> => {
return;
}
// Read after closing dialogs: their history entries are popped by then, so
// this is the state of the page entry.
if (canGoBack()) {
mainWindow.history.back();
// Check if we have history to go back to
const { history } = mainWindow;
if (history.length > 1) {
history.back();
return;
}
await navigate(fallbackPath || "/", { replace: true });
// No history available, navigate to fallback path
const fallback = fallbackPath || "/";
navigate(fallback, { replace: true });
};
-10
View File
@@ -1,10 +0,0 @@
import { mainWindow } from "../dom/get_main_window";
/**
* The path of the page currently shown by the app. The demo routes with the
* hash instead of the pathname, see navigate().
*/
export const currentPath = (): string =>
__DEMO__
? mainWindow.location.hash.substring(1)
: mainWindow.location.pathname;
+79 -18
View File
@@ -10,12 +10,16 @@ interface MeanFrame {
}
interface MinMaxFrame {
// A frame can hold a gap marker before any value lands in it, so the min/max
// slots below only mean something once this is true.
hasValue: boolean;
minPoint: Point;
minX: number;
minY: number;
maxPoint: Point;
maxX: number;
maxY: number;
gapPoint: Point | undefined;
}
const SECOND = 1000;
@@ -49,6 +53,25 @@ function snapFrameSize(step: number): number {
return snapped;
}
// y is NaN for a frame seeded by a gap marker, which has no value yet.
function newFrame(
point: Point,
x: number,
y: number,
gapPoint: Point | undefined
): MinMaxFrame {
return {
hasValue: gapPoint === undefined,
minPoint: point,
minX: x,
minY: y,
maxPoint: point,
maxX: x,
maxY: y,
gapPoint,
};
}
export function downSampleLineData<
T extends [number, number] | NonNullable<LineSeriesOption["data"]>[number],
>(
@@ -82,7 +105,10 @@ export function downSampleLineData<
const pointData = getPointData(point);
if (!Array.isArray(pointData)) continue;
const x = Number(pointData[0]);
const y = Number(pointData[1]);
const rawY = pointData[1] as number | null;
// Number(null) is 0, which would drag the mean towards zero
if (rawY === null) continue;
const y = Number(rawY);
if (isNaN(x) || isNaN(y)) continue;
const frameIndex = Math.floor(x / step);
@@ -120,21 +146,34 @@ export function downSampleLineData<
const pointData = getPointData(point);
if (!Array.isArray(pointData)) continue;
const x = Number(pointData[0]);
const y = Number(pointData[1]);
if (isNaN(x) || isNaN(y)) continue;
if (isNaN(x)) continue;
const rawY = pointData[1] as number | null;
if (rawY === null) {
// The chart data modules push a null value to break the line where an
// entity was unavailable. Number(null) is 0, so such a marker must stay
// out of the comparisons below, where it would win the minimum slot
// whenever the readings are positive and discard the frame's real
// minimum. One marker per frame is enough to break the line, and keeping
// them all would blow up the output on series that are mostly null. The
// last one wins: where the break lands only depends on which points it
// sits between, not on its own x.
const gapIndex = Math.floor(x / step);
const gapFrame = frames.get(gapIndex);
if (gapFrame) {
gapFrame.gapPoint = point;
} else {
frames.set(gapIndex, newFrame(point, x, NaN, point));
}
continue;
}
const y = Number(rawY);
if (isNaN(y)) continue;
const frameIndex = Math.floor(x / step);
const frame = frames.get(frameIndex);
if (!frame) {
frames.set(frameIndex, {
minPoint: point,
minX: x,
minY: y,
maxPoint: point,
maxX: x,
maxY: y,
});
} else {
frames.set(frameIndex, newFrame(point, x, y, undefined));
} else if (frame.hasValue) {
// Match the original strict-less / strict-greater comparisons so the
// first occurrence wins on ties.
if (y < frame.minY) {
@@ -147,18 +186,40 @@ export function downSampleLineData<
frame.maxX = x;
frame.maxY = y;
}
} else {
// the frame held nothing but a marker so far
frame.hasValue = true;
frame.minPoint = point;
frame.minX = x;
frame.minY = y;
frame.maxPoint = point;
frame.maxX = x;
frame.maxY = y;
}
}
const result: T[] = [];
for (const frame of frames.values()) {
// The order of the data must be preserved so max may be before min
if (frame.minX > frame.maxX) {
result.push(frame.maxPoint as T);
if (frame.hasValue) {
// The order of the data must be preserved so max may be before min
if (frame.minX > frame.maxX) {
result.push(frame.maxPoint as T);
}
result.push(frame.minPoint as T);
if (frame.minX < frame.maxX) {
result.push(frame.maxPoint as T);
}
}
result.push(frame.minPoint as T);
if (frame.minX < frame.maxX) {
result.push(frame.maxPoint as T);
if (frame.gapPoint !== undefined) {
// A marker followed by a value in its own frame is a gap that closed
// within one frame, which is about one device pixel: too narrow to show.
// The kept points are exactly min and max, so comparing against the
// later of the two catches that without any work on the ingest path. A
// marker-only frame compares against its own x and always passes.
const lastValueX = frame.minX > frame.maxX ? frame.minX : frame.maxX;
if (Number(getPointData(frame.gapPoint)[0]) >= lastValueX) {
result.push(frame.gapPoint as T);
}
}
}
-15
View File
@@ -65,21 +65,6 @@ export class HaButton extends Button {
box-shadow: var(--ha-button-box-shadow);
}
:host([size="xs"]) .button {
--wa-form-control-height: var(
--ha-button-height,
var(--button-height, 24px)
);
font-size: var(--ha-font-size-m);
--wa-form-control-padding-inline: var(--ha-space-2);
}
/* A default 24px icon would fill the whole xs button. */
:host([size="xs"]) slot[name="start"]::slotted(*),
:host([size="xs"]) slot[name="end"]::slotted(*) {
--mdc-icon-size: 16px;
}
:host([size="s"]) .button {
--wa-form-control-height: var(
--ha-button-height,
@@ -52,6 +52,7 @@ import {
type TargetType,
} from "../../data/target";
import { showMoreInfoDialog } from "../../dialogs/more-info/show-ha-more-info-dialog";
import { buttonLinkStyle } from "../../resources/styles";
import type { HomeAssistant } from "../../types";
import { brandsUrl } from "../../util/brands-url";
import type { HaDevicePickerDeviceFilterFunc } from "../device/ha-device-picker";
@@ -220,28 +221,30 @@ export class HaTargetPickerItemRow extends LitElement {
? html`
<div slot="end" class="summary">
${
this.expand || !entries.referenced_entities.length
? html`<span class="main">
${this.hass.localize(
"ui.components.target-picker.entities_count",
{
count: entries.referenced_entities.length,
}
)}
</span>`
: html`<ha-button
appearance="filled"
variant="brand"
size="xs"
showEntities &&
!this.expand &&
entries?.referenced_entities.length
? html`<button
class="main link"
@click=${this._openDetails}
>
${this.hass.localize(
"ui.components.target-picker.entities_count",
{
count: entries.referenced_entities.length,
count: entries?.referenced_entities.length,
}
)}
</ha-button>`
</button>`
: showEntities
? html`<span class="main">
${this.hass.localize(
"ui.components.target-picker.entities_count",
{
count: entries?.referenced_entities.length,
}
)}
</span>`
: nothing
}
</div>
`
@@ -809,6 +812,7 @@ export class HaTargetPickerItemRow extends LitElement {
};
static styles = [
buttonLinkStyle,
css`
:host {
--md-list-item-top-space: 0;
@@ -879,6 +883,16 @@ export class HaTargetPickerItemRow extends LitElement {
color: var(--secondary-text-color);
}
button.link {
text-decoration: none;
color: var(--primary-color);
}
button.link:hover,
button.link:focus {
text-decoration: underline;
}
.state {
width: fit-content;
font-size: var(--ha-font-size-s);
+6 -4
View File
@@ -146,13 +146,15 @@ export const updateDeviceRegistryEntry = (
...updates,
});
export const removeDeviceFromRegistry = (
export const removeConfigEntryFromDevice = (
hass: HomeAssistant,
deviceId: string
deviceId: string,
configEntryId: string
) =>
hass.callWS<null>({
type: "config/device_registry/remove",
hass.callWS<DeviceRegistryEntry>({
type: "config/device_registry/remove_config_entry",
device_id: deviceId,
config_entry_id: configEntryId,
});
export const sortDeviceRegistryByName = (
+12 -23
View File
@@ -98,31 +98,20 @@ export const showDialog = async (
return false;
}
LOADED[dialogTag] = {
element: dialogImport().then(
() => {
const dialogEl = document.createElement(dialogTag) as
HassDialogNext | HassDialog;
element: dialogImport().then(() => {
const dialogEl = document.createElement(dialogTag) as
HassDialogNext | HassDialog;
if ("showDialog" in dialogEl) {
// provide hass for legacy persistent dialogs
element.provideHass(dialogEl);
}
dialogEl.addEventListener("dialog-closed", _handleClosed);
dialogEl.addEventListener("dialog-closed", _handleClosedFocus);
return dialogEl;
},
(err) => {
// Don't cache a rejected import (e.g. a stale build's chunk 404s
// while the app stayed open): drop the entry so a later open
// re-imports instead of being permanently stuck on the rejected
// promise. The rejection still propagates to the global stale-build
// handler (logging-mixin) for recovery.
delete LOADED[dialogTag];
throw err;
if ("showDialog" in dialogEl) {
// provide hass for legacy persistent dialogs
element.provideHass(dialogEl);
}
),
dialogEl.addEventListener("dialog-closed", _handleClosed);
dialogEl.addEventListener("dialog-closed", _handleClosedFocus);
return dialogEl;
}),
};
}
+10 -14
View File
@@ -40,11 +40,7 @@ import {
getEntityEntryContext,
} from "../../common/entity/context/get_entity_context";
import { shouldHandleRequestSelectedEvent } from "../../common/mwc/handle-request-selected-event";
import {
getHistoryState,
navigate,
updateHistoryState,
} from "../../common/navigate";
import { navigate } from "../../common/navigate";
import type { LocalizeKeys } from "../../common/translations/localize";
import { computeRTL } from "../../common/util/compute_rtl";
import { withViewTransition } from "../../common/util/view-transition";
@@ -272,12 +268,16 @@ export class MoreInfoDialog extends DirtyStateProviderMixin<
}
private _setView(view: MoreInfoView) {
updateHistoryState({
dialogParams: {
...getHistoryState()?.dialogParams,
view,
history.replaceState(
{
...history.state,
dialogParams: {
...history.state?.dialogParams,
view,
},
},
});
""
);
this._currView = view;
}
@@ -1063,10 +1063,6 @@ export class MoreInfoDialog extends DirtyStateProviderMixin<
outline: none;
flex: 1;
overflow: auto;
/* Keep the content width constant when the scrollbar toggles;
otherwise width-dependent content can flicker at the overflow
threshold (#53228). */
scrollbar-gutter: stable;
}
.content-wrapper.settings-view .fade-bottom {
+9 -26
View File
@@ -191,10 +191,6 @@ interface EMOutgoingMessageFocusElement extends EMMessage {
};
}
interface EMOutgoingMessageReloadAndClearCache extends EMMessage {
type: "frontend/reload_and_clear_cache";
}
// These types are handled internally by the Android app via postMessage.
// They are not sent by the frontend and should not be used directly.
// They are intentionally listed here to prevent anyone from using them unintentionally.
@@ -224,7 +220,6 @@ type EMOutgoingMessageWithoutAnswer =
| EMOutgoingMessageImprovConfigureDevice
| EMOutgoingMessageAddEntityTo
| EMOutgoingMessageFocusElement
| EMOutgoingMessageReloadAndClearCache
| EMOutgoingMessageAssistSettings;
export interface EMIncomingMessageRestart {
@@ -516,26 +511,14 @@ export class ExternalMessaging {
// eslint-disable-next-line no-console
console.log("Sending message to external app", msg);
}
fireExternalBusMessage(msg);
if (window.externalAppV2) {
window.externalAppV2.postMessage(
JSON.stringify({ type: "externalBus", payload: msg })
);
} else if (window.externalApp) {
window.externalApp.externalBus(JSON.stringify(msg));
} else {
window.webkit!.messageHandlers.externalBus.postMessage(msg);
}
}
}
/**
* Post a message to the companion app's external bus without needing an
* `ExternalMessaging` instance (i.e. without `hass`). Returns `false` when no
* external bridge is present, so callers can fall back to browser behavior.
*/
export const fireExternalBusMessage = (msg: EMMessage): boolean => {
if (window.externalAppV2) {
window.externalAppV2.postMessage(
JSON.stringify({ type: CALLBACK_EXTERNAL_BUS, payload: msg })
);
} else if (window.externalApp) {
window.externalApp.externalBus(JSON.stringify(msg));
} else if (window.webkit?.messageHandlers?.externalBus) {
window.webkit.messageHandlers.externalBus.postMessage(msg);
} else {
return false;
}
return true;
};
-30
View File
@@ -1,30 +0,0 @@
import { isNavigationClick } from "../common/dom/is-navigation-click";
import { goBack } from "../common/navigate";
import { sanitizeNavigationPath } from "../common/url/sanitize-navigation-path";
/**
* Shared behavior of the toolbar back arrow. The arrow is a link to the
* declared parent page so it can be opened in a new tab, but a plain click
* returns to the page the user came from instead.
*/
export const handleBackClick = (
ev: MouseEvent,
backPath?: string,
backCallback?: () => void
): void => {
const path = sanitizeNavigationPath(backPath);
// Ctrl, cmd and shift click open the parent in a new tab or window: let
// the anchor handle those. A plain click is handled here instead, and
// isNavigationClick calls preventDefault so the anchor stays inert.
if (path && !isNavigationClick(ev)) {
return;
}
if (backCallback) {
backCallback();
return;
}
goBack(path);
};
+3 -26
View File
@@ -1,11 +1,10 @@
import type { CSSResultGroup, TemplateResult } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { css, html, LitElement } from "lit";
import { customElement, property } from "lit/decorators";
import { getHistoryState, goBack } from "../common/navigate";
import { goBack } from "../common/navigate";
import "../components/ha-button";
import "../components/ha-top-app-bar-fixed";
import type { HomeAssistant } from "../types";
import { reloadForUpdate } from "../util/recover-stale-build";
import "../components/ha-alert";
@customElement("hass-error-screen")
@@ -20,9 +19,6 @@ class HassErrorScreen extends LitElement {
@property() public error?: string;
@property({ type: Boolean, attribute: "show-reload" }) public showReload =
false;
protected render(): TemplateResult {
if (!this.toolbar) {
return this._renderContent();
@@ -31,7 +27,7 @@ class HassErrorScreen extends LitElement {
return html`
<ha-top-app-bar-fixed
.narrow=${this.narrow}
.backButton=${!(this.rootnav || getHistoryState()?.root)}
.backButton=${!(this.rootnav || history.state?.root)}
>
${this._renderContent()}
</ha-top-app-bar-fixed>
@@ -43,19 +39,6 @@ class HassErrorScreen extends LitElement {
<div class="content">
<ha-alert alert-type="error">${this.error}</ha-alert>
<slot>
${
this.showReload
? html`
<ha-button
appearance="filled"
size="s"
@click=${this._handleReload}
>
${this.hass?.localize("ui.common.refresh")}
</ha-button>
`
: nothing
}
<ha-button appearance="plain" size="s" @click=${this._handleBack}>
${this.hass?.localize("ui.common.back")}
</ha-button>
@@ -68,12 +51,6 @@ class HassErrorScreen extends LitElement {
goBack();
}
private _handleReload(): void {
// Dirty-aware: reloads when clean, or defers with a toast when an editor
// has unsaved changes.
reloadForUpdate();
}
static get styles(): CSSResultGroup {
return [
css`
+1 -2
View File
@@ -1,7 +1,6 @@
import type { CSSResultGroup, TemplateResult } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import { getHistoryState } from "../common/navigate";
import "../components/animation/ha-fade-in";
import "../components/ha-top-app-bar-fixed";
import "../components/ha-spinner";
@@ -28,7 +27,7 @@ class HassLoadingScreen extends LitElement {
return html`
<ha-top-app-bar-fixed
.narrow=${this.narrow}
.backButton=${!(this.rootnav || getHistoryState()?.root)}
.backButton=${!(this.rootnav || history.state?.root)}
>
${this._renderContent()}
</ha-top-app-bar-fixed>
+3 -15
View File
@@ -5,7 +5,6 @@ import memoizeOne from "memoize-one";
import { navigate } from "../common/navigate";
import { computeRouteTail } from "../common/url/route";
import type { Route } from "../types";
import { recoverFromStaleBuild } from "../util/recover-stale-build";
import { PanelReady } from "./panel-ready";
const extractPage = (path: string, defaultPage: string) => {
@@ -183,21 +182,10 @@ export class HassRouterPage extends ReactiveElement {
this._showLoadingScreenTimeout = undefined;
}
// A stale build (the panel's hashed chunk 404s after an upgrade while
// 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);
// Show error screen, offering a reload action for a stale build. Set
// `showReload` on the returned element rather than through
// createErrorScreen's signature, so router subclasses that override
// createErrorScreen (e.g. ToolsRouter) can't drop it.
const errorScreen = this.createErrorScreen(
`Error while loading page ${newPage}.`
// Show error screen
this.appendChild(
this.createErrorScreen(`Error while loading page ${newPage}.`)
);
errorScreen.showReload = stale;
this.appendChild(errorScreen);
});
// If we don't show loading screen, just show the panel.
+19 -11
View File
@@ -4,9 +4,8 @@ import { customElement, eventOptions, property } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { restoreScroll } from "../common/decorators/restore-scroll";
import type { HASSDomTargetEvent } from "../common/dom/fire_event";
import { getHistoryState } from "../common/navigate";
import { goBack } from "../common/navigate";
import { sanitizeNavigationPath } from "../common/url/sanitize-navigation-path";
import { handleBackClick } from "./back-navigation";
import "../components/ha-icon-button-arrow-prev";
import "../components/ha-menu-button";
import { haStyleScrollbar } from "../resources/styles";
@@ -38,14 +37,19 @@ class HassSubpage extends LitElement {
<div class="toolbar ${classMap({ narrow: this.narrow })}">
<div class="toolbar-content">
${
this.mainPage || (!backPath && getHistoryState()?.root)
this.mainPage || history.state?.root
? html`<ha-menu-button></ha-menu-button>`
: html`
<ha-icon-button-arrow-prev
.href=${backPath}
@click=${this._backTapped}
></ha-icon-button-arrow-prev>
`
: backPath
? html`
<ha-icon-button-arrow-prev
href=${backPath}
></ha-icon-button-arrow-prev>
`
: html`
<ha-icon-button-arrow-prev
@click=${this._backTapped}
></ha-icon-button-arrow-prev>
`
}
<div class="main-title">
@@ -75,8 +79,12 @@ class HassSubpage extends LitElement {
this._savedScrollPos = (e.target as HTMLDivElement).scrollTop;
}
private _backTapped(ev: MouseEvent): void {
handleBackClick(ev, this.backPath, this.backCallback);
private _backTapped(): void {
if (this.backCallback) {
this.backCallback();
return;
}
goBack();
}
static get styles(): CSSResultGroup {
+19 -11
View File
@@ -14,10 +14,9 @@ import { canShowPage } from "../common/config/can_show_page";
import { restoreScroll } from "../common/decorators/restore-scroll";
import type { HASSDomTargetEvent } from "../common/dom/fire_event";
import { isNavigationClick } from "../common/dom/is-navigation-click";
import { getHistoryState, navigate } from "../common/navigate";
import { goBack, navigate } from "../common/navigate";
import type { LocalizeFunc } from "../common/translations/localize";
import { sanitizeNavigationPath } from "../common/url/sanitize-navigation-path";
import { handleBackClick } from "./back-navigation";
import "../components/ha-icon-button-arrow-prev";
import "../components/ha-menu-button";
import "../components/ha-svg-icon";
@@ -174,14 +173,19 @@ export class HassTabsSubpage extends LitElement {
<slot name="toolbar">
<div class="toolbar-content">
${
this.mainPage || (!backPath && getHistoryState()?.root)
this.mainPage || (!backPath && history.state?.root)
? html`<ha-menu-button></ha-menu-button>`
: html`
<ha-icon-button-arrow-prev
.href=${backPath}
@click=${this._backTapped}
></ha-icon-button-arrow-prev>
`
: backPath
? html`
<ha-icon-button-arrow-prev
.href=${backPath}
></ha-icon-button-arrow-prev>
`
: html`
<ha-icon-button-arrow-prev
@click=${this._backTapped}
></ha-icon-button-arrow-prev>
`
}
${
this._narrow || !this.showTabs
@@ -242,8 +246,12 @@ export class HassTabsSubpage extends LitElement {
this._content.focus({ preventScroll: true });
}
private _backTapped(ev: MouseEvent): void {
handleBackClick(ev, this.backPath, this.backCallback);
private _backTapped(): void {
if (this.backCallback) {
this.backCallback();
return;
}
goBack();
}
private _isActiveTabPath(tabPath: string, currentPath: string): boolean {
+4 -3
View File
@@ -19,7 +19,6 @@ import type { HomeAssistant, Route } from "../types";
import { storeState } from "../util/ha-pref-storage";
import { renderLaunchScreenContent } from "../util/launch-screen";
import { checkOnboardingSurveyToast } from "../util/onboarding-survey";
import { reloadForUpdate } from "../util/recover-stale-build";
import {
registerServiceWorker,
supportsServiceWorker,
@@ -248,11 +247,13 @@ export class HomeAssistantAppEl extends QuickBarMixin(HassElement) {
if (registration) {
registration.update();
} else if (oldVersion) {
reloadForUpdate();
// @ts-ignore Firefox supports forceGet
location.reload(true);
}
});
} else if (oldVersion) {
reloadForUpdate();
// @ts-ignore Firefox supports forceGet
location.reload(true);
}
}
}
@@ -644,7 +644,6 @@ class HaConfigAreaPage extends LitElement {
<hass-subpage
.hass=${this.hass}
.narrow=${this.narrow}
back-path="/config/areas/dashboard"
.header=${html`${
area.icon
? html`<ha-icon
@@ -903,7 +902,7 @@ class HaConfigAreaPage extends LitElement {
destructive: true,
confirm: async () => {
await deleteAreaRegistryEntry(this.hass!, area!.area_id);
afterNextRender(() => goBack("/config/areas/dashboard"));
afterNextRender(() => goBack("/config"));
},
});
}
@@ -86,6 +86,8 @@ export class HaConfigAreasDashboard extends LitElement {
@state() private _hierarchy?: AreasFloorHierarchy;
private _searchParms = new URLSearchParams(window.location.search);
private _blockHierarchyUpdate = false;
private _blockHierarchyUpdateTimeout?: number;
@@ -166,7 +168,9 @@ export class HaConfigAreasDashboard extends LitElement {
<hass-tabs-subpage
.hass=${this.hass}
.isWide=${this.isWide}
back-path="/config"
.backPath=${
this._searchParms.has("historyBack") ? undefined : "/config"
}
.tabs=${configSections.areas}
.route=${this.route}
has-fab
@@ -986,7 +986,7 @@ export class HaAutomationEditor extends AutomationScriptEditorMixin<AutomationCo
private async _delete() {
if (this.automationId) {
await deleteAutomation(this.hass, this.automationId);
goBack(this.dashboardPath);
goBack("/config");
}
}
@@ -443,7 +443,9 @@ class HaAutomationPicker extends SubscribeMixin(LitElement) {
<hass-tabs-subpage-data-table
.hass=${this.hass}
.narrow=${this.narrow}
back-path="/config"
.backPath=${
this._searchParms.has("historyBack") ? undefined : "/config"
}
id="entity_id"
.route=${this.route}
.tabs=${configSections.automations}
@@ -147,10 +147,6 @@ export const AutomationScriptEditorMixin = <TConfig extends BaseEditorConfig>(
protected domainHooks!: EditorDomainHooks<TConfig>;
protected get dashboardPath(): string {
return `/config/${this.domainHooks.domain}/dashboard`;
}
protected entityRegCreated?: (
value: PromiseLike<EntityRegistryEntry> | EntityRegistryEntry
) => void;
@@ -256,7 +252,7 @@ export const AutomationScriptEditorMixin = <TConfig extends BaseEditorConfig>(
protected backTapped = async () => {
const result = await this.confirmUnsavedChanged();
if (result) {
afterNextRender(() => goBack(this.dashboardPath));
afterNextRender(() => goBack("/config"));
}
};
@@ -304,7 +300,7 @@ export const AutomationScriptEditorMixin = <TConfig extends BaseEditorConfig>(
),
text: html`<pre>${alertText}</pre>`,
});
goBack(this.dashboardPath);
goBack("/config");
return;
}
const entity = this.entityRegistry?.find(
@@ -321,7 +317,7 @@ export const AutomationScriptEditorMixin = <TConfig extends BaseEditorConfig>(
`ui.panel.config.${domain}.editor.load_error_not_editable`
),
});
goBack(this.dashboardPath);
goBack("/config");
}
}
}
@@ -14,7 +14,7 @@ import { css, html, LitElement, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { isComponentLoaded } from "../../../common/config/is_component_loaded";
import { fireEvent } from "../../../common/dom/fire_event";
import { navigate, replaceCurrentUrl } from "../../../common/navigate";
import { navigate } from "../../../common/navigate";
import { computeRTL } from "../../../common/util/compute_rtl";
import "../../../components/ha-button";
import "../../../components/ha-dropdown";
@@ -110,7 +110,6 @@ export class HaAutomationTrace extends LitElement {
<hass-subpage
.hass=${this.hass}
.narrow=${this.narrow}
back-path="/config/automation/dashboard"
.header=${title}
.scrollable=${this.narrow}
>
@@ -453,7 +452,11 @@ export class HaAutomationTrace extends LitElement {
if (runId) {
const params = new URLSearchParams(location.search);
params.delete("run_id");
replaceCurrentUrl(`${location.pathname}?${params.toString()}`);
history.replaceState(
null,
"",
`${location.pathname}?${params.toString()}`
);
}
await showAlertDialog(this, {
@@ -11,7 +11,6 @@ import { property, query, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { storage } from "../../../common/decorators/storage";
import { fireEvent } from "../../../common/dom/fire_event";
import { replaceCurrentUrl } from "../../../common/navigate";
import { constructUrlCurrentPath } from "../../../common/url/construct-url";
import {
extractSearchParam,
@@ -174,7 +173,11 @@ export const ManualEditorMixin = <TConfig>(
}
protected clearParam(param: string) {
replaceCurrentUrl(constructUrlCurrentPath(removeSearchParam(param)));
window.history.replaceState(
null,
"",
constructUrlCurrentPath(removeSearchParam(param))
);
}
protected async openSidebar(ev: CustomEvent<SidebarConfig>) {
@@ -74,6 +74,8 @@ class HaConfigBackupOverview extends LitElement {
@state() private _config?: BackupConfig;
private _searchParms = new URLSearchParams(window.location.search);
protected willUpdate(changedProperties: PropertyValues<this>): void {
super.willUpdate(changedProperties);
if (changedProperties.has("config") && !this._config) {
@@ -204,7 +206,9 @@ class HaConfigBackupOverview extends LitElement {
return html`
<hass-subpage
back-path="/config/system"
.backPath=${
this._searchParms.has("historyBack") ? undefined : "/config/system"
}
.hass=${this.hass}
.narrow=${this.narrow}
.header=${this.hass.localize("ui.panel.config.backup.overview.header")}
@@ -4,7 +4,6 @@ import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { isComponentLoaded } from "../../../common/config/is_component_loaded";
import { fireEvent } from "../../../common/dom/fire_event";
import { replaceCurrentUrl } from "../../../common/navigate";
import { debounce } from "../../../common/util/debounce";
import { nextRender } from "../../../common/util/render-status";
import "../../../components/ha-alert";
@@ -119,7 +118,7 @@ class HaConfigBackupSettings extends LitElement {
}
private _clearHash() {
replaceCurrentUrl(window.location.pathname);
history.replaceState(null, "", window.location.pathname);
}
protected render() {
@@ -21,7 +21,6 @@ export class CloudForgotPassword extends LitElement {
<hass-subpage
.hass=${this.hass}
.narrow=${this.narrow}
back-path="/config"
.header=${this.hass.localize(
"ui.panel.config.cloud.forgot_password.title"
)}
@@ -45,7 +45,6 @@ export class CloudLoginPanel extends LitElement {
<hass-subpage
.hass=${this.hass}
.narrow=${this.narrow}
back-path="/config"
header="Home Assistant Cloud"
>
<ha-dropdown slot="toolbar-icon" @wa-select=${this._handleMenuAction}>
@@ -38,7 +38,6 @@ export class CloudRegister extends LitElement {
<hass-subpage
.hass=${this.hass}
.narrow=${this.narrow}
back-path="/config"
.header=${this.hass.localize("ui.panel.config.cloud.register.title")}
>
<div class="content">
@@ -66,6 +66,8 @@ class HaConfigSectionUpdates extends LitElement {
@property({ type: Boolean }) public narrow = false;
@state() private _searchParms = new URLSearchParams(window.location.search);
@state() private _showSkipped = false;
@state() private _supervisorInfo?: HassioSupervisorInfo;
@@ -153,7 +155,9 @@ class HaConfigSectionUpdates extends LitElement {
return html`
<hass-subpage
back-path="/config/system"
.backPath=${
this._searchParms.has("historyBack") ? undefined : "/config/system"
}
.hass=${this.hass}
.narrow=${this.narrow}
.header=${this.hass.localize("ui.panel.config.updates.caption")}
@@ -16,7 +16,6 @@ import {
mdiRobot,
mdiScriptText,
mdiShapeOutline,
mdiTextureBox,
mdiTools,
} from "@mdi/js";
import type { HassEntity } from "home-assistant-js-websocket";
@@ -45,7 +44,6 @@ import "../../../components/ha-button";
import "../../../components/ha-dropdown";
import type { HaDropdownSelectEvent } from "../../../components/ha-dropdown";
import "../../../components/ha-dropdown-item";
import "../../../components/ha-icon";
import "../../../components/ha-icon-button";
import "../../../components/ha-icon-next";
import "../../../components/item/ha-list-item-base";
@@ -67,7 +65,7 @@ import {
import { fireRelatedContext, fullEntitiesContext } from "../../../data/context";
import type { DeviceRegistryEntry } from "../../../data/device/device_registry";
import {
removeDeviceFromRegistry,
removeConfigEntryFromDevice,
updateDeviceRegistryEntry,
} from "../../../data/device/device_registry";
import type { DiagnosticInfo } from "../../../data/diagnostics";
@@ -988,7 +986,6 @@ export class HaConfigDevicePage extends LitElement {
return html`<hass-subpage
.hass=${this.hass}
.narrow=${this.narrow}
back-path="/config/devices/dashboard"
.header=${deviceName}
>
<ha-tooltip for="edit-settings-button" slot="toolbar-icon">
@@ -1038,27 +1035,12 @@ export class HaConfigDevicePage extends LitElement {
${
area
? html`<div class="header-name">
<ha-button
href="/config/areas/area/${area.area_id}"
size="s"
appearance="plain"
>
${
area.icon
? html`<ha-icon
slot="start"
.icon=${area.icon}
></ha-icon>`
: html`<ha-svg-icon
slot="start"
.path=${mdiTextureBox}
></ha-svg-icon>`
}
${this.hass.localize(
<a href="/config/areas/area/${area.area_id}"
>${this.hass.localize(
"ui.panel.config.integrations.config_entry.area",
{ area: area.name || "Unnamed Area" }
)}
</ha-button>
)}</a
>
</div>`
: ""
}
@@ -1236,7 +1218,11 @@ export class HaConfigDevicePage extends LitElement {
}
try {
await removeDeviceFromRegistry(this.hass, this.deviceId);
await removeConfigEntryFromDevice(
this.hass,
this.deviceId,
entry.entry_id
);
} catch (err: unknown) {
showAlertDialog(this, {
title: this.hass.localize(
@@ -1761,14 +1747,12 @@ export class HaConfigDevicePage extends LitElement {
.header-name {
display: flex;
align-items: center;
padding-left: var(--ha-space-2);
padding-inline-start: var(--ha-space-2);
padding-inline-end: initial;
direction: var(--direction);
}
.header-name ha-icon,
.header-name ha-svg-icon {
--mdc-icon-size: 18px;
}
.column,
.fullwidth {
box-sizing: border-box;
@@ -23,11 +23,7 @@ import {
PROTOCOL_INTEGRATIONS,
protocolIntegrationPicked,
} from "../../../common/integrations/protocolIntegrationPicked";
import {
getHistoryState,
navigate,
updateHistoryState,
} from "../../../common/navigate";
import { navigate } from "../../../common/navigate";
import type { LocalizeFunc } from "../../../common/translations/localize";
import {
hasRejectedItems,
@@ -69,7 +65,7 @@ import type {
DeviceRegistryEntry,
} from "../../../data/device/device_registry";
import {
removeDeviceFromRegistry,
removeConfigEntryFromDevice,
updateDeviceRegistryEntry,
} from "../../../data/device/device_registry";
import type { EntityRegistryEntry } from "../../../data/entity/entity_registry";
@@ -146,7 +142,7 @@ export class HaConfigDeviceDashboard extends LitElement {
state: true,
subscribe: false,
})
private _filter: string = getHistoryState()?.filter || "";
private _filter: string = history.state?.filter || "";
@state()
private _filters: DataTableFilters = {};
@@ -266,7 +262,7 @@ export class HaConfigDeviceDashboard extends LitElement {
}
this._fromUrl = true;
this._filter = getHistoryState()?.filter || "";
this._filter = history.state?.filter || "";
this._filters = {
"ha-filter-states": {
@@ -782,7 +778,9 @@ export class HaConfigDeviceDashboard extends LitElement {
<hass-tabs-subpage-data-table
.hass=${this.hass}
.narrow=${this.narrow}
back-path="/config"
.backPath=${
this._searchParms.has("historyBack") ? undefined : "/config"
}
.tabs=${configSections.devices}
.route=${this.route}
.searchLabel=${this.hass.localize(
@@ -1045,7 +1043,7 @@ export class HaConfigDeviceDashboard extends LitElement {
private _handleSearchChange(ev: CustomEvent) {
this._filter = ev.detail.value;
updateHistoryState({ filter: this._filter });
history.replaceState({ filter: this._filter }, "");
}
private _addDevice() {
@@ -1208,9 +1206,19 @@ ${rejected
dismissText: this.hass.localize("ui.common.cancel"),
destructive: true,
confirm: async () => {
const proms: Promise<null>[] = [];
const proms: Promise<DeviceRegistryEntry>[] = [];
this._selectedCanDelete.forEach((deviceId) => {
proms.push(removeDeviceFromRegistry(this.hass!, deviceId));
const entries = this.hass!.devices[deviceId]?.config_entries;
entries.forEach((entryId) => {
if (
this.entries.find((entry) => entry.entry_id === entryId)
?.supports_remove_device
) {
proms.push(
removeConfigEntryFromDevice(this.hass!, deviceId, entryId)
);
}
});
});
const results = await Promise.allSettled(proms);
if (hasRejectedItems(results)) {
+7 -1
View File
@@ -78,6 +78,8 @@ class HaConfigEnergy extends LitElement {
@property({ attribute: false }) public route!: Route;
@state() private _searchParms = new URLSearchParams(window.location.search);
@state() private _info?: EnergyInfo;
@state() private _preferences?: EnergyPreferences;
@@ -124,7 +126,11 @@ class HaConfigEnergy extends LitElement {
return html`
<hass-tabs-subpage
.hass=${this.hass}
back-path="/config/lovelace/dashboards"
.backPath=${
this._searchParms.has("historyBack")
? undefined
: "/config/lovelace/dashboards"
}
.route=${this.route}
.tabs=${TABS}
>
@@ -39,7 +39,6 @@ import {
PROTOCOL_INTEGRATIONS,
protocolIntegrationPicked,
} from "../../../common/integrations/protocolIntegrationPicked";
import { getHistoryState, updateHistoryState } from "../../../common/navigate";
import { slugify } from "../../../common/string/slugify";
import type { LocalizeFunc } from "../../../common/translations/localize";
import {
@@ -193,7 +192,7 @@ export class HaConfigEntities extends LitElement {
state: true,
subscribe: false,
})
private _filter: string = getHistoryState()?.filter || "";
private _filter: string = history.state?.filter || "";
@state() private _searchParms = new URLSearchParams(window.location.search);
@@ -811,7 +810,9 @@ export class HaConfigEntities extends LitElement {
<hass-tabs-subpage-data-table
.hass=${this.hass}
.narrow=${this.narrow}
back-path="/config"
.backPath=${
this._searchParms.has("historyBack") ? undefined : "/config"
}
.route=${this.route}
.tabs=${configSections.devices}
.columns=${this._columns(this.hass.localize, filteredEntities)}
@@ -1110,7 +1111,7 @@ export class HaConfigEntities extends LitElement {
}
this._fromUrl = true;
this._filter = getHistoryState()?.filter || "";
this._filter = history.state?.filter || "";
this._filters = {
"ha-filter-states": [],
@@ -1245,7 +1246,7 @@ export class HaConfigEntities extends LitElement {
private _handleSearchChange(ev: CustomEvent) {
this._filter = ev.detail.value;
updateHistoryState({ filter: this._filter });
history.replaceState({ filter: this._filter }, "");
}
private _handleSelectionChanged(
@@ -24,7 +24,7 @@ import { storage } from "../../../common/decorators/storage";
import type { HASSDomEvent } from "../../../common/dom/fire_event";
import { computeAreaName } from "../../../common/entity/compute_area_name";
import { computeStateDomain } from "../../../common/entity/compute_state_domain";
import { getHistoryState, navigate } from "../../../common/navigate";
import { navigate } from "../../../common/navigate";
import type {
LocalizeFunc,
LocalizeKeys,
@@ -644,7 +644,9 @@ export class HaConfigHelpers extends SubscribeMixin(LitElement) {
<hass-tabs-subpage-data-table
.hass=${this.hass}
.narrow=${this.narrow}
back-path="/config"
.backPath=${
this._searchParms.has("historyBack") ? undefined : "/config"
}
.route=${this.route}
.tabs=${configSections.devices}
.searchLabel=${this.hass.localize(
@@ -1007,7 +1009,7 @@ export class HaConfigHelpers extends SubscribeMixin(LitElement) {
}
this._fromUrl = true;
this._filter = getHistoryState()?.filter || "";
this._filter = history.state?.filter || "";
this._filters = {
"ha-filter-floor-areas": area ? { areas: [area] } : undefined,
@@ -22,7 +22,7 @@ import {
type DisableConfigEntryResult,
} from "../../../data/config_entries";
import {
removeDeviceFromRegistry,
removeConfigEntryFromDevice,
updateDeviceRegistryEntry,
type DeviceRegistryEntry,
} from "../../../data/device/device_registry";
@@ -315,6 +315,7 @@ class HaConfigEntryDeviceRow extends LitElement {
};
private _handleDeleteDevice = async () => {
const entry = this.entry;
const confirmed = await showConfirmationDialog(this, {
text: this.hass.localize("ui.panel.config.devices.confirm_delete"),
confirmText: this.hass.localize("ui.common.delete"),
@@ -327,7 +328,11 @@ class HaConfigEntryDeviceRow extends LitElement {
}
try {
await removeDeviceFromRegistry(this.hass!, this.device.id);
await removeConfigEntryFromDevice(
this.hass!,
this.device.id,
entry.entry_id
);
} catch (err: any) {
showAlertDialog(this, {
title: this.hass.localize("ui.panel.config.devices.error_delete"),
@@ -373,11 +373,7 @@ class HaConfigIntegrationPage extends SubscribeMixin(LitElement) {
: this._manifest?.documentation;
return html`
<hass-subpage
.hass=${this.hass}
.narrow=${this.narrow}
back-path="/config/integrations/dashboard"
>
<hass-subpage .hass=${this.hass} .narrow=${this.narrow}>
${
documentationLink
? html`
@@ -905,7 +901,7 @@ class HaConfigIntegrationPage extends SubscribeMixin(LitElement) {
);
if (row) {
row.scrollIntoView({
block: "start",
block: "center",
});
row.classList.add("highlight");
}
@@ -1543,7 +1539,6 @@ class HaConfigIntegrationPage extends SubscribeMixin(LitElement) {
ha-config-entry-row {
display: block;
margin-bottom: 16px;
scroll-margin-top: var(--ha-space-10);
}
a {
text-decoration: none;
@@ -14,11 +14,7 @@ import {
PROTOCOL_INTEGRATIONS,
protocolIntegrationPicked,
} from "../../../common/integrations/protocolIntegrationPicked";
import {
getHistoryState,
navigate,
updateHistoryState,
} from "../../../common/navigate";
import { navigate } from "../../../common/navigate";
import { caseInsensitiveStringCompare } from "../../../common/string/compare";
import { extractSearchParam } from "../../../common/url/search-params";
import { nextRender } from "../../../common/util/render-status";
@@ -167,7 +163,9 @@ class HaConfigIntegrationsDashboard extends KeyboardShortcutMixin(
window.location.hash.substring(1)
);
@state() private _filter: string = getHistoryState()?.filter || "";
@state() private _searchParams = new URLSearchParams(window.location.search);
@state() private _filter: string = history.state?.filter || "";
@state() private _logInfos?: Record<string, IntegrationLogInfo>;
@@ -528,7 +526,9 @@ class HaConfigIntegrationsDashboard extends KeyboardShortcutMixin(
return html`
<hass-tabs-subpage
.hass=${this.hass}
back-path="/config"
.backPath=${
this._searchParams.has("historyBack") ? undefined : "/config"
}
.route=${this.route}
.tabs=${configSections.devices}
has-fab
@@ -885,7 +885,7 @@ class HaConfigIntegrationsDashboard extends KeyboardShortcutMixin(
private _handleSearchChange(ev: InputEvent) {
this._filter = (ev.target as HaInputSearch).value ?? "";
updateHistoryState({ filter: this._filter });
history.replaceState({ filter: this._filter }, "");
}
private async _highlightEntry() {
@@ -249,7 +249,6 @@ export class BluetoothAdvertisementMonitorPanel extends LitElement {
const entry = this._data.find((ent) => ent.address === ev.detail.id);
showBluetoothDeviceInfoDialog(this, {
entry: entry!,
deviceId: this._sourceDevices[ev.detail.id]?.id,
});
}
@@ -2,7 +2,6 @@ import type { CSSResultGroup, TemplateResult } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { fireEvent } from "../../../../../common/dom/fire_event";
import { navigate } from "../../../../../common/navigate";
import { copyToClipboard } from "../../../../../common/util/copy-clipboard";
import "../../../../../components/ha-button";
import "../../../../../components/ha-dialog-footer";
@@ -51,14 +50,6 @@ class DialogBluetoothDeviceInfo extends LitElement {
});
}
private _openDevice(): void {
if (!this._params?.deviceId) {
return;
}
navigate(`/config/devices/device/${this._params.deviceId}`);
this.closeDialog();
}
protected render(): TemplateResult | typeof nothing {
if (!this._params) {
return nothing;
@@ -146,17 +137,6 @@ class DialogBluetoothDeviceInfo extends LitElement {
: nothing
}
<ha-dialog-footer slot="footer">
${
this._params.deviceId
? html`
<ha-button slot="primaryAction" @click=${this._openDevice}>
${this.hass.localize(
"ui.panel.config.bluetooth.open_device"
)}
</ha-button>
`
: nothing
}
<ha-button
slot="secondaryAction"
appearance="plain"
@@ -3,7 +3,6 @@ import type { BluetoothDeviceData } from "../../../../../data/bluetooth";
export interface BluetoothDeviceInfoDialogParams {
entry: BluetoothDeviceData;
deviceId?: string;
}
export const loadBluetoothDeviceInfoDialog = () =>
@@ -95,7 +95,6 @@ export class DHCPConfigPanel extends SubscribeMixin(LitElement) {
.hass=${this.hass}
.narrow=${this.narrow}
.route=${this.route}
back-path="/config/integrations/integration/dhcp"
.columns=${this._columns(this.hass.localize)}
.data=${this._dataWithIds(this._data)}
.noDataText=${this.hass.localize(
@@ -61,11 +61,7 @@ export class MQTTConfigPanel extends LitElement {
protected render(): TemplateResult {
return html`
<hass-subpage
.narrow=${this.narrow}
.hass=${this.hass}
back-path="/config/integrations/integration/mqtt"
>
<hass-subpage .narrow=${this.narrow} .hass=${this.hass}>
<div class="content">
<ha-card
.header=${this.hass.localize("ui.panel.config.mqtt.settings_title")}
@@ -99,7 +99,6 @@ export class SSDPConfigPanel extends SubscribeMixin(LitElement) {
.hass=${this.hass}
.narrow=${this.narrow}
.route=${this.route}
back-path="/config/integrations/integration/ssdp"
.columns=${this._columns(this.hass.localize)}
.initialGroupColumn=${this._activeGrouping}
.initialCollapsedGroups=${this._activeCollapsed}
@@ -106,7 +106,6 @@ export class ZeroconfConfigPanel extends SubscribeMixin(LitElement) {
.hass=${this.hass}
.narrow=${this.narrow}
.route=${this.route}
back-path="/config/integrations/integration/zeroconf"
.columns=${this._columns(this.hass.localize)}
.initialGroupColumn=${this._activeGrouping}
.initialCollapsedGroups=${this._activeCollapsed}
@@ -76,7 +76,6 @@ class ZHAAddDevicesPage extends LitElement {
<hass-subpage
.hass=${this.hass}
.narrow=${this.narrow}
back-path="/config/zha/dashboard"
.header=${this.hass.localize("ui.panel.config.zha.add_device")}
>
<ha-button
@@ -10,7 +10,7 @@ import { css, html, LitElement } from "lit";
import { customElement, property, state } from "lit/decorators";
import { cache } from "lit/directives/cache";
import memoizeOne from "memoize-one";
import { navigate } from "../../../../../common/navigate";
import { goBack, navigate } from "../../../../../common/navigate";
import "../../../../../components/ha-spinner";
import { narrowViewportContext } from "../../../../../data/context";
import type { ZHADevice } from "../../../../../data/zha";
@@ -102,7 +102,7 @@ class ZHADevicePage extends LitElement {
.hass=${this.hass}
.narrow=${this._narrow}
.header=${header}
.backPath=${this._backPath}
.backCallback=${this._goBack}
>
<div class="loading">
<ha-spinner size="large"></ha-spinner>
@@ -130,7 +130,7 @@ class ZHADevicePage extends LitElement {
.hass=${this.hass}
.route=${this.route}
.tabs=${tabNavigation}
.backPath=${this._backPath}
.backCallback=${this._goBack}
>
<div class="container">
<zha-device-summary-card
@@ -253,11 +253,13 @@ class ZHADevicePage extends LitElement {
return ["clusters", "bindings", "signature", "neighbors"].includes(tab);
}
private get _backPath(): string {
return this._device
? `/config/devices/device/${this._device.device_reg_id}`
: "/config/zha/dashboard";
}
private _goBack = (): void => {
goBack(
this._device
? `/config/devices/device/${this._device.device_reg_id}`
: "/config/zha/dashboard"
);
};
private _getTabs = memoizeOne((device: ZHADevice | undefined) => {
const tabs: ZHADevicePageTab[] = ["clusters", "bindings", "signature"];
@@ -55,7 +55,6 @@ export class ZHANetworkVisualizationPage extends LitElement {
<hass-subpage
.hass=${this.hass}
.narrow=${this.narrow}
back-path="/config/zha/dashboard"
.header=${this.hass.localize(
"ui.panel.config.zha.visualization.header"
)}
@@ -640,7 +640,7 @@ class ZWaveJSConfigDashboard extends SubscribeMixin(LitElement) {
}
private _handleBack(): void {
goBack("/config/integrations/integration/zwave_js");
goBack("/config");
}
private _fetchData = async () => {
@@ -34,12 +34,7 @@ class ZWaveJSConfigEntryPicker extends LitElement {
if (this._configEntries.length === 0) {
return html`
<hass-subpage
header="Z-Wave"
.narrow=${this.narrow}
.hass=${this.hass}
back-path="/config/integrations/integration/zwave_js"
>
<hass-subpage header="Z-Wave" .narrow=${this.narrow} .hass=${this.hass}>
<div class="content">
<ha-card>
<div class="card-content">
@@ -56,12 +51,7 @@ class ZWaveJSConfigEntryPicker extends LitElement {
}
return html`
<hass-subpage
header="Z-Wave"
.narrow=${this.narrow}
.hass=${this.hass}
back-path="/config/integrations/integration/zwave_js"
>
<hass-subpage header="Z-Wave" .narrow=${this.narrow} .hass=${this.hass}>
<div class="content">
<ha-card
.header=${this.hass.localize(
+1 -6
View File
@@ -19,7 +19,6 @@ import {
subscribeLabFeatures,
} from "../../../data/labs";
import { showConfirmationDialog } from "../../../dialogs/generic/show-dialog-box";
import "../../../layouts/hass-loading-screen";
import "../../../layouts/hass-subpage";
import { SubscribeMixin } from "../../../mixins/subscribe-mixin";
import { haStyle } from "../../../resources/styles";
@@ -39,7 +38,7 @@ class HaConfigLabs extends SubscribeMixin(LitElement) {
@property({ type: Boolean }) public narrow = false;
@state() private _preview_features?: LabPreviewFeature[];
@state() private _preview_features: LabPreviewFeature[] = [];
@state() private _highlightedPreviewFeature?: string;
@@ -99,10 +98,6 @@ class HaConfigLabs extends SubscribeMixin(LitElement) {
}
protected render() {
if (this._preview_features === undefined) {
return html`<hass-loading-screen></hass-loading-screen>`;
}
const sortedFeatures = this._sortedPreviewFeatures(
this.hass.localize,
this._preview_features
@@ -51,8 +51,6 @@ export class HaConfigLovelaceResources extends LitElement {
@state() private _resources: LovelaceResource[] = [];
@state() private _loaded = false;
@state() private _lovelaceInfo?: LovelaceInfo;
@state()
@@ -136,7 +134,7 @@ export class HaConfigLovelaceResources extends LitElement {
);
protected render(): TemplateResult {
if (!this.hass || !this._loaded) {
if (!this.hass || this._resources === undefined) {
return html` <hass-loading-screen></hass-loading-screen> `;
}
@@ -177,7 +175,6 @@ export class HaConfigLovelaceResources extends LitElement {
.hass=${this.hass}
.narrow=${this.narrow}
.route=${this.route}
back-path="/config/lovelace/dashboards"
.tabs=${lovelaceResourcesTabs}
.columns=${this._columns(this.hass.language, this.hass.localize)}
.data=${this._resources}
@@ -231,7 +228,6 @@ export class HaConfigLovelaceResources extends LitElement {
]);
this._resources = resources;
this._lovelaceInfo = lovelaceInfo;
this._loaded = true;
}
private _editResource(ev: CustomEvent) {
@@ -17,7 +17,6 @@ import {
subscribeRepairsIssueRegistry,
} from "../../../data/repairs";
import "../../../layouts/hass-subpage";
import "../../../layouts/hass-loading-screen";
import { SubscribeMixin } from "../../../mixins/subscribe-mixin";
import type { HomeAssistant } from "../../../types";
import "./ha-config-repairs";
@@ -33,7 +32,7 @@ class HaConfigRepairsDashboard extends SubscribeMixin(LitElement) {
@state() private _repairsIssues: RepairsIssue[] = [];
@state() private _loaded = false;
@state() private _searchParms = new URLSearchParams(window.location.search);
@state() private _showIgnored = false;
@@ -61,7 +60,6 @@ class HaConfigRepairsDashboard extends SubscribeMixin(LitElement) {
this._repairsIssues = repairs.issues.sort(
(a, b) => severitySort[a.severity] - severitySort[b.severity]
);
this._loaded = true;
const integrations = new Set<string>();
for (const issue of this._repairsIssues) {
integrations.add(issue.domain);
@@ -72,10 +70,6 @@ class HaConfigRepairsDashboard extends SubscribeMixin(LitElement) {
}
protected render(): TemplateResult {
if (!this._loaded) {
return html`<hass-loading-screen></hass-loading-screen>`;
}
const issues = this._getFilteredIssues(
this._showIgnored,
this._repairsIssues
@@ -83,7 +77,9 @@ class HaConfigRepairsDashboard extends SubscribeMixin(LitElement) {
return html`
<hass-subpage
back-path="/config/system"
.backPath=${
this._searchParms.has("historyBack") ? undefined : "/config/system"
}
.hass=${this.hass}
.narrow=${this.narrow}
.header=${this.hass.localize("ui.panel.config.repairs.caption")}
@@ -459,7 +459,9 @@ class HaSceneDashboard extends SubscribeMixin(LitElement) {
<hass-tabs-subpage-data-table
.hass=${this.hass}
.narrow=${this.narrow}
back-path="/config"
.backPath=${
this._searchParms.has("historyBack") ? undefined : "/config"
}
.route=${this.route}
.tabs=${configSections.automations}
.searchLabel=${this.hass.localize(
+3 -3
View File
@@ -939,7 +939,7 @@ export class HaSceneEditor extends DirtyStateProviderMixin<number>()(
{ err_no: err.status_code }
),
});
goBack("/config/scene/dashboard");
goBack("/config");
return;
}
@@ -1084,7 +1084,7 @@ export class HaSceneEditor extends DirtyStateProviderMixin<number>()(
if (this._mode === "live") {
applyScene(this.hass, this._storedStates);
}
afterNextRender(() => goBack("/config/scene/dashboard"));
afterNextRender(() => goBack("/config"));
}
private _deleteTapped(): void {
@@ -1111,7 +1111,7 @@ export class HaSceneEditor extends DirtyStateProviderMixin<number>()(
if (this._mode === "live") {
applyScene(this.hass, this._storedStates);
}
goBack("/config/scene/dashboard");
goBack("/config");
}
private async _confirmUnsavedChanged(): Promise<boolean> {
+1 -1
View File
@@ -896,7 +896,7 @@ export class HaScriptEditor extends SubscribeMixin(
private async _delete() {
await deleteScript(this.hass, this.scriptId!);
goBack(this.dashboardPath);
goBack("/config");
}
private async _promptScriptAlias(): Promise<boolean> {
+3 -1
View File
@@ -430,7 +430,9 @@ class HaScriptPicker extends SubscribeMixin(LitElement) {
<hass-tabs-subpage-data-table
.hass=${this.hass}
.narrow=${this.narrow}
back-path="/config"
.backPath=${
this._searchParms.has("historyBack") ? undefined : "/config"
}
.route=${this.route}
.tabs=${configSections.automations}
.searchLabel=${this.hass.localize(
+6 -3
View File
@@ -14,7 +14,7 @@ import { css, html, LitElement, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { isComponentLoaded } from "../../../common/config/is_component_loaded";
import { fireEvent } from "../../../common/dom/fire_event";
import { navigate, replaceCurrentUrl } from "../../../common/navigate";
import { navigate } from "../../../common/navigate";
import "../../../components/ha-button";
import "../../../components/ha-dropdown";
import type { HaDropdownSelectEvent } from "../../../components/ha-dropdown";
@@ -106,7 +106,6 @@ export class HaScriptTrace extends LitElement {
<hass-subpage
.hass=${this.hass}
.narrow=${this.narrow}
back-path="/config/script/dashboard"
.header=${title}
.scrollable=${this.narrow}
>
@@ -434,7 +433,11 @@ export class HaScriptTrace extends LitElement {
if (runId) {
const params = new URLSearchParams(location.search);
params.delete("run_id");
replaceCurrentUrl(`${location.pathname}?${params.toString()}`);
history.replaceState(
null,
"",
`${location.pathname}?${params.toString()}`
);
}
await showAlertDialog(this, {
@@ -16,7 +16,6 @@ import {
listAssistPipelines,
} from "../../../../data/assist_pipeline";
import "../../../../layouts/hass-subpage";
import "../../../../layouts/hass-loading-screen";
import type { HomeAssistant } from "../../../../types";
interface AssistDeviceExtra extends AssistDevice {
@@ -125,15 +124,10 @@ class AssistDevicesPage extends LitElement {
}
render() {
if (!this._devices) {
return html`<hass-loading-screen></hass-loading-screen>`;
}
return html`
<hass-subpage
.hass=${this.hass}
.narrow=${this.narrow}
back-path="/config/voice-assistants/assistants"
.header=${this.hass.localize(
"ui.panel.config.voice_assistants.assistants.pipeline.devices.title"
)}
@@ -149,7 +143,7 @@ class AssistDevicesPage extends LitElement {
this.hass.states,
this._pipelines,
this._preferred,
this._devices
this._devices || []
)}
auto-height
@row-click=${this._handleRowClicked}
@@ -51,7 +51,6 @@ export class AssistPipelineDebug extends LitElement {
return html`<hass-subpage
.narrow=${this.narrow}
.hass=${this.hass}
back-path="/config/voice-assistants/debug"
.header=${this.hass.localize(
"ui.panel.config.voice_assistants.debug.header"
)}
@@ -60,7 +60,6 @@ export class AssistPipelineRunDebug extends LitElement {
<hass-subpage
.narrow=${this.narrow}
.hass=${this.hass}
back-path="/config/voice-assistants/assistants"
.header=${this.hass.localize(
"ui.panel.config.voice_assistants.debug.pipeline.header"
)}
@@ -29,6 +29,8 @@ export class HaConfigVoiceAssistantsAssistants extends LitElement {
@property({ attribute: false }) public route!: Route;
private _searchParms = new URLSearchParams(window.location.search);
protected render() {
if (!this.hass) {
return html`<hass-loading-screen></hass-loading-screen>`;
@@ -37,7 +39,9 @@ export class HaConfigVoiceAssistantsAssistants extends LitElement {
return html`
<hass-tabs-subpage
.hass=${this.hass}
back-path="/config"
.backPath=${
this._searchParms.has("historyBack") ? undefined : "/config"
}
.route=${this.route}
.tabs=${voiceAssistantTabs}
>
@@ -492,7 +492,9 @@ export class VoiceAssistantsExpose extends LitElement {
<hass-tabs-subpage-data-table
.hass=${this.hass}
.narrow=${this.narrow}
back-path="/config"
.backPath=${
this._searchParms.has("historyBack") ? undefined : "/config"
}
.route=${this.route}
.tabs=${voiceAssistantTabs}
.columns=${this._columns(
+5 -1
View File
@@ -57,6 +57,8 @@ export class HaConfigZone extends SubscribeMixin(LitElement) {
@property({ attribute: false }) public route!: Route;
@state() private _searchParms = new URLSearchParams(window.location.search);
@state() private _storageItems?: Zone[];
@state() private _stateItems?: HassEntity[];
@@ -246,7 +248,9 @@ export class HaConfigZone extends SubscribeMixin(LitElement) {
<hass-tabs-subpage
.hass=${this.hass}
.route=${this.route}
back-path="/config"
.backPath=${
this._searchParms.has("historyBack") ? undefined : "/config"
}
.tabs=${configSections.areas}
has-fab
>
@@ -1,96 +0,0 @@
import { mdiCreation } from "@mdi/js";
import type { HassEntity } from "home-assistant-js-websocket";
import type { PropertyValues } from "lit";
import { customElement } from "lit/decorators";
import { computeDomain } from "../../../common/entity/compute_domain";
import { supportsFeature } from "../../../common/entity/supports-feature";
import type { LightEntity } from "../../../data/light";
import { LightEntityFeature } from "../../../data/light";
import type { HomeAssistant } from "../../../types";
import type { LovelaceCardFeature, LovelaceCardFeatureEditor } from "../types";
import { hasConfigChanged } from "../common/has-changed";
import { HuiModeSelectCardFeatureBase } from "./hui-mode-select-card-feature-base";
import type {
LightEffectCardFeatureConfig,
LovelaceCardFeatureContext,
} from "./types";
const supportsLightEffectCardFeatureFromState = (stateObj: HassEntity) => {
const domain = computeDomain(stateObj.entity_id);
return (
domain === "light" &&
supportsFeature(stateObj, LightEntityFeature.EFFECT) &&
!!stateObj.attributes.effect_list?.length
);
};
export const supportsLightEffectCardFeature = (
hass: HomeAssistant,
context: LovelaceCardFeatureContext
) => {
const stateObj = context.entity_id
? hass.states[context.entity_id]
: undefined;
if (!stateObj) return false;
return supportsLightEffectCardFeatureFromState(stateObj);
};
@customElement("hui-light-effect-card-feature")
class HuiLightEffectCardFeature
extends HuiModeSelectCardFeatureBase<
LightEntity,
LightEffectCardFeatureConfig
>
implements LovelaceCardFeature
{
protected readonly _attribute = "effect";
protected readonly _modesAttribute = "effect_list";
protected get _configuredModes() {
const effects = this._config?.effects;
return effects?.length ? effects : undefined;
}
protected readonly _dropdownIconPath = mdiCreation;
protected readonly _allowIconsStyle = false;
protected readonly _hideLabel = false;
protected readonly _serviceDomain = "light";
protected readonly _serviceAction = "turn_on";
static getStubConfig(): LightEffectCardFeatureConfig {
return {
type: "light-effect",
};
}
public static async getConfigElement(): Promise<LovelaceCardFeatureEditor> {
await import("../editor/config-elements/hui-light-effect-card-feature-editor");
return document.createElement("hui-light-effect-card-feature-editor");
}
protected shouldUpdate(changedProps: PropertyValues): boolean {
return (
changedProps.has("_currentValue") ||
changedProps.has("context") ||
changedProps.has("_stateObj") ||
hasConfigChanged(this, changedProps)
);
}
protected _isSupported(): boolean {
return !!(
this._stateObj && supportsLightEffectCardFeatureFromState(this._stateObj)
);
}
}
declare global {
interface HTMLElementTagNameMap {
"hui-light-effect-card-feature": HuiLightEffectCardFeature;
}
}
@@ -26,7 +26,6 @@ import { supportsLawnMowerCommandCardFeature } from "./hui-lawn-mower-commands-c
import { supportsLightBrightnessCardFeature } from "./hui-light-brightness-card-feature";
import { supportsLightColorFavoritesCardFeature } from "./hui-light-color-favorites-card-feature";
import { supportsLightColorTempCardFeature } from "./hui-light-color-temp-card-feature";
import { supportsLightEffectCardFeature } from "./hui-light-effect-card-feature";
import { supportsLockCommandsCardFeature } from "./hui-lock-commands-card-feature";
import { supportsLockOpenDoorCardFeature } from "./hui-lock-open-door-card-feature";
import { supportsMediaPlayerPlaybackCardFeature } from "./hui-media-player-playback-card-feature";
@@ -89,7 +88,6 @@ export const UI_FEATURE_TYPES = [
"light-brightness",
"light-color-temp",
"light-color-favorites",
"light-effect",
"lock-commands",
"lock-open-door",
"media-player-playback",
@@ -145,7 +143,6 @@ export const SUPPORTS_FEATURE_TYPES: Record<UiFeatureType, SupportsFeature> = {
"light-brightness": supportsLightBrightnessCardFeature,
"light-color-temp": supportsLightColorTempCardFeature,
"light-color-favorites": supportsLightColorFavoritesCardFeature,
"light-effect": supportsLightEffectCardFeature,
"lock-commands": supportsLockCommandsCardFeature,
"lock-open-door": supportsLockOpenDoorCardFeature,
"media-player-playback": supportsMediaPlayerPlaybackCardFeature,
@@ -47,11 +47,6 @@ export interface LightColorFavoritesCardFeatureConfig {
type: "light-color-favorites";
}
export interface LightEffectCardFeatureConfig {
type: "light-effect";
effects?: string[];
}
export interface LockCommandsCardFeatureConfig {
type: "lock-commands";
}
@@ -346,7 +341,6 @@ export type LovelaceCardFeatureConfig =
| LightBrightnessCardFeatureConfig
| LightColorTempCardFeatureConfig
| LightColorFavoritesCardFeatureConfig
| LightEffectCardFeatureConfig
| LockCommandsCardFeatureConfig
| LockOpenDoorCardFeatureConfig
| MediaPlayerPlaybackCardFeatureConfig
@@ -28,7 +28,6 @@ const DOMAIN_VARIANTS: Record<string, TileVariant[]> = {
TILE_TOGGLE_VARIANT,
["light-color-temp"],
["light-color-favorites"],
["light-effect"],
],
cover: [
TILE_VARIANT,
@@ -23,7 +23,6 @@ import "../card-features/hui-lawn-mower-commands-card-feature";
import "../card-features/hui-light-brightness-card-feature";
import "../card-features/hui-light-color-temp-card-feature";
import "../card-features/hui-light-color-favorites-card-feature";
import "../card-features/hui-light-effect-card-feature";
import "../card-features/hui-lock-commands-card-feature";
import "../card-features/hui-lock-open-door-card-feature";
import "../card-features/hui-media-player-playback-card-feature";
@@ -83,7 +82,6 @@ const TYPES = new Set<LovelaceCardFeatureConfig["type"]>([
"light-brightness",
"light-color-temp",
"light-color-favorites",
"light-effect",
"lock-commands",
"lock-open-door",
"media-player-playback",
@@ -52,7 +52,6 @@ import { supportsHumidifierToggleCardFeature } from "../../card-features/hui-hum
import { supportsLawnMowerCommandCardFeature } from "../../card-features/hui-lawn-mower-commands-card-feature";
import { supportsLightBrightnessCardFeature } from "../../card-features/hui-light-brightness-card-feature";
import { supportsLightColorTempCardFeature } from "../../card-features/hui-light-color-temp-card-feature";
import { supportsLightEffectCardFeature } from "../../card-features/hui-light-effect-card-feature";
import { supportsLockCommandsCardFeature } from "../../card-features/hui-lock-commands-card-feature";
import { supportsLockOpenDoorCardFeature } from "../../card-features/hui-lock-open-door-card-feature";
import { supportsMediaPlayerPlaybackCardFeature } from "../../card-features/hui-media-player-playback-card-feature";
@@ -115,7 +114,6 @@ const UI_FEATURE_TYPES = [
"light-brightness",
"light-color-temp",
"light-color-favorites",
"light-effect",
"lock-commands",
"lock-open-door",
"media-player-playback",
@@ -162,7 +160,6 @@ const EDITABLES_FEATURE_TYPES = new Set<UiFeatureTypes>([
"lawn-mower-commands",
"media-player-playback",
"light-color-favorites",
"light-effect",
"media-player-sound-mode",
"media-player-source",
"media-player-volume-buttons",
@@ -209,7 +206,6 @@ const SUPPORTS_FEATURE_TYPES: Record<
"light-brightness": supportsLightBrightnessCardFeature,
"light-color-temp": supportsLightColorTempCardFeature,
"light-color-favorites": supportsLightColorFavoritesCardFeature,
"light-effect": supportsLightEffectCardFeature,
"lock-commands": supportsLockCommandsCardFeature,
"lock-open-door": supportsLockOpenDoorCardFeature,
"media-player-playback": supportsMediaPlayerPlaybackCardFeature,
@@ -1,100 +0,0 @@
import { html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { fireEvent } from "../../../../common/dom/fire_event";
import "../../../../components/ha-form/ha-form";
import type { SchemaUnion } from "../../../../components/ha-form/types";
import type { LightEntity } from "../../../../data/light";
import type { HomeAssistant, ValueChangedEvent } from "../../../../types";
import type {
LightEffectCardFeatureConfig,
LovelaceCardFeatureContext,
} from "../../card-features/types";
import type { LovelaceCardFeatureEditor } from "../../types";
import {
customizableListData,
customizableListSchema,
processCustomizableListValue,
} from "./customizable-list-feature";
@customElement("hui-light-effect-card-feature-editor")
export class HuiLightEffectCardFeatureEditor
extends LitElement
implements LovelaceCardFeatureEditor
{
@property({ attribute: false }) public hass?: HomeAssistant;
@property({ attribute: false }) public context?: LovelaceCardFeatureContext;
@state() private _config?: LightEffectCardFeatureConfig;
public setConfig(config: LightEffectCardFeatureConfig): void {
this._config = config;
}
private _schema = memoizeOne((stateObj: LightEntity | undefined) =>
customizableListSchema({
field: "effects",
options:
stateObj?.attributes.effect_list?.map((effect) => ({
value: effect,
label: this.hass!.formatEntityAttributeValue(
stateObj,
"effect",
effect
),
})) ?? [],
})
);
protected render() {
if (!this.hass || !this._config) {
return nothing;
}
const stateObj = this.context?.entity_id
? (this.hass.states[this.context.entity_id] as LightEntity | undefined)
: undefined;
const data = customizableListData(this._config, "effects");
const schema = this._schema(stateObj);
return html`
<ha-form
.hass=${this.hass}
.data=${data}
.schema=${schema}
.computeLabel=${this._computeLabelCallback}
@value-changed=${this._valueChanged}
></ha-form>
`;
}
private _valueChanged(
ev: ValueChangedEvent<LightEffectCardFeatureConfig>
): void {
const stateObj = this.context?.entity_id
? (this.hass!.states[this.context.entity_id] as LightEntity | undefined)
: undefined;
const defaults = stateObj?.attributes.effect_list ?? [];
const config = processCustomizableListValue<LightEffectCardFeatureConfig>(
ev.detail.value,
"effects",
defaults
);
fireEvent(this, "config-changed", { config });
}
private _computeLabelCallback = (
schema: SchemaUnion<ReturnType<typeof this._schema>>
) =>
this.hass!.localize(
`ui.panel.lovelace.editor.features.types.light-effect.${schema.name}`
);
}
declare global {
interface HTMLElementTagNameMap {
"hui-light-effect-card-feature-editor": HuiLightEffectCardFeatureEditor;
}
}
+4 -2
View File
@@ -4,7 +4,7 @@ import type { PropertyValues, TemplateResult } from "lit";
import { html, LitElement } from "lit";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { navigate, replaceCurrentUrl } from "../../common/navigate";
import { navigate } from "../../common/navigate";
import type { LocalizeFunc } from "../../common/translations/localize";
import { constructUrlCurrentPath } from "../../common/url/construct-url";
import {
@@ -509,7 +509,9 @@ export class LovelacePanel extends LitElement {
};
if ("editMode" in props) {
replaceCurrentUrl(
window.history.replaceState(
null,
"",
constructUrlCurrentPath(
props.editMode
? addSearchParam({ edit: "1" })
+34 -25
View File
@@ -27,7 +27,8 @@ import memoizeOne from "memoize-one";
import { isComponentLoaded } from "../../common/config/is_component_loaded";
import { UndoRedoController } from "../../common/controllers/undo-redo-controller";
import { fireEvent } from "../../common/dom/fire_event";
import { goBack, navigate, replaceCurrentUrl } from "../../common/navigate";
import { isNavigationClick } from "../../common/dom/is-navigation-click";
import { goBack, navigate } from "../../common/navigate";
import type { LocalizeKeys } from "../../common/translations/localize";
import { constructUrlCurrentPath } from "../../common/url/construct-url";
import { sanitizeNavigationPath } from "../../common/url/sanitize-navigation-path";
@@ -75,7 +76,6 @@ import { showMoreInfoDialog } from "../../dialogs/more-info/show-ha-more-info-di
import { showQuickBar } from "../../dialogs/quick-bar/show-dialog-quick-bar";
import { showVoiceCommandDialog } from "../../dialogs/voice-command-dialog/show-ha-voice-command-dialog";
import { haStyle } from "../../resources/styles";
import { handleBackClick } from "../../layouts/back-navigation";
import { ChildPanelReady } from "../../layouts/panel-ready";
import type { HomeAssistant, PanelInfo } from "../../types";
import { documentationUrl } from "../../util/documentation-url";
@@ -677,7 +677,11 @@ class HUIRoot extends LitElement {
);
private _clearParam(param: string) {
replaceCurrentUrl(constructUrlCurrentPath(removeSearchParam(param)));
window.history.replaceState(
null,
"",
constructUrlCurrentPath(removeSearchParam(param))
);
}
protected firstUpdated(changedProps: PropertyValues<this>) {
@@ -889,39 +893,44 @@ class HUIRoot extends LitElement {
};
private _goBack(): void {
const configuredBackPath = this._configuredBackPath;
if (configuredBackPath) {
navigate(configuredBackPath, { replace: true });
return;
}
const views = this.lovelace?.config.views ?? [];
// Falling back to the dashboard root only makes sense when its first view
// is a real one.
goBack(views[0]?.subview ? undefined : this.route?.prefix);
const curViewConfig =
typeof this._curView === "number" ? views[this._curView] : undefined;
const backPath = sanitizeNavigationPath(
curViewConfig?.back_path ?? this.backPath
);
if (backPath) {
navigate(backPath, { replace: true });
} else if (history.length > 1) {
goBack();
} else if (!views[0].subview) {
navigate(this.route!.prefix, { replace: true });
} else {
navigate("/");
}
}
private _handleBackClick(ev: MouseEvent): void {
handleBackClick(ev, this._backPath, () => this._goBack());
}
private get _configuredBackPath(): string | undefined {
const views = this.lovelace?.config.views ?? [];
const curViewConfig =
typeof this._curView === "number" ? views[this._curView] : undefined;
return sanitizeNavigationPath(curViewConfig?.back_path ?? this.backPath);
if (this._backPath && !isNavigationClick(ev)) {
return;
}
this._goBack();
}
private get _backPath(): string | undefined {
if (this._configuredBackPath) {
return this._configuredBackPath;
}
const views = this.lovelace?.config.views ?? [];
const curViewConfig =
typeof this._curView === "number" ? views[this._curView] : undefined;
const backPath = sanitizeNavigationPath(
curViewConfig?.back_path ?? this.backPath
);
if (backPath) {
return backPath;
}
return curViewConfig?.subview ? this.route!.prefix : undefined;
}
@@ -9,7 +9,6 @@ import { repeat } from "lit/directives/repeat";
import { styleMap } from "lit/directives/style-map";
import memoizeOne from "memoize-one";
import { clamp } from "../../../common/number/clamp";
import { getHistoryState, updateHistoryState } from "../../../common/navigate";
import "../../../components/ha-icon-button";
import "../../../components/ha-ripple";
import "../../../components/ha-sortable";
@@ -138,7 +137,7 @@ export class SectionsView extends LitElement implements LovelaceViewElement {
"section-visibility-changed",
this._sectionVisibilityChanged
);
this._sidebarTabActive = Boolean(getHistoryState()?.sidebar);
this._sidebarTabActive = Boolean(window.history.state?.sidebar);
}
disconnectedCallback(): void {
@@ -510,7 +509,10 @@ export class SectionsView extends LitElement implements LovelaceViewElement {
this._sidebarTabActive = !this._sidebarTabActive;
// Add sidebar state to history
updateHistoryState({ sidebar: this._sidebarTabActive });
window.history.replaceState(
{ ...window.history.state, sidebar: this._sidebarTabActive },
""
);
// Restore scroll position after view updates
this.updateComplete.then(() => {
@@ -4,7 +4,6 @@ import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { isComponentLoaded } from "../../common/config/is_component_loaded";
import { fireEvent } from "../../common/dom/fire_event";
import { replaceCurrentUrl } from "../../common/navigate";
import { nextRender } from "../../common/util/render-status";
import "../../components/ha-button";
import "../../components/ha-card";
@@ -91,7 +90,7 @@ class HaProfileSectionGeneral extends LitElement {
}
private _clearHash() {
replaceCurrentUrl(window.location.pathname);
history.replaceState(null, "", window.location.pathname);
}
protected render(): TemplateResult {
+1 -4
View File
@@ -209,6 +209,7 @@ class HaRefreshTokens extends LitElement {
<ha-button
variant="danger"
appearance="filled"
size="s"
@click=${this._deleteAllTokens}
>
${this.hass.localize(
@@ -351,10 +352,6 @@ class HaRefreshTokens extends LitElement {
border-radius: var(--ha-border-radius-circle);
margin-right: 6px;
}
.card-actions {
display: flex;
justify-content: flex-end;
}
`,
];
}
+2 -1
View File
@@ -379,7 +379,8 @@ export const connectionMixin = <T extends Constructor<HassBaseEl>>(
// @ts-ignore
this.hass!.callWS({ type: "get_config" }).then((config: HassConfig) => {
if (config.safe_mode) {
location.reload();
// @ts-ignore Firefox supports forceGet
location.reload(true);
}
this._updateHass({ config });
this.checkDataBaseMigration();
-39
View File
@@ -2,7 +2,6 @@ import type { PropertyValues } from "lit";
import type { HASSDomEvent } from "../common/dom/fire_event";
import type { SystemLogLevel } from "../data/system_log";
import type { Constructor } from "../types";
import { recoverFromStaleBuild } from "../util/recover-stale-build";
import type { HassBaseEl } from "./hass-base-mixin";
interface WriteLogParams {
@@ -26,31 +25,7 @@ export const loggingMixin = <T extends Constructor<HassBaseEl>>(
class extends superClass {
protected hassConnected() {
super.hassConnected();
// Resource-load errors (<script>, modulepreload <link>) do not bubble,
// so observe them in the capture phase. A stale build's hashed chunk
// 404 lands here (legacy build / modulepreload); recover instead of
// dead-ending.
window.addEventListener(
"error",
(ev) => {
const target = ev.target as
(HTMLScriptElement & HTMLLinkElement) | null;
if (
target &&
(target.tagName === "SCRIPT" || target.tagName === "LINK")
) {
recoverFromStaleBuild(target.src || target.href, this);
}
},
true
);
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)) {
ev.preventDefault();
return;
}
if (!this.hass?.connected) {
return;
}
@@ -84,20 +59,6 @@ export const loggingMixin = <T extends Constructor<HassBaseEl>>(
}
});
window.addEventListener("unhandledrejection", async (ev) => {
// A failed dynamic import() of a stale build's chunk rejects here
// (dialogs, more-info, cards, config-flow, …); recover rather than
// silently logging it at debug level.
const reason: any = ev.reason;
const reasonMessage =
reason instanceof Error
? reason.message
: typeof reason === "string"
? reason
: "";
if (recoverFromStaleBuild(reasonMessage, this)) {
ev.preventDefault();
return;
}
if (!this.hass?.connected) {
return;
}
+4 -2
View File
@@ -2,7 +2,6 @@
import type { ReactiveElement, PropertyValues } from "lit";
import { fireEvent } from "../common/dom/fire_event";
import { mainWindow } from "../common/dom/get_main_window";
import { updateHistoryState } from "../common/navigate";
import { closeLastDialog } from "../dialogs/make-dialog-manager";
import type { ProvideHassElement } from "../mixins/provide-hass-lit-mixin";
import type { Constructor } from "../types";
@@ -21,7 +20,10 @@ export const urlSyncMixin = <
public connectedCallback(): void {
super.connectedCallback();
if (mainWindow.history.length === 1) {
updateHistoryState({ root: true });
mainWindow.history.replaceState(
{ ...mainWindow.history.state, root: true },
""
);
}
mainWindow.addEventListener("popstate", this._popstateChangeListener);
}
-7
View File
@@ -2543,7 +2543,6 @@
"dismiss": "Dismiss",
"no_matching_link_found": "No matching My link found for {path}",
"new_version_available": "A new version of the frontend is available. This page will update in {seconds, plural, one {# second} other {# seconds}}.",
"new_version_available_reload": "A new version of the frontend is available. It will be applied once you have saved or discarded your changes.",
"update_now": "Update now",
"theme_save_failed": "Unable to save theme settings to your user profile.",
"theme_preferences_unavailable": "Unable to load user profile theme settings.",
@@ -7446,7 +7445,6 @@
"updated": "Updated",
"device": "Device",
"device_information": "Device information",
"open_device": "Open device",
"advertisement_data": "Advertisement data",
"manufacturer_data": "Manufacturer data",
"service_data": "Service data",
@@ -10593,11 +10591,6 @@
"light-color-temp": {
"label": "Light color temperature"
},
"light-effect": {
"label": "Light effect",
"effects": "Effects",
"customize": "Customize effects"
},
"lock-commands": {
"label": "Lock commands"
},
-200
View File
@@ -1,200 +0,0 @@
import { mainWindow } from "../common/dom/get_main_window";
import { fireExternalBusMessage } from "../external_app/external_messaging";
import * as staleBuildPatterns from "./stale-build-patterns.json";
import { showToast } from "./toast";
// Patterns live in a JSON single source (stale-build-patterns.json) so the
// build can inject the exact same ones into the inline boot-time guard
// (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 };
const HASHED_ENTRY = new RegExp(patterns.hashedEntry, "i");
const MODULE_ERROR = new RegExp(patterns.moduleError, "i");
const RELOAD_STORAGE_KEY = "haStaleBuildReload";
const RELOAD_COOLDOWN = 60_000;
// Reuse the service worker update toast id so we never show two competing
// "new version available" toasts (see register-service-worker.ts).
const UPDATE_TOAST_ID = "frontend-update-available";
let reloading = false;
let toastShown = false;
/**
* True when the given string looks like a failed load of a content-hashed
* frontend chunk i.e. a stale build referencing files that no longer exist
* 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 =>
!!urlOrMessage &&
(HASHED_ENTRY.test(urlOrMessage) || MODULE_ERROR.test(urlOrMessage));
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
// cache. Drop the worker and its caches first, then navigate with a
// cache-busting query so a fresh index.html is fetched. (Android's WebView
// has a service worker, so this path recovers it too.)
if ("serviceWorker" in navigator && navigator.serviceWorker.controller) {
try {
const registrations = await navigator.serviceWorker.getRegistrations();
await Promise.all(registrations.map((reg) => reg.unregister()));
} catch (_err) {
// ignore
}
if ("caches" in window) {
try {
const keys = await caches.keys();
await Promise.all(keys.map((key) => caches.delete(key)));
} catch (_err) {
// ignore
}
}
}
const url = new URL(mainWindow.location.href);
url.searchParams.set("ha_cache_bust", String(bust));
mainWindow.location.replace(url.href);
};
/**
* Reload the page onto the current build.
*
* Returns `true` when a reload was actually initiated, `false` when the loop
* 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
* 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.
*/
export const reloadFresh = (): boolean => {
if (reloading) {
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;
}
} 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.
return false;
}
try {
sessionStorage.setItem(
RELOAD_STORAGE_KEY,
JSON.stringify({ n: attempts + 1, t: now })
);
} catch (_err) {
storageOk = false;
}
if (!storageOk) {
// Without a durable cooldown marker (e.g. private mode) we can't stop a
// loop across reloads, so fail closed rather than risk reloading forever.
return false;
}
reloading = true;
// `frontend/reload_and_clear_cache` is a WKWebView (iOS/macOS companion)
// command; the Android bridges (externalApp/externalAppV2) don't handle it.
// Only send it to the WebKit bridge — everything else takes the browser
// path below (Android's WebView has a service worker, so it recovers there).
if (window.webkit?.messageHandlers?.externalBus) {
fireExternalBusMessage({ type: "frontend/reload_and_clear_cache" });
return true;
}
// Fire and forget; the page navigates away once it resolves.
void dropCachesAndReload(now);
return true;
};
const showStaleBuildToast = (rootEl?: HTMLElement): void => {
if (toastShown) {
return;
}
const el =
rootEl ??
(mainWindow.document.querySelector("home-assistant") as HTMLElement | null);
if (!el) {
// Nowhere to anchor the toast yet; a later trigger can retry.
return;
}
toastShown = true;
// This toast is only shown while an editor is dirty, so it deliberately has
// no immediate-reload action that could discard unsaved work: it reloads
// automatically once the user saves or discards.
const reloadWhenClean = () => {
if (!window.isDirtyState) {
window.removeEventListener("dirty-state-changed", reloadWhenClean);
reloadFresh();
}
};
window.addEventListener("dirty-state-changed", reloadWhenClean);
showToast(el, {
id: UPDATE_TOAST_ID,
message: {
translationKey: "ui.notification_toast.new_version_available_reload",
},
duration: -1,
dismissable: false,
});
};
/**
* Reload onto the current build, but never over unsaved work: when an editor
* is dirty, show a non-dismissable toast and defer the reload until the dirty
* state clears. Returns `true` when a reload or the deferral toast was started.
*/
export const reloadForUpdate = (rootEl?: HTMLElement): boolean => {
if (window.isDirtyState) {
showStaleBuildToast(rootEl);
return true;
}
return reloadFresh();
};
/**
* 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).
*
* - 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).
*/
export const recoverFromStaleBuild = (
urlOrMessage: string | undefined,
rootEl?: HTMLElement
): boolean => {
// In dev/demo the entry files are unhashed and rebuild churn can throw
// transient import errors; never auto-reload there.
if (__DEV__ || __DEMO__) {
return false;
}
if (!isStaleBuildError(urlOrMessage)) {
return false;
}
return reloadForUpdate(rootEl);
};
+30
View File
@@ -17,10 +17,24 @@ const generatePoints = (seed: number, count: number): [number, number][] => {
return points;
};
// The chart data modules break the line with a null value. A handful of them
// stands for an entity that went unavailable; a series that is mostly null
// stands for the climate heating dataset, which emits one per inactive state.
const withGaps = (
points: [number, number][],
isGap: (index: number) => boolean
): [number, number | null][] =>
points.map(([x, y], index) => (isGap(index) ? [x, null] : [x, y]));
const small = generatePoints(1, SCALES.small);
const medium = generatePoints(2, SCALES.medium);
const large = generatePoints(3, SCALES.large);
const largeObjects = large.map((value) => ({ value }));
const largeFewGaps = withGaps(large, (index) => index % 20_000 === 0);
const largeMostlyGaps = withGaps(
large,
(index) => Math.floor(index / 50) % 3 !== 0
);
describe("downSampleLineData", () => {
bench("min/max small (1k points)", () => {
@@ -54,4 +68,20 @@ describe("downSampleLineData", () => {
},
{ time: 1000, warmupIterations: 2 }
);
bench(
"min/max large with a few gaps (100k points)",
() => {
downSampleLineData(largeFewGaps, MAX_DETAILS);
},
{ time: 1000, warmupIterations: 2 }
);
bench(
"min/max large mostly gaps (100k points)",
() => {
downSampleLineData(largeMostlyGaps, MAX_DETAILS);
},
{ time: 1000, warmupIterations: 2 }
);
});
-92
View File
@@ -1,92 +0,0 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { NavigateOptions } from "../../src/common/navigate";
import { canGoBack, goBack, navigate } from "../../src/common/navigate";
// navigate() closes open dialogs before touching history.
vi.mock("../../src/dialogs/make-dialog-manager", () => ({
closeAllDialogs: vi.fn(async () => true),
}));
// Fabricates a raw entry like a document load, which navigate() never produces.
const setEntry = (path: string, state: unknown = null) => {
window.history.replaceState(state, "", path);
};
describe("navigate", () => {
beforeEach(() => {
setEntry("/config");
});
it("stamps the path we came from on pushed entries", async () => {
await navigate("/config/devices/dashboard");
expect(window.location.pathname).toEqual("/config/devices/dashboard");
expect(window.history.state).toMatchObject({ from: "/config" });
});
it("keeps caller data alongside the stamp", async () => {
await navigate("/config/areas", { data: { scrollPosition: 42 } });
expect(window.history.state).toMatchObject({
scrollPosition: 42,
from: "/config",
});
});
it("ignores caller data that is not an object", async () => {
await navigate("/config/areas", { data: 42 } as unknown as NavigateOptions);
expect(window.history.state).toEqual({ from: "/config" });
});
it("keeps the stamp when replacing, the predecessor is unchanged", async () => {
await navigate("/config/cloud");
await navigate("/config/cloud/account", { replace: true });
expect(window.location.pathname).toEqual("/config/cloud/account");
expect(window.history.state).toMatchObject({ from: "/config" });
});
it("does not stamp an entry the app did not push", () => {
expect(window.history.state).toBeNull();
expect(canGoBack()).toBe(false);
});
});
describe("goBack", () => {
beforeEach(() => {
setEntry("/config/cloud/remote");
vi.restoreAllMocks();
});
it("goes back when we came from another page in the app", async () => {
const back = vi
.spyOn(window.history, "back")
.mockImplementation(() => undefined);
await navigate("/config/cloud/remote");
await goBack("/config/cloud/account");
expect(back).toHaveBeenCalledOnce();
});
it("falls back to the given path when the previous entry is not ours", async () => {
const back = vi
.spyOn(window.history, "back")
.mockImplementation(() => undefined);
await goBack("/config/cloud/account");
expect(back).not.toHaveBeenCalled();
expect(window.location.pathname).toEqual("/config/cloud/account");
});
it("falls back to the root when no path is given", async () => {
vi.spyOn(window.history, "back").mockImplementation(() => undefined);
await goBack();
expect(window.location.pathname).toEqual("/");
});
});
+204 -6
View File
@@ -19,9 +19,52 @@ const generatePoints = (
return points;
};
const toObjectPoints = (points: [number, number][]) =>
// Gap markers: the chart data modules push a null value to break the line
// where an entity was unavailable.
type GappedPoint = [number, number | null | undefined];
const toObjectPoints = (points: GappedPoint[]) =>
points.map((value) => ({ value }));
const expectXOrdered = (result: { [0]: number }[]) => {
for (let i = 1; i < result.length; i++) {
expect(result[i][0]).toBeGreaterThanOrEqual(result[i - 1][0]);
}
};
// A series whose readings are all positive, with an unavailable stretch that
// starts inside the first frame. Mirrors the point sequence
// state-history-chart-line-data.ts emits for a gap.
const gappedPoints: GappedPoint[] = [
[FIXED_EPOCH_MS, 50],
[FIXED_EPOCH_MS + 1_000, 90], // frame maximum
[FIXED_EPOCH_MS + 2_000, 60],
[FIXED_EPOCH_MS + 3_000, 10], // frame minimum
[FIXED_EPOCH_MS + 4_000, 20], // last reading before the gap
[FIXED_EPOCH_MS + 4_001, null], // gap marker
[FIXED_EPOCH_MS + 30_000, 55],
[FIXED_EPOCH_MS + 31_000, 45],
];
// A generated series with three unavailable stretches of different lengths.
const generateGappedPoints = (seed: number, count: number) => {
const points: GappedPoint[] = generatePoints(seed, count);
for (const [start, length] of [
[Math.floor(count * 0.13), 3],
[Math.floor(count * 0.4), 25],
[Math.floor(count * 0.83), 1],
]) {
const gapStart = points[start][0];
points.splice(
start + 1,
length,
[gapStart + 1, points[start][1]],
[gapStart + 1, null]
);
}
return points;
};
describe("downSampleLineData", () => {
it("returns empty array for undefined data", () => {
expect(downSampleLineData(undefined, 100)).toEqual([]);
@@ -66,11 +109,7 @@ describe("downSampleLineData", () => {
});
it("min/max mode preserves x-order for sorted input", () => {
const points = generatePoints(4, 1000);
const result = downSampleLineData(points, 50);
for (let i = 1; i < result.length; i++) {
expect(result[i][0]).toBeGreaterThanOrEqual(result[i - 1][0]);
}
expectXOrdered(downSampleLineData(generatePoints(4, 1000), 50));
});
it("min/max mode matches characterization snapshot", () => {
@@ -193,6 +232,165 @@ describe("downSampleLineData", () => {
).toMatchSnapshot();
});
it("keeps the frame minimum when a gap marker shares the frame", () => {
// Without special handling the marker becomes y=0, wins the minimum slot
// and the real minimum (10) is dropped.
expect(downSampleLineData(gappedPoints, 3)).toEqual([
[FIXED_EPOCH_MS + 1_000, 90],
[FIXED_EPOCH_MS + 3_000, 10],
[FIXED_EPOCH_MS + 4_001, null],
[FIXED_EPOCH_MS + 30_000, 55],
[FIXED_EPOCH_MS + 31_000, 45],
]);
});
it("drops a marker whose gap closes within the same frame", () => {
// A frame spans about one device pixel, so a gap that opens and closes
// inside one is too narrow to show. The values around it stay.
const points: GappedPoint[] = [
[FIXED_EPOCH_MS, 50],
[FIXED_EPOCH_MS + 1_000, 10], // frame minimum, before the marker
[FIXED_EPOCH_MS + 1_001, null],
[FIXED_EPOCH_MS + 2_000, 90], // frame maximum, after the marker
[FIXED_EPOCH_MS + 3_000, 60],
[FIXED_EPOCH_MS + 30_000, 55],
[FIXED_EPOCH_MS + 31_000, 45],
];
expect(downSampleLineData(points, 3)).toEqual([
[FIXED_EPOCH_MS + 1_000, 10],
[FIXED_EPOCH_MS + 2_000, 90],
[FIXED_EPOCH_MS + 30_000, 55],
[FIXED_EPOCH_MS + 31_000, 45],
]);
});
it("keeps a marker sharing its x with a value after that value", () => {
// statistics-chart-data.ts ends the line and breaks it at the same x
const points: GappedPoint[] = [
[FIXED_EPOCH_MS, 50],
[FIXED_EPOCH_MS + 1_000, 90],
[FIXED_EPOCH_MS + 2_000, 10],
[FIXED_EPOCH_MS + 2_000, null],
[FIXED_EPOCH_MS + 30_000, 55],
[FIXED_EPOCH_MS + 31_000, 45],
];
expect(downSampleLineData(points, 3)).toEqual([
[FIXED_EPOCH_MS + 1_000, 90],
[FIXED_EPOCH_MS + 2_000, 10],
[FIXED_EPOCH_MS + 2_000, null],
[FIXED_EPOCH_MS + 30_000, 55],
[FIXED_EPOCH_MS + 31_000, 45],
]);
});
it("keeps a gap marker whose frame holds no values", () => {
const points: GappedPoint[] = [
[FIXED_EPOCH_MS, 50],
[FIXED_EPOCH_MS + 1_000, 90],
[FIXED_EPOCH_MS + 2_000, 60],
[FIXED_EPOCH_MS + 15_000, null],
[FIXED_EPOCH_MS + 30_000, 55],
[FIXED_EPOCH_MS + 31_000, 45],
];
expect(downSampleLineData(points, 3)).toEqual([
[FIXED_EPOCH_MS, 50],
[FIXED_EPOCH_MS + 1_000, 90],
[FIXED_EPOCH_MS + 15_000, null],
[FIXED_EPOCH_MS + 30_000, 55],
[FIXED_EPOCH_MS + 31_000, 45],
]);
});
it("keeps a single gap marker per frame", () => {
// A run of nulls inside one frame renders the same as a single null: the
// break only depends on which points the marker sits between.
const points: GappedPoint[] = [
[FIXED_EPOCH_MS, 50],
[FIXED_EPOCH_MS + 1_000, 90],
[FIXED_EPOCH_MS + 2_000, 10],
[FIXED_EPOCH_MS + 3_000, null],
[FIXED_EPOCH_MS + 4_000, null],
[FIXED_EPOCH_MS + 5_000, null],
[FIXED_EPOCH_MS + 30_000, 55],
[FIXED_EPOCH_MS + 31_000, 45],
];
expect(downSampleLineData(points, 3)).toEqual([
[FIXED_EPOCH_MS + 1_000, 90],
[FIXED_EPOCH_MS + 2_000, 10],
[FIXED_EPOCH_MS + 5_000, null],
[FIXED_EPOCH_MS + 30_000, 55],
[FIXED_EPOCH_MS + 31_000, 45],
]);
});
it("bounds the output on a series where most points are null", () => {
// The climate heating/cooling datasets push a null for every state where
// the mode is inactive, so markers must not escape the frame budget.
const values = generatePoints(20, SCALES.medium);
const random = createSeededRandom(21);
const gapped: GappedPoint[] = values.map(([x, y]) =>
random() < 0.65 ? [x, null] : [x, y]
);
const gapless = downSampleLineData(values, 500);
const result = downSampleLineData(gapped, 500);
// Both series share an x grid, so they share frames, and the gapless one
// emits at least one point per frame. A gapped frame emits at most three:
// min, max and a single marker.
expect(result.length).toBeLessThanOrEqual(3 * gapless.length);
expect(
result.filter((point) => point[1] === null).length
).toBeLessThanOrEqual(gapless.length);
});
it("handles gap markers on object-shaped points", () => {
const points = toObjectPoints(gappedPoints);
expect(downSampleLineData(points, 3)).toEqual([
points[1],
points[3],
points[5],
points[6],
points[7],
]);
});
it("handles gap markers on Date x values", () => {
// statistics charts use Date objects for x
const points = gappedPoints.map(
([x, y]) => [new Date(x), y] as [Date, number | null | undefined]
);
expect(downSampleLineData(points, 3)).toEqual([
points[1],
points[3],
points[5],
points[6],
points[7],
]);
});
it("mean mode leaves gap markers out of the average", () => {
const points: GappedPoint[] = [
[FIXED_EPOCH_MS, 10],
[FIXED_EPOCH_MS + 1_000, 20],
[FIXED_EPOCH_MS + 1_001, null],
[FIXED_EPOCH_MS + 2_000, 30],
[FIXED_EPOCH_MS + 30_000, 100],
[FIXED_EPOCH_MS + 31_000, 100],
];
expect(downSampleLineData(points, 3, undefined, undefined, true)).toEqual([
// (10 + 20 + 30) / 3, not (10 + 20 + 0 + 30) / 4
[FIXED_EPOCH_MS + 1_000, 20],
[FIXED_EPOCH_MS + 30_500, 100],
]);
});
it("min/max mode preserves x-order for gapped input", () => {
const result = downSampleLineData(generateGappedPoints(22, 1000), 50);
// Of the three gaps only the 25 point one outlasts its frame; the one and
// three point gaps close within theirs and are dropped.
expect(result.filter((point) => point[1] === null)).toHaveLength(1);
expectXOrdered(result);
});
it("large scale mean-mode digest is stable", () => {
expect(
digestResult(
+2 -2
View File
@@ -28,7 +28,7 @@ afterEach(() => {
describe("hass-subpage back path", () => {
it("links to a path on the current origin", async () => {
const backButton = await mount("/config/system");
expect(backButton!.href).toEqual("/config/system");
expect(backButton!.getAttribute("href")).toEqual("/config/system");
});
// eslint-disable-next-line no-script-url
@@ -36,7 +36,7 @@ describe("hass-subpage back path", () => {
"does not link to %s",
async (backPath) => {
const backButton = await mount(backPath);
expect(backButton!.href).toBeUndefined();
expect(backButton!.hasAttribute("href")).toBe(false);
}
);
});
@@ -1,81 +0,0 @@
import { describe, expect, it } from "vitest";
import type { HassEntity } from "home-assistant-js-websocket";
import { supportsLightEffectCardFeature } from "../../../../src/panels/lovelace/card-features/hui-light-effect-card-feature";
import { LightEntityFeature } from "../../../../src/data/light";
import type { HomeAssistant } from "../../../../src/types";
const entity = (
entityId: string,
attributes: HassEntity["attributes"] = {}
): HassEntity =>
({
entity_id: entityId,
state: "on",
attributes,
last_changed: "",
last_updated: "",
context: { id: "", parent_id: null, user_id: null },
}) as HassEntity;
const hassWith = (...entities: HassEntity[]): HomeAssistant =>
({
states: Object.fromEntries(entities.map((e) => [e.entity_id, e])),
}) as unknown as HomeAssistant;
describe("supportsLightEffectCardFeature", () => {
it("supports a light entity with EFFECT and a populated effect_list", () => {
const stateObj = entity("light.test", {
supported_features: LightEntityFeature.EFFECT,
effect_list: ["candle", "fire"],
});
const hass = hassWith(stateObj);
expect(
supportsLightEffectCardFeature(hass, { entity_id: stateObj.entity_id })
).toBe(true);
});
it("does not support a light entity with EFFECT but an empty effect_list", () => {
const stateObj = entity("light.test", {
supported_features: LightEntityFeature.EFFECT,
effect_list: [],
});
const hass = hassWith(stateObj);
expect(
supportsLightEffectCardFeature(hass, { entity_id: stateObj.entity_id })
).toBe(false);
});
it("does not support a light entity with EFFECT but no effect_list", () => {
const stateObj = entity("light.test", {
supported_features: LightEntityFeature.EFFECT,
});
const hass = hassWith(stateObj);
expect(
supportsLightEffectCardFeature(hass, { entity_id: stateObj.entity_id })
).toBe(false);
});
it("does not support a light entity without the EFFECT feature flag", () => {
const stateObj = entity("light.test", {
supported_features: 0,
effect_list: ["candle", "fire"],
});
const hass = hassWith(stateObj);
expect(
supportsLightEffectCardFeature(hass, { entity_id: stateObj.entity_id })
).toBe(false);
});
it("does not support other domains", () => {
const stateObj = entity("switch.test");
const hass = hassWith(stateObj);
expect(
supportsLightEffectCardFeature(hass, { entity_id: stateObj.entity_id })
).toBe(false);
});
it("does not support a context with no entity_id", () => {
const hass = hassWith();
expect(supportsLightEffectCardFeature(hass, {})).toBe(false);
});
});
-220
View File
@@ -1,220 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { ShowToastParams } from "../../src/managers/notification-manager";
import type {
isStaleBuildError,
recoverFromStaleBuild,
} from "../../src/util/recover-stale-build";
interface RecoverModule {
isStaleBuildError: typeof isStaleBuildError;
recoverFromStaleBuild: typeof recoverFromStaleBuild;
}
const STALE_URL = "/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).
const RELOAD_KEY = "haStaleBuildReload";
describe("recover-stale-build", () => {
let mod: RecoverModule;
let root: HTMLElement;
let notifications: ShowToastParams[];
let serviceWorkerDescriptor: PropertyDescriptor | undefined;
let cachesDescriptor: PropertyDescriptor | undefined;
const latestNotification = () => notifications[notifications.length - 1];
const reloadMarker = () => sessionStorage.getItem(RELOAD_KEY);
beforeEach(async () => {
globalThis.__DEV__ = false;
globalThis.__DEMO__ = false;
window.isDirtyState = false;
sessionStorage.clear();
// No controlling service worker → reloadFresh() takes the synchronous
// path straight to the (jsdom no-op) navigate.
serviceWorkerDescriptor = Object.getOwnPropertyDescriptor(
navigator,
"serviceWorker"
);
cachesDescriptor = Object.getOwnPropertyDescriptor(window, "caches");
Object.defineProperty(navigator, "serviceWorker", {
configurable: true,
value: { controller: null },
});
// Capture toasts fired via showToast (a "hass-notification" event).
notifications = [];
root = document.createElement("home-assistant");
root.addEventListener("hass-notification", (event) => {
notifications.push((event as CustomEvent<ShowToastParams>).detail);
});
document.body.append(root);
// Fresh module state per test (resets the reloading/toastShown singletons).
vi.resetModules();
mod = await import("../../src/util/recover-stale-build");
});
afterEach(() => {
root.remove();
if (serviceWorkerDescriptor) {
Object.defineProperty(
navigator,
"serviceWorker",
serviceWorkerDescriptor
);
} else {
Reflect.deleteProperty(navigator, "serviceWorker");
}
if (cachesDescriptor) {
Object.defineProperty(window, "caches", cachesDescriptor);
} else {
Reflect.deleteProperty(window, "caches");
}
Reflect.deleteProperty(window, "externalApp");
Reflect.deleteProperty(window, "webkit");
window.isDirtyState = false;
globalThis.__DEV__ = false;
globalThis.__DEMO__ = false;
});
describe("isStaleBuildError", () => {
it.each([
"/frontend_latest/core.abc12345.js",
"https://ha.local/frontend_es5/panel-config.deadbeef99.js",
"Failed to fetch dynamically imported module: /frontend_latest/x.abcdef12.js",
"Importing a module script failed.",
"error loading dynamically imported module",
"ChunkLoadError: Loading chunk 5 failed",
])("detects a stale-build error: %s", (message) => {
expect(mod.isStaleBuildError(message)).toBe(true);
});
it.each([
undefined,
"",
"/frontend_latest/core.js", // dev, unhashed
"/static/translations/en-abc12345.json", // hashed, but not an entry chunk
"TypeError: x is not a function",
])("ignores non-stale input: %s", (message) => {
expect(mod.isStaleBuildError(message)).toBe(false);
});
});
describe("recoverFromStaleBuild", () => {
it("does nothing in development", () => {
globalThis.__DEV__ = true;
expect(mod.recoverFromStaleBuild(STALE_URL, root)).toBe(false);
expect(reloadMarker()).toBeNull();
expect(notifications).toHaveLength(0);
});
it("does nothing in demo", () => {
globalThis.__DEMO__ = true;
expect(mod.recoverFromStaleBuild(STALE_URL, root)).toBe(false);
expect(reloadMarker()).toBeNull();
});
it("ignores a non-stale error", () => {
expect(mod.recoverFromStaleBuild("TypeError: boom", root)).toBe(false);
expect(reloadMarker()).toBeNull();
expect(notifications).toHaveLength(0);
});
it("reloads onto the current build when clean", () => {
expect(mod.recoverFromStaleBuild(STALE_URL, root)).toBe(true);
// reloadFresh() ran (marker written before navigating) and did not toast.
expect(reloadMarker()).not.toBeNull();
expect(notifications).toHaveLength(0);
});
it("drops the service worker and caches before reloading", async () => {
const unregister = vi.fn().mockResolvedValue(true);
Object.defineProperty(navigator, "serviceWorker", {
configurable: true,
value: {
controller: {},
getRegistrations: vi.fn().mockResolvedValue([{ unregister }]),
},
});
const cacheDelete = vi.fn().mockResolvedValue(true);
Object.defineProperty(window, "caches", {
configurable: true,
value: {
keys: vi.fn().mockResolvedValue(["a", "b"]),
delete: cacheDelete,
},
});
expect(mod.recoverFromStaleBuild(STALE_URL, root)).toBe(true);
await vi.waitFor(() => expect(unregister).toHaveBeenCalledOnce());
expect(cacheDelete).toHaveBeenCalledTimes(2);
});
it("uses the companion-app command when the WebKit bridge is present", () => {
const postMessage = vi.fn();
(
window as unknown as {
webkit: {
messageHandlers: {
externalBus: { postMessage: typeof postMessage };
};
};
}
).webkit = { messageHandlers: { externalBus: { postMessage } } };
expect(mod.recoverFromStaleBuild(STALE_URL, root)).toBe(true);
// Asks the native app to purge its cache and reload instead of the
// browser path.
expect(postMessage).toHaveBeenCalledOnce();
expect(postMessage.mock.calls[0][0]).toMatchObject({
type: "frontend/reload_and_clear_cache",
});
expect(notifications).toHaveLength(0);
});
it("defers with a toast instead of reloading when dirty", () => {
window.isDirtyState = true;
expect(mod.recoverFromStaleBuild(STALE_URL, root)).toBe(true);
// Took the toast branch, not the reload branch, and the toast has no
// immediate-reload action that could discard unsaved work.
expect(reloadMarker()).toBeNull();
expect(latestNotification()).toMatchObject({
id: "frontend-update-available",
message: {
translationKey: "ui.notification_toast.new_version_available_reload",
},
duration: -1,
dismissable: false,
});
expect(latestNotification().action).toBeUndefined();
});
it("does not reload again while the cooldown marker is set (loop guard)", async () => {
expect(mod.recoverFromStaleBuild(STALE_URL, root)).toBe(true);
const firstMarker = reloadMarker();
expect(firstMarker).not.toBeNull();
// Simulate the reloaded page: a fresh module (the in-memory reloading
// flag is reset) but the sessionStorage cooldown marker persists.
vi.resetModules();
const reloaded: RecoverModule =
await import("../../src/util/recover-stale-build");
// Blocked by the cooldown → returns false so the caller still surfaces it.
expect(
reloaded.recoverFromStaleBuild("/frontend_latest/app.def67890.js", root)
).toBe(false);
expect(reloadMarker()).toBe(firstMarker);
});
});
});