mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 03:09:36 +00:00
#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
210 lines
12 KiB
JavaScript
210 lines
12 KiB
JavaScript
// Переключение пространств не доигрывает чужие переходы (#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);
|