Compare commits

...
Author SHA1 Message Date
Bram KragtenandClaude Opus 4.8 fb4eac09dd Add rspack persistent cache (nightly writes, CI reads)
Production rspack compilation is now the tallest pole in the nightly/CI
build. This adds rspack's persistent filesystem cache, opt-in per
environment via the RSPACK_CACHE env var:

- Nightly writes it (readwrite) and persists it via actions/cache.
- CI reuses it read-only (readonly) — no save step, so PRs never write the
  shared cache and it can't be poisoned across branches.
- Release builds do not use it (unset), so shipped wheels are unaffected
  while we validate.

Locally (12-core) a warm build drops rspack-prod-app from ~2.4 min to ~43 s;
a CI-style read (IS_TEST=1) of a nightly-style cache still lands at ~52 s,
confirming the source-map-only difference between the two does not break
reuse.

Invalidation avoids dropping the whole cache on unrelated dependency bumps:
- `version` folds in node major + the build-toolchain versions (rspack/swc,
  babel core/presets/plugins, terser, core-js, browserslist) — the machinery
  rspack's node_modules snapshot cannot see.
- Runtime dependencies are left to that snapshot, which invalidates their
  modules per-package.
- `buildDependencies` tracks our own config/loaders/plugins (not yarn.lock).
- The GHA cache key carries no lockfile/build-scripts fingerprint: the nightly
  is the sole writer and rspack decides validity internally, so the cache rolls
  forward daily instead of resetting on every dependency change.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-08-12 10:53:15 +02:00
