Files
houseplan-card/demo/smoke_space_switch_transitions.mjs
T
Codex 5afdb644c7 Смена пространства выбрасывает слой, а не сверяет его поэлементно
#525 увёл оба списка сцены с `map()` на `repeat(items, (item) => item.id, …)`,
чтобы Lit не переиспользовал узлы по позиции и не проигрывал анимацию двери,
которой не было. Правка верная, но на плане с двумя сотнями маркеров она
оказалась дорогой ровно там, где пользы не приносит: при смене пространства
ключи не пересекаются вовсе, и `repeat` строит две карты ключей и обходит оба
списка, чтобы затем всё равно выбросить всё и создать заново.

Бисект по медиане `switchCycleMs` на `large-house`: 871,2 перед #525 → 953,5
после. На раннере эти 80 мс распадаются на шесть-девять дополнительных длинных
задач, и `longTask.countP95` вышел за порог стабильного гейта.

Теперь оба списка рендерятся как `keyed(space.id, repeat(…))`. Внешний ключ
делает смену пространства: поддерево выбрасывается целиком, дифа нет. Внутренний
остаётся, потому что состав списков едет и внутри пространства — у маркеров от
призраков редактора и живого синка, у проёмов от записи с нерешённым хостом,
которая живёт только в режиме plan, — а переходы на `.device-shell-frame`,
`.op-leaf` и `.op-arc` никуда не делись.

Замер после правки: `switchCycleMs` 889,1 против 936,0 на `main` и 871,2 до
#525. Потолок карточки поднят на одну строку — на `import { keyed }`;
переносить нечего, сам рендер не вырос ни на символ.

Issue: #534
User-Visible: yes
2026-09-11 22:30:11 +03:00

