perf(card): a floor switch stops forcing layout and re-walking the config (#725)

Profiling #694 found three costs on every View pass, paid even with the
summary panel hidden.

The summary panel read the safe-area probe's computed style in layout(),
which the card reaches up to five times per render (renderControls,
menuItems, renderPanel twice, the clock check), and its updated()
measured the stage, probe and kiosk buttons after every DOM commit. The
insets now live in the measured state: measureLayout is the only method
that reads style or layout, and updated() calls it only when an input of
the measurement changed (probe, kiosk buttons or stage element, title,
language, mode, kiosk, kiosk scale, narrow, HA theme), after connect()
or an identity change, on visibility, once after document.fonts.ready,
and from resized() as before. A floor switch or an HA tick no longer
measures.

The _model getter rebuilt the config fingerprint (a walk over every
space and room with JSON.stringify of room settings) on each of its
dozens of reads per render. ConfigFingerprintPass remembers the whole
cache key (epoch and fingerprint) from the start of willUpdate() to the
end of render() while the epoch, the config object and its spaces array
are unchanged. Remembering only the fingerprint and concatenating the key
on every read was tried first: in 2.5D on the large house the switch cycle
measured slower than without any memo, and CPU profiles showed several
times more garbage collection on load and on the first visit of a floor;
one remembered key per pass has neither. Outside the pass (handlers, updated(),
timers) every read still builds the key, so an in-place edit without an
epoch bump stays visible (HP-1454-04). No write to the fingerprinted
fields is reachable from willUpdate() or render().

_isoScene read the stage box during render only to feed an aspect into
the overlay fit, whose frame has not depended on the aspect since #713.
It now uses the frame's own aspect and passes stageSize: null.

render-layout-read.mjs now also judges _isoScene and the whole summary
runtime except measureLayout, forbids layout property reads
(clientWidth, offsetTop, ...) besides the two calls, and reports every
violation. Two registered mutants restore the old reads.

No visible change: panel caps, side, offsets and kiosk clearance are
computed from the same values; the 2.5D frame is the same.

Issue: #725
User-Visible: no
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd
This commit is contained in:
Claude
2026-10-01 03:17:40 +00:00
committed by claude[bot]
parent 8fac66e1c3
commit 235f60e693
11 changed files with 561 additions and 51 deletions
+60 -24
View File
@@ -52,6 +52,10 @@ type SummaryLayoutState = {
height: number;
minimumHeight: number;
controlTop: number;
safeLeft: number;
safeRight: number;
safeTop: number;
safeBottom: number;
};
/**
@@ -75,7 +79,10 @@ export class LoadedSummaryPanelRuntime {
private storageKey: string | null = null;
private stage: SummaryLayoutState = {
width: 0, height: 0, minimumHeight: 162, controlTop: 0,
safeLeft: 0, safeRight: 0, safeTop: 0, safeBottom: 0,
};
/** #725: what the last measurement saw; `null` measures on the next `updated()`. */
private measured: { key: string; elements: readonly (Element | null)[] } | null = null;
private clock = new Date();
private clockTimer = 0;
private deviceMemo: {
@@ -115,6 +122,8 @@ export class LoadedSummaryPanelRuntime {
this.connected = true;
this.syncLifecycle();
this.loadLocal();
this.measured = null;
this.measureAfterFonts();
}
public disconnect(): void {
@@ -143,6 +152,7 @@ export class LoadedSummaryPanelRuntime {
this.host.requestUpdate();
return;
}
this.measured = null;
this.clock = new Date();
this.syncClock();
this.host.requestUpdate();
@@ -152,7 +162,7 @@ export class LoadedSummaryPanelRuntime {
this.syncLifecycle();
this.syncNativeNarrow();
this.loadLocal();
this.measureLayout();
this.measureIfInputsChanged();
this.syncClock();
this.ensureDialogStyle();
this.presentation.updated(this.host.renderRoot.querySelector<HTMLElement>('.summary-overlay'));
@@ -392,6 +402,8 @@ export class LoadedSummaryPanelRuntime {
if (this.clockTimer) clearTimeout(this.clockTimer);
this.clockTimer = 0;
this.lifecycleIdentity = this.connected ? this.identity() : '';
this.measured = null;
if (this.connected) this.measureAfterFonts();
this.host.requestUpdate();
}
@@ -845,13 +857,9 @@ export class LoadedSummaryPanelRuntime {
}).catch(() => undefined).finally(() => { this.metricsLoad = null; });
}
/** Render-time layout from the last measurement: no style or layout read (#725). */
private layout() {
return resolveSummaryLayout({
width: this.stage.width, height: this.stage.height,
...this.safeInsets(),
controlTop: this.host._kiosk ? this.stage.controlTop : 0,
minimumHeight: this.stage.minimumHeight,
});
return resolveSummaryLayout({ ...this.stage, controlTop: this.host._kiosk ? this.stage.controlTop : 0 });
}
private syncPresentation(immediate = false): void {
@@ -866,37 +874,65 @@ export class LoadedSummaryPanelRuntime {
immediate || !eligible || this.host.ownerDocument.visibilityState === 'hidden');
}
/**
* #725: the card calls `updated()` after every render — a floor switch, an HA
* tick — and measuring there forced a synchronous layout each time. Measure
* only when an input of the measurement changed: the probe and kiosk-button
* elements, the stage element, the title, language, mode, kiosk, its scale,
* `narrow` and the HA theme. The stage size arrives through `resized()`.
* Whatever is not listed and might matter resets `measured` instead.
*/
private measureIfInputsChanged(): void {
const { hass, renderRoot, _stageEl, _config, _mode, _kiosk, _kioskScale, narrow } = this.host;
const elements = [_stageEl, ...['.summary-measure', '.summary-safe-probe', '.summary-control.kiosk']
.map((selector) => renderRoot.querySelector(selector))];
const themes = hass?.themes as Record<string, unknown> | undefined;
const key = JSON.stringify([
this.config().config?.title ?? null, langOf(hass, _config?.language), _mode, _kiosk, _kioskScale, narrow,
hass?.selectedTheme ?? null,
...['theme', 'darkMode', 'default_theme', 'default_dark_theme'].map((name) => themes?.[name] ?? null),
]);
const last = this.measured;
if (last && last.key === key && last.elements.every((element, index) => element === elements[index])) return;
this.measured = { key, elements };
this.measureLayout();
}
/** #725: a late web font changes the probe height without resizing the stage. */
private measureAfterFonts(): void {
const generation = this.lifecycleGeneration;
void this.host.ownerDocument.fonts?.ready.then(() => { if (this.current(generation)) this.measureLayout(); });
}
/**
* The one method that reads style and layout (#725; `scripts/render-layout-read.mjs`
* names it): the stage, probe and kiosk-button boxes and the safe-area insets.
* Render paths use what it stored.
*/
private measureLayout(): void {
const stage = this.host._stageEl;
if (!stage) return;
const probe = this.host.renderRoot.querySelector('.summary-measure') as HTMLElement | null;
const controls = this.host.renderRoot.querySelector('.summary-control.kiosk') as HTMLElement | null;
const root = this.host.renderRoot;
const probe = root.querySelector('.summary-measure') as HTMLElement | null;
const controls = root.querySelector('.summary-control.kiosk') as HTMLElement | null;
const safeProbe = root.querySelector('.summary-safe-probe') as HTMLElement | null;
const stageBox = stage.getBoundingClientRect();
const controlBox = controls?.getBoundingClientRect();
const next = {
const style = safeProbe ? this.host.ownerDocument.defaultView?.getComputedStyle(safeProbe) : undefined;
const px = (value: string | undefined): number => Number.parseFloat(value ?? '') || 0;
const next: SummaryLayoutState = {
width: Math.round(stage.clientWidth), height: Math.round(stage.clientHeight),
minimumHeight: Math.max(162, Math.ceil(probe?.getBoundingClientRect().height || 0)),
controlTop: controlBox ? Math.max(0, Math.ceil(controlBox.bottom - stageBox.top + 12)) : 0,
safeLeft: px(style?.paddingLeft), safeRight: px(style?.paddingRight),
safeTop: px(style?.paddingTop), safeBottom: px(style?.paddingBottom),
};
if (next.width !== this.stage.width || next.height !== this.stage.height
|| next.minimumHeight !== this.stage.minimumHeight || next.controlTop !== this.stage.controlTop) {
if ((Object.keys(next) as (keyof SummaryLayoutState)[]).some((name) => next[name] !== this.stage[name])) {
this.stage = next;
this.host.requestUpdate();
}
}
private safeInsets(): { safeLeft: number; safeRight: number; safeTop: number; safeBottom: number } {
const probe = this.host.renderRoot.querySelector('.summary-safe-probe') as HTMLElement | null;
if (!probe) return { safeLeft: 0, safeRight: 0, safeTop: 0, safeBottom: 0 };
const style = this.host.ownerDocument.defaultView?.getComputedStyle(probe);
if (!style) return { safeLeft: 0, safeRight: 0, safeTop: 0, safeBottom: 0 };
const px = (value: string): number => Number.parseFloat(value) || 0;
return {
safeLeft: px(style.paddingLeft), safeRight: px(style.paddingRight),
safeTop: px(style.paddingTop), safeBottom: px(style.paddingBottom),
};
}
private hasVisibleClock(): boolean {
const config = this.config().config;
if (this.host.ownerDocument.visibilityState === 'hidden' || !config) return false;