Compare commits

...
Author SHA1 Message Date
Bram Kragten fa0b579c26 fix panels missing updates 2026-08-07 16:56:34 +02:00
Paul BotteinandGitHub 78a2d4d6df Fix disabled save button when leaving an automation with unsaved changes (#53538) 2026-08-07 13:40:00 +00:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
0f8d7b4d41 Update dependency @rspack/core to v2.1.8 (#53547)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-08-07 16:27:32 +03:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
cf3009f408 Update dependency marked to v18.0.9 (#53544)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-08-07 16:26:21 +03:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
bdf629f836 Update dependency typescript-eslint to v8.66.0 (#53541)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-08-07 05:18:39 +00:00
Krisjanis LejejsandGitHub 464b58af04 Add cloud page UI to the Gallery (#53532)
Add cloud page UI to the design
2026-08-06 17:03:08 +02:00
Bram KragtenandGitHub 29a75209ac Wait with showing pending http changes dialog until loading screen is… (#53534)
* Wait with showing pending http changes dialog until loading screen is gone

* fix timing
2026-08-06 17:12:08 +03:00
Petar PetrovandGitHub f89b3c23c7 Keep selected option visible in disabled select box (#53535) 2026-08-06 15:02:41 +01:00
Bram KragtenandGitHub 29b631b960 Fix link to different port being handled as internal link (#53530) 2026-08-06 15:43:44 +02:00
renovate[bot]andGitHub dd94a7bac1 Update dependency @rspack/dev-server to v2.2.0 (#53529) 2026-08-06 14:19:01 +01:00
Aidan TimsonandGitHub 34154195c8 Remove "dev" from tools actions description (#53528)
* Remove "dev" from tools actions description

* Clarify vt is browser developer tools
2026-08-06 14:53:54 +02:00
0b34fcb559 Show target-state entity icon in scene editor review mode (#52520)
* Show target-state entity icon in scene editor review mode

Render the entity badge in review mode (previously live-only) across both
the device-grouped and standalone entity lists. In review mode the badge
uses the scene's stored target state instead of the entity's live state, so
the icon previews what the scene will set once applied.

* Strip stale entity_picture from the synthesized review-mode state

state-badge hides the icon and renders a background image whenever a state
carries an entity_picture (see its willUpdate). A scene snapshots that URL
with an access token that is stale by the time review mode renders, so an
image-backed entity - for example a robot vacuum's "Map data" camera entity
on its device - showed a blank badge in review mode instead of an icon.

Drop entity_picture / entity_picture_local from the synthesized state so the
entity's own icon resolves. This is not a pre-existing defect: it handles a
case that rendering the badge in review mode (previous commit) introduces.

* Handle null and scalar scene entity values in review mode badges

An entity left without a value in the YAML editor parses as null, which
crashed the review-mode render. The scene config API also returns raw
scenes.yaml content without validation, so hand-edited scenes deliver
boolean states as-is (YAML 1.1 parses unquoted on/off as booleans);
these previously rendered as if the entity had no state at all.

Booleans map to on/off to match how the backend applies them when a
scene is activated (_convert_states in the homeassistant scene
platform). The backend rejects null and numeric states at save, but
review mode renders before save, so the frontend has to tolerate them.

* Extract and memoize the scene target-state synthesis

The editor re-renders on every hass change, and building a fresh state
object per row each time defeated Lit dirty-checking: every state-badge
re-ran willUpdate and every ha-state-icon restarted its async icon
resolution. Memoizing the synthesized objects per config keeps the
references stable so unchanged badges skip all of that.

Moving the synthesis to src/data/scene.ts makes it unit-testable; the
null, boolean, numeric, string, and picture-stripping cases are now
covered by tests.

* Sanitize brightness and rgb_color in the synthesized review-mode state

state-badge does arithmetic on brightness and joins rgb_color, assuming
backend-shaped values. Hand-typed YAML can hold both as strings: a
string rgb_color threw a TypeError that left the badge blank, and a
string brightness computed a brightness(36049%) filter that washed the
icon out to invisible. Coerce numeric-string brightness and drop
malformed values so the badge always renders the target state.

* Borrow the live device_class for review-mode badge icons

Icon resolution keys on device_class, which string-shorthand and
hand-written minimal scene entries do not carry, so a garage cover fell
back to the generic window icon and sensors to the domain default. Only
this identity attribute is borrowed from the live state - merging
stateful attributes like rgb_color would mis-color an off target.

* Reject unusable scene targets and trim the badge-state synthesis

Review-mode badges now render only when the scene holds a usable
target state. Entries with no state to show - null values, dicts
without a state key, arrays, non-scalar states - yield no badge
instead of falling back to the live state, which was
indistinguishable from a real target and, for dicts without a state,
crashed state-badge via stateColorCss on lights.

rgb_color and brightness are dropped from inactive targets: a live
entity never carries them while off, and state-badge applies them
without checking activity, so a scene turning a light off rendered an
active-looking colored icon.

Entity pictures are stripped only for DOMAINS_WITH_DYNAMIC_PICTURE,
matching createHistoricState in the logbook; stable pictures on other
domains are kept.

The brightness/rgb_color type coercion and the live device_class
borrowing are removed: they defended against hand-typed shapes that
state-badge already warns about, and the borrowing made the memoized
synthesis depend on hass state outside its memoize key.

* Apply suggestion from @MindFreeze

---------

Co-authored-by: Przemysław Szypowicz <[email protected]>
Co-authored-by: Petar Petrov <[email protected]>
2026-08-06 11:37:39 +00:00
20 changed files with 1335 additions and 206 deletions
+1
View File
@@ -228,6 +228,7 @@ export default [
"entity-state",
"ha-markdown",
"integration-card",
"cloud-account",
"box-shadow",
"util-long-press",
"remove-delete-add-create",
@@ -106,6 +106,17 @@ export class DemoHaSelectBox extends LitElement {
</ha-card>
`;
})}
<ha-card>
<div class="card-content">
<label>Disabled with a selected option</label>
<ha-select-box
.value=${"card"}
.options=${fullOptions}
.disabled=${true}
>
</ha-select-box>
</div>
</ha-card>
<ha-card>
<div class="card-content">
<p class="title"><b>Column layout</b></p>
@@ -0,0 +1,8 @@
---
title: Cloud account
---
The [Home Assistant Cloud](https://www.nabucasa.com/) account page, rendered from
mocked cloud data. The controls at the top flip the mocked subscription, remote,
backup, onboarding, and feature state so every UI state can be previewed here
without a real cloud account.
+568
View File
@@ -0,0 +1,568 @@
import { css, html, LitElement, nothing } from "lit";
import { customElement, state } from "lit/decorators";
import type { HASSDomEvent } from "../../../../src/common/dom/fire_event";
import "../../../../src/components/ha-formfield";
import "../../../../src/components/ha-select";
import type { HaSelectSelectEvent } from "../../../../src/components/ha-select";
import "../../../../src/components/ha-switch";
import type { HaSwitch } from "../../../../src/components/ha-switch";
import type { BackupConfig } from "../../../../src/data/backup";
import { BackupScheduleRecurrence } from "../../../../src/data/backup";
import type {
CloudStatusLoggedIn,
RemoteCertificateStatus,
SubscriptionInfo,
} from "../../../../src/data/cloud";
import { ONBOARDING_ITEMS } from "../../../../src/data/cloud";
import type { Webhook } from "../../../../src/data/webhook";
import type { ShowDialogParams } from "../../../../src/dialogs/make-dialog-manager";
import { showDialog } from "../../../../src/dialogs/make-dialog-manager";
import type { MockHomeAssistant } from "../../../../src/fake_data/provide_hass";
import { provideHass } from "../../../../src/fake_data/provide_hass";
import type { ProvideHassElement } from "../../../../src/mixins/provide-hass-lit-mixin";
import "../../../../src/panels/config/cloud/account/cloud-account-onboarding";
import "../../../../src/panels/config/cloud/account/cloud-account-overview";
import { onboardingComplete } from "../../../../src/panels/config/cloud/account/cloud-account-status";
import { showCloudOnboardingDialog } from "../../../../src/panels/config/cloud/account/show-dialog-cloud-onboarding";
import type { HomeAssistant } from "../../../../src/types";
// This demo renders the self-contained cloud account cards (overview +
// onboarding) directly, driven by mocked data, with controls to flip the
// subscription, remote, backup, onboarding, and feature state so every UI state
// can be previewed. It intentionally does NOT render the <cloud-account> panel
// wrapper, which adds a hass-subpage toolbar/back button and route links that
// have no home in the gallery. See src/panels/config/cloud/account.
// The five PaymentSubscriptionState values.
type DemoCloudAccount =
"active" | "trialing" | "canceled" | "expired" | "unknown";
// "local": automatic backups run, but only to the local agent (no cloud copy).
// "none": no automatic backups at all.
type DemoCloudBackup = "fresh" | "stale" | "failed" | "local" | "none";
interface CloudDemoScenario {
account: DemoCloudAccount;
onboarded: boolean;
postponed: boolean;
remote: boolean;
remoteStatus: RemoteCertificateStatus;
backup: DemoCloudBackup;
alexa: boolean;
google: boolean;
webrtc: boolean;
webhooks: boolean;
}
const DEFAULT_SCENARIO: CloudDemoScenario = {
account: "active",
// Onboarding not completed and streaming (WebRTC) left off, so the onboarding
// card shows by default with streaming as the remaining step.
onboarded: false,
postponed: false,
remote: true,
remoteStatus: "ready",
backup: "fresh",
alexa: true,
google: true,
webrtc: false,
webhooks: true,
};
const SUBSCRIPTION_OPTIONS: { value: DemoCloudAccount; label: string }[] = [
{ value: "active", label: "Active" },
{ value: "trialing", label: "Trialing" },
{ value: "canceled", label: "Canceled" },
{ value: "expired", label: "Expired" },
{ value: "unknown", label: "Unknown" },
];
const REMOTE_STATUS_OPTIONS: {
value: RemoteCertificateStatus;
label: string;
}[] = [
{ value: "ready", label: "Ready" },
{ value: "generating", label: "Preparing" },
{ value: "loading", label: "Loading" },
{ value: "loaded", label: "Loaded" },
{ value: "error", label: "Error" },
];
const BACKUP_OPTIONS: { value: DemoCloudBackup; label: string }[] = [
{ value: "fresh", label: "Recent" },
{ value: "stale", label: "Old" },
{ value: "failed", label: "Failed" },
{ value: "local", label: "Local only" },
{ value: "none", label: "None" },
];
const TOGGLES: [keyof CloudDemoScenario, string][] = [
["onboarded", "Onboarded"],
["postponed", "Onboarding postponed"],
["remote", "Remote access"],
["alexa", "Alexa linked"],
["google", "Google linked"],
["webrtc", "Cameras (WebRTC)"],
["webhooks", "Has webhooks"],
];
const CLOUD_AGENT = "cloud.cloud";
const emptyFilter = () => ({
include_domains: [],
include_entities: [],
exclude_domains: [],
exclude_entities: [],
});
const demoWebhooks: Webhook[] = [
{
webhook_id: "demo_front_door",
domain: "automation",
name: "Front door motion",
local_only: false,
},
{
webhook_id: "demo_companion_app",
domain: "mobile_app",
name: "Companion app",
local_only: false,
},
];
const buildSubscription = (scenario: CloudDemoScenario): SubscriptionInfo => ({
human_description: "Demo subscription, renews automatically",
provider: "Nabu Casa, Inc.",
plan_renewal_date: 4102444800,
subscription: { status: scenario.account },
});
const buildCloudStatus = (scenario: CloudDemoScenario): CloudStatusLoggedIn => {
const active =
scenario.account !== "canceled" && scenario.account !== "expired";
const cloudhooks = scenario.webhooks
? Object.fromEntries(
demoWebhooks.map((webhook) => [
webhook.webhook_id,
{
webhook_id: webhook.webhook_id,
cloudhook_id: `demo-${webhook.webhook_id}`,
cloudhook_url: `https://hooks.nabu.casa/demo-${webhook.webhook_id}`,
managed: false,
},
])
)
: {};
return {
logged_in: true,
cloud: "connected",
cloud_last_disconnect_reason: null,
email: "[email protected]",
google_registered: scenario.google,
google_entities: emptyFilter(),
google_domains: ["light", "switch", "climate", "cover"],
alexa_registered: scenario.alexa,
alexa_entities: emptyFilter(),
remote_domain: "demo-instance.ui.nabu.casa",
remote_connected: scenario.remote,
remote_certificate: {
common_name: "demo-instance.ui.nabu.casa",
expire_date: "2099-01-01T00:00:00+00:00",
fingerprint: "demodemodemodemodemodemodemodemodemodemodemodemodemo",
alternative_names: ["demo-instance.ui.nabu.casa"],
},
remote_certificate_status: scenario.remoteStatus,
http_use_ssl: false,
active_subscription: active,
onboarding_postponed: scenario.postponed,
onboarding_completed: scenario.onboarded,
prefs: {
google_enabled: scenario.google,
alexa_enabled: scenario.alexa,
remote_enabled: scenario.remote,
remote_allow_remote_enable: true,
strict_connection: "disabled",
google_secure_devices_pin: undefined,
cloudhooks,
alexa_report_state: true,
google_report_state: true,
tts_default_voice: ["en-US", "JennyNeural"],
cloud_ice_servers_enabled: scenario.webrtc,
onboarded_items: scenario.onboarded ? [...ONBOARDING_ITEMS] : [],
onboarding_postponed_until: scenario.postponed
? new Date(Date.now() + 24 * 3600 * 1000).toISOString()
: null,
},
};
};
const buildBackupConfig = (scenario: CloudDemoScenario): BackupConfig => {
const now = Date.now();
const recent = new Date(now - 12 * 3600 * 1000).toISOString();
const old = new Date(now - 5 * 86400000).toISOString();
const future = new Date(now + 86400000).toISOString();
const overdue = new Date(now - 6 * 3600 * 1000).toISOString();
const cloudEnabled =
scenario.backup === "fresh" ||
scenario.backup === "stale" ||
scenario.backup === "failed";
let configured = true;
let lastCompleted: string | null = null;
let lastAttempted: string | null = null;
let next: string | null = null;
switch (scenario.backup) {
case "fresh":
case "local":
lastCompleted = recent;
lastAttempted = recent;
next = future;
break;
case "stale":
lastCompleted = old;
lastAttempted = old;
next = overdue;
break;
case "failed":
lastCompleted = old;
lastAttempted = recent;
next = future;
break;
case "none":
configured = false;
break;
}
return {
automatic_backups_configured: configured,
last_attempted_automatic_backup: lastAttempted,
last_completed_automatic_backup: lastCompleted,
next_automatic_backup: next,
next_automatic_backup_additional: false,
create_backup: {
agent_ids: cloudEnabled
? ["backup.local", CLOUD_AGENT]
: ["backup.local"],
include_addons: [],
include_all_addons: true,
include_database: true,
include_folders: [],
name: null,
password: null,
},
retention: { copies: 3, days: null },
schedule: {
recurrence: BackupScheduleRecurrence.DAILY,
time: null,
days: [],
},
agents: {
"backup.local": { protected: true, retention: null },
"cloud.cloud": { protected: true, retention: null },
},
};
};
@customElement("demo-misc-cloud-account")
export class DemoMiscCloudAccount
extends LitElement
implements ProvideHassElement
{
@state() private hass!: HomeAssistant;
@state() private _scenario: CloudDemoScenario = { ...DEFAULT_SCENARIO };
@state() private _cloudStatus!: CloudStatusLoggedIn;
@state() private _subscription!: SubscriptionInfo;
@state() private _backupConfig!: BackupConfig;
constructor() {
super();
const hass = provideHass(this);
hass.updateTranslations(null, "en");
hass.updateTranslations("config", "en");
hass.updateHass({
config: {
...hass.config,
components: [
...(hass.config?.components ?? []),
"cloud",
"backup",
"webhook",
],
},
});
this._registerMocks(hass);
this._applyScenario();
}
public provideHass(el) {
el.hass = this.hass;
}
public connectedCallback() {
super.connectedCallback();
this.addEventListener("show-dialog", this._showDialog);
this.addEventListener("cloud-open-onboarding", this._openOnboarding);
this.addEventListener("ha-refresh-cloud-status", this._refreshFromMocks);
// The overview and onboarding dialog contain real <a href="/config/..">
// links to panel routes that do not exist in the gallery. Keep them inert.
this.addEventListener("click", this._neutralizeNavigation);
}
public disconnectedCallback() {
super.disconnectedCallback();
this.removeEventListener("show-dialog", this._showDialog);
this.removeEventListener("cloud-open-onboarding", this._openOnboarding);
this.removeEventListener("ha-refresh-cloud-status", this._refreshFromMocks);
this.removeEventListener("click", this._neutralizeNavigation);
}
protected render() {
if (!this.hass) {
return nothing;
}
const showOnboarding =
this._cloudStatus.active_subscription &&
!this._cloudStatus.onboarding_completed &&
!this._cloudStatus.onboarding_postponed &&
!onboardingComplete(this._cloudStatus, this._backupConfig);
return html`
<div class="options">
<div class="selects">
${this._select("Subscription", "account", SUBSCRIPTION_OPTIONS)}
${this._select("Remote status", "remoteStatus", REMOTE_STATUS_OPTIONS)}
${this._select("Backups", "backup", BACKUP_OPTIONS)}
</div>
<div class="switches">
${TOGGLES.map(([field, label]) => this._toggle(label, field))}
</div>
</div>
<div class="preview">
${
showOnboarding
? html`
<cloud-account-onboarding
.hass=${this.hass}
.cloudStatus=${this._cloudStatus}
.backupConfig=${this._backupConfig}
></cloud-account-onboarding>
`
: nothing
}
<cloud-account-overview
.hass=${this.hass}
.cloudStatus=${this._cloudStatus}
.subscription=${this._subscription}
.backupConfig=${this._backupConfig}
.webhooks=${demoWebhooks}
></cloud-account-overview>
</div>
`;
}
private _select(
label: string,
field: keyof CloudDemoScenario,
options: { value: string; label: string }[]
) {
return html`
<ha-select
.label=${label}
.value=${String(this._scenario[field])}
.options=${options}
data-field=${field}
@selected=${this._selectChanged}
></ha-select>
`;
}
private _toggle(label: string, field: keyof CloudDemoScenario) {
return html`
<ha-formfield .label=${label}>
<ha-switch
.checked=${this._scenario[field] as boolean}
data-field=${field}
@change=${this._switchChanged}
></ha-switch>
</ha-formfield>
`;
}
private _selectChanged(ev: HaSelectSelectEvent) {
const field = (ev.currentTarget as HTMLElement).dataset
.field as keyof CloudDemoScenario;
this._setField(field, ev.detail.value as string);
}
private _switchChanged(ev: Event) {
const target = ev.target as HaSwitch;
this._setField(
target.dataset.field as keyof CloudDemoScenario,
target.checked
);
}
private _setField(field: keyof CloudDemoScenario, value: string | boolean) {
if (this._scenario[field] === value) {
return;
}
this._scenario = { ...this._scenario, [field]: value };
this._applyScenario();
}
private _applyScenario() {
this._cloudStatus = buildCloudStatus(this._scenario);
this._subscription = buildSubscription(this._scenario);
this._backupConfig = buildBackupConfig(this._scenario);
}
private _openOnboarding = () => {
showCloudOnboardingDialog(this, {
cloudStatus: this._cloudStatus,
backupConfig: this._backupConfig,
onChanged: () => this._refreshFromMocks(),
});
};
private _showDialog = (ev: HASSDomEvent<ShowDialogParams<unknown>>) => {
const { dialogTag, dialogImport, dialogParams, addHistory, parentElement } =
ev.detail;
showDialog(
this,
dialogTag,
dialogParams,
dialogImport,
parentElement,
addHistory
);
};
private _refreshFromMocks = () => {
this._cloudStatus = {
...this._cloudStatus,
prefs: { ...this._cloudStatus.prefs },
};
this._backupConfig = { ...this._backupConfig };
const cloudBackup =
this._backupConfig.create_backup.agent_ids.includes(CLOUD_AGENT);
this._scenario = {
...this._scenario,
onboarded: this._cloudStatus.onboarding_completed,
postponed: this._cloudStatus.onboarding_postponed,
remote: this._cloudStatus.prefs.remote_enabled,
webrtc: this._cloudStatus.prefs.cloud_ice_servers_enabled,
backup: !this._backupConfig.automatic_backups_configured
? "none"
: cloudBackup
? this._scenario.backup === "fresh" ||
this._scenario.backup === "stale" ||
this._scenario.backup === "failed"
? this._scenario.backup
: "fresh"
: "local",
};
};
private _neutralizeNavigation = (ev: MouseEvent) => {
const anchor = ev
.composedPath()
.find((el): el is HTMLAnchorElement => el instanceof HTMLAnchorElement);
if (anchor?.getAttribute("href")?.startsWith("/")) {
ev.preventDefault();
}
};
private _registerMocks(hass: MockHomeAssistant) {
hass.mockWS("cloud/status", () => ({
...this._cloudStatus,
prefs: { ...this._cloudStatus.prefs },
}));
hass.mockWS("cloud/update_prefs", (msg) => {
const { type, ...prefs } = msg;
this._cloudStatus.prefs = { ...this._cloudStatus.prefs, ...prefs };
return { success: true };
});
hass.mockWS("cloud/onboarding/postpone", () => {
this._cloudStatus.onboarding_postponed = true;
this._cloudStatus.prefs.onboarding_postponed_until = new Date(
Date.now() + 24 * 3600 * 1000
).toISOString();
return { ...this._cloudStatus, prefs: { ...this._cloudStatus.prefs } };
});
hass.mockWS("cloud/remote/connect", () => {
this._cloudStatus.remote_connected = true;
this._cloudStatus.prefs.remote_enabled = true;
return null;
});
hass.mockWS("cloud/remote/disconnect", () => {
this._cloudStatus.remote_connected = false;
this._cloudStatus.prefs.remote_enabled = false;
return null;
});
hass.mockWS("backup/config/info", () => ({
config: { ...this._backupConfig },
}));
hass.mockWS("backup/config/update", (msg) => {
const { type, ...update } = msg;
if (update.create_backup) {
this._backupConfig.create_backup = {
...this._backupConfig.create_backup,
...update.create_backup,
};
}
if (update.automatic_backups_configured !== undefined) {
this._backupConfig.automatic_backups_configured =
update.automatic_backups_configured;
}
return null;
});
}
static styles = css`
.options {
max-width: 600px;
margin: 16px auto 0;
padding: 0 16px 16px;
border-bottom: 1px solid var(--divider-color);
display: flex;
flex-direction: column;
gap: 16px;
}
.selects {
display: flex;
flex-wrap: wrap;
gap: 12px;
}
.selects ha-select {
min-width: 160px;
flex: 1;
}
.switches {
display: flex;
flex-wrap: wrap;
gap: 4px 16px;
}
.preview {
padding: 24px 16px;
display: flex;
flex-direction: column;
gap: var(--ha-space-6, 24px);
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"demo-misc-cloud-account": DemoMiscCloudAccount;
}
}
+4 -4
View File
@@ -114,7 +114,7 @@
"lit": "3.3.3",
"lit-html": "3.3.3",
"luxon": "3.7.2",
"marked": "18.0.7",
"marked": "18.0.9",
"memoize-one": "6.0.0",
"node-vibrant": "4.0.4",
"object-hash": "3.0.0",
@@ -152,8 +152,8 @@
"@octokit/rest": "22.0.1",
"@playwright/test": "1.62.1",
"@rsdoctor/rspack-plugin": "1.6.1",
"@rspack/core": "2.1.7",
"@rspack/dev-server": "2.1.0",
"@rspack/core": "2.1.8",
"@rspack/dev-server": "2.2.0",
"@types/babel__plugin-transform-runtime": "7.9.5",
"@types/chromecast-caf-receiver": "6.0.26",
"@types/chromecast-caf-sender": "1.0.11",
@@ -210,7 +210,7 @@
"terser-webpack-plugin": "5.6.1",
"ts-lit-plugin": "2.0.2",
"typescript": "6.0.3",
"typescript-eslint": "8.65.0",
"typescript-eslint": "8.66.0",
"vite-tsconfig-paths": "6.1.1",
"vitest": "4.1.10",
"webpack-stats-plugin": "1.1.3",
+11 -11
View File
@@ -23,24 +23,24 @@ export const isNavigationClick = (e: MouseEvent, preventDefault = true) => {
return undefined;
}
let href = anchor.href;
if (!href || href.indexOf("mailto:") !== -1) {
let url: URL;
try {
// anchor.href is always absolute; an empty or unparseable value throws.
url = new URL(anchor.href);
} catch {
return undefined;
}
const location = window.location;
const origin = location.origin || location.protocol + "//" + location.host;
if (!href.startsWith(origin)) {
return undefined;
}
href = href.slice(origin.length);
if (href === "#") {
// Only intercept same-origin links. A different scheme, host, or port is a
// different origin (e.g. another port like ":8123") and must trigger a full
// browser navigation instead of an in-app route change. Non-http(s) schemes
// such as mailto: resolve to a null origin and are excluded here too.
if (url.origin !== window.location.origin) {
return undefined;
}
if (preventDefault) {
e.preventDefault();
}
return href;
return url.pathname + url.search + url.hash;
};
+2 -1
View File
@@ -56,6 +56,7 @@ export class HaSelectBox extends LitElement {
class="list"
style=${styleMap({ "--columns": columns })}
.value=${this.value}
?disabled=${this.disabled}
@change=${this._radioChanged}
>
${this.options.map((option) => this._renderOption(option))}
@@ -100,7 +101,7 @@ export class HaSelectBox extends LitElement {
)}
aria-labelledby=${`label-${option.value}`}
.value=${option.value}
.disabled=${disabled}
.disabled=${option.disabled || false}
></ha-radio-option>
<div class="text">
<span id=${`label-${option.value}`} class="label"
@@ -53,7 +53,6 @@ export class HaSelectorAutomationBehavior extends LitElement {
value: behavior,
label: this._localizeOption(behavior, "label"),
description: this._localizeOption(behavior, "description"),
disabled: this.disabled,
...(isTrigger && {
image: {
src: `/static/images/form/automation_behavior_trigger_${behavior}.svg`,
@@ -66,6 +65,7 @@ export class HaSelectorAutomationBehavior extends LitElement {
<ha-select-box
.options=${options}
.value=${this.value ?? ""}
.disabled=${this.disabled}
max_columns="1"
?stacked_image=${isTrigger}
@value-changed=${this._valueChanged}
@@ -104,6 +104,7 @@ export class HaSelectSelector extends LitElement {
<ha-select-box
.options=${options}
.value=${this.value as string | undefined}
.disabled=${this.disabled}
@value-changed=${this._selectChanged}
.maxColumns=${this.selector.select?.box_max_columns}
></ha-select-box>
+73
View File
@@ -1,7 +1,11 @@
import type {
HassEntity,
HassEntityAttributeBase,
HassEntityBase,
} from "home-assistant-js-websocket";
import { DOMAINS_WITH_DYNAMIC_PICTURE } from "../common/const";
import { computeDomain } from "../common/entity/compute_domain";
import { stateActive } from "../common/entity/state_active";
import { navigate } from "../common/navigate";
import type { HomeAssistant, ServiceCallResponse } from "../types";
@@ -66,6 +70,75 @@ export type SceneMetaData = Record<
{ entity_only?: boolean | undefined }
>;
// Hand-edited scenes.yaml is parsed as YAML 1.1 by the backend, so unquoted
// on/off arrive here as booleans. The backend applies boolean states as
// on/off; mirror that so the badge reflects what activating the scene will
// actually do. Everything else the backend rejects (numbers, null, arrays,
// objects) yields undefined.
const normalizeSceneEntityState = (sceneState: unknown): string | undefined => {
if (typeof sceneState === "boolean") {
return sceneState ? "on" : "off";
}
if (typeof sceneState === "string") {
return sceneState;
}
return undefined;
};
// Builds a state object from the scene's stored target state, so the scene
// editor can render the icon the entity will have once the scene is applied
// rather than its current live icon. The parameter is typed unknown because
// the scene config API returns raw YAML: booleans, numbers, nulls, and
// malformed shapes occur in hand-edited files beyond what the SceneEntities
// union declares. Entries that hold no usable target state (an entity left
// without a value in the YAML editor parses as null, a dict may lack a state
// key) yield undefined so the caller renders no badge instead of guessing.
//
// The result is a partial HassEntity meant for badge rendering only - it has
// no last_changed, last_updated, or context.
export const sceneEntityStateObj = (
entityId: string,
sceneEntity: unknown
): HassEntity | undefined => {
if (
typeof sceneEntity !== "object" ||
sceneEntity === null ||
Array.isArray(sceneEntity)
) {
const state = normalizeSceneEntityState(sceneEntity);
return state === undefined
? undefined
: ({ entity_id: entityId, state, attributes: {} } as HassEntity);
}
const { state: sceneState, ...attributes } = sceneEntity as Record<
string,
unknown
>;
const state = normalizeSceneEntityState(sceneState);
if (state === undefined) {
return undefined;
}
// Media-derived entity pictures are snapshotted with an access token that
// is stale by the time review mode renders, which would leave the badge
// showing a broken image instead of an icon. Stable pictures on other
// domains are kept - the same policy createHistoricState applies for the
// logbook.
if (DOMAINS_WITH_DYNAMIC_PICTURE.has(computeDomain(entityId))) {
delete attributes.entity_picture;
delete attributes.entity_picture_local;
}
const stateObj = { entity_id: entityId, state, attributes } as HassEntity;
// A live entity never carries color attributes while off, and state-badge
// applies rgb_color and brightness without checking activity; drop them for
// inactive targets so a scene that turns a light off does not render an
// active-looking colored icon.
if (!stateActive(stateObj)) {
delete attributes.rgb_color;
delete attributes.brightness;
}
return stateObj;
};
export const activateScene = (
hass: HomeAssistant,
entityId: string
+21 -3
View File
@@ -55,6 +55,13 @@ export class HassRouterPage extends ReactiveElement {
private _currentLoadProm?: Promise<void>;
// True while a route change is loading and the outgoing panel (or a loading
// screen) is still shown, waiting to be replaced. While true we don't forward
// property updates, because they are meant for the incoming panel. It stays
// false when the new panel is shown immediately (no loading screen), so that
// panel keeps receiving updates while its module finishes loading.
private _replacingPanel = false;
private _panelReady = new PanelReady();
private _cache = {};
@@ -79,9 +86,9 @@ export class HassRouterPage extends ReactiveElement {
}
if (!changedProps.has("route")) {
// Do not update if we have a currentLoadProm, because that means
// that there is still an old panel shown and we're moving to a new one.
if (this.lastChild && !this._currentLoadProm) {
// Skip while the outgoing panel is still shown for a pending route
// change; the update is meant for the incoming panel, not this one.
if (this.lastChild && !this._replacingPanel) {
this.updatePageEl(this.lastChild, changedProps);
}
return;
@@ -188,6 +195,9 @@ export class HassRouterPage extends ReactiveElement {
this._currentLoadProm = undefined;
};
this._currentLoadProm = loadProm.then(loadComplete, loadComplete);
// The new panel is shown right away, so keep forwarding updates to it
// while its module loads.
this._replacingPanel = false;
this._createPanel(routerOptions, newPage, routeOptions);
return;
}
@@ -195,6 +205,9 @@ export class HassRouterPage extends ReactiveElement {
// We are only going to show the loading screen after some time.
// That way we won't have a double fast flash on fast connections.
let created = false;
// The outgoing panel stays shown until the new one has loaded; don't
// forward updates to it in the meantime.
this._replacingPanel = true;
this._showLoadingScreenTimeout = window.setTimeout(() => {
if (created || this._currentPage !== newPage) {
@@ -223,9 +236,14 @@ export class HassRouterPage extends ReactiveElement {
// @ts-ignore TS forgot this is not a string.
routeOptions
);
// The new panel is now shown; resume forwarding updates to it.
this._replacingPanel = false;
},
() => {
this._currentLoadProm = undefined;
if (this._currentPage === newPage) {
this._replacingPanel = false;
}
}
);
}
+26 -13
View File
@@ -102,13 +102,12 @@ export class HomeAssistantAppEl extends QuickBarMixin(HassElement) {
) {
this.checkDataBaseMigration();
}
// Wait for `hass.user` to populate so the admin guard can run; it arrives
// asynchronously after `hass.config`.
if (
changedProps.has("hass") &&
this.hass?.user &&
oldHass?.user !== this.hass.user
) {
// Wait for `hass.user` to first populate so the admin guard can run; it
// arrives asynchronously after `hass.config`. `hass.user` also gets a fresh
// reference at runtime (reconnect, profile refresh via subscribeUser), so
// only trigger on the initial population (null -> user). Reconnect re-checks
// come from connection-mixin, the launch-screen swap re-check from update().
if (changedProps.has("hass") && this.hass?.user && !oldHass?.user) {
this.checkHttpPendingConfig();
}
if (
@@ -125,12 +124,12 @@ export class HomeAssistantAppEl extends QuickBarMixin(HassElement) {
}
protected update(changedProps: PropertyValues<this>) {
if (
this.hass?.states &&
this.hass.config &&
this.hass.services &&
this._databaseMigration === false
) {
const removingLaunchScreen =
!!this.hass?.states &&
!!this.hass.config &&
!!this.hass.services &&
this._databaseMigration === false;
if (removingLaunchScreen) {
this.render = this.renderHass;
this.update = super.update;
// partial-panel-resolver removes the launch screen after the first panel
@@ -138,6 +137,13 @@ export class HomeAssistantAppEl extends QuickBarMixin(HassElement) {
// screen covers the frontend until frontend/loaded is sent.
}
super.update(changedProps);
if (removingLaunchScreen) {
// Surface the HTTP pending config dialog only after super.update() has
// committed the render swap above, which clears the launch screen from
// the shadow root. Appending the dialog before that render would let it
// tear the freshly-added dialog straight back out of the DOM.
this.checkHttpPendingConfig();
}
}
protected firstUpdated(changedProps: PropertyValues<this>) {
@@ -256,6 +262,13 @@ export class HomeAssistantAppEl extends QuickBarMixin(HassElement) {
if (__DEMO__ || this._httpPendingDialogOpen) {
return;
}
// Only show once the main UI is rendered. During startup the root swaps
// the launch screen for the app, which clears its shadow root and would
// tear the freshly-appended dialog straight back out of the DOM (closing
// it). When called too early we skip; the swap in update() re-runs this.
if (this.render !== this.renderHass) {
return;
}
if (!this.hass?.user?.is_admin) {
return;
}
@@ -128,8 +128,12 @@ class DialogAutomationSave
`;
}
private get _isDiscardDialog(): boolean {
return this._params?.onDiscard !== undefined;
}
protected _renderDiscard() {
if (!this._params?.onDiscard) {
if (!this._isDiscardDialog) {
return nothing;
}
return html`
@@ -324,10 +328,14 @@ class DialogAutomationSave
<ha-button
slot="primaryAction"
@click=${this._save}
.disabled=${!!this._params.config.alias && !this.isDirtyState}
.disabled=${
!!this._params.config.alias &&
!this._isDiscardDialog &&
!this.isDirtyState
}
>
${this.hass.localize(
this._params.config.alias && !this._params.onDiscard
this._params.config.alias && !this._isDiscardDialog
? "ui.panel.config.automation.editor.rename"
: "ui.common.save"
)}
@@ -462,7 +462,7 @@ class HaConfigHttpForm extends LitElement {
"ui.panel.config.network.http.restart_address.text"
)}
</p>
<a href=${url} rel="noreferrer noopener">${url}</a>
<a href=${url} target="_blank" rel="noreferrer noopener">${url}</a>
<p class="dialog-note">
${this.hass.localize(
"ui.panel.config.network.http.restart_address.note"
+48 -14
View File
@@ -15,7 +15,7 @@ import {
mdiPlaylistEdit,
mdiTag,
} from "@mdi/js";
import type { HassEvent } from "home-assistant-js-websocket";
import type { HassEntity, HassEvent } from "home-assistant-js-websocket";
import type { CSSResultGroup, PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
@@ -67,6 +67,7 @@ import {
getSceneEditorInitData,
saveScene,
SCENE_IGNORED_DOMAINS,
sceneEntityStateObj,
showSceneEditor,
} from "../../../data/scene";
import {
@@ -447,13 +448,15 @@ export class HaSceneEditor extends DirtyStateProviderMixin<number>()(
const integrationName = platform
? domainToName(this.hass.localize, platform)
: undefined;
const badgeStateObj = this._badgeStateObj(
entityId,
entityStateObj
);
return html`
<ha-list-item
hasMeta
?twoline=${!!secondary}
.graphic=${
this._mode === "live" ? "icon" : undefined
}
graphic="icon"
.entityId=${entityId}
@click=${
this._mode === "live"
@@ -463,10 +466,10 @@ export class HaSceneEditor extends DirtyStateProviderMixin<number>()(
.noninteractive=${this._mode === "review"}
>
${
this._mode === "live"
badgeStateObj
? html`
<state-badge
.stateObj=${entityStateObj}
.stateObj=${badgeStateObj}
slot="graphic"
></state-badge>
`
@@ -552,14 +555,16 @@ export class HaSceneEditor extends DirtyStateProviderMixin<number>()(
this.hass.localize,
computeDomain(entityId)
);
const badgeStateObj = this._badgeStateObj(
entityId,
entityStateObj
);
return html`
<ha-list-item
class="entity"
hasMeta
?twoline=${!!secondary}
.graphic=${
this._mode === "live" ? "icon" : undefined
}
graphic="icon"
.entityId=${entityId}
@click=${
this._mode === "live"
@@ -569,11 +574,13 @@ export class HaSceneEditor extends DirtyStateProviderMixin<number>()(
.noninteractive=${this._mode === "review"}
>
${
this._mode === "live"
? html` <state-badge
.stateObj=${entityStateObj}
slot="graphic"
></state-badge>`
badgeStateObj
? html`
<state-badge
.stateObj=${badgeStateObj}
slot="graphic"
></state-badge>
`
: nothing
}
${primary}
@@ -1188,6 +1195,33 @@ export class HaSceneEditor extends DirtyStateProviderMixin<number>()(
return { ...stateObj.attributes, state: stateObj.state };
}
// Memoized per config so re-renders reuse the same object references and
// the state badges skip work when nothing changed.
private _sceneStateObjs = memoizeOne((config?: SceneConfig) => {
const objs: Record<string, HassEntity | undefined> = {};
for (const entityId of Object.keys(config?.entities ?? {})) {
objs[entityId] = sceneEntityStateObj(
entityId,
config!.entities[entityId]
);
}
return objs;
});
// Picks the state the row's icon should reflect: the live state in live
// mode, the scene's stored target in review mode. Undefined in review mode
// when the scene holds no usable target for the entity - the row then
// renders no badge rather than a live state that could be mistaken for a
// target.
private _badgeStateObj(
entityId: string,
entityStateObj: HassEntity
): HassEntity | undefined {
return this._mode === "live"
? entityStateObj
: this._sceneStateObjs(this._config)[entityId];
}
private _generateConfigFromLive() {
this._config = {
...this._config!,
+2 -2
View File
@@ -3854,7 +3854,7 @@
},
"disable_view_transition": {
"title": "Disable view transitions",
"description": "Disable animated view transitions to prevent browser crash when using browser dev tools."
"description": "Disable animated view transitions to prevent browser crash when using browser developer tools."
},
"entity_diagnostic": {
"title": "Entity diagnostic",
@@ -3899,7 +3899,7 @@
},
"actions": {
"title": "Actions",
"description": "The actions dev tool allows you to perform any action available in Home Assistant.",
"description": "The actions tool allows you to perform any action available in Home Assistant.",
"call_service": "Perform action",
"response": "Response",
"column_parameter": "Parameter",
+136
View File
@@ -0,0 +1,136 @@
import { describe, expect, it, vi } from "vitest";
import { isNavigationClick } from "../../../src/common/dom/is-navigation-click";
const createAnchor = (
href: string,
attributes: Record<string, string> = {}
): HTMLAnchorElement => {
const anchor = document.createElement("a");
anchor.href = href;
for (const [name, value] of Object.entries(attributes)) {
anchor.setAttribute(name, value);
}
return anchor;
};
const clickEvent = (
anchor: HTMLAnchorElement,
overrides: Partial<MouseEvent> = {}
): MouseEvent =>
({
defaultPrevented: false,
button: 0,
metaKey: false,
ctrlKey: false,
shiftKey: false,
composedPath: () => [anchor],
preventDefault: vi.fn(),
...overrides,
}) as unknown as MouseEvent;
describe("isNavigationClick", () => {
it("returns the path for same-origin links", () => {
expect(
isNavigationClick(clickEvent(createAnchor(`${location.origin}/config`)))
).toEqual("/config");
expect(
isNavigationClick(
clickEvent(createAnchor(`${location.origin}/energy?historyBack=1`))
)
).toEqual("/energy?historyBack=1");
expect(
isNavigationClick(
clickEvent(createAnchor(`${location.origin}/config/areas#section`))
)
).toEqual("/config/areas#section");
});
it("does not intercept a link on a different port", () => {
// The current origin has no port, so a ported URL shares its string prefix
// but is a different origin — it must not be treated as internal.
expect(
isNavigationClick(clickEvent(createAnchor(`${location.origin}:8123/`)))
).toBeUndefined();
});
it("does not intercept a host that merely starts with the origin", () => {
expect(
isNavigationClick(
clickEvent(createAnchor(`${location.origin}.example.com/`))
)
).toBeUndefined();
});
it("does not intercept other origins", () => {
expect(
isNavigationClick(clickEvent(createAnchor("https://example.com/")))
).toBeUndefined();
});
it("calls preventDefault for intercepted navigations", () => {
const event = clickEvent(createAnchor(`${location.origin}/config`));
isNavigationClick(event);
expect(event.preventDefault).toHaveBeenCalled();
});
it("does not preventDefault when disabled", () => {
const event = clickEvent(createAnchor(`${location.origin}/config`));
expect(isNavigationClick(event, false)).toEqual("/config");
expect(event.preventDefault).not.toHaveBeenCalled();
});
it("ignores clicks with a target, download, or rel=external", () => {
expect(
isNavigationClick(
clickEvent(
createAnchor(`${location.origin}/config`, { target: "_blank" })
)
)
).toBeUndefined();
expect(
isNavigationClick(
clickEvent(createAnchor(`${location.origin}/config`, { download: "" }))
)
).toBeUndefined();
expect(
isNavigationClick(
clickEvent(
createAnchor(`${location.origin}/config`, { rel: "external" })
)
)
).toBeUndefined();
});
it("ignores modified clicks and non-left buttons", () => {
const anchor = createAnchor(`${location.origin}/config`);
expect(
isNavigationClick(clickEvent(anchor, { button: 1 }))
).toBeUndefined();
expect(
isNavigationClick(clickEvent(anchor, { metaKey: true }))
).toBeUndefined();
expect(
isNavigationClick(clickEvent(anchor, { ctrlKey: true }))
).toBeUndefined();
expect(
isNavigationClick(clickEvent(anchor, { shiftKey: true }))
).toBeUndefined();
expect(
isNavigationClick(clickEvent(anchor, { defaultPrevented: true }))
).toBeUndefined();
});
it("ignores mailto links and clicks without an anchor", () => {
expect(
isNavigationClick(clickEvent(createAnchor("mailto:[email protected]")))
).toBeUndefined();
expect(
isNavigationClick(
clickEvent(undefined as any, {
composedPath: () => [document.createElement("div")],
})
)
).toBeUndefined();
});
});
+116
View File
@@ -0,0 +1,116 @@
import { describe, expect, it } from "vitest";
import { sceneEntityStateObj } from "../../src/data/scene";
describe("sceneEntityStateObj", () => {
it("builds a state object from string shorthand", () => {
expect(sceneEntityStateObj("light.kitchen", "on")).toEqual({
entity_id: "light.kitchen",
state: "on",
attributes: {},
});
});
it("builds a state object from the dict form", () => {
expect(
sceneEntityStateObj("light.kitchen", {
state: "on",
brightness: 180,
rgb_color: [255, 64, 112],
})
).toEqual({
entity_id: "light.kitchen",
state: "on",
attributes: { brightness: 180, rgb_color: [255, 64, 112] },
});
});
it("strips media-derived entity pictures but keeps other attributes", () => {
const sceneEntity = {
state: "playing",
entity_picture: "/api/media_player_proxy/x?token=stale",
entity_picture_local: "/local/x.jpg",
friendly_name: "Player",
};
expect(sceneEntityStateObj("media_player.x", sceneEntity)).toEqual({
entity_id: "media_player.x",
state: "playing",
attributes: { friendly_name: "Player" },
});
// The input scene config must not be mutated.
expect(sceneEntity.entity_picture).toBe(
"/api/media_player_proxy/x?token=stale"
);
});
it("keeps a stable entity picture on other domains", () => {
expect(
sceneEntityStateObj("vacuum.robot", {
state: "cleaning",
entity_picture: "/local/robot.png",
})?.attributes.entity_picture
).toBe("/local/robot.png");
});
it("returns undefined for null and undefined values", () => {
// An entity left without a value in the YAML editor parses as null.
expect(sceneEntityStateObj("light.kitchen", null)).toBeUndefined();
expect(sceneEntityStateObj("light.kitchen", undefined)).toBeUndefined();
});
it("returns undefined for an array value", () => {
expect(sceneEntityStateObj("light.kitchen", [255, 100])).toBeUndefined();
});
it("returns undefined for a numeric value", () => {
// The backend only accepts string and boolean states, so a number never
// becomes a target the scene can apply.
expect(sceneEntityStateObj("input_number.x", 23)).toBeUndefined();
expect(
sceneEntityStateObj("input_number.x", { state: 23 })
).toBeUndefined();
});
it("returns undefined when the dict holds no usable state", () => {
expect(
sceneEntityStateObj("light.kitchen", { brightness: 100 })
).toBeUndefined();
expect(
sceneEntityStateObj("light.kitchen", { state: null })
).toBeUndefined();
expect(
sceneEntityStateObj("light.kitchen", { state: { brightness: 100 } })
).toBeUndefined();
});
it("normalizes boolean shorthand to on/off like the backend does", () => {
// YAML 1.1 parses unquoted on/off in scenes.yaml as booleans.
expect(sceneEntityStateObj("light.kitchen", true)?.state).toBe("on");
expect(sceneEntityStateObj("light.kitchen", false)?.state).toBe("off");
});
it("normalizes a boolean state in the dict form", () => {
expect(sceneEntityStateObj("light.kitchen", { state: true })).toEqual({
entity_id: "light.kitchen",
state: "on",
attributes: {},
});
});
it("drops color attributes for an inactive target", () => {
// A live entity never carries these while off, and state-badge applies
// them without checking activity.
expect(
sceneEntityStateObj("light.kitchen", {
state: "off",
brightness: 180,
rgb_color: [255, 0, 0],
friendly_name: "Kitchen",
})
).toEqual({
entity_id: "light.kitchen",
state: "off",
attributes: { friendly_name: "Kitchen" },
});
});
});
+133 -1
View File
@@ -1,6 +1,6 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { ReactiveElement } from "lit";
import { state } from "lit/decorators";
import { property, state } from "lit/decorators";
import type { RouterOptions } from "../../src/layouts/hass-router-page";
import { HassRouterPage } from "../../src/layouts/hass-router-page";
import { panelIsReady } from "../../src/layouts/panel-ready";
@@ -36,9 +36,71 @@ const loadedPanelImport = new Promise<void>((resolve) => {
class LoadedPanel extends HTMLElement {}
let resolvePropPanelImport: () => void;
const propPanelImport = new Promise<void>((resolve) => {
resolvePropPanelImport = resolve;
});
class PropPanel extends HTMLElement {
public value = 0;
}
// A router that shows its panel immediately (no `showLoading`) and forwards a
// property to it on every update, mirroring how the config and profile panels
// forward `hass` to their child sections.
class PropRouter extends HassRouterPage {
@property({ attribute: false }) public value = 0;
protected routerOptions: RouterOptions = {
routes: {
slow: { tag: "test-prop-panel", load: () => propPanelImport },
},
};
protected updatePageEl(el: PropPanel) {
el.value = this.value;
}
}
let resolveSecondPanelImport: () => void;
const secondPanelImport = new Promise<void>((resolve) => {
resolveSecondPanelImport = resolve;
});
class SwapFirstPanel extends HTMLElement {
public value = 0;
}
class SwapSecondPanel extends HTMLElement {
public value = 0;
}
// A router that shows a loading screen between routes, so the outgoing panel
// stays visible while the next one loads.
class SwapRouter extends HassRouterPage {
@property({ attribute: false }) public value = 0;
protected routerOptions: RouterOptions = {
showLoading: true,
routes: {
first: { tag: "test-swap-first-panel" },
second: { tag: "test-swap-second-panel", load: () => secondPanelImport },
},
};
protected updatePageEl(el: SwapFirstPanel | SwapSecondPanel) {
el.value = this.value;
}
}
customElements.define("test-router", TestRouter);
customElements.define("test-immediate-panel", ImmediatePanel);
customElements.define("test-deferred-panel", DeferredPanel);
customElements.define("test-prop-router", PropRouter);
customElements.define("test-prop-panel", PropPanel);
customElements.define("test-swap-router", SwapRouter);
customElements.define("test-swap-first-panel", SwapFirstPanel);
customElements.define("test-swap-second-panel", SwapSecondPanel);
let router: TestRouter | undefined;
@@ -101,11 +163,81 @@ describe("HassRouterPage panel readiness", () => {
});
});
describe("HassRouterPage update propagation during load", () => {
it("forwards updates to a panel shown while its module is still loading", async () => {
const element = document.createElement("test-prop-router") as PropRouter;
element.route = { prefix: "", path: "/slow" };
document.body.append(element);
await element.updateComplete;
const panel = element.lastElementChild as PropPanel;
expect(panel).toBeInstanceOf(PropPanel);
expect(panel.value).toBe(0);
// A new value (e.g. an updated `hass` carrying freshly loaded fragment
// translations) arrives while the panel module is still loading. The panel
// is already shown, so the update must reach it right away rather than
// being skipped as if an outgoing panel were still being replaced.
element.value = 1;
await element.updateComplete;
expect(panel.value).toBe(1);
// Finishing the load must not lose the value it received in the meantime.
resolvePropPanelImport();
await propPanelImport;
await element.updateComplete;
expect(panel.value).toBe(1);
element.remove();
});
it("does not forward updates to the outgoing panel while a new one loads", async () => {
const element = document.createElement("test-swap-router") as SwapRouter;
element.route = { prefix: "", path: "/first" };
document.body.append(element);
// A router with a loading screen creates the panel once its load resolves.
await vi.waitFor(() => {
expect(element.lastElementChild).toBeInstanceOf(SwapFirstPanel);
});
const first = element.lastElementChild as SwapFirstPanel;
expect(first.value).toBe(0);
// Navigate to a page whose module is still loading. The first panel stays
// shown in the meantime (the loading screen only appears after a delay).
element.route = { prefix: "", path: "/second" };
await element.updateComplete;
expect(element.lastElementChild).toBe(first);
// An update now is meant for the incoming panel, so it must not leak to the
// outgoing one that is about to be replaced.
element.value = 1;
await element.updateComplete;
expect(first.value).toBe(0);
// Once the new panel is shown, updates resume normally.
resolveSecondPanelImport();
await vi.waitFor(() => {
expect(element.lastElementChild).toBeInstanceOf(SwapSecondPanel);
});
const second = element.lastElementChild as SwapSecondPanel;
element.value = 2;
await element.updateComplete;
expect(second.value).toBe(2);
element.remove();
});
});
declare global {
interface HTMLElementTagNameMap {
"test-router": TestRouter;
"test-immediate-panel": ImmediatePanel;
"test-deferred-panel": DeferredPanel;
"test-loaded-panel": LoadedPanel;
"test-prop-router": PropRouter;
"test-prop-panel": PropPanel;
"test-swap-router": SwapRouter;
"test-swap-first-panel": SwapFirstPanel;
"test-swap-second-panel": SwapSecondPanel;
}
}
+161 -152
View File
@@ -2561,13 +2561,13 @@ __metadata:
languageName: node
linkType: hard
"@emnapi/core@npm:1.11.2":
version: 1.11.2
resolution: "@emnapi/core@npm:1.11.2"
"@emnapi/core@npm:1.11.3":
version: 1.11.3
resolution: "@emnapi/core@npm:1.11.3"
dependencies:
"@emnapi/wasi-threads": "npm:1.2.2"
"@emnapi/wasi-threads": "npm:1.2.3"
tslib: "npm:^2.4.0"
checksum: 10/b3e9693f79ca1d8bb90cb8a950150c7738f54eca0ae1849d3d5103a87ce4e388c2669fb253cc123d3449ad2ae12583435455dbdc0270b1e9efb0cfd502c952b4
checksum: 10/6ed871d5bbd0f6e25a09fe91583e886e4b90ae9344fa9292c78f0891b0b2a7d84899ea65a7d576450b9ac50440c698dad1907907e3d686ec9460ed876a9363a2
languageName: node
linkType: hard
@@ -2589,12 +2589,12 @@ __metadata:
languageName: node
linkType: hard
"@emnapi/runtime@npm:1.11.2":
version: 1.11.2
resolution: "@emnapi/runtime@npm:1.11.2"
"@emnapi/runtime@npm:1.11.3":
version: 1.11.3
resolution: "@emnapi/runtime@npm:1.11.3"
dependencies:
tslib: "npm:^2.4.0"
checksum: 10/280a219d3bf302615dabaaa248223b0e5fe9c49bacf0987dd958ca37ab425b62cf05287053cb188e711681418aaa5e447eaed6b62a0848c0a1303b2707864600
checksum: 10/ec834cc0fe248e06dd84f6dee10162133f8a692636189e99aa992303c791d4be1679536ee91aeee6661c4478609768cee9c085acd858caa92784c67acaab44a5
languageName: node
linkType: hard
@@ -2616,6 +2616,15 @@ __metadata:
languageName: node
linkType: hard
"@emnapi/wasi-threads@npm:1.2.3":
version: 1.2.3
resolution: "@emnapi/wasi-threads@npm:1.2.3"
dependencies:
tslib: "npm:^2.4.0"
checksum: 10/d9fef5c321a6df1988e813e0b621ed1e01d274c23d3028ff9511af97f8935adc921ae60a35167ccf4e0414fecc040b66b9b13458e7c00cfaad622457e1b03529
languageName: node
linkType: hard
"@eslint-community/eslint-utils@npm:^4.8.0, @eslint-community/eslint-utils@npm:^4.9.1":
version: 4.9.1
resolution: "@eslint-community/eslint-utils@npm:4.9.1"
@@ -4756,110 +4765,110 @@ __metadata:
languageName: node
linkType: hard
"@rspack/binding-darwin-arm64@npm:2.1.7":
version: 2.1.7
resolution: "@rspack/binding-darwin-arm64@npm:2.1.7"
"@rspack/binding-darwin-arm64@npm:2.1.8":
version: 2.1.8
resolution: "@rspack/binding-darwin-arm64@npm:2.1.8"
conditions: os=darwin & cpu=arm64
languageName: node
linkType: hard
"@rspack/binding-darwin-x64@npm:2.1.7":
version: 2.1.7
resolution: "@rspack/binding-darwin-x64@npm:2.1.7"
"@rspack/binding-darwin-x64@npm:2.1.8":
version: 2.1.8
resolution: "@rspack/binding-darwin-x64@npm:2.1.8"
conditions: os=darwin & cpu=x64
languageName: node
linkType: hard
"@rspack/binding-linux-arm64-gnu@npm:2.1.7":
version: 2.1.7
resolution: "@rspack/binding-linux-arm64-gnu@npm:2.1.7"
"@rspack/binding-linux-arm64-gnu@npm:2.1.8":
version: 2.1.8
resolution: "@rspack/binding-linux-arm64-gnu@npm:2.1.8"
conditions: os=linux & cpu=arm64 & libc=glibc
languageName: node
linkType: hard
"@rspack/binding-linux-arm64-musl@npm:2.1.7":
version: 2.1.7
resolution: "@rspack/binding-linux-arm64-musl@npm:2.1.7"
"@rspack/binding-linux-arm64-musl@npm:2.1.8":
version: 2.1.8
resolution: "@rspack/binding-linux-arm64-musl@npm:2.1.8"
conditions: os=linux & cpu=arm64 & libc=musl
languageName: node
linkType: hard
"@rspack/binding-linux-riscv64-gnu@npm:2.1.7":
version: 2.1.7
resolution: "@rspack/binding-linux-riscv64-gnu@npm:2.1.7"
"@rspack/binding-linux-riscv64-gnu@npm:2.1.8":
version: 2.1.8
resolution: "@rspack/binding-linux-riscv64-gnu@npm:2.1.8"
conditions: os=linux & cpu=riscv64 & libc=glibc
languageName: node
linkType: hard
"@rspack/binding-linux-riscv64-musl@npm:2.1.7":
version: 2.1.7
resolution: "@rspack/binding-linux-riscv64-musl@npm:2.1.7"
"@rspack/binding-linux-riscv64-musl@npm:2.1.8":
version: 2.1.8
resolution: "@rspack/binding-linux-riscv64-musl@npm:2.1.8"
conditions: os=linux & cpu=riscv64 & libc=musl
languageName: node
linkType: hard
"@rspack/binding-linux-x64-gnu@npm:2.1.7":
version: 2.1.7
resolution: "@rspack/binding-linux-x64-gnu@npm:2.1.7"
"@rspack/binding-linux-x64-gnu@npm:2.1.8":
version: 2.1.8
resolution: "@rspack/binding-linux-x64-gnu@npm:2.1.8"
conditions: os=linux & cpu=x64 & libc=glibc
languageName: node
linkType: hard
"@rspack/binding-linux-x64-musl@npm:2.1.7":
version: 2.1.7
resolution: "@rspack/binding-linux-x64-musl@npm:2.1.7"
"@rspack/binding-linux-x64-musl@npm:2.1.8":
version: 2.1.8
resolution: "@rspack/binding-linux-x64-musl@npm:2.1.8"
conditions: os=linux & cpu=x64 & libc=musl
languageName: node
linkType: hard
"@rspack/binding-wasm32-wasi@npm:2.1.7":
version: 2.1.7
resolution: "@rspack/binding-wasm32-wasi@npm:2.1.7"
"@rspack/binding-wasm32-wasi@npm:2.1.8":
version: 2.1.8
resolution: "@rspack/binding-wasm32-wasi@npm:2.1.8"
dependencies:
"@emnapi/core": "npm:1.11.2"
"@emnapi/runtime": "npm:1.11.2"
"@emnapi/core": "npm:1.11.3"
"@emnapi/runtime": "npm:1.11.3"
"@napi-rs/wasm-runtime": "npm:1.1.6"
conditions: cpu=wasm32
languageName: node
linkType: hard
"@rspack/binding-win32-arm64-msvc@npm:2.1.7":
version: 2.1.7
resolution: "@rspack/binding-win32-arm64-msvc@npm:2.1.7"
"@rspack/binding-win32-arm64-msvc@npm:2.1.8":
version: 2.1.8
resolution: "@rspack/binding-win32-arm64-msvc@npm:2.1.8"
conditions: os=win32 & cpu=arm64
languageName: node
linkType: hard
"@rspack/binding-win32-ia32-msvc@npm:2.1.7":
version: 2.1.7
resolution: "@rspack/binding-win32-ia32-msvc@npm:2.1.7"
"@rspack/binding-win32-ia32-msvc@npm:2.1.8":
version: 2.1.8
resolution: "@rspack/binding-win32-ia32-msvc@npm:2.1.8"
conditions: os=win32 & cpu=ia32
languageName: node
linkType: hard
"@rspack/binding-win32-x64-msvc@npm:2.1.7":
version: 2.1.7
resolution: "@rspack/binding-win32-x64-msvc@npm:2.1.7"
"@rspack/binding-win32-x64-msvc@npm:2.1.8":
version: 2.1.8
resolution: "@rspack/binding-win32-x64-msvc@npm:2.1.8"
conditions: os=win32 & cpu=x64
languageName: node
linkType: hard
"@rspack/binding@npm:2.1.7":
version: 2.1.7
resolution: "@rspack/binding@npm:2.1.7"
"@rspack/binding@npm:2.1.8":
version: 2.1.8
resolution: "@rspack/binding@npm:2.1.8"
dependencies:
"@rspack/binding-darwin-arm64": "npm:2.1.7"
"@rspack/binding-darwin-x64": "npm:2.1.7"
"@rspack/binding-linux-arm64-gnu": "npm:2.1.7"
"@rspack/binding-linux-arm64-musl": "npm:2.1.7"
"@rspack/binding-linux-riscv64-gnu": "npm:2.1.7"
"@rspack/binding-linux-riscv64-musl": "npm:2.1.7"
"@rspack/binding-linux-x64-gnu": "npm:2.1.7"
"@rspack/binding-linux-x64-musl": "npm:2.1.7"
"@rspack/binding-wasm32-wasi": "npm:2.1.7"
"@rspack/binding-win32-arm64-msvc": "npm:2.1.7"
"@rspack/binding-win32-ia32-msvc": "npm:2.1.7"
"@rspack/binding-win32-x64-msvc": "npm:2.1.7"
"@rspack/binding-darwin-arm64": "npm:2.1.8"
"@rspack/binding-darwin-x64": "npm:2.1.8"
"@rspack/binding-linux-arm64-gnu": "npm:2.1.8"
"@rspack/binding-linux-arm64-musl": "npm:2.1.8"
"@rspack/binding-linux-riscv64-gnu": "npm:2.1.8"
"@rspack/binding-linux-riscv64-musl": "npm:2.1.8"
"@rspack/binding-linux-x64-gnu": "npm:2.1.8"
"@rspack/binding-linux-x64-musl": "npm:2.1.8"
"@rspack/binding-wasm32-wasi": "npm:2.1.8"
"@rspack/binding-win32-arm64-msvc": "npm:2.1.8"
"@rspack/binding-win32-ia32-msvc": "npm:2.1.8"
"@rspack/binding-win32-x64-msvc": "npm:2.1.8"
dependenciesMeta:
"@rspack/binding-darwin-arm64":
optional: true
@@ -4885,15 +4894,15 @@ __metadata:
optional: true
"@rspack/binding-win32-x64-msvc":
optional: true
checksum: 10/1400d8553d2122850fc43a45b69828bcdcbccf7715381a627802e160da6f93e90ca41d0280a69d175894bc29a428a374c80157b874883363b998090dc0aff33c
checksum: 10/74c73ac543059fa62365557ca949ef7a84e1cebc42fe51a343126c875d90f557b35a52b33bacd9498d9231e8fdb8ab13907988e1fd39131ffbd56becb4260785
languageName: node
linkType: hard
"@rspack/core@npm:2.1.7":
version: 2.1.7
resolution: "@rspack/core@npm:2.1.7"
"@rspack/core@npm:2.1.8":
version: 2.1.8
resolution: "@rspack/core@npm:2.1.8"
dependencies:
"@rspack/binding": "npm:2.1.7"
"@rspack/binding": "npm:2.1.8"
peerDependencies:
"@module-federation/runtime-tools": ^0.24.1 || ^2.0.0
"@swc/helpers": ^0.5.23
@@ -4902,7 +4911,7 @@ __metadata:
optional: true
"@swc/helpers":
optional: true
checksum: 10/84526df889fa2813cfa7dfbed71b713c47c2010b1f55da26db698a87ea11f3f433a931ec29616ff300cb63d0ea27cbe7d2b40df64abf45f9b1a484b59c94ca1a
checksum: 10/c818257225a24feb22af81eb3ed7d02d4b74d6e6cb70689c8cbcd34e3facfcfe833dfe4078e0a35277e926f431cae9fd7f2101c3944b396a72baa7cf3ce99d19
languageName: node
linkType: hard
@@ -4918,9 +4927,9 @@ __metadata:
languageName: node
linkType: hard
"@rspack/dev-server@npm:2.1.0":
version: 2.1.0
resolution: "@rspack/dev-server@npm:2.1.0"
"@rspack/dev-server@npm:2.2.0":
version: 2.2.0
resolution: "@rspack/dev-server@npm:2.2.0"
dependencies:
"@rspack/dev-middleware": "npm:^2.0.3"
peerDependencies:
@@ -4929,7 +4938,7 @@ __metadata:
peerDependenciesMeta:
selfsigned:
optional: true
checksum: 10/402d4f96c60beaba354081a36b053c3cf7c1dda7fddab791031dc4206b9873b98437895d5a6a68247e07cb8a5922a1770143c560772dfdaa00ba90a5396251d8
checksum: 10/edfc57223fad64c43c96be95b0b6650194ac2e02d929bef649d524faa9c618233afe8f970ac2e21c77e1b92f6c65dac20f06817e73a9bd661f43c73057ae6b6b
languageName: node
linkType: hard
@@ -5762,105 +5771,105 @@ __metadata:
languageName: node
linkType: hard
"@typescript-eslint/eslint-plugin@npm:8.65.0":
version: 8.65.0
resolution: "@typescript-eslint/eslint-plugin@npm:8.65.0"
"@typescript-eslint/eslint-plugin@npm:8.66.0":
version: 8.66.0
resolution: "@typescript-eslint/eslint-plugin@npm:8.66.0"
dependencies:
"@eslint-community/regexpp": "npm:^4.12.2"
"@typescript-eslint/scope-manager": "npm:8.65.0"
"@typescript-eslint/type-utils": "npm:8.65.0"
"@typescript-eslint/utils": "npm:8.65.0"
"@typescript-eslint/visitor-keys": "npm:8.65.0"
"@typescript-eslint/scope-manager": "npm:8.66.0"
"@typescript-eslint/type-utils": "npm:8.66.0"
"@typescript-eslint/utils": "npm:8.66.0"
"@typescript-eslint/visitor-keys": "npm:8.66.0"
ignore: "npm:^7.0.5"
natural-compare: "npm:^1.4.0"
ts-api-utils: "npm:^2.5.0"
peerDependencies:
"@typescript-eslint/parser": ^8.65.0
"@typescript-eslint/parser": ^8.66.0
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
typescript: ">=4.8.4 <6.1.0"
checksum: 10/20b1e5fd0c01d450c345750582e4911affef8ba934b2b78953ff593102d2a01f21c5f6469e13239fdd6a0e30152d6122906e7623f53aa8c937c6faae9407be47
checksum: 10/bb144ff0c27592b6a53d964fe1e2145dd0737f5fe50fe7dcd88217ca5ac4e470d7965d97745a63de16252ac2880d00732a472b9cec14d7d6929ebda97e7bcbb1
languageName: node
linkType: hard
"@typescript-eslint/parser@npm:8.65.0":
version: 8.65.0
resolution: "@typescript-eslint/parser@npm:8.65.0"
"@typescript-eslint/parser@npm:8.66.0":
version: 8.66.0
resolution: "@typescript-eslint/parser@npm:8.66.0"
dependencies:
"@typescript-eslint/scope-manager": "npm:8.65.0"
"@typescript-eslint/types": "npm:8.65.0"
"@typescript-eslint/typescript-estree": "npm:8.65.0"
"@typescript-eslint/visitor-keys": "npm:8.65.0"
"@typescript-eslint/scope-manager": "npm:8.66.0"
"@typescript-eslint/types": "npm:8.66.0"
"@typescript-eslint/typescript-estree": "npm:8.66.0"
"@typescript-eslint/visitor-keys": "npm:8.66.0"
debug: "npm:^4.4.3"
peerDependencies:
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
typescript: ">=4.8.4 <6.1.0"
checksum: 10/55f68666953c02c8adae35a46076848da6456181b1849a28ec836a5866ca37b902f475fb4c32ba7aa3a6a7e5d66828df8859edec297da09181fb937aeea30f8e
checksum: 10/a0451abe17d2eeff6ff2e1bf637d5ea19b400378d98f4511672e0f30dc5a7971d03352e32764927176c3632f245d5f85e8b28a0a3e66d020effcf0c61624e9b6
languageName: node
linkType: hard
"@typescript-eslint/project-service@npm:8.65.0":
version: 8.65.0
resolution: "@typescript-eslint/project-service@npm:8.65.0"
"@typescript-eslint/project-service@npm:8.66.0":
version: 8.66.0
resolution: "@typescript-eslint/project-service@npm:8.66.0"
dependencies:
"@typescript-eslint/tsconfig-utils": "npm:^8.65.0"
"@typescript-eslint/types": "npm:^8.65.0"
"@typescript-eslint/tsconfig-utils": "npm:^8.66.0"
"@typescript-eslint/types": "npm:^8.66.0"
debug: "npm:^4.4.3"
peerDependencies:
typescript: ">=4.8.4 <6.1.0"
checksum: 10/915662449a66d90f03661a805f7c62a5efaa8f7887671272e08b684b41edab51a9021f47aac4d78001a0f87960e8587adc037424d56f13de883e0ce699e7ca55
checksum: 10/e8a71f69ee7f4bd9ca1c6a0a5110361b9927d17df2f41cdf6d042203b49aabfbddece79dec7882fde8dc016b6278671a2ba3f7bf08e5ae241582627b68be3a8b
languageName: node
linkType: hard
"@typescript-eslint/scope-manager@npm:8.65.0":
version: 8.65.0
resolution: "@typescript-eslint/scope-manager@npm:8.65.0"
"@typescript-eslint/scope-manager@npm:8.66.0":
version: 8.66.0
resolution: "@typescript-eslint/scope-manager@npm:8.66.0"
dependencies:
"@typescript-eslint/types": "npm:8.65.0"
"@typescript-eslint/visitor-keys": "npm:8.65.0"
checksum: 10/038e208c907aa45fe5bb7168e1dccf89c1fc6678d1715a9c04f4b332d4e79ab719b5b43a4e0c2d5a183ea863813ff59fda040b2717fe5517468453af6b99a50a
"@typescript-eslint/types": "npm:8.66.0"
"@typescript-eslint/visitor-keys": "npm:8.66.0"
checksum: 10/f2024cd2819dc3b967f32089ea1a0b9099acabf12860699432f0ba802d75b01ed35ea488a50576cae544813d5cbe9bd9ee5815bc4f11cefc89fe2fb24559238f
languageName: node
linkType: hard
"@typescript-eslint/tsconfig-utils@npm:8.65.0, @typescript-eslint/tsconfig-utils@npm:^8.65.0":
version: 8.65.0
resolution: "@typescript-eslint/tsconfig-utils@npm:8.65.0"
"@typescript-eslint/tsconfig-utils@npm:8.66.0, @typescript-eslint/tsconfig-utils@npm:^8.66.0":
version: 8.66.0
resolution: "@typescript-eslint/tsconfig-utils@npm:8.66.0"
peerDependencies:
typescript: ">=4.8.4 <6.1.0"
checksum: 10/f88253a4df1d599a1bebeeb403611538485e22c52213f2f2b9c435bde8ebce64645d6bb985c900b01ef221032835fcd4f6fea4b94d178220c18de5d93af905c4
checksum: 10/271b28660edc9e6272ae07a91f8da2c69e8a6b009e8588f1009c485c11fe4e66e09356d50dbeabf8e6a44c95c8adeb630f1b6089bdd68aec2512d0036cef7704
languageName: node
linkType: hard
"@typescript-eslint/type-utils@npm:8.65.0":
version: 8.65.0
resolution: "@typescript-eslint/type-utils@npm:8.65.0"
"@typescript-eslint/type-utils@npm:8.66.0":
version: 8.66.0
resolution: "@typescript-eslint/type-utils@npm:8.66.0"
dependencies:
"@typescript-eslint/types": "npm:8.65.0"
"@typescript-eslint/typescript-estree": "npm:8.65.0"
"@typescript-eslint/utils": "npm:8.65.0"
"@typescript-eslint/types": "npm:8.66.0"
"@typescript-eslint/typescript-estree": "npm:8.66.0"
"@typescript-eslint/utils": "npm:8.66.0"
debug: "npm:^4.4.3"
ts-api-utils: "npm:^2.5.0"
peerDependencies:
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
typescript: ">=4.8.4 <6.1.0"
checksum: 10/d52e0c341c9731d8f3bfd2f475bbcd5a5632434e11fc39e8e21acb706145bedb8c6c1998b8ced73e132b43179dd95f2c318effc36c9a1dd61f14546b07b25852
checksum: 10/6d3c55a591b96cbac4ac845ad1d281bac54df12a631ca550370d266aed30760fa9d498e44b938e454941930e3decda820c6e544e0748254c5089425d120e1ed1
languageName: node
linkType: hard
"@typescript-eslint/types@npm:8.65.0, @typescript-eslint/types@npm:^8.56.0, @typescript-eslint/types@npm:^8.65.0":
version: 8.65.0
resolution: "@typescript-eslint/types@npm:8.65.0"
checksum: 10/a6fc10a733adbb98bbb9c312c8d99791e1a2fd3efef5c2a5b51da1c166aac3db4427c30bf32059808abe305a289f820bf610683914e897baeb18315af6a1d16c
"@typescript-eslint/types@npm:8.66.0, @typescript-eslint/types@npm:^8.56.0, @typescript-eslint/types@npm:^8.66.0":
version: 8.66.0
resolution: "@typescript-eslint/types@npm:8.66.0"
checksum: 10/de1ea79e7056c11e38e4001a899b88511793a0aa1702508af42ee179bc7303bed2eb02f177a7ae438f20eab00aee9407c7e842bec6fc5b7bb1fdcab4a7cd5c4b
languageName: node
linkType: hard
"@typescript-eslint/typescript-estree@npm:8.65.0":
version: 8.65.0
resolution: "@typescript-eslint/typescript-estree@npm:8.65.0"
"@typescript-eslint/typescript-estree@npm:8.66.0":
version: 8.66.0
resolution: "@typescript-eslint/typescript-estree@npm:8.66.0"
dependencies:
"@typescript-eslint/project-service": "npm:8.65.0"
"@typescript-eslint/tsconfig-utils": "npm:8.65.0"
"@typescript-eslint/types": "npm:8.65.0"
"@typescript-eslint/visitor-keys": "npm:8.65.0"
"@typescript-eslint/project-service": "npm:8.66.0"
"@typescript-eslint/tsconfig-utils": "npm:8.66.0"
"@typescript-eslint/types": "npm:8.66.0"
"@typescript-eslint/visitor-keys": "npm:8.66.0"
debug: "npm:^4.4.3"
minimatch: "npm:^10.2.2"
semver: "npm:^7.7.3"
@@ -5868,32 +5877,32 @@ __metadata:
ts-api-utils: "npm:^2.5.0"
peerDependencies:
typescript: ">=4.8.4 <6.1.0"
checksum: 10/711fdb5eff67ff34437c56a1a661b16a2e1d6bcd96c9f96196c4242b8d4ddaea8e835ca8badd340c703e043d8dfe8cfca90b32eca60069a4f1d2880afdb670fb
checksum: 10/0834efcdd4468c0dd954089daf26b40175e204402bd4075b06cd910786bb6de7a15aec4e79e40f0980f39fce5bfd9c871ec8fcc6871a8895a34a273bb187fd26
languageName: node
linkType: hard
"@typescript-eslint/utils@npm:8.65.0":
version: 8.65.0
resolution: "@typescript-eslint/utils@npm:8.65.0"
"@typescript-eslint/utils@npm:8.66.0":
version: 8.66.0
resolution: "@typescript-eslint/utils@npm:8.66.0"
dependencies:
"@eslint-community/eslint-utils": "npm:^4.9.1"
"@typescript-eslint/scope-manager": "npm:8.65.0"
"@typescript-eslint/types": "npm:8.65.0"
"@typescript-eslint/typescript-estree": "npm:8.65.0"
"@typescript-eslint/scope-manager": "npm:8.66.0"
"@typescript-eslint/types": "npm:8.66.0"
"@typescript-eslint/typescript-estree": "npm:8.66.0"
peerDependencies:
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
typescript: ">=4.8.4 <6.1.0"
checksum: 10/c1dcd555b58aef1e066164978335e521809acac36b56bd6a6dae62cffae80f3ea5f43527506be76dfef0fe3d4c8382a24355a28867c4904b0a7729691ba45656
checksum: 10/e7a24ee0f35d58b4392daa03fb1ea37ab7e4519949a034be89151df1b0e9fc416831f50cb69f54150272e252cba350964632ee9bfbdb7e62ac599cc731de56e1
languageName: node
linkType: hard
"@typescript-eslint/visitor-keys@npm:8.65.0":
version: 8.65.0
resolution: "@typescript-eslint/visitor-keys@npm:8.65.0"
"@typescript-eslint/visitor-keys@npm:8.66.0":
version: 8.66.0
resolution: "@typescript-eslint/visitor-keys@npm:8.66.0"
dependencies:
"@typescript-eslint/types": "npm:8.65.0"
"@typescript-eslint/types": "npm:8.66.0"
eslint-visitor-keys: "npm:^5.0.0"
checksum: 10/e7f86d21f0bf03ca7cff9fa9428aab98620564d15fb06c9f56a294c13cee324624d42f9115f3d76fbad92266220479cca334b5c66562f885da5c4d2bda3d87b3
checksum: 10/d47e936b10ec94a96f69f77a62463f4634fe72d12d42dd302d7777f7b98689468e0b67c538a52232b723695208f1b3fdeae5745eb3e71a70a90818fdd827a0bc
languageName: node
linkType: hard
@@ -9927,8 +9936,8 @@ __metadata:
"@playwright/test": "npm:1.62.1"
"@replit/codemirror-indentation-markers": "npm:6.5.3"
"@rsdoctor/rspack-plugin": "npm:1.6.1"
"@rspack/core": "npm:2.1.7"
"@rspack/dev-server": "npm:2.1.0"
"@rspack/core": "npm:2.1.8"
"@rspack/dev-server": "npm:2.2.0"
"@swc/helpers": "npm:0.5.23"
"@thomasloven/round-slider": "npm:0.6.0"
"@tsparticles/engine": "npm:4.3.2"
@@ -10008,7 +10017,7 @@ __metadata:
lodash.template: "npm:4.18.1"
luxon: "npm:3.7.2"
map-stream: "npm:0.0.7"
marked: "npm:18.0.7"
marked: "npm:18.0.9"
memoize-one: "npm:6.0.0"
minify-literals: "npm:2.1.0"
node-vibrant: "npm:4.0.4"
@@ -10031,7 +10040,7 @@ __metadata:
tinykeys: "patch:tinykeys@npm%3A4.0.0#~/.yarn/patches/tinykeys-npm-4.0.0-a6ca3fd771.patch"
ts-lit-plugin: "npm:2.0.2"
typescript: "npm:6.0.3"
typescript-eslint: "npm:8.65.0"
typescript-eslint: "npm:8.66.0"
vite-tsconfig-paths: "npm:6.1.1"
vitest: "npm:4.1.10"
webpack-stats-plugin: "npm:1.1.3"
@@ -11651,12 +11660,12 @@ __metadata:
languageName: node
linkType: hard
"marked@npm:18.0.7":
version: 18.0.7
resolution: "marked@npm:18.0.7"
"marked@npm:18.0.9":
version: 18.0.9
resolution: "marked@npm:18.0.9"
bin:
marked: bin/marked.js
checksum: 10/7ea7b8556a9e8cab2881b194815a7550c61d77639c6b8f8d9022f96790fd2b289b2ea28969cea6e01c24c3b8ec822373288ee02b7c50f178bc3ec79aa42d05f1
checksum: 10/99d337c50acd57034734f8460f25b28e9658b15627f950092707cb443b840f0bd29fe343bf211b948e643303c34abdb5b5a12cf5245a846635750697524bb747
languageName: node
linkType: hard
@@ -14883,18 +14892,18 @@ __metadata:
languageName: node
linkType: hard
"typescript-eslint@npm:8.65.0":
version: 8.65.0
resolution: "typescript-eslint@npm:8.65.0"
"typescript-eslint@npm:8.66.0":
version: 8.66.0
resolution: "typescript-eslint@npm:8.66.0"
dependencies:
"@typescript-eslint/eslint-plugin": "npm:8.65.0"
"@typescript-eslint/parser": "npm:8.65.0"
"@typescript-eslint/typescript-estree": "npm:8.65.0"
"@typescript-eslint/utils": "npm:8.65.0"
"@typescript-eslint/eslint-plugin": "npm:8.66.0"
"@typescript-eslint/parser": "npm:8.66.0"
"@typescript-eslint/typescript-estree": "npm:8.66.0"
"@typescript-eslint/utils": "npm:8.66.0"
peerDependencies:
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
typescript: ">=4.8.4 <6.1.0"
checksum: 10/5b2242f59005afdd57190849be7df2e86ce33b007fa0bf605f700ad0e38ea14fc14c48deafdf4a039614c1f012b71c61a19edb27f76d52fdc924cde476a100d1
checksum: 10/e76da9c684e4fe012b49a5120dd57553df0788cf1cffcbd5dc72c98de33e549f306495e710292f111ee058366b1e5d8d603c2bb14385b12bb35edc7bbf13ad55
languageName: node
linkType: hard