mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-01 20:29:00 +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:
@@ -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');
|
||||
|
||||
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user