fix: satisfy render performance validation gates

Issue: #451
User-Visible: no
This commit is contained in:
Sergey Matyunin
2026-09-04 22:04:03 +03:00
parent c0d61ca3d2
commit 5d594e70c8
35 changed files with 629 additions and 177 deletions
+7 -5
View File
@@ -6,7 +6,7 @@
* 2) LEGACY fallback — baked-in country-house data (src/data/*), coordinates in a 1489×1053 canvas.
* The icon layout is stored on the server (houseplan/layout/*), fallback — localStorage.
*/
import { LitElement, html, svg, nothing, noChange, TemplateResult, PropertyValues } from 'lit';
import { LitElement, html, svg, nothing, noChange, TemplateResult, PropertyValues, type PropertyDeclaration } from 'lit';
import { guard } from 'lit/directives/guard.js';
import { repeat } from 'lit/directives/repeat.js';
import './hp-dialog';
@@ -306,6 +306,7 @@ import {
type RenderDeviceSnapshot,
} from './render-device-snapshot';
import { RenderLifecycle, intakeHass } from './houseplan-render-lifecycle';
import type { HassRenderSnapshot } from './render-invalidation';
import { deviceFaceStyle, deviceThemeClass, renderDeviceFace } from './device-face';
import { effectiveDeviceBaseSize } from './device-marker-geometry'; import { renderZigbeeTopologyOverlay } from './zigbee-topology-overlay-bridge';
import {
@@ -657,11 +658,12 @@ type FixedFloorState = FixedFloorSelection | { kind: 'pending'; value: unknown }
const HANDLE_PAINT_ORDER = ['edges', 'corners'] as const;
export class HouseplanCard extends LitElement {
public requestUpdate(name?: PropertyKey, oldValue?: unknown, options?: any): void {
public requestUpdate(name?: PropertyKey, oldValue?: unknown, options?: PropertyDeclaration): void {
if (name === 'hass' && this.hass && this._liveRt) {
const snapshot = this._visibleDeviceSnapshot || this._candidateDeviceSnapshot;
const render = this._liveRt.hass(
oldValue, this.hass, snapshot ? { entityIds: snapshot.entityIds } : null,
oldValue as HassRenderSnapshot | null | undefined, this.hass,
snapshot ? { entityIds: snapshot.entityIds } : null,
() => intakeHass(this),
);
if (!render) return;
@@ -4042,7 +4044,7 @@ export class HouseplanCard extends LitElement {
}
if (changed.has('hass') && this.hass) {
const snapshot = this._visibleDeviceSnapshot || this._candidateDeviceSnapshot;
this._renderLife.observe(changed.get('hass'), this.hass,
this._renderLife.observe(changed.get('hass') as HassRenderSnapshot | null | undefined, this.hass,
snapshot ? { entityIds: snapshot.entityIds } : null, () => intakeHass(this));
}
if (this._continuity.hasCompleteFrame && this._continuity.state === 'steady') {
@@ -4500,7 +4502,7 @@ export class HouseplanCard extends LitElement {
private _haRegistryConnection: any = null;
private _haRegistryRev = -1;
private _haBindingCacheKey = '';
private _planHassMemo: { hass: any; sig: string; active: any; full: any } | null = null;
private _planHassMemo: { hass: unknown; sig: string; active: unknown; full: unknown } | null = null;
private _renderLife = new RenderLifecycle();
private _liveEditorPaintCount = 0;
+27 -8
View File
@@ -2,7 +2,7 @@ import {
haRegistryDiagnostics,
type HaBindingStatus,
} from './ha-binding-status';
import type { HassRenderDependencies } from './render-invalidation';
import type { HassRenderDependencies, HassRenderSnapshot } from './render-invalidation';
export interface HouseplanDiagnosticsCore {
registry: ReturnType<typeof haRegistryDiagnostics>;
@@ -13,12 +13,12 @@ type DiagnosticMarker = { removed?: boolean; binding: string };
/** State kept outside the already oversized card element (#34). */
export class RenderLifecycle {
private seen: any = null;
private seen: unknown = null;
private diagnosticsCache: HouseplanDiagnosticsCore | null = null;
observe(
before: any,
after: any,
before: HassRenderSnapshot | null | undefined,
after: HassRenderSnapshot | null | undefined,
dependencies: HassRenderDependencies | null,
intake: () => void,
): void {
@@ -26,7 +26,7 @@ export class RenderLifecycle {
this.intake(after, intake);
}
intake(snapshot: any, run: () => void): void {
intake(snapshot: unknown, run: () => void): void {
if (!snapshot || this.seen === snapshot) return;
this.seen = snapshot;
run();
@@ -37,7 +37,9 @@ export class RenderLifecycle {
}
private invalidateDiagnosticsForHass(
before: any, after: any, dependencies: HassRenderDependencies | null,
before: HassRenderSnapshot | null | undefined,
after: HassRenderSnapshot | null | undefined,
dependencies: HassRenderDependencies | null,
): void {
if (!this.diagnosticsCache) return;
if (before?.entities !== after?.entities || before?.devices !== after?.devices
@@ -48,7 +50,7 @@ export class RenderLifecycle {
}
diagnostics(
hass: any,
hass: unknown,
markers: readonly DiagnosticMarker[],
resolve: (binding: string) => HaBindingStatus,
): HouseplanDiagnosticsCore {
@@ -66,7 +68,24 @@ export class RenderLifecycle {
}
/** Operational work that must survive a deliberately skipped visual update. */
export function intakeHass(host: any): void {
interface HassIntakeHost {
_hassSequence: number;
_renderSnapshotAt: number;
_continuity: { note: (reason: string) => void };
_ensureHaRegistryAuthority: () => void;
_planHassMemo: unknown;
_hookConnection: () => void;
_loadOk: boolean;
_loading: boolean;
_loadTries: number;
_loadFromServer: () => unknown;
_maybeRebuildDevices: () => void;
_vacTick: () => void;
_activityTick: () => void;
}
export function intakeHass(value: object): void {
const host = value as HassIntakeHost;
host._hassSequence++;
host._renderSnapshotAt = Date.now();
host._continuity.note('hass-snapshot');
+63 -15
View File
@@ -6,6 +6,48 @@ interface LiveEditorState {
hidden: HTMLElement[];
}
interface LiveEditorHost {
isConnected: boolean;
renderRoot: ParentNode;
requestUpdate: () => void;
_mode: 'view' | 'plan' | 'devices' | 'decor';
_deviceDrag: { id: string } | null;
_physicalDrag?: unknown;
_physicalRotate?: unknown;
_decorDraft?: unknown;
_decorMove?: unknown;
_dtDrag?: unknown;
_bdDrag?: unknown;
_opDrag?: unknown;
_resize?: { dragging: boolean };
_layout: unknown;
_dtBox: { id: string; x: number; y: number; w: number; h: number } | null;
_dtSel: { id: string; kind: string } | null;
_opMeasureView: { guide: unknown } | null;
_devices: { id: string }[];
_liveEditorPaintCount: number;
_baseVb: () => number[];
_viewOr: (viewBox: number[]) => { x: number; y: number; w: number; h: number };
_spaceDisplayForRender: () => unknown;
_renderWallBodies: (display: unknown) => unknown;
_renderResizeMeasurements: () => unknown;
_renderOpenings: (display: unknown) => unknown;
_renderResizeLayer: (view: unknown) => unknown;
_renderMarkupLayer: (viewBox: number[]) => unknown;
_renderHiddenWallDiagnosticOverlay: () => unknown;
_renderOpeningPlacementPreview: () => unknown;
_renderOpeningDimensionGuides: (measure: unknown) => unknown;
_renderOpeningCenterTick: (guide: unknown) => unknown;
_renderActiveChainInk: () => unknown;
_renderPlanSnapOverlay: () => unknown;
_renderWallThickUi: () => unknown;
_renderDecorLayer: () => unknown;
_renderBackdropFrame: (view: unknown) => unknown;
_renderTextFrame: (view: unknown) => unknown;
_livePos: (device: { id: string }) => { x: number; y: number };
_scenePoint: (point: number[]) => number[];
}
const states = new WeakMap<object, LiveEditorState>();
const liveProperties = new Set<PropertyKey>([
@@ -26,7 +68,7 @@ const stateOf = (host: object): LiveEditorState => {
return state;
};
const activeEditorGesture = (host: any): boolean => host._mode !== 'view' && (
const activeEditorGesture = (host: LiveEditorHost): boolean => host._mode !== 'view' && (
!!host._deviceDrag || !!host._physicalDrag || !!host._physicalRotate
|| !!host._decorDraft || !!host._decorMove || !!host._dtDrag || !!host._bdDrag
|| !!host._opDrag
@@ -39,16 +81,19 @@ const activeEditorGesture = (host: any): boolean => host._mode !== 'view' && (
* and terminal null assignments still receive a normal host update.
*/
export function routeHouseplanEditorUpdate(
host: any,
value: object,
name?: PropertyKey,
oldValue?: unknown,
): boolean {
const host = value as LiveEditorHost;
if (!host?.isConnected || host._mode === 'view') return false;
const namedLive = name !== undefined && liveProperties.has(name);
if (name === '_layout' && !host._deviceDrag) return false;
if (name === '_dtBox' && !host._dtDrag) return false;
if (name !== undefined && gestureProperties.has(name)
&& oldValue == null && host[name] != null && activeEditorGesture(host)) {
&& oldValue == null
&& (host as unknown as Record<PropertyKey, unknown>)[name] != null
&& activeEditorGesture(host)) {
// Pointerdown may change selection/chrome once. Subsequent moves stay live.
return false;
}
@@ -74,7 +119,7 @@ const restore = (state: LiveEditorState): void => {
state.hidden.length = 0;
};
const planTemplate = (host: any): TemplateResult => {
const planTemplate = (host: LiveEditorHost): TemplateResult => {
const vb = host._baseVb();
const view = host._viewOr(vb);
const measure = host._opMeasureView;
@@ -99,7 +144,7 @@ const planTemplate = (host: any): TemplateResult => {
</g>`;
};
const editorTemplate = (host: any): TemplateResult | typeof nothing => {
const editorTemplate = (host: LiveEditorHost): TemplateResult | typeof nothing => {
if (host._mode === 'plan') return planTemplate(host);
if (host._mode === 'decor') {
const view = host._viewOr(host._baseVb());
@@ -112,10 +157,10 @@ const editorTemplate = (host: any): TemplateResult | typeof nothing => {
return nothing;
};
const paintDevice = (host: any, root: ParentNode): void => {
const paintDevice = (host: LiveEditorHost, root: ParentNode): void => {
const drag = host._deviceDrag;
if (!drag) return;
const device = host._devices.find((candidate: any) => candidate.id === drag.id);
const device = host._devices.find((candidate) => candidate.id === drag.id);
const element = [...root.querySelectorAll<HTMLElement>('[data-hp="device"]')]
.find((candidate) => candidate.dataset.id === drag.id);
if (!device || !element) return;
@@ -127,8 +172,9 @@ const paintDevice = (host: any, root: ParentNode): void => {
};
/** One editor-only render root per card; the settled scene remains untouched. */
export function paintHouseplanEditor(host: any): void {
const root = host.renderRoot as ParentNode | undefined;
export function paintHouseplanEditor(value: object): void {
const host = value as LiveEditorHost;
const root = host.renderRoot;
if (!root) return;
const state = stateOf(host);
restore(state);
@@ -146,17 +192,18 @@ export function paintHouseplanEditor(host: any): void {
host._liveEditorPaintCount = (host._liveEditorPaintCount || 0) + 1;
}
export function scheduleHouseplanEditor(host: any): void {
const state = stateOf(host);
export function scheduleHouseplanEditor(value: object): void {
const state = stateOf(value);
if (state.raf || typeof requestAnimationFrame !== 'function') return;
state.raf = requestAnimationFrame(() => {
state.raf = 0;
paintHouseplanEditor(host);
paintHouseplanEditor(value);
});
}
/** Only text needs a DOM measurement; every box-like item owns its config box. */
export function measureHouseplanDecorText(host: any): void {
export function measureHouseplanDecorText(value: object): void {
const host = value as LiveEditorHost;
const shape = host._dtSel;
if (!shape) {
if (host._dtBox) { host._dtBox = null; host.requestUpdate(); }
@@ -182,8 +229,9 @@ export function measureHouseplanDecorText(host: any): void {
host.requestUpdate();
}
export function commitHouseplanEditor(host: any): void {
const state = stateOf(host);
export function commitHouseplanEditor(value: object): void {
const host = value as LiveEditorHost;
const state = stateOf(value);
if (state.raf && typeof cancelAnimationFrame === 'function') cancelAnimationFrame(state.raf);
state.raf = 0;
restore(state);
+15 -5
View File
@@ -16,6 +16,15 @@ interface HoverState {
space: string;
}
interface LiveHoverHost {
renderRoot: ParentNode;
_tip: LiveTip | null;
_t: (key: 'tip.temp_avg' | 'tip.hum_avg' | 'tip.lqi') => string;
_hoverRoom: { space: string; room: unknown } | null;
_spaceModel: () => unknown;
_roomHoverPaths: (model: unknown) => { fillD: string; outlineD: string } | null;
}
const states = new WeakMap<object, HoverState>();
const appendMeta = (tip: HTMLElement, label: string, value?: string, color?: string): void => {
@@ -33,7 +42,7 @@ const appendMeta = (tip: HTMLElement, label: string, value?: string, color?: str
tip.append(row);
};
const syncTip = (host: any, root: ParentNode): void => {
const syncTip = (host: LiveHoverHost, root: ParentNode): void => {
const element = root.querySelector<HTMLElement>('[data-hp-live-tip]');
if (!element) return;
const tip = host._tip as LiveTip | null;
@@ -64,16 +73,17 @@ const setRoomPath = (root: ParentNode, selector: string, d: string): void => {
};
/** Update the ordinary mouse hover without scheduling a full card render. */
export function syncHouseplanHover(host: any): void {
const root = host.renderRoot as ParentNode | undefined;
export function syncHouseplanHover(value: object): void {
const host = value as LiveHoverHost;
const root = host.renderRoot;
if (!root) return;
syncTip(host, root);
const hover = host._hoverRoom;
const previous = states.get(host);
const previous = states.get(value);
const space = hover?.space || '';
const room = hover?.room || null;
if (previous?.room === room && previous?.space === space) return;
states.set(host, { room, space });
states.set(value, { room, space });
const model = hover && host._spaceModel();
const paths = model ? host._roomHoverPaths(model) : null;
setRoomPath(root, '[data-hp-live-room-hover="fill"]', paths?.fillD || '');
+24 -5
View File
@@ -3,20 +3,39 @@ import {
} from './live-viewport';
import { resetHouseplanHover, syncHouseplanHover } from './live-hover';
import {
classifyHassRenderChange, type HassRenderDependencies,
classifyHassRenderChange, type HassRenderDependencies, type HassRenderSnapshot,
} from './render-invalidation';
import type { RenderLifecycle } from './houseplan-render-lifecycle';
interface LiveRuntimeHost {
_renderLife: RenderLifecycle;
_pointers: { size: number };
_cameraTransition: { active: boolean };
_deviceDrag?: unknown;
_physicalDrag?: unknown;
_physicalRotate?: unknown;
_decorMove?: unknown;
_decorDraft?: unknown;
_dtDrag?: unknown;
_bdDrag?: unknown;
_opDrag?: unknown;
_resize?: { dragging: boolean };
}
/** Lazily loaded DOM-only fast paths; the card keeps a full-render fallback. */
export class LiveRuntime {
private deferredHass = false;
public constructor(private readonly host: any) {}
public constructor(private readonly host: object) {}
public hass(
before: any, after: any, dependencies: HassRenderDependencies | null, intake: () => void,
before: HassRenderSnapshot | null | undefined,
after: HassRenderSnapshot | null | undefined,
dependencies: HassRenderDependencies | null,
intake: () => void,
): boolean {
const change = classifyHassRenderChange(before, after, dependencies);
const defer = change === 'state' && this.active();
if (defer) this.deferredHass = true;
this.host._renderLife.observe(before, after, dependencies, intake);
(this.host as LiveRuntimeHost)._renderLife.observe(before, after, dependencies, intake);
return change !== 'none' && !defer;
}
public clear(): void { this.deferredHass = false; }
@@ -28,7 +47,7 @@ export class LiveRuntime {
public viewport(): void { scheduleHouseplanViewport(this.host); }
public hover(): void { syncHouseplanHover(this.host); }
public active(): boolean {
const host = this.host;
const host = this.host as LiveRuntimeHost;
return host._pointers.size > 0 || host._cameraTransition.active || !!host._deviceDrag
|| !!host._physicalDrag || !!host._physicalRotate || !!host._decorMove
|| !!host._decorDraft || !!host._dtDrag || !!host._bdDrag || !!host._opDrag
+15 -5
View File
@@ -17,6 +17,14 @@ interface LiveViewportState {
raf: number;
}
interface LiveViewportHost {
_viewOr: (viewBox: number[]) => LiveViewBox;
_baseVb: () => number[];
_floorView: (view: LiveViewBox) => LiveViewBox;
_zoom: number;
renderRoot: ParentNode;
}
const states = new WeakMap<object, LiveViewportState>();
const stateOf = (host: object): LiveViewportState => {
let state = states.get(host);
@@ -95,14 +103,15 @@ export function paintLiveViewport(
}
}
const frameOf = (host: any): LiveViewportFrame => {
const frameOf = (host: LiveViewportHost): LiveViewportFrame => {
const view = host._viewOr(host._baseVb());
return { view: { ...view }, floor: { ...host._floorView(view) }, zoom: host._zoom };
};
/** Coalesce camera input into one lightweight paint per animation frame. */
export function scheduleHouseplanViewport(host: any): void {
const state = stateOf(host);
export function scheduleHouseplanViewport(value: object): void {
const host = value as LiveViewportHost;
const state = stateOf(value);
state.pending = frameOf(host);
if (state.raf || typeof requestAnimationFrame !== 'function') return;
state.raf = requestAnimationFrame(() => {
@@ -117,8 +126,9 @@ export function scheduleHouseplanViewport(host: any): void {
}
/** Record a complete Lit frame and remove any temporary HTML projection. */
export function commitHouseplanViewport(host: any): void {
const state = stateOf(host);
export function commitHouseplanViewport(value: object): void {
const host = value as LiveViewportHost;
const state = stateOf(value);
if (state.raf && typeof cancelAnimationFrame === 'function') cancelAnimationFrame(state.raf);
state.raf = 0;
state.pending = null;
+22 -14
View File
@@ -5,14 +5,22 @@ export interface HassRenderDependencies {
export type HassRenderChange = 'none' | 'state' | 'structural';
const scalarChanged = (before: any, after: any, path: readonly string[]): boolean => {
let a = before, b = after;
for (const part of path) {
a = a?.[part];
b = b?.[part];
}
return a !== b;
};
export interface HassRenderSnapshot {
readonly states?: Readonly<Record<string, unknown>>;
readonly connection?: unknown;
readonly entities?: unknown;
readonly devices?: unknown;
readonly areas?: unknown;
readonly themes?: unknown;
readonly user?: unknown;
readonly config?: unknown;
readonly floors?: unknown;
readonly services?: unknown;
readonly panels?: unknown;
readonly language?: unknown;
readonly locale?: Readonly<Record<string, unknown>> | null;
readonly [key: string]: unknown;
}
/**
* Classify one HA assignment without walking the complete `hass.states` map.
@@ -24,8 +32,8 @@ const scalarChanged = (before: any, after: any, path: readonly string[]): boolea
* rare and can alter formatting, registry resolution, permissions or actions.
*/
export function classifyHassRenderChange(
before: any,
after: any,
before: HassRenderSnapshot | null | undefined,
after: HassRenderSnapshot | null | undefined,
dependencies: HassRenderDependencies | null,
): HassRenderChange {
if (before === after) return 'none';
@@ -42,10 +50,10 @@ export function classifyHassRenderChange(
|| before.services !== after.services
|| before.panels !== after.panels
|| before.language !== after.language
|| scalarChanged(before, after, ['locale', 'language'])
|| scalarChanged(before, after, ['locale', 'number_format'])
|| scalarChanged(before, after, ['locale', 'time_format'])
|| scalarChanged(before, after, ['locale', 'date_format'])) {
|| before.locale?.language !== after.locale?.language
|| before.locale?.number_format !== after.locale?.number_format
|| before.locale?.time_format !== after.locale?.time_format
|| before.locale?.date_format !== after.locale?.date_format) {
return 'structural';
}