Compare commits

..
81 changed files with 450 additions and 1146 deletions
+3 -30
View File
@@ -38,11 +38,6 @@ jobs:
run: ./script/translations_download
env:
LOKALISE_TOKEN: ${{ secrets.LOKALISE_TOKEN }}
# The wheel only builds the app (build-app), which does not merge
# backend translations. Skipping the whole-project backend export (as
# the release does) keeps this off the build's critical path; the full
# translations artifact is produced in parallel by the job below.
SKIP_BACKEND_TRANSLATIONS: "1"
- name: Bump version
run: script/version_bump.js nightly
@@ -79,6 +74,9 @@ jobs:
path: .compress-cache
key: compress-cache-${{ runner.os }}-${{ github.run_id }}-${{ github.run_attempt }}
- name: Archive translations
run: tar -czvf translations.tar.gz translations
- name: Upload build artifacts
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
@@ -86,31 +84,6 @@ jobs:
path: dist/home_assistant_frontend*.whl
if-no-files-found: error
# The full translations (including the slow backend/core export) are only
# needed for the uploaded artifact, not the wheel, so they are downloaded in
# parallel here instead of blocking the build above.
translations:
name: Translations
runs-on: ubuntu-latest
steps:
- name: Checkout the repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Setup Node and install
uses: ./.github/actions/setup
with:
immutable: false
- name: Download translations
run: ./script/translations_download
env:
LOKALISE_TOKEN: ${{ secrets.LOKALISE_TOKEN }}
- name: Archive translations
run: tar -czvf translations.tar.gz translations
- name: Upload translations
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
+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;
-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,
+108 -4
View File
@@ -1,11 +1,89 @@
import type { TemplateResult } from "lit";
import { animate } from "@lit-labs/motion";
import { css, html, LitElement } from "lit";
import { customElement } from "lit/decorators";
import { customElement, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { styleMap } from "lit/directives/style-map";
const THUMB_SIZE = 40;
@customElement("ha-icon-button-group")
export class HaIconButtonGroup extends LitElement {
protected render(): TemplateResult {
return html`<slot></slot>`;
@state() private _thumbX = 0;
@state() private _thumbVisible = false;
@state() private _thumbBorderOnly = false;
// When the thumb appears, only fade it in at its new position instead of
// also sliding it from wherever it was last visible.
private _thumbAppearing = false;
private _observer = new MutationObserver(() => this._updateThumb());
public disconnectedCallback(): void {
super.disconnectedCallback();
this._observer.disconnect();
}
protected render() {
return html`
<div
class="thumb ${classMap({
visible: this._thumbVisible,
"border-only": this._thumbBorderOnly,
})}"
style=${styleMap({ left: `${this._thumbX}px` })}
${animate(() => ({
properties: this._thumbAppearing ? ["opacity"] : ["left", "opacity"],
keyframeOptions: {
duration: this._animationDuration(),
easing: "ease-in-out",
},
skipInitial: true,
}))}
></div>
<slot @slotchange=${this._handleSlotchange}></slot>
`;
}
protected updated() {
this._thumbAppearing = false;
}
private _animationDuration(): number {
return (
parseFloat(
getComputedStyle(this).getPropertyValue("--ha-animation-duration-fast")
) || 150
);
}
private _handleSlotchange(ev: Event) {
this._observer.disconnect();
const slot = ev.target as HTMLSlotElement;
for (const el of slot.assignedElements()) {
this._observer.observe(el, {
attributes: true,
attributeFilter: ["selected", "disabled"],
});
}
// Positions are only valid once the slotted buttons are laid out.
requestAnimationFrame(() => this._updateThumb());
}
private _updateThumb() {
const selected = this.querySelector<HTMLElement>(
"ha-icon-button-toggle[selected]:not([disabled])"
);
if (!selected) {
this._thumbVisible = false;
return;
}
this._thumbAppearing = !this._thumbVisible;
this._thumbBorderOnly = selected.hasAttribute("border-only");
this._thumbX =
selected.offsetLeft + (selected.offsetWidth - THUMB_SIZE) / 2;
this._thumbVisible = true;
}
static styles = css`
@@ -21,6 +99,32 @@ export class HaIconButtonGroup extends LitElement {
width: auto;
padding: 0;
}
/* The selected toggle's circle is drawn here so it can slide between
toggles; their own circles are suppressed below. */
.thumb {
position: absolute;
top: calc(50% - 20px);
opacity: 0;
width: 40px;
height: 40px;
border-radius: var(--ha-border-radius-circle);
background-color: var(
--ha-icon-button-group-thumb-color,
var(--primary-text-color)
);
box-sizing: border-box;
}
.thumb.visible {
opacity: 1;
}
.thumb.border-only {
background-color: transparent;
border: 2px solid
var(--ha-icon-button-group-thumb-color, var(--primary-text-color));
}
::slotted(ha-icon-button-toggle) {
--ha-icon-button-toggle-thumb-opacity: 0;
}
::slotted(.separator) {
background-color: rgba(var(--rgb-primary-text-color), 0.15);
width: 1px;
+3 -1
View File
@@ -44,8 +44,10 @@ export class HaIconButtonToggle extends HaIconButton {
color: var(--primary-background-color);
background-color: unset;
}
/* ha-icon-button-group zeroes this so its sliding thumb draws the
circle instead. */
:host([selected]:not([disabled])) ha-button::part(base)::before {
opacity: 1;
opacity: var(--ha-icon-button-toggle-thumb-opacity, 1);
}
::slotted(*) {
display: block;
@@ -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);
+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;
}),
};
}
@@ -367,10 +367,10 @@ class MoreInfoLight extends LitElement {
width: auto;
}
.wheel {
width: 30px;
height: 30px;
width: 28px;
height: 28px;
flex: none;
border-radius: var(--ha-border-radius-xl);
border-radius: var(--ha-border-radius-circle);
}
.wheel.color {
background-image: url("/static/images/color_wheel.png");
+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";
@@ -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>`
: ""
}
@@ -1761,14 +1743,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,
@@ -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() {
+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,
@@ -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
>
+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);
}
-2
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",
-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);
};
-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("/");
});
});
+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);
}
);
});
-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);
});
});
});