fix: openings and markers keep their identity across a space switch (#525)

Lit reuses list nodes by position. The opening list and the device markers had
no keys, so on a space switch the leaf that held a slot kept its DOM node and
only changed values — and `.op-leaf` (transform) and `.op-arc`
(stroke-dashoffset) carry a 0.6 s transition, so the browser animated a door
that never moved: the new floor's leaf drove in from the previous floor's
opening angle. Measured on two spaces with a door in the same place and
opposite contact states: the node is reused, transform goes
`rotate(-90deg) → rotate(0deg)`, dash offset `0 → 125.66`, both transitions
`running`. The marker shell adds two more with its `box-shadow`.

Both lists are now rendered through `repeat(…, (item) => item.id, …)`, the
same lesson `glow-scene.ts` already learned for the Glow spots. The trap is
written where it starts — above the two transitions in `plan.styles.ts` —
because that is the file someone edits when adding the next animated property.
`houseplan-card.ts` is at its line ceiling, and the note would have cost the
budget a dozen lines for nothing: the swap itself is line-for-line.

The witness walks the shadow tree per element. `document.getAnimations()` is
empty here EVEN ON THE BROKEN CODE — the card lives in a shadow root and the
document-level call does not reach into it, and the issue proposed exactly
that call. The smoke also builds its own fixture: the demo home has no
openings at all, so two doors in two spaces are prepared in the smoke, and it
asserts the other half of the contract as well — a real contact change inside
one space still animates the leaf.

On `origin/dev` the smoke fails on five facts, naming the offenders:
`op-arc:stroke-dashoffset`, `op-leaf:transform`, `device-shell-frame:box-shadow`
twice. Mutants `openings-rendered-without-keys` and
`device-markers-rendered-without-keys` put each `map` back.

Perf, 7 samples against `19e421b3`: spaceSwitchMs 524.8 (limit 769.35, base
512.9), switchCycleMs 1293.9 (1696.28, 1256.5), firstStableRenderMs 2533.8
(3000, 2529.2), modelReadyMs 732.7 (944.97, 726.9), longTask.maxSingleMs 663
(910, 660) — `benchmark:compare` green in full.

The initial View graph grows 241 B gzip: `repeat` enters it for the first
time. The #438 ceiling is recentred 300 400 → 300 700 with the usual dated
note; measured 300 059 B keeps 641 B above and 1 359 B below the band. The
301 066 B budget is untouched, but only 366 B now separate the ceiling from
it — the #367 headroom debt has stopped being theoretical.

Issue: #525
User-Visible: yes
This commit is contained in:
Claude
2026-09-11 01:42:46 +00:00
committed by claude[bot]
parent 95175a7616
commit 9edef041a9
50 changed files with 813 additions and 578 deletions
+18
View File
@@ -147,3 +147,21 @@ test('свидетель направляющих ведёт жест, а не
assert.match(source, /ZeroSettledRenders/);
});
// #525. Карточка живёт в shadow root, и `document.getAnimations()` в неё не
// заходит: на сломанном коде документный вызов возвращал пустой список, пока
// створка двери честно доигрывала чужой переход. Свидетель обязан считать
// поэлементно, обходя вложенные теневые деревья, — иначе он зелен всегда.
test('свидетель переходов при переключении меряет поэлементно (#525)', () => {
const source = read('smoke_space_switch_transitions.mjs');
// Ловушка названа в шапке смока словами — упоминание в комментарии это не
// вызов; проверяется код, а не текст о нём.
const code = source.replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, '');
assert.doesNotMatch(code, /document\.getAnimations\(\)/,
'smoke_space_switch_transitions.mjs: документный getAnimations() пуст даже на'
+ ' сломанном коде — считать надо по узлам внутри renderRoot (#525)');
assert.match(code, /element\.getAnimations\(\)/);
assert.match(code, /element\.shadowRoot\) walk\(element\.shadowRoot\)/,
'обход обязан заходить во вложенные shadow root');
assert.match(code, /_pickSpace/, 'переключение делается штатным путём');
});