Paul BotteinandGitHub 49ea96e091 Add icon button group animation (#53597)
* Animate the selected toggle circle in ha-icon-button-group

* Round the light color wheels to fit the selected ring

* Use lit motion
2026-08-12 10:19:07 +03:00
08b33ccbc1 Decouple translations artifact from the nightly build (#53602)
* Skip backend translations download in nightly build

The nightly only builds the app (build-app), which does not merge backend
translations — the shipped app fetches those from core at runtime. The
backend Lokalise export is a whole-project download across all languages
and the slowest part of the translations step. Skipping it, as the release
already does, cuts several minutes off every nightly.

Co-Authored-By: Claude Opus 4.8 <[email protected]>

* Decouple translations artifact into a parallel job

The full translations (including the slow Lokalise backend/core export) are
only needed for the uploaded `translations` artifact, not the wheel:
build-app does not merge backend translations. Move that download and the
artifact upload into a separate `translations` job that runs in parallel
with the build, so the backend export no longer sits on the build's
critical path. Both jobs run in the same workflow run, so consumers still
find both the `wheels` and `translations` artifacts.

Co-Authored-By: Claude Opus 4.8 <[email protected]>

---------

Co-authored-by: Claude Opus 4.8 <[email protected]>
2026-08-11 20:48:38 +02:00
Aidan TimsonandGitHub 048e754149 Match tokens card actions position and size of button with others (#53605) 2026-08-11 18:22:19 +02:00
Aidan TimsonandGitHub 3a30ea5973 Fix loading states for async config pages (#53604) 2026-08-11 15:32:53 +02:00
Aidan TimsonandGitHub f7836fd3d5 Show loading screen while Labs features load (#53601) 2026-08-11 16:11:04 +03:00
Paul BotteinandGitHub 03d8c092ce Render the target picker entities count as a button (#53598)
* Implement the xs button size

* Render the target picker entities count as a button
2026-08-11 15:40:14 +03:00
Aidan TimsonandGitHub b3aa3c83d5 Change area navigation to icon button in device page (#53600)
* Add area navigation button to device page

* Remove area button tooltip
2026-08-11 15:00:00 +03:00
85c7d071fe Recover from a stale build after boot (#53582)
* Recover from a stale build after boot (lazy-chunk 404s)

When the app stays open across a Home Assistant upgrade, the previous
build's content-hashed lazy chunks are deleted, so opening a dialog,
more-info, card, or panel that was not yet loaded 404s. Today that
dead-ends: dialogs fail silently, panels show a Back-only error screen.

Add a shared recovery authority (recover-stale-build.ts): detect a stale
hashed-chunk load failure and either reload onto the current build (drop
the service worker + caches, cache-busting nav, one-shot cooldown guard)
or, when an editor has unsaved changes, show a non-dismissable toast that
reloads once the dirty state clears. Hook it into the global
error/unhandledrejection handlers, the router's swallowed load error, and
give hass-error-screen a reload action. Chunk-error patterns come from a
JSON single source shared with the boot guard.

Part of home-assistant/epics#113.

Co-Authored-By: Claude Opus 4.8 <[email protected]>

* Wire stale-build recovery into more paths, add tests

- make-dialog-manager: don't cache a rejected dialog import, so a stale
  chunk 404 (or transient failure) no longer permanently breaks that
  dialog until a full page reload — a later open re-imports.
- home-assistant: _checkUpdate now uses reloadFresh() instead of the
  no-longer-effective location.reload(true) (forceGet is ignored by
  modern browsers).
- connection-mixin: drop the dead reload(true) forceGet arg on the
  safe_mode reload.
- Add unit tests for isStaleBuildError and the recoverFromStaleBuild
  clean / dirty / dev-demo / non-stale / loop-guard branches.

Part of home-assistant/epics#113.

Co-Authored-By: Claude Opus 4.8 <[email protected]>

* Recover in the companion app via the reload_and_clear_cache command

The iOS/Android companion app (WKWebView) has no service worker, and its
document HTTP cache is not cleared by the Cache API, so the web-level
reload path is ineffective there. When an external bus is present,
reloadFresh() now fires the native `frontend/reload_and_clear_cache`
command (shipped in home-assistant/iOS#5190) so the app purges its cache
and reloads; browsers still take the web path.

Adds the outgoing message type and extracts the bus transport into an
exported fireExternalBusMessage() so it can be sent without a hass/bus
reference.

Closes #53405. Part of home-assistant/epics#113.

Co-Authored-By: Claude Opus 4.8 <[email protected]>

* Address Copilot review feedback

- reloadFresh: only send frontend/reload_and_clear_cache to the WebKit
  (iOS) bridge; Android and browsers take the service-worker + cache-clear
  path (Android's WebView has a service worker). Fail closed when the
  sessionStorage cooldown marker can't be persisted so it can't loop. Return
  whether a reload was actually started, so a guard-blocked failure is still
  surfaced/logged instead of silently swallowed.
- Add a dirty-aware reloadForUpdate() and route _checkUpdate and the error
  screen's Refresh button through it; drop the dirty toast's immediate
  action (it auto-reloads once changes are saved/discarded).
- Set showReload on the error-screen element at the call site so router
  overrides (e.g. ToolsRouter) can't drop it.
- Tests: exercise the WebKit bridge and the SW/cache-clear branch; assert
  the dirty toast has no reload action.

Part of home-assistant/epics#113.

Co-Authored-By: Claude Opus 4.8 <[email protected]>

---------

Co-authored-by: Claude Opus 4.8 <[email protected]>
2026-08-11 13:37:44 +02:00
Aidan TimsonandGitHub 6fe34bbf7e Add device link to Bluetooth advertisement dialog (#53599) 2026-08-11 14:27:06 +03:00
Aidan TimsonandGitHub 5291a84c87 Align highlighted config entries to the top (#53595)
* Align highlighted config entries to the top

* Apply suggestion from @timmo001
2026-08-11 10:54:58 +00:00
Paul BotteinandGitHub c0575fcb42 Unify back navigation across subpages (#53501)
* Unify back navigation across subpages

* Address back navigation review findings

* Fix state

* Add missing back path

* Remove back path from editors to fix unsaved changes prompt

* Use more specific back fallback paths
2026-08-11 10:39:38 +00:00
ea98a85088 Reserve scrollbar gutter in more-info dialog to prevent flicker (#53522)
* Reserve scrollbar gutter in more-info dialog to prevent flicker

* Update src/dialogs/more-info/ha-more-info-dialog.ts

---------

Co-authored-by: Petar Petrov <[email protected]>
2026-08-11 10:33:12 +00:00
Bram KragtenandGitHub a1370e331f Replace remove device command (#53594) 2026-08-11 11:22:28 +02:00
pcan08andGitHub 75e862540f Add light-effect tile card feature and related suggestion (#53589) 2026-08-11 08:52:15 +02:00
95 changed files with 1679 additions and 375 deletions
+4
View File
@@ -11,6 +11,9 @@ inputs:
is-test:
description: Set IS_TEST for the build (skips source maps and compression)
default: "false"
rspack-cache:
description: rspack persistent cache mode ("readwrite", "readonly", or "" to disable)
default: ""
runs:
using: composite
@@ -21,3 +24,4 @@ runs:
env:
GITHUB_TOKEN: ${{ inputs.github-token }}
IS_TEST: ${{ inputs.is-test }}
RSPACK_CACHE: ${{ inputs.rspack-cache }}
+14
View File
@@ -111,12 +111,26 @@ jobs:
uses: ./.github/actions/setup
with:
node-modules-cache: true
# Read-only reuse of the rspack cache written by the nightly (see
# nightly.yaml). rspack itself decides what is still valid (version +
# buildDependencies + node_modules snapshot), so the GHA key just restores
# the latest nightly cache; no fingerprint, and no save step (CI never
# writes the shared cache).
- name: Restore rspack cache
continue-on-error: true
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: .rspack-cache
key: rspack-cache-${{ runner.os }}-${{ github.run_id }}
restore-keys: |
rspack-cache-${{ runner.os }}-
- name: Build Application
uses: ./.github/actions/build
with:
target: build-app
github-token: ${{ secrets.GITHUB_TOKEN }}
is-test: true
rspack-cache: readonly
- name: Upload bundle stats
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
+53 -3
View File
@@ -38,6 +38,11 @@ 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
@@ -53,9 +58,24 @@ jobs:
restore-keys: |
compress-cache-${{ runner.os }}-
# The nightly writes the rspack persistent cache; CI reads it read-only
# (see ci.yaml). rspack invalidates internally (version + buildDependencies
# + node_modules snapshot), so the cache rolls forward daily and a single
# dependency bump keeps most of it warm instead of dropping the lineage.
- name: Restore rspack cache
id: rspack-cache
continue-on-error: true
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: .rspack-cache
key: rspack-cache-${{ runner.os }}-${{ github.run_id }}-${{ github.run_attempt }}
restore-keys: |
rspack-cache-${{ runner.os }}-
- name: Build nightly Python wheels
env:
COMPRESS_CACHE_DIR: ${{ github.workspace }}/.compress-cache
RSPACK_CACHE: readwrite
run: |
pip install build
yarn install
@@ -64,7 +84,7 @@ jobs:
rm -rf dist home_assistant_frontend.egg-info
python3 -m build
# Not gated on the restore step: a transient restore failure (it is
# Not gated on the restore steps: a transient restore failure (they are
# continue-on-error) must not stop us persisting a freshly built cache.
- name: Save compression cache
if: success()
@@ -74,8 +94,13 @@ 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: Save rspack cache
if: success()
continue-on-error: true
uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: .rspack-cache
key: rspack-cache-${{ runner.os }}-${{ github.run_id }}-${{ github.run_attempt }}
- name: Upload build artifacts
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
@@ -84,6 +109,31 @@ 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
View File
@@ -7,6 +7,7 @@ dist/
/hass_frontend/
/translations/
/.compress-cache/
/.rspack-cache/
# Composite action source, not build output
!/.github/actions/build/
+77
View File
@@ -16,6 +16,39 @@ const SafeWebpackBar = require("./safe-webpackbar.cjs");
const paths = require("./paths.cjs");
const bundle = require("./bundle.cjs");
// Build-toolchain packages whose version changes the emitted bytes but which
// are loader/compiler machinery, not modules in the build graph — so rspack's
// node_modules snapshot cannot see them. Their versions are folded into the
// persistent cache `version` so a toolchain upgrade invalidates the cache,
// while ordinary runtime-dependency bumps (handled by the snapshot) do not.
const TOOLCHAIN_PACKAGES = [
"@rspack/core",
"@babel/core",
"@babel/preset-env",
"babel-plugin-polyfill-corejs3",
"@babel/plugin-transform-runtime",
"@babel/plugin-transform-class-properties",
"@babel/plugin-transform-private-methods",
"@babel/runtime",
"babel-loader",
"core-js",
"terser",
"terser-webpack-plugin",
"browserslist",
"caniuse-lite",
];
const toolchainHash = () =>
require("crypto")
.createHash("sha256")
.update(
TOOLCHAIN_PACKAGES.map(
(pkg) => `${pkg}@${require(`${pkg}/package.json`).version}`
).join("\n")
)
.digest("hex")
.slice(0, 16);
class LogStartCompilePlugin {
ignoredFirst = false;
@@ -376,6 +409,50 @@ const createRspackConfig = ({
])
),
},
// Persistent filesystem cache for production builds, opt-in per environment
// via RSPACK_CACHE ("readwrite" writes it, "readonly" only reads a warm
// cache — e.g. CI reusing the nightly-written one). Unset (releases, local,
// tests) = no cache.
...(isProdBuild && process.env.RSPACK_CACHE
? {
cache: {
type: "persistent",
// `name` is already unique per variant (frontend-modern/-legacy).
name,
// Invalidate on node and on the build toolchain versions. Runtime
// dependencies are deliberately NOT hashed here — rspack's
// node_modules snapshot invalidates their modules per-package, so a
// single unrelated dep bump keeps the rest of the cache warm. Only
// the toolchain (loader/compiler machinery, not modules in the
// graph, so the snapshot can't see it) needs an explicit version.
version: `node${process.versions.node.split(".")[0]}-${toolchainHash()}`,
// Our own transform logic. (Not yarn.lock — that would drop the
// whole cache on any dependency change; see version/snapshot above.)
buildDependencies: [
__filename,
path.join(__dirname, "bundle.cjs"),
path.join(__dirname, "minify-template-literals-loader.cjs"),
path.join(__dirname, "lit-disable-dev-mode-loader.cjs"),
path.join(
__dirname,
"babel-plugins",
"custom-polyfill-plugin.js"
),
path.join(
__dirname,
"babel-plugins",
"inline-constants-plugin.cjs"
),
],
storage: {
type: "filesystem",
directory: path.resolve(paths.root_dir, ".rspack-cache"),
},
// CI reads the nightly-written cache but must not modify it.
readonly: process.env.RSPACK_CACHE === "readonly",
},
}
: {}),
experiments: {
outputModule: true,
},
+31 -1
View File
@@ -17,6 +17,9 @@ 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,
@@ -111,7 +114,16 @@ export default tseslint.config(
"no-bitwise": "error",
"no-console": "error",
"no-restricted-globals": [2, "event"],
"no-restricted-syntax": ["error", "LabeledStatement", "WithStatement"],
"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.",
},
],
"wc/no-self-class": "off",
// import-x rules
@@ -222,6 +234,24 @@ 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,6 +37,16 @@ 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,6 +56,19 @@ 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`
+4 -2
View File
@@ -1,8 +1,9 @@
import type { ReactiveElement } from "lit";
import { getHistoryState, updateHistoryState } from "../navigate";
import { throttle } from "../util/throttle";
const throttleReplaceState = throttle((value) => {
history.replaceState({ scrollPosition: value }, "");
updateHistoryState({ scrollPosition: value });
}, 300);
export function restoreScroll(selector: string) {
@@ -39,7 +40,8 @@ export function restoreScroll(selector: string) {
newDescriptor = {
get(this: ReactiveElement) {
return (
this[`__${String(propertyKey)}`] || history.state?.scrollPosition
this[`__${String(propertyKey)}`] ||
getHistoryState()?.scrollPosition
);
},
set(this: ReactiveElement, value) {
+78 -41
View File
@@ -1,6 +1,7 @@
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
@@ -11,12 +12,38 @@ declare global {
export interface NavigateOptions {
replace?: boolean;
data?: any;
data?: Record<string, unknown>;
}
// 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
@@ -24,10 +51,7 @@ const DIALOG_WAIT_TIMEOUT = 500;
* The current URL is not changed.
*/
export const setRefreshUrl = (path: string) => {
mainWindow.history.replaceState(
{ ...mainWindow.history.state, refreshUrl: path },
""
);
updateHistoryState({ refreshUrl: path });
};
/**
@@ -56,6 +80,17 @@ 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) {
@@ -63,37 +98,32 @@ export const navigate = async (path: string, options?: NavigateOptions) => {
}
const replace = options?.replace || false;
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),
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()),
"",
path
);
} else {
mainWindow.history.pushState(options?.data ?? null, "", path);
}
fireEvent(mainWindow, "location-changed", {
replace,
});
@@ -101,8 +131,17 @@ export const navigate = async (path: string, options?: NavigateOptions) => {
};
/**
* 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.
* 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).
*/
export const goBack = async (fallbackPath?: string): Promise<void> => {
const canProceed = await ensureDialogsClosed(Date.now());
@@ -110,14 +149,12 @@ export const goBack = async (fallbackPath?: string): Promise<void> => {
return;
}
// Check if we have history to go back to
const { history } = mainWindow;
if (history.length > 1) {
history.back();
// 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();
return;
}
// No history available, navigate to fallback path
const fallback = fallbackPath || "/";
navigate(fallback, { replace: true });
await navigate(fallbackPath || "/", { replace: true });
};
+10
View File
@@ -0,0 +1,10 @@
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,6 +65,21 @@ 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,7 +52,6 @@ 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";
@@ -221,30 +220,28 @@ export class HaTargetPickerItemRow extends LitElement {
? html`
<div slot="end" class="summary">
${
showEntities &&
!this.expand &&
entries?.referenced_entities.length
? html`<button
class="main link"
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"
@click=${this._openDetails}
>
${this.hass.localize(
"ui.components.target-picker.entities_count",
{
count: entries?.referenced_entities.length,
count: entries.referenced_entities.length,
}
)}
</button>`
: showEntities
? html`<span class="main">
${this.hass.localize(
"ui.components.target-picker.entities_count",
{
count: entries?.referenced_entities.length,
}
)}
</span>`
: nothing
</ha-button>`
}
</div>
`
@@ -812,7 +809,6 @@ export class HaTargetPickerItemRow extends LitElement {
};
static styles = [
buttonLinkStyle,
css`
:host {
--md-list-item-top-space: 0;
@@ -883,16 +879,6 @@ 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);
+4 -6
View File
@@ -146,15 +146,13 @@ export const updateDeviceRegistryEntry = (
...updates,
});
export const removeConfigEntryFromDevice = (
export const removeDeviceFromRegistry = (
hass: HomeAssistant,
deviceId: string,
configEntryId: string
deviceId: string
) =>
hass.callWS<DeviceRegistryEntry>({
type: "config/device_registry/remove_config_entry",
hass.callWS<null>({
type: "config/device_registry/remove",
device_id: deviceId,
config_entry_id: configEntryId,
});
export const sortDeviceRegistryByName = (
+23 -12
View File
@@ -98,20 +98,31 @@ 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);
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;
}
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");
+14 -10
View File
@@ -40,7 +40,11 @@ import {
getEntityEntryContext,
} from "../../common/entity/context/get_entity_context";
import { shouldHandleRequestSelectedEvent } from "../../common/mwc/handle-request-selected-event";
import { navigate } from "../../common/navigate";
import {
getHistoryState,
navigate,
updateHistoryState,
} from "../../common/navigate";
import type { LocalizeKeys } from "../../common/translations/localize";
import { computeRTL } from "../../common/util/compute_rtl";
import { withViewTransition } from "../../common/util/view-transition";
@@ -268,16 +272,12 @@ export class MoreInfoDialog extends DirtyStateProviderMixin<
}
private _setView(view: MoreInfoView) {
history.replaceState(
{
...history.state,
dialogParams: {
...history.state?.dialogParams,
view,
},
updateHistoryState({
dialogParams: {
...getHistoryState()?.dialogParams,
view,
},
""
);
});
this._currView = view;
}
@@ -1063,6 +1063,10 @@ 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 {
+26 -9
View File
@@ -191,6 +191,10 @@ 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.
@@ -220,6 +224,7 @@ type EMOutgoingMessageWithoutAnswer =
| EMOutgoingMessageImprovConfigureDevice
| EMOutgoingMessageAddEntityTo
| EMOutgoingMessageFocusElement
| EMOutgoingMessageReloadAndClearCache
| EMOutgoingMessageAssistSettings;
export interface EMIncomingMessageRestart {
@@ -511,14 +516,26 @@ export class ExternalMessaging {
// eslint-disable-next-line no-console
console.log("Sending message to external app", 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);
}
fireExternalBusMessage(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
@@ -0,0 +1,30 @@
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);
};
+26 -3
View File
@@ -1,10 +1,11 @@
import type { CSSResultGroup, TemplateResult } from "lit";
import { css, html, LitElement } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import { goBack } from "../common/navigate";
import { getHistoryState, 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")
@@ -19,6 +20,9 @@ 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();
@@ -27,7 +31,7 @@ class HassErrorScreen extends LitElement {
return html`
<ha-top-app-bar-fixed
.narrow=${this.narrow}
.backButton=${!(this.rootnav || history.state?.root)}
.backButton=${!(this.rootnav || getHistoryState()?.root)}
>
${this._renderContent()}
</ha-top-app-bar-fixed>
@@ -39,6 +43,19 @@ 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>
@@ -51,6 +68,12 @@ 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`
+2 -1
View File
@@ -1,6 +1,7 @@
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";
@@ -27,7 +28,7 @@ class HassLoadingScreen extends LitElement {
return html`
<ha-top-app-bar-fixed
.narrow=${this.narrow}
.backButton=${!(this.rootnav || history.state?.root)}
.backButton=${!(this.rootnav || getHistoryState()?.root)}
>
${this._renderContent()}
</ha-top-app-bar-fixed>
+15 -3
View File
@@ -5,6 +5,7 @@ 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) => {
@@ -182,10 +183,21 @@ export class HassRouterPage extends ReactiveElement {
this._showLoadingScreenTimeout = undefined;
}
// Show error screen
this.appendChild(
this.createErrorScreen(`Error while loading page ${newPage}.`)
// 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}.`
);
errorScreen.showReload = stale;
this.appendChild(errorScreen);
});
// If we don't show loading screen, just show the panel.
+11 -19
View File
@@ -4,8 +4,9 @@ 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 { goBack } from "../common/navigate";
import { getHistoryState } 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";
@@ -37,19 +38,14 @@ class HassSubpage extends LitElement {
<div class="toolbar ${classMap({ narrow: this.narrow })}">
<div class="toolbar-content">
${
this.mainPage || history.state?.root
this.mainPage || (!backPath && getHistoryState()?.root)
? html`<ha-menu-button></ha-menu-button>`
: 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>
`
: html`
<ha-icon-button-arrow-prev
.href=${backPath}
@click=${this._backTapped}
></ha-icon-button-arrow-prev>
`
}
<div class="main-title">
@@ -79,12 +75,8 @@ class HassSubpage extends LitElement {
this._savedScrollPos = (e.target as HTMLDivElement).scrollTop;
}
private _backTapped(): void {
if (this.backCallback) {
this.backCallback();
return;
}
goBack();
private _backTapped(ev: MouseEvent): void {
handleBackClick(ev, this.backPath, this.backCallback);
}
static get styles(): CSSResultGroup {
+11 -19
View File
@@ -14,9 +14,10 @@ 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 { goBack, navigate } from "../common/navigate";
import { getHistoryState, 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";
@@ -173,19 +174,14 @@ export class HassTabsSubpage extends LitElement {
<slot name="toolbar">
<div class="toolbar-content">
${
this.mainPage || (!backPath && history.state?.root)
this.mainPage || (!backPath && getHistoryState()?.root)
? html`<ha-menu-button></ha-menu-button>`
: 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>
`
: html`
<ha-icon-button-arrow-prev
.href=${backPath}
@click=${this._backTapped}
></ha-icon-button-arrow-prev>
`
}
${
this._narrow || !this.showTabs
@@ -246,12 +242,8 @@ export class HassTabsSubpage extends LitElement {
this._content.focus({ preventScroll: true });
}
private _backTapped(): void {
if (this.backCallback) {
this.backCallback();
return;
}
goBack();
private _backTapped(ev: MouseEvent): void {
handleBackClick(ev, this.backPath, this.backCallback);
}
private _isActiveTabPath(tabPath: string, currentPath: string): boolean {
+3 -4
View File
@@ -19,6 +19,7 @@ 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,
@@ -247,13 +248,11 @@ export class HomeAssistantAppEl extends QuickBarMixin(HassElement) {
if (registration) {
registration.update();
} else if (oldVersion) {
// @ts-ignore Firefox supports forceGet
location.reload(true);
reloadForUpdate();
}
});
} else if (oldVersion) {
// @ts-ignore Firefox supports forceGet
location.reload(true);
reloadForUpdate();
}
}
}
@@ -644,6 +644,7 @@ class HaConfigAreaPage extends LitElement {
<hass-subpage
.hass=${this.hass}
.narrow=${this.narrow}
back-path="/config/areas/dashboard"
.header=${html`${
area.icon
? html`<ha-icon
@@ -902,7 +903,7 @@ class HaConfigAreaPage extends LitElement {
destructive: true,
confirm: async () => {
await deleteAreaRegistryEntry(this.hass!, area!.area_id);
afterNextRender(() => goBack("/config"));
afterNextRender(() => goBack("/config/areas/dashboard"));
},
});
}
@@ -86,8 +86,6 @@ export class HaConfigAreasDashboard extends LitElement {
@state() private _hierarchy?: AreasFloorHierarchy;
private _searchParms = new URLSearchParams(window.location.search);
private _blockHierarchyUpdate = false;
private _blockHierarchyUpdateTimeout?: number;
@@ -168,9 +166,7 @@ export class HaConfigAreasDashboard extends LitElement {
<hass-tabs-subpage
.hass=${this.hass}
.isWide=${this.isWide}
.backPath=${
this._searchParms.has("historyBack") ? undefined : "/config"
}
back-path="/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("/config");
goBack(this.dashboardPath);
}
}
@@ -443,9 +443,7 @@ class HaAutomationPicker extends SubscribeMixin(LitElement) {
<hass-tabs-subpage-data-table
.hass=${this.hass}
.narrow=${this.narrow}
.backPath=${
this._searchParms.has("historyBack") ? undefined : "/config"
}
back-path="/config"
id="entity_id"
.route=${this.route}
.tabs=${configSections.automations}
@@ -147,6 +147,10 @@ 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;
@@ -252,7 +256,7 @@ export const AutomationScriptEditorMixin = <TConfig extends BaseEditorConfig>(
protected backTapped = async () => {
const result = await this.confirmUnsavedChanged();
if (result) {
afterNextRender(() => goBack("/config"));
afterNextRender(() => goBack(this.dashboardPath));
}
};
@@ -300,7 +304,7 @@ export const AutomationScriptEditorMixin = <TConfig extends BaseEditorConfig>(
),
text: html`<pre>${alertText}</pre>`,
});
goBack("/config");
goBack(this.dashboardPath);
return;
}
const entity = this.entityRegistry?.find(
@@ -317,7 +321,7 @@ export const AutomationScriptEditorMixin = <TConfig extends BaseEditorConfig>(
`ui.panel.config.${domain}.editor.load_error_not_editable`
),
});
goBack("/config");
goBack(this.dashboardPath);
}
}
}
@@ -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 } from "../../../common/navigate";
import { navigate, replaceCurrentUrl } from "../../../common/navigate";
import { computeRTL } from "../../../common/util/compute_rtl";
import "../../../components/ha-button";
import "../../../components/ha-dropdown";
@@ -110,6 +110,7 @@ export class HaAutomationTrace extends LitElement {
<hass-subpage
.hass=${this.hass}
.narrow=${this.narrow}
back-path="/config/automation/dashboard"
.header=${title}
.scrollable=${this.narrow}
>
@@ -452,11 +453,7 @@ export class HaAutomationTrace extends LitElement {
if (runId) {
const params = new URLSearchParams(location.search);
params.delete("run_id");
history.replaceState(
null,
"",
`${location.pathname}?${params.toString()}`
);
replaceCurrentUrl(`${location.pathname}?${params.toString()}`);
}
await showAlertDialog(this, {
@@ -11,6 +11,7 @@ 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,
@@ -173,11 +174,7 @@ export const ManualEditorMixin = <TConfig>(
}
protected clearParam(param: string) {
window.history.replaceState(
null,
"",
constructUrlCurrentPath(removeSearchParam(param))
);
replaceCurrentUrl(constructUrlCurrentPath(removeSearchParam(param)));
}
protected async openSidebar(ev: CustomEvent<SidebarConfig>) {
@@ -74,8 +74,6 @@ 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) {
@@ -206,9 +204,7 @@ class HaConfigBackupOverview extends LitElement {
return html`
<hass-subpage
.backPath=${
this._searchParms.has("historyBack") ? undefined : "/config/system"
}
back-path="/config/system"
.hass=${this.hass}
.narrow=${this.narrow}
.header=${this.hass.localize("ui.panel.config.backup.overview.header")}
@@ -4,6 +4,7 @@ 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";
@@ -118,7 +119,7 @@ class HaConfigBackupSettings extends LitElement {
}
private _clearHash() {
history.replaceState(null, "", window.location.pathname);
replaceCurrentUrl(window.location.pathname);
}
protected render() {
@@ -21,6 +21,7 @@ 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,6 +45,7 @@ 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,6 +38,7 @@ 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,8 +66,6 @@ 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;
@@ -155,9 +153,7 @@ class HaConfigSectionUpdates extends LitElement {
return html`
<hass-subpage
.backPath=${
this._searchParms.has("historyBack") ? undefined : "/config/system"
}
back-path="/config/system"
.hass=${this.hass}
.narrow=${this.narrow}
.header=${this.hass.localize("ui.panel.config.updates.caption")}
@@ -16,6 +16,7 @@ import {
mdiRobot,
mdiScriptText,
mdiShapeOutline,
mdiTextureBox,
mdiTools,
} from "@mdi/js";
import type { HassEntity } from "home-assistant-js-websocket";
@@ -44,6 +45,7 @@ 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";
@@ -65,7 +67,7 @@ import {
import { fireRelatedContext, fullEntitiesContext } from "../../../data/context";
import type { DeviceRegistryEntry } from "../../../data/device/device_registry";
import {
removeConfigEntryFromDevice,
removeDeviceFromRegistry,
updateDeviceRegistryEntry,
} from "../../../data/device/device_registry";
import type { DiagnosticInfo } from "../../../data/diagnostics";
@@ -986,6 +988,7 @@ 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">
@@ -1035,12 +1038,27 @@ export class HaConfigDevicePage extends LitElement {
${
area
? html`<div class="header-name">
<a href="/config/areas/area/${area.area_id}"
>${this.hass.localize(
<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(
"ui.panel.config.integrations.config_entry.area",
{ area: area.name || "Unnamed Area" }
)}</a
>
)}
</ha-button>
</div>`
: ""
}
@@ -1218,11 +1236,7 @@ export class HaConfigDevicePage extends LitElement {
}
try {
await removeConfigEntryFromDevice(
this.hass,
this.deviceId,
entry.entry_id
);
await removeDeviceFromRegistry(this.hass, this.deviceId);
} catch (err: unknown) {
showAlertDialog(this, {
title: this.hass.localize(
@@ -1747,12 +1761,14 @@ 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,7 +23,11 @@ import {
PROTOCOL_INTEGRATIONS,
protocolIntegrationPicked,
} from "../../../common/integrations/protocolIntegrationPicked";
import { navigate } from "../../../common/navigate";
import {
getHistoryState,
navigate,
updateHistoryState,
} from "../../../common/navigate";
import type { LocalizeFunc } from "../../../common/translations/localize";
import {
hasRejectedItems,
@@ -65,7 +69,7 @@ import type {
DeviceRegistryEntry,
} from "../../../data/device/device_registry";
import {
removeConfigEntryFromDevice,
removeDeviceFromRegistry,
updateDeviceRegistryEntry,
} from "../../../data/device/device_registry";
import type { EntityRegistryEntry } from "../../../data/entity/entity_registry";
@@ -142,7 +146,7 @@ export class HaConfigDeviceDashboard extends LitElement {
state: true,
subscribe: false,
})
private _filter: string = history.state?.filter || "";
private _filter: string = getHistoryState()?.filter || "";
@state()
private _filters: DataTableFilters = {};
@@ -262,7 +266,7 @@ export class HaConfigDeviceDashboard extends LitElement {
}
this._fromUrl = true;
this._filter = history.state?.filter || "";
this._filter = getHistoryState()?.filter || "";
this._filters = {
"ha-filter-states": {
@@ -778,9 +782,7 @@ export class HaConfigDeviceDashboard extends LitElement {
<hass-tabs-subpage-data-table
.hass=${this.hass}
.narrow=${this.narrow}
.backPath=${
this._searchParms.has("historyBack") ? undefined : "/config"
}
back-path="/config"
.tabs=${configSections.devices}
.route=${this.route}
.searchLabel=${this.hass.localize(
@@ -1043,7 +1045,7 @@ export class HaConfigDeviceDashboard extends LitElement {
private _handleSearchChange(ev: CustomEvent) {
this._filter = ev.detail.value;
history.replaceState({ filter: this._filter }, "");
updateHistoryState({ filter: this._filter });
}
private _addDevice() {
@@ -1206,19 +1208,9 @@ ${rejected
dismissText: this.hass.localize("ui.common.cancel"),
destructive: true,
confirm: async () => {
const proms: Promise<DeviceRegistryEntry>[] = [];
const proms: Promise<null>[] = [];
this._selectedCanDelete.forEach((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)
);
}
});
proms.push(removeDeviceFromRegistry(this.hass!, deviceId));
});
const results = await Promise.allSettled(proms);
if (hasRejectedItems(results)) {
+1 -7
View File
@@ -78,8 +78,6 @@ 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;
@@ -126,11 +124,7 @@ class HaConfigEnergy extends LitElement {
return html`
<hass-tabs-subpage
.hass=${this.hass}
.backPath=${
this._searchParms.has("historyBack")
? undefined
: "/config/lovelace/dashboards"
}
back-path="/config/lovelace/dashboards"
.route=${this.route}
.tabs=${TABS}
>
@@ -39,6 +39,7 @@ 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 {
@@ -192,7 +193,7 @@ export class HaConfigEntities extends LitElement {
state: true,
subscribe: false,
})
private _filter: string = history.state?.filter || "";
private _filter: string = getHistoryState()?.filter || "";
@state() private _searchParms = new URLSearchParams(window.location.search);
@@ -810,9 +811,7 @@ export class HaConfigEntities extends LitElement {
<hass-tabs-subpage-data-table
.hass=${this.hass}
.narrow=${this.narrow}
.backPath=${
this._searchParms.has("historyBack") ? undefined : "/config"
}
back-path="/config"
.route=${this.route}
.tabs=${configSections.devices}
.columns=${this._columns(this.hass.localize, filteredEntities)}
@@ -1111,7 +1110,7 @@ export class HaConfigEntities extends LitElement {
}
this._fromUrl = true;
this._filter = history.state?.filter || "";
this._filter = getHistoryState()?.filter || "";
this._filters = {
"ha-filter-states": [],
@@ -1246,7 +1245,7 @@ export class HaConfigEntities extends LitElement {
private _handleSearchChange(ev: CustomEvent) {
this._filter = ev.detail.value;
history.replaceState({ filter: this._filter }, "");
updateHistoryState({ 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 { navigate } from "../../../common/navigate";
import { getHistoryState, navigate } from "../../../common/navigate";
import type {
LocalizeFunc,
LocalizeKeys,
@@ -644,9 +644,7 @@ export class HaConfigHelpers extends SubscribeMixin(LitElement) {
<hass-tabs-subpage-data-table
.hass=${this.hass}
.narrow=${this.narrow}
.backPath=${
this._searchParms.has("historyBack") ? undefined : "/config"
}
back-path="/config"
.route=${this.route}
.tabs=${configSections.devices}
.searchLabel=${this.hass.localize(
@@ -1009,7 +1007,7 @@ export class HaConfigHelpers extends SubscribeMixin(LitElement) {
}
this._fromUrl = true;
this._filter = history.state?.filter || "";
this._filter = getHistoryState()?.filter || "";
this._filters = {
"ha-filter-floor-areas": area ? { areas: [area] } : undefined,
@@ -22,7 +22,7 @@ import {
type DisableConfigEntryResult,
} from "../../../data/config_entries";
import {
removeConfigEntryFromDevice,
removeDeviceFromRegistry,
updateDeviceRegistryEntry,
type DeviceRegistryEntry,
} from "../../../data/device/device_registry";
@@ -315,7 +315,6 @@ 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"),
@@ -328,11 +327,7 @@ class HaConfigEntryDeviceRow extends LitElement {
}
try {
await removeConfigEntryFromDevice(
this.hass!,
this.device.id,
entry.entry_id
);
await removeDeviceFromRegistry(this.hass!, this.device.id);
} catch (err: any) {
showAlertDialog(this, {
title: this.hass.localize("ui.panel.config.devices.error_delete"),
@@ -373,7 +373,11 @@ class HaConfigIntegrationPage extends SubscribeMixin(LitElement) {
: this._manifest?.documentation;
return html`
<hass-subpage .hass=${this.hass} .narrow=${this.narrow}>
<hass-subpage
.hass=${this.hass}
.narrow=${this.narrow}
back-path="/config/integrations/dashboard"
>
${
documentationLink
? html`
@@ -901,7 +905,7 @@ class HaConfigIntegrationPage extends SubscribeMixin(LitElement) {
);
if (row) {
row.scrollIntoView({
block: "center",
block: "start",
});
row.classList.add("highlight");
}
@@ -1539,6 +1543,7 @@ 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,7 +14,11 @@ import {
PROTOCOL_INTEGRATIONS,
protocolIntegrationPicked,
} from "../../../common/integrations/protocolIntegrationPicked";
import { navigate } from "../../../common/navigate";
import {
getHistoryState,
navigate,
updateHistoryState,
} from "../../../common/navigate";
import { caseInsensitiveStringCompare } from "../../../common/string/compare";
import { extractSearchParam } from "../../../common/url/search-params";
import { nextRender } from "../../../common/util/render-status";
@@ -163,9 +167,7 @@ class HaConfigIntegrationsDashboard extends KeyboardShortcutMixin(
window.location.hash.substring(1)
);
@state() private _searchParams = new URLSearchParams(window.location.search);
@state() private _filter: string = history.state?.filter || "";
@state() private _filter: string = getHistoryState()?.filter || "";
@state() private _logInfos?: Record<string, IntegrationLogInfo>;
@@ -526,9 +528,7 @@ class HaConfigIntegrationsDashboard extends KeyboardShortcutMixin(
return html`
<hass-tabs-subpage
.hass=${this.hass}
.backPath=${
this._searchParams.has("historyBack") ? undefined : "/config"
}
back-path="/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 ?? "";
history.replaceState({ filter: this._filter }, "");
updateHistoryState({ filter: this._filter });
}
private async _highlightEntry() {
@@ -249,6 +249,7 @@ 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,6 +2,7 @@ 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";
@@ -50,6 +51,14 @@ 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;
@@ -137,6 +146,17 @@ 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,6 +3,7 @@ import type { BluetoothDeviceData } from "../../../../../data/bluetooth";
export interface BluetoothDeviceInfoDialogParams {
entry: BluetoothDeviceData;
deviceId?: string;
}
export const loadBluetoothDeviceInfoDialog = () =>
@@ -95,6 +95,7 @@ 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,7 +61,11 @@ export class MQTTConfigPanel extends LitElement {
protected render(): TemplateResult {
return html`
<hass-subpage .narrow=${this.narrow} .hass=${this.hass}>
<hass-subpage
.narrow=${this.narrow}
.hass=${this.hass}
back-path="/config/integrations/integration/mqtt"
>
<div class="content">
<ha-card
.header=${this.hass.localize("ui.panel.config.mqtt.settings_title")}
@@ -99,6 +99,7 @@ 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,6 +106,7 @@ 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,6 +76,7 @@ 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 { goBack, navigate } from "../../../../../common/navigate";
import { 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}
.backCallback=${this._goBack}
.backPath=${this._backPath}
>
<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}
.backCallback=${this._goBack}
.backPath=${this._backPath}
>
<div class="container">
<zha-device-summary-card
@@ -253,13 +253,11 @@ class ZHADevicePage extends LitElement {
return ["clusters", "bindings", "signature", "neighbors"].includes(tab);
}
private _goBack = (): void => {
goBack(
this._device
? `/config/devices/device/${this._device.device_reg_id}`
: "/config/zha/dashboard"
);
};
private get _backPath(): string {
return 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,6 +55,7 @@ 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");
goBack("/config/integrations/integration/zwave_js");
}
private _fetchData = async () => {
@@ -34,7 +34,12 @@ class ZWaveJSConfigEntryPicker extends LitElement {
if (this._configEntries.length === 0) {
return html`
<hass-subpage header="Z-Wave" .narrow=${this.narrow} .hass=${this.hass}>
<hass-subpage
header="Z-Wave"
.narrow=${this.narrow}
.hass=${this.hass}
back-path="/config/integrations/integration/zwave_js"
>
<div class="content">
<ha-card>
<div class="card-content">
@@ -51,7 +56,12 @@ class ZWaveJSConfigEntryPicker extends LitElement {
}
return html`
<hass-subpage header="Z-Wave" .narrow=${this.narrow} .hass=${this.hass}>
<hass-subpage
header="Z-Wave"
.narrow=${this.narrow}
.hass=${this.hass}
back-path="/config/integrations/integration/zwave_js"
>
<div class="content">
<ha-card
.header=${this.hass.localize(
+6 -1
View File
@@ -19,6 +19,7 @@ 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";
@@ -38,7 +39,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;
@@ -98,6 +99,10 @@ 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,6 +51,8 @@ export class HaConfigLovelaceResources extends LitElement {
@state() private _resources: LovelaceResource[] = [];
@state() private _loaded = false;
@state() private _lovelaceInfo?: LovelaceInfo;
@state()
@@ -134,7 +136,7 @@ export class HaConfigLovelaceResources extends LitElement {
);
protected render(): TemplateResult {
if (!this.hass || this._resources === undefined) {
if (!this.hass || !this._loaded) {
return html` <hass-loading-screen></hass-loading-screen> `;
}
@@ -175,6 +177,7 @@ 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}
@@ -228,6 +231,7 @@ export class HaConfigLovelaceResources extends LitElement {
]);
this._resources = resources;
this._lovelaceInfo = lovelaceInfo;
this._loaded = true;
}
private _editResource(ev: CustomEvent) {
@@ -17,6 +17,7 @@ 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";
@@ -32,7 +33,7 @@ class HaConfigRepairsDashboard extends SubscribeMixin(LitElement) {
@state() private _repairsIssues: RepairsIssue[] = [];
@state() private _searchParms = new URLSearchParams(window.location.search);
@state() private _loaded = false;
@state() private _showIgnored = false;
@@ -60,6 +61,7 @@ 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);
@@ -70,6 +72,10 @@ 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
@@ -77,9 +83,7 @@ class HaConfigRepairsDashboard extends SubscribeMixin(LitElement) {
return html`
<hass-subpage
.backPath=${
this._searchParms.has("historyBack") ? undefined : "/config/system"
}
back-path="/config/system"
.hass=${this.hass}
.narrow=${this.narrow}
.header=${this.hass.localize("ui.panel.config.repairs.caption")}
@@ -459,9 +459,7 @@ class HaSceneDashboard extends SubscribeMixin(LitElement) {
<hass-tabs-subpage-data-table
.hass=${this.hass}
.narrow=${this.narrow}
.backPath=${
this._searchParms.has("historyBack") ? undefined : "/config"
}
back-path="/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");
goBack("/config/scene/dashboard");
return;
}
@@ -1084,7 +1084,7 @@ export class HaSceneEditor extends DirtyStateProviderMixin<number>()(
if (this._mode === "live") {
applyScene(this.hass, this._storedStates);
}
afterNextRender(() => goBack("/config"));
afterNextRender(() => goBack("/config/scene/dashboard"));
}
private _deleteTapped(): void {
@@ -1111,7 +1111,7 @@ export class HaSceneEditor extends DirtyStateProviderMixin<number>()(
if (this._mode === "live") {
applyScene(this.hass, this._storedStates);
}
goBack("/config");
goBack("/config/scene/dashboard");
}
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("/config");
goBack(this.dashboardPath);
}
private async _promptScriptAlias(): Promise<boolean> {
+1 -3
View File
@@ -430,9 +430,7 @@ class HaScriptPicker extends SubscribeMixin(LitElement) {
<hass-tabs-subpage-data-table
.hass=${this.hass}
.narrow=${this.narrow}
.backPath=${
this._searchParms.has("historyBack") ? undefined : "/config"
}
back-path="/config"
.route=${this.route}
.tabs=${configSections.automations}
.searchLabel=${this.hass.localize(
+3 -6
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 } from "../../../common/navigate";
import { navigate, replaceCurrentUrl } from "../../../common/navigate";
import "../../../components/ha-button";
import "../../../components/ha-dropdown";
import type { HaDropdownSelectEvent } from "../../../components/ha-dropdown";
@@ -106,6 +106,7 @@ export class HaScriptTrace extends LitElement {
<hass-subpage
.hass=${this.hass}
.narrow=${this.narrow}
back-path="/config/script/dashboard"
.header=${title}
.scrollable=${this.narrow}
>
@@ -433,11 +434,7 @@ export class HaScriptTrace extends LitElement {
if (runId) {
const params = new URLSearchParams(location.search);
params.delete("run_id");
history.replaceState(
null,
"",
`${location.pathname}?${params.toString()}`
);
replaceCurrentUrl(`${location.pathname}?${params.toString()}`);
}
await showAlertDialog(this, {
@@ -16,6 +16,7 @@ import {
listAssistPipelines,
} from "../../../../data/assist_pipeline";
import "../../../../layouts/hass-subpage";
import "../../../../layouts/hass-loading-screen";
import type { HomeAssistant } from "../../../../types";
interface AssistDeviceExtra extends AssistDevice {
@@ -124,10 +125,15 @@ 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"
)}
@@ -143,7 +149,7 @@ class AssistDevicesPage extends LitElement {
this.hass.states,
this._pipelines,
this._preferred,
this._devices || []
this._devices
)}
auto-height
@row-click=${this._handleRowClicked}
@@ -51,6 +51,7 @@ 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,6 +60,7 @@ 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,8 +29,6 @@ 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>`;
@@ -39,9 +37,7 @@ export class HaConfigVoiceAssistantsAssistants extends LitElement {
return html`
<hass-tabs-subpage
.hass=${this.hass}
.backPath=${
this._searchParms.has("historyBack") ? undefined : "/config"
}
back-path="/config"
.route=${this.route}
.tabs=${voiceAssistantTabs}
>
@@ -492,9 +492,7 @@ export class VoiceAssistantsExpose extends LitElement {
<hass-tabs-subpage-data-table
.hass=${this.hass}
.narrow=${this.narrow}
.backPath=${
this._searchParms.has("historyBack") ? undefined : "/config"
}
back-path="/config"
.route=${this.route}
.tabs=${voiceAssistantTabs}
.columns=${this._columns(
+1 -5
View File
@@ -57,8 +57,6 @@ 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[];
@@ -248,9 +246,7 @@ export class HaConfigZone extends SubscribeMixin(LitElement) {
<hass-tabs-subpage
.hass=${this.hass}
.route=${this.route}
.backPath=${
this._searchParms.has("historyBack") ? undefined : "/config"
}
back-path="/config"
.tabs=${configSections.areas}
has-fab
>
@@ -0,0 +1,96 @@
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,6 +26,7 @@ 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";
@@ -88,6 +89,7 @@ export const UI_FEATURE_TYPES = [
"light-brightness",
"light-color-temp",
"light-color-favorites",
"light-effect",
"lock-commands",
"lock-open-door",
"media-player-playback",
@@ -143,6 +145,7 @@ 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,6 +47,11 @@ export interface LightColorFavoritesCardFeatureConfig {
type: "light-color-favorites";
}
export interface LightEffectCardFeatureConfig {
type: "light-effect";
effects?: string[];
}
export interface LockCommandsCardFeatureConfig {
type: "lock-commands";
}
@@ -341,6 +346,7 @@ export type LovelaceCardFeatureConfig =
| LightBrightnessCardFeatureConfig
| LightColorTempCardFeatureConfig
| LightColorFavoritesCardFeatureConfig
| LightEffectCardFeatureConfig
| LockCommandsCardFeatureConfig
| LockOpenDoorCardFeatureConfig
| MediaPlayerPlaybackCardFeatureConfig
@@ -28,6 +28,7 @@ const DOMAIN_VARIANTS: Record<string, TileVariant[]> = {
TILE_TOGGLE_VARIANT,
["light-color-temp"],
["light-color-favorites"],
["light-effect"],
],
cover: [
TILE_VARIANT,
@@ -23,6 +23,7 @@ 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";
@@ -82,6 +83,7 @@ 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,6 +52,7 @@ 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";
@@ -114,6 +115,7 @@ const UI_FEATURE_TYPES = [
"light-brightness",
"light-color-temp",
"light-color-favorites",
"light-effect",
"lock-commands",
"lock-open-door",
"media-player-playback",
@@ -160,6 +162,7 @@ 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",
@@ -206,6 +209,7 @@ 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,
@@ -0,0 +1,100 @@
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;
}
}
+2 -4
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 } from "../../common/navigate";
import { navigate, replaceCurrentUrl } from "../../common/navigate";
import type { LocalizeFunc } from "../../common/translations/localize";
import { constructUrlCurrentPath } from "../../common/url/construct-url";
import {
@@ -509,9 +509,7 @@ export class LovelacePanel extends LitElement {
};
if ("editMode" in props) {
window.history.replaceState(
null,
"",
replaceCurrentUrl(
constructUrlCurrentPath(
props.editMode
? addSearchParam({ edit: "1" })
+24 -33
View File
@@ -27,8 +27,7 @@ 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 { isNavigationClick } from "../../common/dom/is-navigation-click";
import { goBack, navigate } from "../../common/navigate";
import { goBack, navigate, replaceCurrentUrl } 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";
@@ -76,6 +75,7 @@ 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,11 +677,7 @@ class HUIRoot extends LitElement {
);
private _clearParam(param: string) {
window.history.replaceState(
null,
"",
constructUrlCurrentPath(removeSearchParam(param))
);
replaceCurrentUrl(constructUrlCurrentPath(removeSearchParam(param)));
}
protected firstUpdated(changedProps: PropertyValues<this>) {
@@ -893,44 +889,39 @@ class HUIRoot extends LitElement {
};
private _goBack(): void {
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) {
navigate(backPath, { replace: true });
} else if (history.length > 1) {
goBack();
} else if (!views[0].subview) {
navigate(this.route!.prefix, { replace: true });
} else {
navigate("/");
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);
}
private _handleBackClick(ev: MouseEvent): void {
if (this._backPath && !isNavigationClick(ev)) {
return;
}
this._goBack();
handleBackClick(ev, this._backPath, () => this._goBack());
}
private get _backPath(): string | undefined {
private get _configuredBackPath(): string | undefined {
const views = this.lovelace?.config.views ?? [];
const curViewConfig =
typeof this._curView === "number" ? views[this._curView] : undefined;
const backPath = sanitizeNavigationPath(
curViewConfig?.back_path ?? this.backPath
);
return sanitizeNavigationPath(curViewConfig?.back_path ?? this.backPath);
}
if (backPath) {
return backPath;
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;
return curViewConfig?.subview ? this.route!.prefix : undefined;
}
@@ -9,6 +9,7 @@ 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";
@@ -137,7 +138,7 @@ export class SectionsView extends LitElement implements LovelaceViewElement {
"section-visibility-changed",
this._sectionVisibilityChanged
);
this._sidebarTabActive = Boolean(window.history.state?.sidebar);
this._sidebarTabActive = Boolean(getHistoryState()?.sidebar);
}
disconnectedCallback(): void {
@@ -509,10 +510,7 @@ export class SectionsView extends LitElement implements LovelaceViewElement {
this._sidebarTabActive = !this._sidebarTabActive;
// Add sidebar state to history
window.history.replaceState(
{ ...window.history.state, sidebar: this._sidebarTabActive },
""
);
updateHistoryState({ sidebar: this._sidebarTabActive });
// Restore scroll position after view updates
this.updateComplete.then(() => {
@@ -4,6 +4,7 @@ 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";
@@ -90,7 +91,7 @@ class HaProfileSectionGeneral extends LitElement {
}
private _clearHash() {
history.replaceState(null, "", window.location.pathname);
replaceCurrentUrl(window.location.pathname);
}
protected render(): TemplateResult {
+4 -1
View File
@@ -209,7 +209,6 @@ class HaRefreshTokens extends LitElement {
<ha-button
variant="danger"
appearance="filled"
size="s"
@click=${this._deleteAllTokens}
>
${this.hass.localize(
@@ -352,6 +351,10 @@ class HaRefreshTokens extends LitElement {
border-radius: var(--ha-border-radius-circle);
margin-right: 6px;
}
.card-actions {
display: flex;
justify-content: flex-end;
}
`,
];
}
+1 -2
View File
@@ -379,8 +379,7 @@ export const connectionMixin = <T extends Constructor<HassBaseEl>>(
// @ts-ignore
this.hass!.callWS({ type: "get_config" }).then((config: HassConfig) => {
if (config.safe_mode) {
// @ts-ignore Firefox supports forceGet
location.reload(true);
location.reload();
}
this._updateHass({ config });
this.checkDataBaseMigration();
+39
View File
@@ -2,6 +2,7 @@ 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 {
@@ -25,7 +26,31 @@ 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;
}
@@ -59,6 +84,20 @@ 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;
}
+2 -4
View File
@@ -2,6 +2,7 @@
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";
@@ -20,10 +21,7 @@ export const urlSyncMixin = <
public connectedCallback(): void {
super.connectedCallback();
if (mainWindow.history.length === 1) {
mainWindow.history.replaceState(
{ ...mainWindow.history.state, root: true },
""
);
updateHistoryState({ root: true });
}
mainWindow.addEventListener("popstate", this._popstateChangeListener);
}
+7
View File
@@ -2543,6 +2543,7 @@
"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.",
@@ -7445,6 +7446,7 @@
"updated": "Updated",
"device": "Device",
"device_information": "Device information",
"open_device": "Open device",
"advertisement_data": "Advertisement data",
"manufacturer_data": "Manufacturer data",
"service_data": "Service data",
@@ -10591,6 +10593,11 @@
"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
@@ -0,0 +1,200 @@
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
@@ -0,0 +1,92 @@
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!.getAttribute("href")).toEqual("/config/system");
expect(backButton!.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!.hasAttribute("href")).toBe(false);
expect(backButton!.href).toBeUndefined();
}
);
});
@@ -0,0 +1,81 @@
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
@@ -0,0 +1,220 @@
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);
});
});
});