Compare commits

..

3 Commits

Author SHA1 Message Date
copilot-swe-agent[bot] d059777cc3 Address panel readiness review feedback
Co-authored-by: timmo001 <28114703+timmo001@users.noreply.github.com>
2026-08-04 08:46:56 +00:00
Aidan Timson 6e8a8fda6a Consolidate panel readiness tests 2026-08-04 09:00:19 +01:00
Aidan Timson ea2e959d06 Wait for Integrations panel readiness 2026-08-04 09:00:03 +01:00
29 changed files with 283 additions and 665 deletions
+1 -28
View File
@@ -31,33 +31,6 @@ When creating a pull request, use `.github/PULL_REQUEST_TEMPLATE.md` as the body
## Recurring Review Issues
Scope and public surface:
- Keep changes independently reviewable and limited to the requested area.
- Prefer existing Home Assistant helpers, Lit primitives, and component seams over parallel implementations.
- Challenge new public properties and optional feature surface when transient options or existing seams meet the requirement with less lifecycle and consistency cost.
Stateful and asynchronous UI:
- Review transitions in both directions, not only individual rendered states.
- When controls reappear, restore valid defaults instead of retaining state that was only valid while they were hidden.
- Establish immutable dirty-state baselines before asynchronous work, guard against stale responses, and preserve unsaved state in mounted editors.
- Determine an action's current meaning before applying dirty-state checks, especially when an action can change between Save and Close.
Readiness and invalidation:
- Treat readiness as the first displayable terminal result, including stable empty and error states.
- Register child readiness before resolving the parent, do not treat fallback work as terminal, and replay readiness correctly for cached or reused panels.
- Ensure every value read by memoized output participates in its invalidation.
Repository-owned contracts:
- Consult the public [frontend developer documentation](https://developers.home-assistant.io/docs/frontend/) for documented architecture, data flow, design, and development workflows.
- For new leaf components, load `ha-frontend-contexts` and verify they consume narrow contexts instead of introducing a broad `hass` property; containers and external APIs may still require `hass`.
- Verify backend assumptions against the owning Core, Supervisor, or WebSocket implementation, and component assumptions against the exported component contract.
- Prefer canonical repository helpers and test setup over duplicate local implementations.
- Promote AI-review concerns into durable guidance only when supported by code evidence, reproduced behavior, an accepted corrective commit, or human-maintainer validation.
User experience and accessibility:
- Forms need proper labels, helper text, and validation feedback.
@@ -97,4 +70,4 @@ Configuration and props:
- Identify behavioral regressions, bugs, accessibility issues, and missing tests first.
- Keep style-only comments secondary unless they affect maintainability or user experience.
- Prefer small, direct fixes over large refactors during review follow-up.
- Load the matching `ha-frontend-*` skill when a finding falls within its area.
- Cross-load `ha-frontend-contexts`, `ha-frontend-components`, `ha-frontend-events`, `ha-frontend-styling`, `ha-frontend-testing`, or `ha-frontend-user-facing-text` when a finding falls in that area.
+1 -1
View File
@@ -3,8 +3,8 @@
import { constants } from "node:zlib";
import gulp from "gulp";
import brotli from "gulp-brotli";
import zopfli from "gulp-zopfli-green";
import paths from "../paths.cjs";
import zopfli from "../zopfli.mjs";
const filesGlob = "*.{js,json,css,svg,xml}";
const brotliOptions = {
-18
View File
@@ -1,18 +0,0 @@
// Worker side of the zopfli pool. @gfx/zopfli is a synchronous WASM build, so
// compressing on the main thread blocks the event loop; one instance per worker
// is what makes the work parallel.
import { parentPort } from "node:worker_threads";
import zopfli from "@gfx/zopfli";
parentPort.on("message", ({ contents, options }) => {
zopfli.gzip(contents, options, (error, result) => {
if (error) {
parentPort.postMessage({ error: error.message ?? String(error) });
return;
}
// `result` is a fresh Uint8Array copied out of the WASM heap, so it owns
// its ArrayBuffer and can be transferred instead of cloned.
parentPort.postMessage({ result }, [result.buffer]);
});
});
-196
View File
@@ -1,196 +0,0 @@
// Gulp transform that gzips files with zopfli across a pool of worker threads.
//
// Drop-in replacement for gulp-zopfli-green. That plugin compresses on the main
// thread, and @gfx/zopfli is synchronous WASM, so it pins a single core and
// blocks the event loop for the whole compression step. Running one WASM
// instance per worker parallelises it; the output bytes are unchanged.
import { availableParallelism } from "node:os";
import { Transform } from "node:stream";
import { buffer as readStream } from "node:stream/consumers";
import { Worker } from "node:worker_threads";
const WORKER_URL = new URL("./zopfli-worker.mjs", import.meta.url);
const EXTENSION = ".gz";
// Left empty on purpose: @gfx/zopfli then applies its own defaults, which is
// what gulp-zopfli-green did, so compressed output stays byte-identical.
const ZOPFLI_OPTIONS = {};
const poolSize = () => {
const configured = Number(process.env.ZOPFLI_WORKERS);
return Number.isInteger(configured) && configured > 0
? configured
: availableParallelism();
};
// One job per worker at a time, so the worker itself is the job slot.
const createPool = (size) => {
const live = new Set();
const idle = [];
const waiting = [];
const inFlight = new Map();
// A dead worker must leave the pool, or it gets handed a job that never
// completes and the build hangs instead of failing.
const retire = (worker, error) => {
if (!live.delete(worker)) {
return;
}
const index = idle.indexOf(worker);
if (index !== -1) {
idle.splice(index, 1);
}
const job = inFlight.get(worker);
inFlight.delete(worker);
job?.reject(error);
waiting.shift()?.();
};
const spawn = () => {
const worker = new Worker(WORKER_URL);
live.add(worker);
// Idle workers must not hold the process open. Each job refs its worker for
// as long as it runs, so a pending compression always keeps the event loop
// alive.
worker.unref();
worker.on("message", ({ error, result }) => {
const job = inFlight.get(worker);
if (!job) {
return;
}
inFlight.delete(worker);
worker.unref();
idle.push(worker);
if (error) {
job.reject(new Error(error));
} else {
job.resolve(
Buffer.from(result.buffer, result.byteOffset, result.byteLength)
);
}
waiting.shift()?.();
});
worker.on("error", (error) => retire(worker, error));
worker.on("exit", () =>
retire(worker, new Error("zopfli worker exited unexpectedly"))
);
return worker;
};
return (contents) =>
new Promise((resolve, reject) => {
const start = () => {
const worker = idle.pop() ?? (live.size < size ? spawn() : undefined);
if (!worker) {
waiting.push(start);
return;
}
inFlight.set(worker, { resolve, reject });
worker.ref();
// `contents` is cloned rather than transferred: buffers read by vinyl
// can share a pooled ArrayBuffer with unrelated buffers, and
// transferring would detach those too.
worker.postMessage({ contents, options: ZOPFLI_OPTIONS });
};
start();
});
};
// Shared by every transform this module hands out, so the worker count is a
// property of the process rather than of how many streams happen to run.
let pool;
const sharedPool = () => {
if (!pool) {
const size = poolSize();
pool = { size, compress: createPool(size) };
}
return pool;
};
// through2 and node's Transform both wait for the previous callback before
// handling the next file, which would serialise the pool down to one worker.
// This keeps `limit` files in flight and applies backpressure beyond that.
class ParallelTransform extends Transform {
#limit;
#handle;
#inFlight = 0;
#resume;
#finish;
constructor(limit, handle) {
super({ objectMode: true, highWaterMark: limit });
this.#limit = limit;
this.#handle = handle;
}
// eslint-disable-next-line @typescript-eslint/naming-convention -- node's Transform API
_transform(file, _encoding, callback) {
this.#inFlight += 1;
this.#handle(file)
.then((result) => {
if (result) {
this.push(result);
}
})
.catch((error) => this.destroy(error))
.finally(() => {
this.#inFlight -= 1;
const resume = this.#resume;
this.#resume = undefined;
resume?.();
if (this.#inFlight === 0) {
const finish = this.#finish;
this.#finish = undefined;
finish?.();
}
});
if (this.#inFlight < this.#limit) {
callback();
} else {
this.#resume = callback;
}
}
// eslint-disable-next-line @typescript-eslint/naming-convention -- node's Transform API
_flush(callback) {
if (this.#inFlight === 0) {
callback();
} else {
this.#finish = callback;
}
}
}
/**
* @param {object} [options]
* @param {number} [options.threshold] Skip files smaller than this many bytes.
*/
export default ({ threshold = 0 } = {}) => {
const { size, compress } = sharedPool();
return new ParallelTransform(size, async (file) => {
if (file.isNull()) {
return file;
}
if (file.isStream()) {
file.contents = await readStream(file.contents);
}
if (threshold && file.contents.length < threshold) {
// Passed through unrenamed and uncompressed, as gulp-zopfli-green did.
return file;
}
file.contents = await compress(file.contents);
file.path += EXTENSION;
return file;
});
};
+3
View File
@@ -1 +1,4 @@
import { availableParallelism } from "node:os";
import "./build-scripts/gulp/index.mjs";
process.env.UV_THREADPOOL_SIZE = availableParallelism();
+2 -2
View File
@@ -103,6 +103,7 @@
"echarts": "6.1.0",
"element-internals-polyfill": "3.0.2",
"fuse.js": "7.5.0",
"gulp-zopfli-green": "7.0.0",
"hls.js": "1.6.16",
"home-assistant-js-websocket": "9.6.0",
"idb-keyval": "6.3.0",
@@ -144,7 +145,6 @@
"@babel/preset-env": "8.0.2",
"@bundle-stats/plugin-webpack-filter": "4.22.2",
"@eslint/js": "10.0.1",
"@gfx/zopfli": "1.0.15",
"@html-eslint/eslint-plugin": "0.64.0",
"@lokalise/node-api": "16.3.0",
"@octokit/auth-oauth-device": "8.0.3",
@@ -164,7 +164,7 @@
"@types/leaflet-draw": "1.0.13",
"@types/leaflet.markercluster": "1.5.6",
"@types/lodash.merge": "4.6.9",
"@types/luxon": "3.7.3",
"@types/luxon": "3.7.2",
"@types/qrcode": "1.5.6",
"@types/sortablejs": "1.15.9",
"@types/tar": "7.0.87",
-23
View File
@@ -294,26 +294,3 @@ export const formatCallyDateRange = (
return `${startDate}/${endDate}`;
};
/**
* August 2021, for calendar days coming out of cally.
*
* cally hands out calendar days as `Date` objects anchored to UTC midnight, and
* formats its own headings in UTC too. Anything derived from a cally event has
* to be formatted the same way: the resolved time zone shifts the day back for
* negative UTC offsets, which becomes a wrong month — and in January a wrong
* year — whenever the day is the 1st.
*/
export const formatCallyMonthYear = (
dateObj: Date,
locale: FrontendLocaleData
) => formatCallyMonthYearMem(locale.language).format(dateObj);
const formatCallyMonthYearMem = memoizeOne(
(language: string) =>
new Intl.DateTimeFormat(language, {
month: "long",
year: "numeric",
timeZone: "UTC",
})
);
-1
View File
@@ -15,7 +15,6 @@ export class HaInputChip extends InputChip {
styles,
css`
:host {
max-width: 100%;
--md-sys-color-primary: var(--primary-text-color);
--md-sys-color-on-surface: var(--primary-text-color);
--md-sys-color-on-surface-variant: var(--primary-text-color);
+37 -21
View File
@@ -9,8 +9,8 @@ import { customElement, property, queryAll, state } from "lit/decorators";
import { firstWeekdayIndex } from "../../common/datetime/first_weekday";
import {
formatCallyDateRange,
formatCallyMonthYear,
formatDateMonthYear,
formatDateMonth,
formatDateYear,
formatISODateOnly,
} from "../../common/datetime/format_date";
import { transform } from "../../common/decorators/transform";
@@ -60,16 +60,13 @@ export class DateRangePicker extends MobileAwareMixin(LitElement) {
})
private _hassConfig!: HassConfig;
/** used to show month and year in calendar-range header */
@state() private _pickerMonthYear?: string;
/** used to show month in calendar-range header */
@state() private _pickerMonth?: string;
/**
* used for today to navigate focus in calendar-range
*
* Always mirrors the day calendar-range has focused, never cleared: once this
* has held a date, rendering `undefined` over it makes cally fall back to the
* selected range and page the calendar away from where the user is.
*/
/** used to show year in calendar-date header */
@state() private _pickerYear?: string;
/** used for today to navigate focus in calendar-range */
@state() private _focusDate?: string;
@state() private _dateValue?: string;
@@ -95,7 +92,12 @@ export class DateRangePicker extends MobileAwareMixin(LitElement) {
this._hassConfig
)
: undefined;
this._pickerMonthYear = formatDateMonthYear(
this._pickerMonth = formatDateMonth(
date,
this._i18n.locale,
this._hassConfig
);
this._pickerYear = formatDateYear(
date,
this._i18n.locale,
this._hassConfig
@@ -165,7 +167,9 @@ export class DateRangePicker extends MobileAwareMixin(LitElement) {
slot="previous"
></ha-icon-button-prev>
<div class="heading" slot="heading">
<span class="month-year">${this._pickerMonthYear}</span>
<span class="month-year"
>${this._pickerMonth} ${this._pickerYear}</span
>
<ha-icon-button
@click=${this._focusToday}
.path=${mdiCalendarToday}
@@ -229,7 +233,12 @@ export class DateRangePicker extends MobileAwareMixin(LitElement) {
this._i18n.locale,
this._hassConfig
);
this._pickerMonthYear = formatDateMonthYear(
this._pickerMonth = formatDateMonth(
date,
this._i18n.locale,
this._hassConfig
);
this._pickerYear = formatDateYear(
date,
this._i18n.locale,
this._hassConfig
@@ -302,12 +311,19 @@ export class DateRangePicker extends MobileAwareMixin(LitElement) {
});
}
private _focusChanged(
ev: HASSDomEvent<Date> &
HASSDomTargetEvent<HTMLElementTagNameMap["calendar-range"]>
) {
this._pickerMonthYear = formatCallyMonthYear(ev.detail, this._i18n.locale);
this._focusDate = ev.target.focusedDate;
private _focusChanged(ev: HASSDomEvent<Date>) {
const date = ev.detail;
this._pickerMonth = formatDateMonth(
date,
this._i18n.locale,
this._hassConfig
);
this._pickerYear = formatDateYear(
date,
this._i18n.locale,
this._hassConfig
);
this._focusDate = undefined;
}
private _handleChange(
@@ -315,7 +331,7 @@ export class DateRangePicker extends MobileAwareMixin(LitElement) {
) {
const dateElement = ev.target as HTMLElementTagNameMap["calendar-range"];
this._dateValue = dateElement.value;
this._focusDate = dateElement.focusedDate;
this._focusDate = undefined;
}
private _clickDateRangeChip(
@@ -6,8 +6,7 @@ import type { HassConfig } from "home-assistant-js-websocket/dist/types";
import { css, html, LitElement, nothing } from "lit";
import { customElement, state } from "lit/decorators";
import {
formatCallyMonthYear,
formatDateMonthYear,
formatDateMonth,
formatDateShort,
formatDateYear,
formatISODateOnly,
@@ -61,16 +60,13 @@ export class HaDialogDatePicker extends DialogMixin<DatePickerDialogParams>(
dateString: string;
};
/** used to show month and year in calendar-date header */
@state() private _pickerMonthYear?: string;
/** used to show month in calendar-date header */
@state() private _pickerMonth?: string;
/**
* used for today to navigate focus in cally-calendar-date
*
* Always mirrors the day calendar-date has focused, never cleared: once this
* has held a date, rendering `undefined` over it makes cally fall back to the
* selected value and page the calendar away from where the user is.
*/
/** used to show year in calendar-date header */
@state() private _pickerYear?: string;
/** used for today to navigate focus in cally-calendar-date */
@state() private _focusDate?: string;
public connectedCallback() {
@@ -79,7 +75,12 @@ export class HaDialogDatePicker extends DialogMixin<DatePickerDialogParams>(
if (this.params) {
const date = valueToDate(this.params.value);
this._pickerMonthYear = formatDateMonthYear(
this._pickerYear = formatDateYear(
date,
this._i18n.locale,
this._hassConfig
);
this._pickerMonth = formatDateMonth(
date,
this._i18n.locale,
this._hassConfig
@@ -87,7 +88,7 @@ export class HaDialogDatePicker extends DialogMixin<DatePickerDialogParams>(
this._value = this.params.value
? {
year: formatDateYear(date, this._i18n.locale, this._hassConfig),
year: this._pickerYear,
title: formatDateShort(date, this._i18n.locale, this._hassConfig),
dateString: formatISODateOnly(
date,
@@ -143,7 +144,9 @@ export class HaDialogDatePicker extends DialogMixin<DatePickerDialogParams>(
slot="previous"
></ha-icon-button-prev>
<div class="heading" slot="heading">
<span class="month-year">${this._pickerMonthYear}</span>
<span class="month-year"
>${this._pickerMonth} ${this._pickerYear}</span
>
<ha-icon-button
@click=${this._setToday}
.path=${mdiCalendarToday}
@@ -186,7 +189,12 @@ export class HaDialogDatePicker extends DialogMixin<DatePickerDialogParams>(
if (setFocusDay) {
this._focusDate = this._value.dateString;
this._pickerMonthYear = formatDateMonthYear(
this._pickerMonth = formatDateMonth(
date,
this._i18n.locale,
this._hassConfig
);
this._pickerYear = formatDateYear(
date,
this._i18n.locale,
this._hassConfig
@@ -194,11 +202,19 @@ export class HaDialogDatePicker extends DialogMixin<DatePickerDialogParams>(
}
}
private _focusChanged(
ev: HASSDomEvent<Date> & HASSDomTargetEvent<CalendarDate>
) {
this._pickerMonthYear = formatCallyMonthYear(ev.detail, this._i18n.locale);
this._focusDate = ev.target.focusedDate;
private _focusChanged(ev: HASSDomEvent<Date>) {
const date = ev.detail;
this._pickerMonth = formatDateMonth(
date,
this._i18n.locale,
this._hassConfig
);
this._pickerYear = formatDateYear(
date,
this._i18n.locale,
this._hassConfig
);
this._focusDate = undefined;
}
private _clear() {
-30
View File
@@ -1,30 +0,0 @@
import { css, html, LitElement } from "lit";
import type { TemplateResult } from "lit";
import { customElement, property } from "lit/decorators";
import type { HaFormDividerSchema, HaFormElement } from "./types";
@customElement("ha-form-divider")
export class HaFormDivider extends LitElement implements HaFormElement {
@property({ attribute: false }) public schema!: HaFormDividerSchema;
protected render(): TemplateResult {
return html`<hr />`;
}
static styles = css`
:host {
display: block;
}
hr {
border: none;
border-top: 1px solid var(--ha-color-border-neutral-quiet);
margin: 0;
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"ha-form-divider": HaFormDivider;
}
}
@@ -12,11 +12,9 @@ import type { HaDropdownSelectEvent } from "../ha-dropdown";
import "../ha-dropdown-item";
import "../ha-svg-icon";
import "./ha-form";
import "./ha-form-divider";
import type { HaForm } from "./ha-form";
import type {
HaFormDataContainer,
HaFormDividerSchema,
HaFormElement,
HaFormOptionalActionsSchema,
HaFormSchema,
@@ -24,11 +22,6 @@ import type {
const NO_ACTIONS = [];
const DIVIDER = {
name: "",
type: "divider",
} as const satisfies HaFormDividerSchema;
@customElement("ha-form-optional_actions")
export class HaFormOptionalActions extends LitElement implements HaFormElement {
@property({ attribute: false }) public localize?: LocalizeFunc;
@@ -94,9 +87,7 @@ export class HaFormOptionalActions extends LitElement implements HaFormElement {
schema: readonly HaFormSchema[],
displayActions: string[]
): HaFormSchema[] =>
schema
.filter((item) => displayActions.includes(item.name))
.flatMap((item) => [DIVIDER, item])
schema.filter((item) => displayActions.includes(item.name))
);
public render(): TemplateResult {
@@ -137,7 +128,6 @@ export class HaFormOptionalActions extends LitElement implements HaFormElement {
${
hiddenActions.length > 0
? html`
<ha-form-divider></ha-form-divider>
<ha-dropdown
@wa-select=${this._handleAddAction}
@closed=${stopPropagation}
-1
View File
@@ -20,7 +20,6 @@ type HaFormDataContainerChangedEvent = ValueChangedEvent<HaFormDataContainer>;
const LOAD_ELEMENTS = {
boolean: () => import("./ha-form-boolean"),
constant: () => import("./ha-form-constant"),
divider: () => import("./ha-form-divider"),
float: () => import("./ha-form-float"),
grid: () => import("./ha-form-grid"),
expandable: () => import("./ha-form-expandable"),
-5
View File
@@ -4,7 +4,6 @@ import type { HaDurationData } from "../ha-duration-input";
export type HaFormSchema =
| HaFormConstantSchema
| HaFormDividerSchema
| HaFormStringSchema
| HaFormIntegerSchema
| HaFormFloatSchema
@@ -98,10 +97,6 @@ export interface HaFormConstantSchema extends HaFormBaseSchema {
value?: string;
}
export interface HaFormDividerSchema extends HaFormBaseSchema {
type: "divider";
}
export interface HaFormIntegerSchema extends HaFormBaseSchema {
type: "integer";
default?: HaFormIntegerData;
@@ -187,7 +187,6 @@ export class HaSelectSelector extends LitElement {
.idx=${idx}
@remove=${this._removeItem}
.label=${label}
.title=${label}
selected
>
${
@@ -317,9 +317,7 @@ class HaAutomationPicker extends SubscribeMixin(LitElement) {
color:
automation.state === UNAVAILABLE
? "var(--error-color)"
: automation.state === "on"
? "var(--state-active-color)"
: "unset",
: "unset",
})}
></ha-state-icon>`,
},
@@ -348,17 +346,19 @@ class HaAutomationPicker extends SubscribeMixin(LitElement) {
maxWidth: "82px",
sortable: true,
groupable: true,
hidden: narrow,
type: "overflow",
title: this.hass.localize("ui.panel.config.automation.picker.state"),
template: (automation) => html`
<ha-switch
@click=${stopPropagation}
@change=${this._handleSwitchToggle}
.automation=${automation}
.checked=${automation.state === "on"}
></ha-switch>
`,
template: (automation) =>
narrow
? automation.formatted_state
: html`
<ha-switch
@click=${stopPropagation}
@change=${this._handleSwitchToggle}
.automation=${automation}
.checked=${automation.state === "on"}
></ha-switch>
`,
},
actions: {
lastFixed: true,
@@ -68,7 +68,6 @@ export class CloudAccount extends SubscribeMixin(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}>
+1
View File
@@ -106,6 +106,7 @@ class HaPanelConfig extends HassRouterPage {
integrations: {
tag: "ha-config-integrations",
load: () => import("./integrations/ha-config-integrations"),
waitForReady: true,
},
labels: {
tag: "ha-config-labels",
@@ -1,4 +1,5 @@
import { mdiFilterVariant, mdiPlus } from "@mdi/js";
import { consume } from "@lit/context";
import type { IFuseOptions } from "fuse.js";
import Fuse from "fuse.js";
import type { UnsubscribeFunc } from "home-assistant-js-websocket";
@@ -54,6 +55,10 @@ import type { ImprovDiscoveredDevice } from "../../../external_app/external_mess
import "../../../layouts/hass-loading-screen";
import "../../../layouts/hass-tabs-subpage";
import type { HassTabsSubpage } from "../../../layouts/hass-tabs-subpage";
import {
childPanelReadyContext,
type RegisterChildPanelReady,
} from "../../../layouts/panel-ready";
import { KeyboardShortcutMixin } from "../../../mixins/keyboard-shortcut-mixin";
import { SubscribeMixin } from "../../../mixins/subscribe-mixin";
import { haStyle } from "../../../resources/styles";
@@ -168,6 +173,17 @@ class HaConfigIntegrationsDashboard extends KeyboardShortcutMixin(
@query("hass-tabs-subpage") private _tabsSubpage?: HassTabsSubpage;
private _resolveInitialRender?: () => void;
private _initialRenderComplete = new Promise<void>((resolve) => {
this._resolveInitialRender = resolve;
});
private _childReadyRegistered = false;
@consume({ context: childPanelReadyContext, subscribe: true })
private _registerChildPanelReady?: RegisterChildPanelReady;
public disconnectedCallback(): void {
super.disconnectedCallback();
window.removeEventListener(
@@ -388,6 +404,10 @@ class HaConfigIntegrationsDashboard extends KeyboardShortcutMixin(
protected updated(changed: PropertyValues<this>) {
super.updated(changed);
if (!this._childReadyRegistered && this._registerChildPanelReady) {
this._registerChildPanelReady(this._initialRenderComplete);
this._childReadyRegistered = true;
}
if (changed.has("route")) {
this._handleRouteChanged();
}
@@ -415,6 +435,9 @@ class HaConfigIntegrationsDashboard extends KeyboardShortcutMixin(
}
if (this.configEntries && this.configEntriesInProgress) {
this._resolveInitialRender?.();
this._resolveInitialRender = undefined;
const activeElement = deepActiveElement();
if (
@@ -11,6 +11,7 @@ import {
import type { DataEntryFlowProgress } from "../../../data/data_entry_flow";
import { domainToName } from "../../../data/integration";
import "../../../layouts/hass-loading-screen";
import { ChildPanelReady } from "../../../layouts/panel-ready";
import type { RouterOptions } from "../../../layouts/hass-router-page";
import { HassRouterPage } from "../../../layouts/hass-router-page";
import { SubscribeMixin } from "../../../mixins/subscribe-mixin";
@@ -70,6 +71,11 @@ class HaConfigIntegrations extends SubscribeMixin(HassRouterPage) {
private _loadTranslationsPromise?: Promise<LocalizeFunc>;
public constructor() {
super();
new ChildPanelReady(this);
}
public hassSubscribe() {
return [
subscribeConfigEntries(
@@ -135,10 +135,6 @@ export class HuiShortcutBadgeEditor
},
},
},
{
name: "",
type: "divider",
},
{
name: "double_tap_action",
selector: {
@@ -168,10 +168,6 @@ export class HuiShortcutCardEditor
},
},
},
{
name: "",
type: "divider",
},
{
name: "double_tap_action",
selector: {
@@ -199,10 +199,6 @@ export class HuiTileCardEditor
},
context: ACTION_RELATED_CONTEXT,
},
{
name: "",
type: "divider",
},
{
name: "icon_tap_action",
selector: {
-152
View File
@@ -1,152 +0,0 @@
/**
* @vitest-environment node
*/
import { Buffer } from "node:buffer";
import process from "node:process";
import { Readable } from "node:stream";
import gfxZopfli from "@gfx/zopfli";
import { describe, expect, it } from "vitest";
// Keep the pool small; it is created once, on the first factory call.
process.env.ZOPFLI_WORKERS = "2";
const { default: zopfli } = await import("../../build-scripts/zopfli.mjs");
const THRESHOLD = 150;
// Stand-in for the vinyl files gulp.src yields in buffer mode.
const file = (path, contents) => ({
path,
contents,
isNull() {
return this.contents === null;
},
isStream() {
return this.contents instanceof Readable;
},
});
const run = (files, options = { threshold: THRESHOLD }) =>
new Promise((resolve, reject) => {
const stream = zopfli(options);
const out = [];
stream.on("data", (result) => out.push(result));
stream.on("error", reject);
stream.on("end", () => resolve(out));
files.forEach((entry) => stream.write(entry));
stream.end();
});
// What the old in-process plugin did, for byte-for-byte comparison.
const gzipInProcess = (contents) =>
new Promise((resolve, reject) => {
gfxZopfli.gzip(contents, {}, (error, result) =>
error ? reject(error) : resolve(Buffer.from(result))
);
});
const filler = (length) => Buffer.alloc(length, "a");
describe("zopfli worker pool", () => {
it("appends .gz and matches in-process zopfli byte for byte", async () => {
const contents = filler(4096);
const [result] = await run([file("/out/app.js", contents)]);
expect(result.path).toBe("/out/app.js.gz");
expect(result.contents).toEqual(await gzipInProcess(contents));
});
it("returns contents as a Buffer, which vinyl requires", async () => {
const [result] = await run([file("/out/app.js", filler(1024))]);
expect(Buffer.isBuffer(result.contents)).toBe(true);
});
it("appends to the path rather than replacing the extension", async () => {
const [result] = await run([file("/out/nested/chunk.min.js", filler(500))]);
expect(result.path).toBe("/out/nested/chunk.min.js.gz");
});
it("passes files under the threshold through untouched", async () => {
const contents = filler(THRESHOLD - 1);
const [result] = await run([file("/out/tiny.js", contents)]);
expect(result.path).toBe("/out/tiny.js");
expect(result.contents).toBe(contents);
});
it("compresses a file exactly at the threshold", async () => {
const [result] = await run([file("/out/edge.js", filler(THRESHOLD))]);
expect(result.path).toBe("/out/edge.js.gz");
});
it("compresses everything when no threshold is given", async () => {
const [result] = await run([file("/out/tiny.js", filler(1))], {});
expect(result.path).toBe("/out/tiny.js.gz");
});
it("passes null files through", async () => {
const [result] = await run([file("/out/adirectory", null)]);
expect(result.path).toBe("/out/adirectory");
expect(result.contents).toBeNull();
});
it("compresses only the requested slice of a pooled buffer", async () => {
// Small reads share a pooled ArrayBuffer, so contents often start at a
// non-zero byteOffset into a much larger allocation.
const pool = Buffer.alloc(8192, "z");
const contents = pool.subarray(1000, 1000 + 512);
contents.fill("a");
const [result] = await run([file("/out/pooled.js", contents)]);
expect(result.contents).toEqual(await gzipInProcess(filler(512)));
});
it("buffers stream-mode contents", async () => {
const contents = filler(600);
const [result] = await run([
file("/out/streamed.js", Readable.from([contents])),
]);
expect(result.path).toBe("/out/streamed.js.gz");
expect(result.contents).toEqual(await gzipInProcess(contents));
});
it("handles more files than the pool has workers", async () => {
const count = 25;
const files = Array.from({ length: count }, (_, index) =>
file(`/out/chunk${index}.js`, filler(200 + index))
);
const results = await run(files);
expect(results).toHaveLength(count);
expect(new Set(results.map((result) => result.path)).size).toBe(count);
await Promise.all(
results.map(async (result) => {
const index = Number(result.path.match(/chunk(\d+)/)[1]);
expect(result.contents).toEqual(
await gzipInProcess(filler(200 + index))
);
})
);
});
it("serves two concurrent streams from the same pool", async () => {
const [first, second] = await Promise.all([
run([file("/out/a.js", filler(300))]),
run([file("/out/b.js", filler(400))]),
]);
expect(first[0].path).toBe("/out/a.js.gz");
expect(second[0].path).toBe("/out/b.js.gz");
expect(first[0].contents).toEqual(await gzipInProcess(filler(300)));
expect(second[0].contents).toEqual(await gzipInProcess(filler(400)));
});
});
-40
View File
@@ -1,6 +1,5 @@
import { describe, expect, it } from "vitest";
import {
formatCallyMonthYear,
formatDate,
formatDateWeekdayDay,
formatDateShort,
@@ -264,43 +263,4 @@ describe("formatDate", () => {
).toBe("Sat");
});
});
describe("formatCallyMonthYear", () => {
// How cally hands out a calendar day: a Date anchored to UTC midnight
const julyFirst = new Date(Date.UTC(2026, 6, 1));
const januaryFirst = new Date(Date.UTC(2026, 0, 1));
// demoConfig.time_zone is America/Los_Angeles, so this resolves to a
// negative UTC offset whatever zone the tests run in
const locale = {
language: "en",
number_format: NumberFormat.language,
time_format: TimeFormat.language,
date_format: DateFormat.language,
time_zone: TimeZone.server,
first_weekday: FirstWeekday.language,
};
it("Formats in UTC rather than the resolved time zone", () => {
// A time zone aware formatter reads UTC midnight on the 1st as the
// previous month, which desyncs the header from the calendar grid
expect(formatDateMonthYear(julyFirst, locale, demoConfig)).toBe(
"June 2026"
);
expect(formatCallyMonthYear(julyFirst, locale)).toBe("July 2026");
});
it("Keeps the year on a January page", () => {
expect(formatDateMonthYear(januaryFirst, locale, demoConfig)).toBe(
"December 2025"
);
expect(formatCallyMonthYear(januaryFirst, locale)).toBe("January 2026");
});
it("Orders month and year by locale", () => {
expect(
formatCallyMonthYear(julyFirst, { ...locale, language: "ja" })
).toBe("2026年7月");
});
});
});
+82 -81
View File
@@ -4,7 +4,7 @@
* Run with:
* yarn test:e2e:app
*/
import { test, expect, type Page } from "@playwright/test";
import { test, expect } from "@playwright/test";
import {
appSidebar,
appSidebarConfig,
@@ -161,65 +161,100 @@ test.describe("Quick search", () => {
defineRouteSmokeTests(appRouteSmokeGroups);
const assertInitialReadiness = async (
page: Page,
options: {
test("keeps the launch screen until initial panel content renders", async ({
page,
}) => {
const cases: {
name: string;
path: string;
loadingSelector: string;
resolver:
"rejectMediaBrowse" | "resolveCalendarRegistry" | "resolveMediaBrowse";
readySelector: string;
}
) => {
await goToPanel(page, options.path);
const launchScreen = page.locator("#ha-launch-screen");
await expect(launchScreen).toBeAttached({ timeout: QUICK_TIMEOUT });
await expect(page.locator(options.loadingSelector)).toBeAttached({
timeout: QUICK_TIMEOUT,
});
await page.evaluate((resolver) => window[resolver]?.(), options.resolver);
await expect(page.locator(options.readySelector)).toBeAttached({
timeout: PANEL_TIMEOUT,
});
await expect(launchScreen).not.toBeAttached({ timeout: QUICK_TIMEOUT });
};
test.describe("Initial readiness", () => {
test("keeps the launch screen until calendar content renders", async ({
page,
}) => {
await assertInitialReadiness(page, {
resolvers: (
| "rejectMediaBrowse"
| "resolveCalendarRegistry"
| "resolveConfigEntries"
| "resolveConfigEntriesInProgress"
| "resolveGeneratedDashboard"
| "resolveLovelaceConfig"
| "resolveMediaBrowse"
)[];
}[] = [
{
name: "calendar",
path: "/?scenario=delayed-calendar#/calendar",
loadingSelector: "ha-panel-calendar ha-spinner",
resolver: "resolveCalendarRegistry",
readySelector: "ha-full-calendar",
});
});
test("keeps the launch screen until media content renders", async ({
page,
}) => {
await assertInitialReadiness(page, {
resolvers: ["resolveCalendarRegistry"],
},
{
name: "media browser",
path: "/?scenario=delayed-media-browse#/media-browser/browser",
loadingSelector: "ha-media-player-browse > ha-spinner",
resolver: "resolveMediaBrowse",
readySelector: "ha-media-player-browse .no-items",
});
});
test("keeps the launch screen until a media error renders", async ({
page,
}) => {
await assertInitialReadiness(page, {
resolvers: ["resolveMediaBrowse"],
},
{
name: "integrations",
path: "/?scenario=delayed-integrations#/config/integrations",
loadingSelector: "ha-config-integrations-dashboard hass-loading-screen",
readySelector: "ha-config-integrations-dashboard hass-tabs-subpage",
resolvers: ["resolveConfigEntries", "resolveConfigEntriesInProgress"],
},
{
name: "media browser error",
path: "/?scenario=delayed-media-browse-error#/media-browser/browser",
loadingSelector: "ha-media-player-browse > ha-spinner",
resolver: "rejectMediaBrowse",
readySelector: "ha-media-player-browse ha-alert",
resolvers: ["rejectMediaBrowse"],
},
{
name: "generated dashboard",
path: "/?scenario=delayed-generated-dashboard#/climate",
loadingSelector: "#ha-launch-screen",
readySelector: "hui-view",
resolvers: ["resolveGeneratedDashboard"],
},
{
name: "Lovelace dashboard",
path: "/?scenario=delayed-lovelace#/lovelace",
loadingSelector: "#ha-launch-screen",
readySelector: "hui-card",
resolvers: ["resolveLovelaceConfig"],
},
];
for (const readinessCase of cases) {
// eslint-disable-next-line no-await-in-loop
await test.step(readinessCase.name, async () => {
await goToPanel(page, readinessCase.path);
const launchScreen = page.locator("#ha-launch-screen");
const loadingScreen = page.locator(readinessCase.loadingSelector);
const readyContent = page.locator(readinessCase.readySelector).first();
await expect(launchScreen).toBeAttached({ timeout: QUICK_TIMEOUT });
await expect(loadingScreen).toBeAttached({ timeout: QUICK_TIMEOUT });
await expect(readyContent).not.toBeAttached();
await readinessCase.resolvers.reduce(
async (previousResolver, resolver, index) => {
await previousResolver;
await page.evaluate((resolverName) => {
window[resolverName]?.();
}, resolver);
if (index < readinessCase.resolvers.length - 1) {
await expect(launchScreen).toBeAttached();
await expect(loadingScreen).toBeAttached();
await expect(readyContent).not.toBeAttached();
}
},
Promise.resolve()
);
await expect(readyContent).toBeAttached({ timeout: PANEL_TIMEOUT });
await expect(launchScreen).not.toBeAttached({ timeout: QUICK_TIMEOUT });
});
});
}
});
// ---------------------------------------------------------------------------
@@ -227,40 +262,6 @@ test.describe("Initial readiness", () => {
// ---------------------------------------------------------------------------
test.describe("Lovelace dashboard", () => {
test("keeps the launch screen until generated content renders", async ({
page,
}) => {
await goToPanel(page, "/?scenario=delayed-generated-dashboard#/climate");
const launchScreen = page.locator("#ha-launch-screen");
await expect(launchScreen).toBeAttached({ timeout: QUICK_TIMEOUT });
await expect(page.locator("hui-view")).not.toBeAttached();
await page.evaluate(() => window.resolveGeneratedDashboard?.());
await expect(page.locator("hui-view")).toBeAttached({
timeout: PANEL_TIMEOUT,
});
await expect(launchScreen).not.toBeAttached({ timeout: QUICK_TIMEOUT });
});
test("keeps the launch screen until initial content renders", async ({
page,
}) => {
await goToPanel(page, "/?scenario=delayed-lovelace#/lovelace");
const launchScreen = page.locator("#ha-launch-screen");
await expect(launchScreen).toBeAttached({ timeout: QUICK_TIMEOUT });
await expect(page.locator("hui-card")).not.toBeAttached();
await page.evaluate(() => window.resolveLovelaceConfig?.());
await expect(page.locator("hui-card").first()).toBeAttached({
timeout: PANEL_TIMEOUT,
});
await expect(launchScreen).not.toBeAttached({ timeout: QUICK_TIMEOUT });
});
test("renders cards", async ({ page }) => {
await goToPanel(page, "/lovelace");
// At least one card should appear
+2
View File
@@ -94,6 +94,8 @@ declare global {
__mockHass: MockHomeAssistant;
rejectMediaBrowse?: () => void;
resolveCalendarRegistry?: () => void;
resolveConfigEntries?: () => void;
resolveConfigEntriesInProgress?: () => void;
resolveGeneratedDashboard?: () => void;
resolveLovelaceConfig?: () => void;
resolveMediaBrowse?: () => void;
+20
View File
@@ -185,6 +185,25 @@ const delayedCalendarScenario: Scenario = (hass) => {
hass.mockWS("config/entity_registry/list", () => registryPromise);
};
const delayedIntegrationsScenario: Scenario = (hass) => {
addLaunchScreen();
hass.mockWS(
"config_entries/subscribe",
(_msg, _currentHass, onChange?: (updates: unknown[]) => void) => {
window.resolveConfigEntries = () => onChange?.([]);
return () => undefined;
}
);
hass.mockWS(
"config_entries/flow/subscribe",
(_msg, _currentHass, onChange?: (updates: unknown[]) => void) => {
window.resolveConfigEntriesInProgress = () => onChange?.([]);
return () => undefined;
}
);
};
const delayedMediaBrowseScenario: Scenario = (hass) => {
addLaunchScreen();
@@ -230,6 +249,7 @@ export const scenarios: Record<string, Scenario> = {
"custom-theme": customThemeScenario,
"delayed-calendar": delayedCalendarScenario,
"delayed-generated-dashboard": delayedGeneratedDashboardScenario,
"delayed-integrations": delayedIntegrationsScenario,
"delayed-media-browse": delayedMediaBrowseScenario,
"delayed-media-browse-error": delayedMediaBrowseErrorScenario,
"light-more-info": lightMoreInfoScenario,
+56 -9
View File
@@ -2945,7 +2945,7 @@ __metadata:
languageName: node
linkType: hard
"@gfx/zopfli@npm:1.0.15":
"@gfx/zopfli@npm:^1.0.15":
version: 1.0.15
resolution: "@gfx/zopfli@npm:1.0.15"
dependencies:
@@ -5641,10 +5641,10 @@ __metadata:
languageName: node
linkType: hard
"@types/luxon@npm:3.7.3":
version: 3.7.3
resolution: "@types/luxon@npm:3.7.3"
checksum: 10/f097bd3f7c47ae766928e0fa496ec13fe4d72c42b29afa4bed457775ce4fb4d8574ccccc0c974bf8915d1e6a7e226fa6efb989905786c4c9c1714754f74e8a17
"@types/luxon@npm:3.7.2":
version: 3.7.2
resolution: "@types/luxon@npm:3.7.2"
checksum: 10/6634ebaceeec84d39cbb456d4db358481bc2ba7d05df47890a4f2d235100c29a52f621e1401f016747093093fa86c117bef01446199e0b1af3bcdcee47a57b1f
languageName: node
linkType: hard
@@ -6744,6 +6744,13 @@ __metadata:
languageName: node
linkType: hard
"any-promise@npm:^1.1.0":
version: 1.3.0
resolution: "any-promise@npm:1.3.0"
checksum: 10/6737469ba353b5becf29e4dc3680736b9caa06d300bda6548812a8fee63ae7d336d756f88572fa6b5219aed36698d808fa55f62af3e7e6845c7a1dc77d240edb
languageName: node
linkType: hard
"anymatch@npm:^3.1.3, anymatch@npm:~3.1.2":
version: 3.1.3
resolution: "anymatch@npm:3.1.3"
@@ -7321,7 +7328,7 @@ __metadata:
languageName: node
linkType: hard
"bytes@npm:3.1.2":
"bytes@npm:3.1.2, bytes@npm:^3.1.2":
version: 3.1.2
resolution: "bytes@npm:3.1.2"
checksum: 10/a10abf2ba70c784471d6b4f58778c0beeb2b5d405148e66affa91f23a9f13d07603d0a0354667310ae1d6dc141474ffd44e2a074be0f6e2254edb8fc21445388
@@ -9766,6 +9773,19 @@ __metadata:
languageName: node
linkType: hard
"gulp-zopfli-green@npm:7.0.0":
version: 7.0.0
resolution: "gulp-zopfli-green@npm:7.0.0"
dependencies:
"@gfx/zopfli": "npm:^1.0.15"
bytes: "npm:^3.1.2"
plugin-error: "npm:^2.0.1"
stream-to-array: "npm:^2.3.0"
through2: "npm:^4.0.2"
checksum: 10/25db42b9a8f6395847e3cc06c548c3f3d51c84606166f18de71d91d505e7293176b45b4b78b7e0b1566c26263fe0dc212e56d2b367461c3d712bee9d22d5224f
languageName: node
linkType: hard
"gulp@npm:5.0.1":
version: 5.0.1
resolution: "gulp@npm:5.0.1"
@@ -9899,7 +9919,6 @@ __metadata:
"@fullcalendar/list": "npm:6.1.21"
"@fullcalendar/luxon3": "npm:6.1.21"
"@fullcalendar/timegrid": "npm:6.1.21"
"@gfx/zopfli": "npm:1.0.15"
"@home-assistant/webawesome": "npm:3.7.0-ha.0"
"@html-eslint/eslint-plugin": "npm:0.64.0"
"@lezer/highlight": "npm:1.2.3"
@@ -9937,7 +9956,7 @@ __metadata:
"@types/leaflet-draw": "npm:1.0.13"
"@types/leaflet.markercluster": "npm:1.5.6"
"@types/lodash.merge": "npm:4.6.9"
"@types/luxon": "npm:3.7.3"
"@types/luxon": "npm:3.7.2"
"@types/qrcode": "npm:1.5.6"
"@types/sortablejs": "npm:1.15.9"
"@types/tar": "npm:7.0.87"
@@ -9982,6 +10001,7 @@ __metadata:
gulp-brotli: "npm:3.0.0"
gulp-json-transform: "npm:0.5.0"
gulp-rename: "npm:2.1.0"
gulp-zopfli-green: "npm:7.0.0"
hls.js: "npm:1.6.16"
home-assistant-js-websocket: "npm:9.6.0"
html-minifier-terser: "npm:7.2.0"
@@ -12764,6 +12784,15 @@ __metadata:
languageName: node
linkType: hard
"plugin-error@npm:^2.0.1":
version: 2.0.1
resolution: "plugin-error@npm:2.0.1"
dependencies:
ansi-colors: "npm:^1.0.1"
checksum: 10/9a4f91461cd24cce401112098969991d7aa6b4c94f78e0381234280c07da779570a8b21ab143292b534ec0117c09705a67e5d756c1c303d4706fdd7f861bf5bc
languageName: node
linkType: hard
"pngjs@npm:^3.0.0":
version: 3.4.0
resolution: "pngjs@npm:3.4.0"
@@ -12977,7 +13006,7 @@ __metadata:
languageName: node
linkType: hard
"readable-stream@npm:2 || 3, readable-stream@npm:^3.4.0":
"readable-stream@npm:2 || 3, readable-stream@npm:3, readable-stream@npm:^3.4.0":
version: 3.6.2
resolution: "readable-stream@npm:3.6.2"
dependencies:
@@ -14165,6 +14194,15 @@ __metadata:
languageName: node
linkType: hard
"stream-to-array@npm:^2.3.0":
version: 2.3.0
resolution: "stream-to-array@npm:2.3.0"
dependencies:
any-promise: "npm:^1.1.0"
checksum: 10/7feaf63b38399b850615e6ffcaa951e96e4c8f46745dbce4b553a94c5dc43966933813747014935a3ff97793e7f30a65270bde19f82b2932871a1879229a77cf
languageName: node
linkType: hard
"streamx@npm:^2.12.0, streamx@npm:^2.12.5, streamx@npm:^2.13.2, streamx@npm:^2.14.0":
version: 2.28.0
resolution: "streamx@npm:2.28.0"
@@ -14571,6 +14609,15 @@ __metadata:
languageName: node
linkType: hard
"through2@npm:^4.0.2":
version: 4.0.2
resolution: "through2@npm:4.0.2"
dependencies:
readable-stream: "npm:3"
checksum: 10/72c246233d9a989bbebeb6b698ef0b7b9064cb1c47930f79b25d87b6c867e075432811f69b7b2ac8da00ca308191c507bdab913944be8019ac43b036ce88f6ba
languageName: node
linkType: hard
"time-stamp@npm:^1.0.0":
version: 1.1.0
resolution: "time-stamp@npm:1.1.0"