mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-04 21:58:56 +00:00
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:
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* #725: the card's config fingerprint is built at most once per update pass.
|
||||
*
|
||||
* `_model` keys its memo on the config epoch plus a structural fingerprint that
|
||||
* walks every space and room. One render reads `_model` dozens of times, and
|
||||
* each read used to walk the whole house again. Inside one pass — from the
|
||||
* start of `willUpdate()` to the end of `render()` — the first read builds the
|
||||
* key with its fingerprint and the rest reuse that very string while the epoch,
|
||||
* the config object and its `spaces` array stay the same. Remembering only the
|
||||
* fingerprint and gluing the key anew on each read was tried first: on the large
|
||||
* house in 2.5D it measured slower than no memo at all (more GC on load and on a
|
||||
* first floor visit), while one remembered key is faster.
|
||||
*
|
||||
* Outside a pass (handlers, `updated()`, timers, async continuations) every
|
||||
* read builds the fingerprint, exactly as before: that is where an in-place
|
||||
* mutation without an epoch bump can happen, and the fingerprint is the belt
|
||||
* that still sees it (HP-1454-04).
|
||||
*/
|
||||
export class ConfigFingerprintPass {
|
||||
private serial = 0;
|
||||
private open = false;
|
||||
private memo: { epoch: number; config: object | null; spaces: unknown; value: string } | null = null;
|
||||
|
||||
/**
|
||||
* Opens a pass and forgets the previous one. A pass that never reaches
|
||||
* `end()` — `willUpdate()` threw, so Lit never rendered — closes itself in a
|
||||
* microtask, before any handler or timer could read a remembered fingerprint.
|
||||
*/
|
||||
public begin(): void {
|
||||
const serial = ++this.serial;
|
||||
this.open = true;
|
||||
this.memo = null;
|
||||
queueMicrotask(() => { if (this.serial === serial) this.end(); });
|
||||
}
|
||||
|
||||
public end(): void {
|
||||
this.open = false;
|
||||
this.memo = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* What `build` returns for these inputs — the card passes its whole `_model`
|
||||
* key, epoch and fingerprint — remembered inside a pass, built outside.
|
||||
*/
|
||||
public read(epoch: number, config: { spaces?: unknown } | null, build: () => string): string {
|
||||
if (!this.open) return build();
|
||||
const spaces = config?.spaces;
|
||||
const memo = this.memo;
|
||||
if (memo && memo.epoch === epoch && memo.config === config && memo.spaces === spaces) return memo.value;
|
||||
const value = build();
|
||||
this.memo = { epoch, config, spaces, value };
|
||||
return value;
|
||||
}
|
||||
}
|
||||
+16
-12
@@ -301,6 +301,7 @@ import { HeaderMenu, headerMenuItems, renderHeaderActions } from './header-menu'
|
||||
import { isoWallMaterialVars, parseCssColor } from './iso-materials'; import { IsoFirstFrameState, isoPaperContext } from './iso-first-frame';
|
||||
import { renderIsoTileShadow } from './iso-tiles';
|
||||
import { displayVersion } from './card-version';
|
||||
import { ConfigFingerprintPass } from './config-fingerprint-pass';
|
||||
const CARD_VERSION = '1.79.0-beta.1';
|
||||
const EDITOR_RETRY_ASSET = '__HOUSEPLAN_EDITOR_RETRY_ASSET__';
|
||||
const ISO_RETRY_ASSET = '__HOUSEPLAN_ISO_RETRY_ASSET__';
|
||||
@@ -3770,6 +3771,7 @@ export class HouseplanCard extends LitElement {
|
||||
private _cfgEpochPreservedConfig: ServerConfig | null = null;
|
||||
private _terminalFrame: 0 | 1 | 2 = 0; // 1=restored cancel, 2=deferred HA
|
||||
private _modelCache: { key: string; model: SpaceModel[] } | null = null;
|
||||
private readonly _cfgPass = new ConfigFingerprintPass(); // #725: one fingerprint per willUpdate→render pass
|
||||
private _emptySpaceStateActive = false;
|
||||
private _decorSnapCache: {
|
||||
epoch: number; space: string; height: number; exclude: string; geometry: SnapGeometry;
|
||||
@@ -3810,8 +3812,9 @@ export class HouseplanCard extends LitElement {
|
||||
private get _model(): SpaceModel[] {
|
||||
if (!this._serverCfg) return [];
|
||||
// In-place mutations mean the epoch can lag, so the key also carries the
|
||||
// config's structural fingerprint.
|
||||
const key = this._cfgEpoch + '|' + this._cfgFingerprint();
|
||||
// config's structural fingerprint. #725: a pass remembers the whole key; a kept
|
||||
// fingerprint glued anew on every read measured slower in 2.5D than no memo.
|
||||
const key = this._cfgPass.read(this._cfgEpoch, this._serverCfg, () => this._cfgEpoch + '|' + this._cfgFingerprint());
|
||||
if (this._modelCache && this._modelCache.key === key) return this._modelCache.model;
|
||||
const built = this._buildModel();
|
||||
this._modelCache = { key, model: built };
|
||||
@@ -3998,7 +4001,7 @@ export class HouseplanCard extends LitElement {
|
||||
}
|
||||
|
||||
protected willUpdate(changed: PropertyValues): void {
|
||||
this._isoProjectionSnapshot = null; this._isoFirstFrame.prepare(this._desiredProjection, isoPaperContext(this._space, this._mode, !!this._spaceModel()?.bg, this.hass?.themes)); this._syncVolumetricSetting(); this._summary?.willUpdate();
|
||||
this._cfgPass.begin(); this._isoProjectionSnapshot = null; this._isoFirstFrame.prepare(this._desiredProjection, isoPaperContext(this._space, this._mode, !!this._spaceModel()?.bg, this.hass?.themes)); this._syncVolumetricSetting(); this._summary?.willUpdate();
|
||||
if (changed.has('hass')) {
|
||||
// Observe every user/connection transition, including A→B→A while an
|
||||
// old promise is waiting. Equality at completion must not revive it.
|
||||
@@ -5995,14 +5998,13 @@ export class HouseplanCard extends LitElement {
|
||||
});
|
||||
if (!space || !disp.showBorders) return scene;
|
||||
const cfgSize = this._config?.icon_size ?? 2.5, iconPct = cfgSize > 8 ? 2.5 : cfgSize;
|
||||
const stageSize = this._stageEl?.getBoundingClientRect?.() ?? null;
|
||||
const aspect = stageSize?.height ? stageSize.width / stageSize.height : scene.frame.w / scene.frame.h;
|
||||
const aspect = scene.frame.w / scene.frame.h; // #713/#725: the fit frame ignores aspect; no layout read in render
|
||||
const overlays = this._isoOverlayScene(
|
||||
space, this._renderDevices.filter((device) => device.space === space.id && !device.hidden),
|
||||
disp, runtime.resolveIsoDecorationLayers(disp), scene, iconPct, effectiveDeviceBaseSize(iconPct),
|
||||
disp.showLqi ?? this._config?.show_signal ?? true);
|
||||
const envelope = overlays && runtime.resolveIsoOverlayFitEnvelope({
|
||||
baseBounds: scene.frame, entries: overlays.entries, stageSize, targetView: (bounds) =>
|
||||
baseBounds: scene.frame, entries: overlays.entries, stageSize: null, targetView: (bounds) =>
|
||||
fitView([bounds.x, bounds.y, bounds.w, bounds.h], aspect) });
|
||||
return envelope ? { ...scene, frame: envelope.bounds, overlayFitEntries: overlays.entries } : scene;
|
||||
}
|
||||
@@ -10568,12 +10570,14 @@ export class HouseplanCard extends LitElement {
|
||||
}
|
||||
|
||||
protected render(): TemplateResult | typeof nothing | typeof noChange {
|
||||
const body = this._renderBody();
|
||||
// `nothing` is the only root that has no decision surface. `noChange` is
|
||||
// deliberately nested: it preserves the committed body while allowing the
|
||||
// sibling hp-confirm to settle/cancel on a ready -> warm transition.
|
||||
if (body === nothing) return body;
|
||||
return this._renderRoot(body);
|
||||
try {
|
||||
const body = this._renderBody();
|
||||
// `nothing` is the only root that has no decision surface. `noChange` is
|
||||
// deliberately nested: it preserves the committed body while allowing the
|
||||
// sibling hp-confirm to settle/cancel on a ready -> warm transition.
|
||||
if (body === nothing) return body;
|
||||
return this._renderRoot(body);
|
||||
} finally { this._cfgPass.end(); } // #725: the pass ends with render, before DOM commit and updated()
|
||||
}
|
||||
|
||||
private _renderBody(): TemplateResult | typeof nothing | typeof noChange {
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user