210 lines
12 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Переключение пространств не доигрывает чужие переходы (#525).
//
// Lit переиспользует узлы списка по позиции. Пока у створки двери
// (`.op-leaf`, `transform`) и у дуги (`.op-arc`, `stroke-dashoffset`) есть
// переход в 0.6 с, позиционное переиспользование превращает смену
// пространства в анимацию: створка нового этажа доезжает из положения двери,
// которая занимала этот слот раньше. То же у оболочки маркера
// (`.device-shell-frame`, `box-shadow`).
//
// Две ловушки, обе стоили бы свидетелю правдивости:
// 1. `document.getAnimations()` здесь пуст ДАЖЕ НА СЛОМАННОМ КОДЕ — карточка
// живёт в shadow root, и документный вызов туда не заходит. Считаем
// поэлементно, обходя вложенные теневые деревья.
// 2. В демо-доме проёмов нет вовсе (`space.openings` пуст у обоих
// пространств), поэтому дверь в двух пространствах готовит сам смок.
import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch();
const res = await page.evaluate(async () => {
const out = {};
const c = window.__card;
const root = () => c.shadowRoot || c.renderRoot;
const frame = () => new Promise((resolve) => requestAnimationFrame(() => resolve()));
const settle = async (frames = 10, ms = 500) => {
for (let i = 0; i < frames; i++) await frame();
await new Promise((resolve) => setTimeout(resolve, ms));
};
/** Все бегущие анимации внутри карточки, включая вложенные shadow root. */
const runningAnimations = () => {
const found = [];
const walk = (node) => {
for (const element of node.querySelectorAll('*')) {
const list = element.getAnimations ? element.getAnimations() : [];
for (const animation of list) {
if (animation.playState !== 'running') continue;
found.push({
cls: element.getAttribute?.('class') || element.tagName,
property: animation.transitionProperty || animation.animationName || '?',
});
}
if (element.shadowRoot) walk(element.shadowRoot);
}
};
walk(root());
return found;
};
// Вкладка подсвечивается, слайд едет, значения сводной панели проявляются —
// это и есть переключение. Всё остальное, что бежит сразу после него,
// доигрывает чужие данные.
const EXPECTED = (entry) => /(^|\s)tab(\s|$)|zoomwrap/.test(entry.cls)
|| /^summary-/.test(entry.property);
// ---- фикстура: дверь на одном месте в двух пространствах ---------------
const ids = c._serverCfg.spaces.map((space) => space.id).slice(0, 2);
out.twoSpacesExist = ids.length === 2;
const physicalize = (space) => {
for (const room of [...(space.rooms || [])]) {
const rendered = c._spaceModelById(space.id)?.rooms?.find((item) => item.id === room.id);
if (!rendered?.poly?.length) continue;
c._wallDialog = {
a: rendered.poly[0], b: rendered.poly[1], value: '15', roomId: room.id,
source: { kind: 'room' }, sx: 0, sy: 0,
};
c._wallThickApply(true);
}
};
for (const id of ids) physicalize(c._serverCfg.spaces.find((space) => space.id === id));
c._geometryHistory.length = 0;
const contactOpen = 'binary_sensor.probe_door_open';
const contactShut = 'binary_sensor.probe_door_shut';
const withContacts = (openState) => ({
...c.hass,
states: {
...c.hass.states,
[contactOpen]: { entity_id: contactOpen, state: openState, attributes: { device_class: 'door' } },
[contactShut]: { entity_id: contactShut, state: 'off', attributes: { device_class: 'door' } },
},
entities: {
...(c.hass.entities || {}),
[contactOpen]: { entity_id: contactOpen },
[contactShut]: { entity_id: contactShut },
},
});
c.hass = withContacts('on');
const door = (id, contact) => ({
id, type: 'door', x: 0.2, y: 0.14, angle: 0, length: 0.08, contact,
});
c._serverCfg.spaces.find((space) => space.id === ids[0]).openings = [door('opA', contactOpen)];
c._serverCfg.spaces.find((space) => space.id === ids[1]).openings = [door('opB', contactShut)];
c._cfgEpoch++;
c.requestUpdate();
await c.updateComplete;
// ---- 1) штатное переключение не анимирует чужую створку ----------------
c._pickSpace(ids[0]);
await c.updateComplete;
await settle();
const leafBefore = root().querySelector('.op-leaf');
out.doorIsDrawnInTheFirstSpace = !!leafBefore;
// #528: маркеры судятся по идентичности узлов, а не по бегущему переходу.
// Прежняя проверка смотрела на переход `box-shadow` у оболочки — он исчез
// вместе с правкой #524, и мутант «маркеры без ключей» начал выживать:
// признак стал тривиально истинным. Идентичность узла не зависит ни от
// одного перехода и переживёт любую правку стилей.
const markersBefore = [...root().querySelectorAll('[data-hp="device"]')]
.map((node) => ({ node, id: node.dataset.id }));
out.markersDrawnInTheFirstSpace = markersBefore.length >= 3;
out.quietBeforeTheSwitch = runningAnimations().filter((entry) => !EXPECTED(entry)).length === 0;
// поворот створки живёт инлайновым стилем, не атрибутом
const leafTransform = (leaf) => leaf?.style?.transform || leaf?.getAttribute('transform') || null;
const transformBefore = leafTransform(leafBefore);
c._pickSpace(ids[1]);
await c.updateComplete;
await frame();
const afterSwitch = runningAnimations();
const leafAfter = root().querySelector('.op-leaf');
out.doorIsDrawnInTheSecondSpace = !!leafAfter;
out.theTwoDoorsDifferInState = !!transformBefore && transformBefore !== leafTransform(leafAfter);
out.noOpeningTransitionOnSwitch = afterSwitch
.filter((entry) => /op-leaf|op-arc/.test(entry.cls)).length === 0;
out.noMarkerTransitionOnSwitch = afterSwitch
.filter((entry) => /device-shell-frame/.test(entry.cls)).length === 0;
// Узел, живший до переключения, не имеет права остаться в DOM под чужим
// `data-id`: это и есть позиционное переиспользование, ради которого в
// `repeat` стоят ключи (#525 AC2, #528).
const reused = markersBefore.filter((entry) => entry.node.isConnected
&& entry.node.dataset.id !== entry.id);
out.noMarkerNodeReusedForAnotherDevice = reused.length === 0;
out.reusedMarkerNodes = reused.map((entry) => `${entry.id} → ${entry.node.dataset.id}`);
const unexpected = afterSwitch.filter((entry) => !EXPECTED(entry));
out.onlyTheSwitchItselfAnimates = unexpected.length === 0;
out.unexpectedAnimations = unexpected.map((entry) => `${entry.cls}:${entry.property}`);
// ещё кадр — переход мог стартовать с задержкой в один тик
await frame();
await frame();
const later = runningAnimations().filter((entry) => !EXPECTED(entry));
out.stillQuietOneFrameLater = later.length === 0;
// ---- 2) настоящая смена состояния двери по-прежнему анимируется --------
await settle();
out.quietBeforeTheRealChange = runningAnimations()
.filter((entry) => /op-leaf|op-arc/.test(entry.cls)).length === 0;
c.hass = { ...c.hass, states: { ...c.hass.states,
[contactShut]: { entity_id: contactShut, state: 'on', attributes: { device_class: 'door' } } } };
await c.updateComplete;
await frame();
const onRealChange = runningAnimations();
out.aRealDoorOpeningStillAnimates = onRealChange
.filter((entry) => /op-leaf/.test(entry.cls)).length >= 1;
// ---- 3) состав списка меняется ВНУТРИ пространства (#534) --------------
// Внешний ключ `keyed(space.id, …)` снимает дорогой диф на смене
// пространства, но внутри пространства состав списков всё равно ездит:
// у маркеров — призраки редактора и живой синк, у проёмов — запись с
// нерешённым хостом, которая живёт ТОЛЬКО в режиме plan. Если убрать
// внутренний `repeat`, позиционное переиспользование вернётся через эту
// дверь, и ни одна проверка выше не покраснеет.
await settle();
const byId = (nodes) => new Map(nodes.map((node) => [node.dataset.id, node]));
const markerNodes = () => [...root().querySelectorAll('[data-hp="device"]')];
const openingNodes = () => [...root().querySelectorAll('.opening')];
const markersKept = byId(markerNodes());
const template = c._renderDevices.find((device) => device.space === c._space);
out.markerTemplateFound = !!template;
c._devices = [{ ...template, id: 'hp-534-probe', name: 'probe' }, ...c._devices];
c._cfgEpoch++;
c.requestUpdate();
await c.updateComplete;
await frame();
const markersAfterInsert = byId(markerNodes());
const movedMarkers = [...markersKept].filter(([id, node]) =>
markersAfterInsert.has(id) && markersAfterInsert.get(id) !== node);
out.listGrewInsideTheSpace = markersAfterInsert.size > markersKept.size;
out.noMarkerNodeSwappedInsideTheSpace = movedMarkers.length === 0;
out.swappedMarkerNodes = movedMarkers.map(([id]) => id);
out.quietAfterTheInsert = runningAnimations().filter((entry) => !EXPECTED(entry)).length === 0;
// Проёмы: запись с нерешённым хостом отдаётся только в режиме plan, поэтому
// вход в редактор добавляет её в список и сдвигает позиции остальных.
await settle();
const space = c._serverCfg.spaces.find((item) => item.id === c._space);
const donor = space.openings[0];
out.openingDonorFound = !!donor;
space.openings = [
{ ...donor, id: 'hp-534-orphan', host: { kind: 'partition', id: 'hp-534-missing' } },
...space.openings,
];
c._cfgEpoch++;
c.requestUpdate();
await c.updateComplete;
await settle();
const openingsKept = byId(openingNodes());
c._setMode('plan');
await c.updateComplete;
await frame();
const openingsInPlan = byId(openingNodes());
const movedOpenings = [...openingsKept].filter(([id, node]) =>
openingsInPlan.has(id) && openingsInPlan.get(id) !== node);
out.orphanAppearsOnlyInPlan = openingsInPlan.size > openingsKept.size;
out.noOpeningNodeSwappedInsideTheSpace = movedOpenings.length === 0;
out.swappedOpeningNodes = movedOpenings.map(([id]) => id);
out.quietAfterTheModeSwitch = runningAnimations()
.filter((entry) => /op-leaf|op-arc/.test(entry.cls)).length === 0;
return out;
});
checkAll(res, { unexpectedAnimations: [], reusedMarkerNodes: [],
swappedMarkerNodes: [], swappedOpeningNodes: [] });
await finish(browser, res);