Files
houseplan-card/scripts/render-layout-read.mjs
T
Claudeandclaude[bot] 235f60e693 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
2026-10-01 03:17:40 +00:00

89 lines
3.9 KiB
JavaScript

#!/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 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,
);
const callName = (node) => !ts.isCallExpression(node) ? null
: ts.isIdentifier(node.expression) ? node.expression.text
: ts.isPropertyAccessExpression(node.expression) ? node.expression.name.text : null;
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) => {
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', '_isoScene']);
const checked = new Set();
const visit = (node) => {
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);
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 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');