fix: stabilize cold 2.5D first frame

Issue: #654
User-Visible: yes
This commit is contained in:
Sergey Matyunin
2026-09-26 09:05:20 +03:00
parent 6d3738adef
commit f5c6d70b7c
92 changed files with 1162 additions and 784 deletions
+8 -1
View File
@@ -354,8 +354,15 @@ export const LOW_HEADROOM_WARNING_BYTES = 15_000;
* граф, иначе первый кадр 2.5D рисует круги Flat. Геометрия света и коллизии
* остались в ленивом графе. Факт оставляет 896 Б сверху и 1 104 Б до нижней
* границы; общий бюджет не меняется.
*
* 2026-09-26, #654: 294 900 → 296 000 (замер 295 047). Холодный первый кадр
* обязан знать, что 2.5D запрошен, пока его ленивый граф ещё не загружен, и
* удерживать заставку до вычисления цвета бумаги; эту границу по определению
* нельзя положить внутрь охраняемого ею чанка. Геометрия и сам рендер остаются
* ленивыми. Факт оставляет 953 Б сверху и 1 047 Б до нижней границы; общий
* бюджет не меняется.
*/
export const INITIAL_VIEW_GZIP_CEILING = 294_900;
export const INITIAL_VIEW_GZIP_CEILING = 296_000;
export const INITIAL_VIEW_CEILING_BAND = 2_000;
/**
+1 -1
View File
@@ -320,7 +320,7 @@ export const CHECKS = {
},
frontend: {
// npm run typecheck, npm test, npm run build, bundle:budget, lint:unused (#624)
entries: ['test/*.test.mjs', 'scripts/no-new-any.mjs', 'scripts/no-new-private-writes.mjs', 'scripts/bundle-budget.mjs',
entries: ['test/*.test.mjs', 'scripts/no-new-any.mjs', 'scripts/no-new-private-writes.mjs', 'scripts/render-layout-read.mjs', 'scripts/bundle-budget.mjs',
'scripts/fix-test-build.mjs', 'scripts/unused-locals-gate.mjs'],
// demo/helpers/** — область no-new-private-writes (#629): гейт читает их текст.
roots: [...BUILD_INPUTS, 'test/**', 'tsconfig*.json', 'scripts/monolith-baseline.json', 'demo/smoke_*.mjs', 'demo/benchmark_*.mjs', 'demo/guard/*.mjs', 'demo/helpers/**', ...WORKFLOW],
+1
View File
@@ -63,6 +63,7 @@ export function parallelSteps(base) {
return [
{ name: 'сборка + typecheck (npm run build)', cmd: npm, args: ['run', 'build'] },
{ name: 'новый код не добавляет any', cmd: process.execPath, args: ['scripts/no-new-any.mjs', '--base', base, '--head', 'HEAD'] },
{ name: 'render не читает layout синхронно', cmd: process.execPath, args: ['scripts/render-layout-read.mjs'] },
// #629: смоки не добавляют записей в приватное состояние карточки.
{ name: 'смоки не пишут в приватное состояние', cmd: process.execPath, args: ['scripts/no-new-private-writes.mjs', '--base', base, '--head', 'HEAD'], hint: 'window.__hpTest (docs/TESTING.md) или // private-ok: <причина>' },
{ name: 'смоки по диффу (smoke-select)', cmd: process.execPath, args: ['scripts/smoke-select.mjs', '--base', base, '--head', 'HEAD', '--json'], informational: true },
+36
View File
@@ -12568,6 +12568,42 @@ const MUTANT_DEFINITIONS = [
replace: ' return this.center(room, polygonOverride, spaceId); // mutant: preview prunes state',
}],
},
{
id: 'iso-first-frame-reads-paper-during-render',
guard: 'node scripts/render-layout-read.mjs',
because: '#654 AC2: reading computed paper colour in _renderBody forces layout and sees the '
+ 'host fallback before .hp-paper exists on the cold first render; the AST guard forbids '
+ 'that regression independently of formatting or comments',
patches: [{
file: 'src/houseplan-card.ts',
find: 'this._isoFirstFrame.lightFloors(new Map([...roomFills.byId].map(([id, fill]) => [id, fill && fill.opacity > 0 ? fill : glowBase.byId.get(id) ?? null])))',
replace: "memoIsoLightFloorRooms(null, new Map([...roomFills.byId].map(([id, fill]) => [id, fill && fill.opacity > 0 ? fill : glowBase.byId.get(id) ?? null])), parseCssColor(getComputedStyle(this.renderRoot.querySelector('.hp-paper') ?? this).fill) ?? [255, 255, 255]).rooms",
}],
},
{
id: 'iso-first-frame-reveals-flat-during-lazy-load',
guard: 'node demo/smoke_iso_first_frame.mjs',
because: '#654 AC1/AC3: removing the pending class exposes the effective Flat projection '
+ 'while the 2.5D chunk is still loading, most visibly in kiosk mode after the ordinary '
+ 'boot window has elapsed',
patches: [{
file: 'src/houseplan-card.ts',
find: "${isoFirstFramePending ? ' hpiso-pending' : ''}",
replace: "${isoFirstFramePending ? '' : ''}",
}],
},
{
id: 'iso-light-floor-memo-never-reuses',
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
+ '&& node --test --test-name-pattern="#654 light-floor memo" test/iso-stage6.test.mjs',
because: '#654 AC4: HA state churn that leaves paper, fills and room membership unchanged '
+ 'must reuse the classification Set instead of rebuilding it on every card render',
patches: [{
file: 'src/iso-materials.ts',
find: ' return previous?.key === key',
replace: " return previous?.key === '__never__'",
}],
},
];
const mutationCardSource = readFileSync(join(repoRoot, 'src/houseplan-card.ts'), 'utf8');
+45
View File
@@ -0,0 +1,45 @@
#!/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.
import { readFileSync, readdirSync } from 'node:fs';
import ts from 'typescript';
const forbidden = new Set(['getComputedStyle', 'getBoundingClientRect']);
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 forbiddenCalls = (node) => {
const found = [];
const visit = (child) => {
const name = callName(child);
if (name && forbidden.has(name)) found.push(name);
ts.forEachChild(child, visit);
};
visit(node);
return found;
};
const card = source('src/houseplan-card.ts');
const required = new Set(['render', '_renderBody', 'willUpdate']);
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(', ')}`);
}
ts.forEachChild(node, visit);
};
visit(card);
if ([...required].some((name) => !checked.has(name))) throw new Error('guarded lifecycle method is missing');
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(', ')}`);
}
console.log('render/layout-read AST contract: OK');
+8
View File
@@ -27,6 +27,14 @@
/** @type {SmokeLink[]} */
export const SMOKE_LINKS = [
{
symbols: ['memoIsoLightFloorRooms', 'IsoFirstFrameState', 'isoPaperContext'],
smokes: ['smoke_iso_first_frame.mjs'],
because: '#654: the production-bundle witness delays and rejects the lazy 2.5D chunk, then '
+ 'samples every animation frame in normal, kiosk and Flat views; no pure import can prove '
+ 'that the loading veil hides the transient Flat render or that the first visible marker '
+ 'already uses the settled paper-dependent floor class',
},
{
symbols: ['baseStyles', '_containerOwnedHeight', 'measuredCardHeaderHeight',
'settleSoftStageLayout'],