perf(led): the led-strips-v1 profile proves caches, disconnect and a late import (#780)

r1 M5: the runtime and field chunks release a card's frame and field cache
on disconnect (ledRelease from disconnectedCallback), never cache for a
disconnected card, and a chunk that lands after disconnect renders nothing
(the card's LED hook is connected-only). The profile now reports the three
caches of the shown space separately (shapes ≤ 50, visibility ≤ 50, retained
per-emitter fans ≤ 2500) through the runtime's ledStats, asserts 0 retained
entries and 0 live LED timers/frames/observers after every disconnect,
judges the Long Tasks of a 100-step camera series as well as the interaction
profile's camera scenario, runs one extra cold mount whose runtime response
is held while the card is removed, and enforces ≥ 7 samples after ≥ 1
warm-up — also on reports merged from parts (--warmup-only, --merge).

Issue: #780
User-Visible: no
This commit is contained in:
Codex
2026-10-02 20:54:50 +03:00
parent b40e8ff58d
commit e447364636
10 changed files with 375 additions and 97 deletions
+5 -3
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 { ledAnchor, ledRuntime, ledStripsByMarker, ledVisible } from './led-strip-gate';
import { ledAnchor, ledRelease, ledRuntime, ledStripsByMarker, ledVisible } from './led-strip-gate';
import { ledButton, ledEditorFor, ledHistory, ledImportNote, ledSection, ledWallsNote } from './led-strip-card';
import { LitElement, html, svg, nothing, noChange, TemplateResult, PropertyValues, type PropertyDeclaration } from 'lit';
import { cache as litCache } from 'lit/directives/cache.js';
@@ -2772,6 +2772,7 @@ export class HouseplanCard extends LitElement {
this._continuityHistory = [...this._continuityHistory, ...this._continuity.trace].slice(-80);
this._continuity.dispose();
this._continuityDisposed = true;
ledRelease(this); // #780 r1 M5: no retained LED cache after disconnect
super.disconnectedCallback();
}
@@ -10509,8 +10510,9 @@ export class HouseplanCard extends LitElement {
/** #780: the lazy LED chunk when the space shows a strip (ТЗ §13.1). */
private _ledRt(space: SpaceModel) {
return ledVisible(space, this._renderDevices, this._mode === 'devices' && this._showAll, this._serverCfg?.markers)
? ledRuntime(space.id, () => this.requestUpdate()) : null;
// A disconnected card never applies the chunk (a late import, r1 M5).
return this.isConnected && ledVisible(space, this._renderDevices, this._mode === 'devices' && this._showAll, this._serverCfg?.markers)
? ledRuntime(space.id, () => this.isConnected && this.requestUpdate()) : null;
}
/** #780: the Devices-editor LED tool, its own lazy chunk (src/led-strip-card.ts). */
+16
View File
@@ -96,6 +96,12 @@ export class LedFieldCache {
return value;
}
clear(): void { this.entries.clear(); this.ids.clear(); this.space = ''; }
/** Retained visibility fans (the per-emitter source cache, ТЗ §13.2: ≤ 2500). */
get sources(): number {
let n = 0;
for (const value of this.entries.values()) for (const piece of value?.pieces || []) n += piece.clip?.length ?? 0;
return n;
}
}
const pointsKey = (points: readonly number[][]): string =>
@@ -234,6 +240,16 @@ export interface LedFieldInput {
}
const fieldCaches = new WeakMap<object, LedFieldCache>();
/** Disconnect (ТЗ §13.2): no retained entry of this owner. */
export function releaseLedField(owner: object): void {
fieldCaches.get(owner)?.clear();
fieldCaches.delete(owner);
}
/** The performance witness: what this owner retains right now. */
export function ledFieldStats(owner: object): { visibility: number; sources: number; recomputes: number } {
const cache = fieldCaches.get(owner);
return { visibility: cache?.size ?? 0, sources: cache?.sources ?? 0, recomputes: cache?.recomputes ?? 0 };
}
export function ledFieldCache(owner: object): LedFieldCache {
let cache = fieldCaches.get(owner);
if (!cache) {
+3
View File
@@ -89,6 +89,9 @@ export function ledRuntime(entry: string, ready: () => void): LedRuntime | null
'__HOUSEPLAN_LED_RETRY_ASSET__', (module) => module.LED_RUNTIME_FINGERPRINT);
}
/** Disconnect: release this card's LED caches if the chunk is here (ТЗ §13.2). */
export function ledRelease(owner: object): void { runtimeSlot.module?.releaseLed(owner); }
/** The Devices-editor LED tool chunk (ТЗ §13.1): only on an explicit entry. */
export function ledEditorModule(entry: string, ready: () => void): LedEditorModule | null {
return lazy(editorSlot, entry, ready, () => import('./led-strip-editor'),
+18 -2
View File
@@ -413,6 +413,8 @@ export function ledFrame(input: LedFrameInput): LedFrame {
// (ТЗ §13.1). The host is the card itself, read structurally.
export interface LedCardHost {
/** A late chunk never re-renders (and re-fills caches of) a disconnected card. */
isConnected?: boolean;
_renderDevices: readonly DevItem[];
_renderPlanHass: any; // any-ok: the card's HA snapshot type is internal to the card
_virtualLights: VirtualLightSnapshot;
@@ -443,6 +445,18 @@ export interface LedCardHost {
const frames = new WeakMap<object, { key: unknown[]; frame: LedFrame }>();
/** Disconnect (ТЗ §13.2, r1 M5): the frame and the field caches of this card are released. */
export function releaseLed(owner: object): void {
frames.delete(owner);
field?.releaseLedField(owner);
}
/** The performance witness: shapes (frame), visibility entries and retained fans of this card. */
export function ledStats(owner: object): { shapes: number; visibility: number; sources: number; recomputes: number } {
return { shapes: frames.get(owner)?.frame.views.length ?? 0,
...(field?.ledFieldStats(owner) ?? { visibility: 0, sources: 0, recomputes: 0 }) };
}
/** The LED frame of a space for this card, rebuilt only when an input changed. */
export function ledFrameFor(host: LedCardHost, space: SpaceModel, spaceGlow: boolean): LedFrame {
const polygons = space.rooms.flatMap((room) => {
@@ -472,7 +486,8 @@ export function ledFrameFor(host: LedCardHost, space: SpaceModel, spaceGlow: boo
inRoom: (point, room) => host._pointInRoom(point, room),
showHidden: host._mode === 'devices' && host._showAll,
});
frames.set(host, { key, frame });
// A disconnected card keeps nothing (a pending update after disconnect, r1 M5).
if (host.isConnected !== false) frames.set(host, { key, frame });
return frame;
}
@@ -524,8 +539,9 @@ export function renderLedLayerFor(
}
export function renderLedFieldFor(host: LedCardHost, space: SpaceModel, spaceGlow: boolean): TemplateResult {
if (host.isConnected === false) return svg`` as unknown as TemplateResult;
const frame = ledFrameFor(host, space, spaceGlow);
const module = frame.scene && fieldWanted(frame.views) ? ledField(space.id, () => host.requestUpdate()) : null;
const module = frame.scene && fieldWanted(frame.views) ? ledField(space.id, () => host.isConnected !== false && host.requestUpdate()) : null;
if (!module) return svg`` as unknown as TemplateResult;
return module.renderLedField({
views: frame.views,