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
+29
View File
@@ -13843,6 +13843,35 @@ const MUTANT_DEFINITIONS = [
replace: " host.setAttribute('lang', lang); // mutant: written on every render\n",
}],
},
// #725 AC5: принудительные layout в пути рендера — возврат ловит AST-гейт.
{
id: 'iso-scene-reads-stage-box-during-render',
guard: 'node scripts/render-layout-read.mjs',
because: '#725 AC5: _isoScene runs inside _renderBody through _effectiveProjection; reading '
+ 'the stage box there forces a synchronous layout on every 2.5D floor render, and the fit '
+ 'frame does not depend on the aspect since #713',
patches: [{
file: 'src/houseplan-card.ts',
find: ' const aspect = scene.frame.w / scene.frame.h; // #713/#725: the fit frame ignores aspect; no layout read in render\n',
replace: ' const stageSize = this._stageEl?.getBoundingClientRect?.() ?? null;\n'
+ ' const aspect = stageSize?.height ? stageSize.width / stageSize.height : scene.frame.w / scene.frame.h;\n',
}],
},
{
id: 'summary-layout-reads-safe-insets-during-render',
guard: 'node scripts/render-layout-read.mjs',
because: '#725 AC5: the summary panel computes layout() up to five times per card render; '
+ 'reading the safe-area probe style there forces a style recalculation each time instead of '
+ 'using the insets measureLayout stored',
patches: [{
file: 'src/summary-panel-runtime-loaded.ts',
find: ' return resolveSummaryLayout({ ...this.stage, controlTop: this.host._kiosk ? this.stage.controlTop : 0 });\n',
replace: " const probe = this.host.renderRoot.querySelector('.summary-safe-probe') as HTMLElement | null;\n"
+ ' const style = probe ? this.host.ownerDocument.defaultView?.getComputedStyle(probe) : undefined;\n'
+ ' return resolveSummaryLayout({ ...this.stage, safeTop: Number.parseFloat(style?.paddingTop ?? \'\') || 0,\n'
+ ' controlTop: this.host._kiosk ? this.stage.controlTop : 0 });\n',
}],
},
];
const mutationCardSource = readFileSync(join(repoRoot, 'src/houseplan-card.ts'), 'utf8');
+56 -13
View File
@@ -1,10 +1,23 @@
#!/usr/bin/env node
// #654: render and 2.5D pure helpers must not force browser layout. This is an
// AST contract, not a regex anchor: formatting and comments cannot satisfy it.
// #725: a forced layout is a call (`getComputedStyle`, `getBoundingClientRect`)
// or a read of a layout property (`clientWidth`, `offsetTop`, …). The contract
// also judges `_isoScene` in the card (reached from `_renderBody` through
// `_effectiveProjection`) and the whole summary-panel runtime, whose render and
// lifecycle methods run on every card pass; only its one measurement method
// may read layout.
import { readFileSync, readdirSync } from 'node:fs';
import ts from 'typescript';
const forbidden = new Set(['getComputedStyle', 'getBoundingClientRect']);
const forbiddenCallNames = new Set(['getComputedStyle', 'getBoundingClientRect']);
const forbiddenReads = new Set([
'clientWidth', 'clientHeight', 'offsetWidth', 'offsetHeight', 'offsetTop', 'offsetLeft',
'scrollWidth', 'scrollHeight',
]);
/** The summary-panel runtime method allowed to measure (#725). */
const SUMMARY_MEASURE_METHOD = 'measureLayout';
const source = (path) => ts.createSourceFile(
path, readFileSync(new URL(`../${path}`, import.meta.url), 'utf8'),
ts.ScriptTarget.Latest, true,
@@ -12,34 +25,64 @@ const source = (path) => ts.createSourceFile(
const callName = (node) => !ts.isCallExpression(node) ? null
: ts.isIdentifier(node.expression) ? node.expression.text
: ts.isPropertyAccessExpression(node.expression) ? node.expression.name.text : null;
const forbiddenCalls = (node) => {
const readName = (node) => ts.isPropertyAccessExpression(node) ? node.name.text
: ts.isElementAccessExpression(node) && ts.isStringLiteralLike(node.argumentExpression)
? node.argumentExpression.text
: ts.isBindingElement(node) && ts.isObjectBindingPattern(node.parent)
? (node.propertyName ?? node.name).getText() : null;
const methodName = (node) => (ts.isMethodDeclaration(node) || ts.isGetAccessorDeclaration(node))
&& node.name && ts.isIdentifier(node.name) ? node.name.text : null;
/** Forced-layout calls and reads under `node`, skipping subtrees `skip` accepts. */
const layoutReads = (node, skip = () => false) => {
const found = [];
const visit = (child) => {
const name = callName(child);
if (name && forbidden.has(name)) found.push(name);
if (skip(child)) return;
const call = callName(child);
if (call && forbiddenCallNames.has(call)) found.push(call);
const read = readName(child);
if (read && forbiddenReads.has(read)) found.push(read);
ts.forEachChild(child, visit);
};
visit(node);
return found;
};
const problems = [];
const card = source('src/houseplan-card.ts');
const required = new Set(['render', '_renderBody', 'willUpdate']);
const required = new Set(['render', '_renderBody', 'willUpdate', '_isoScene']);
const checked = new Set();
const visit = (node) => {
if (ts.isMethodDeclaration(node) && node.name && ts.isIdentifier(node.name)
&& required.has(node.name.text)) {
checked.add(node.name.text);
const calls = forbiddenCalls(node.body);
if (calls.length) throw new Error(`${node.name.text} forces layout: ${calls.join(', ')}`);
const name = methodName(node);
if (name && required.has(name) && ts.isMethodDeclaration(node)) {
checked.add(name);
const reads = layoutReads(node.body);
if (reads.length) problems.push(`${name} forces layout: ${reads.join(', ')}`);
}
ts.forEachChild(node, visit);
};
visit(card);
if ([...required].some((name) => !checked.has(name))) throw new Error('guarded lifecycle method is missing');
for (const name of required) if (!checked.has(name)) problems.push(`guarded method is missing: ${name}`);
const summaryFile = 'src/summary-panel-runtime-loaded.ts';
let measureMethods = 0;
const summaryReads = layoutReads(source(summaryFile), (node) => {
if (methodName(node) !== SUMMARY_MEASURE_METHOD) return false;
measureMethods += 1;
return true;
});
if (measureMethods !== 1) {
problems.push(`${summaryFile}: expected one measurement method ${SUMMARY_MEASURE_METHOD}, found ${measureMethods}`);
}
if (summaryReads.length) {
problems.push(`${summaryFile} forces layout outside ${SUMMARY_MEASURE_METHOD}: ${summaryReads.join(', ')}`);
}
for (const file of readdirSync(new URL('../src', import.meta.url)).filter((name) => /^iso-.*\.ts$/.test(name))) {
const calls = forbiddenCalls(source(`src/${file}`));
if (calls.length) throw new Error(`${file} forces layout: ${calls.join(', ')}`);
const reads = layoutReads(source(`src/${file}`));
if (reads.length) problems.push(`${file} forces layout: ${reads.join(', ')}`);
}
if (problems.length) {
for (const problem of problems) console.error(problem);
process.exit(1);
}
console.log('render/layout-read AST contract: OK');