mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-07 06:59:46 +00:00
perf(light): batch source entry and reuse render-local barriers
Coalesce source-entry updates without changing the 500 ms transition or field geometry. Resolve shared barrier revisions once within a synchronous render and always recheck content on the next pass. Release main/static LED owners on no-strip exits and static owner/config teardown. Protect scheduling, real browser fades, cold imports and the bundled render-pass wiring. Performance acceptance remains a separate exact-SHA full Linux run; this commit alone does not assert that AC3 has passed. Issue: #789 User-Visible: yes
This commit is contained in:
@@ -347,6 +347,322 @@ check('static live_states:true + light_pools:true — source colour and the fiel
|
||||
JSON.stringify({ state: 'on', core: '#80d5ff', field: true, passive: true, badge: true }));
|
||||
check('a click on any static stripe toggles nothing', await evaluate(() => window.__card.hass.states['light.ceiling'].state), 'on');
|
||||
|
||||
// #789 AC2: real transition events, not merely the declared CSS duration.
|
||||
// Do not read computed style/layout between insertion and its entering rAF:
|
||||
// that flush can manufacture the initial opacity and hide a lost fade.
|
||||
await page.emulateMedia({ reducedMotion: 'no-preference' });
|
||||
await evaluate(async () => {
|
||||
const main = window.__card;
|
||||
const host = document.createElement('div');
|
||||
host.style.width = '700px'; document.body.appendChild(host);
|
||||
const stat = document.createElement('houseplan-space-card');
|
||||
stat.setConfig({ type: 'custom:houseplan-space-card', space: 'f1', light_pools: true, live_states: true });
|
||||
stat.hass = main.hass; host.appendChild(stat);
|
||||
await stat.updateComplete;
|
||||
const proof = window.__ledFadeProof = {
|
||||
cards: [main, stat], host, savedHass: main.hass, log: [], listeners: [],
|
||||
selectors: ['[data-led-field="ceiling"]', '[data-glow-source="light.floor_lamp"]'],
|
||||
parent: main.parentNode, next: main.nextSibling, updates: [0, 0], late: [], descriptors: [],
|
||||
};
|
||||
proof.nodes = () => proof.cards.map(card => proof.selectors.map(selector => card.shadowRoot.querySelector(selector)));
|
||||
proof.set = async state => {
|
||||
const states = { ...main.hass.states };
|
||||
for (const eid of ['light.ceiling', 'light.floor_lamp']) states[eid] = { ...states[eid], state };
|
||||
const hass = { ...main.hass, states };
|
||||
for (const card of proof.cards) card.hass = hass;
|
||||
await Promise.all(proof.cards.map(card => card.updateComplete));
|
||||
};
|
||||
for (const [cardIndex, card] of proof.cards.entries()) {
|
||||
const listener = event => {
|
||||
if (event.propertyName !== 'opacity') return;
|
||||
const kind = proof.selectors.findIndex(selector => event.target.matches(selector));
|
||||
if (kind >= 0) proof.log.push({ card: cardIndex, kind, event: event.type, elapsed: event.elapsedTime });
|
||||
};
|
||||
for (const name of ['transitionrun', 'transitionend']) card.shadowRoot.addEventListener(name, listener);
|
||||
proof.listeners.push(listener);
|
||||
}
|
||||
await proof.set('off');
|
||||
});
|
||||
try {
|
||||
await page.waitForFunction(() => window.__ledFadeProof.cards.every(card =>
|
||||
card.shadowRoot.querySelector('[data-led-strip]')));
|
||||
await page.waitForTimeout(650);
|
||||
check('#789: both kinds start absent on both surfaces', await evaluate(() =>
|
||||
window.__ledFadeProof.nodes().every(nodes => nodes.every(node => !node))), true);
|
||||
const fadeSet = state => evaluate(async state => {
|
||||
const proof = window.__ledFadeProof; proof.log = []; await proof.set(state);
|
||||
}, state);
|
||||
const fadeSnapshot = () => evaluate(() => {
|
||||
const proof = window.__ledFadeProof;
|
||||
return { log: proof.log, nodes: proof.nodes().map(nodes => nodes.map(node => node ? {
|
||||
opacity: Number(getComputedStyle(node).opacity), leaving: node.classList.contains('is-leaving'),
|
||||
entering: node.classList.contains('is-entering'),
|
||||
} : null)) };
|
||||
});
|
||||
const checkFadeEvents = (phase, result, cards = [0, 1], kinds = [0, 1]) => {
|
||||
for (const card of cards) for (const kind of kinds) {
|
||||
const events = result.log.filter(event => event.card === card && event.kind === kind);
|
||||
const label = `#789: ${card ? 'static' : 'main'} ${kind ? 'ordinary' : 'LED'} ${phase}`;
|
||||
check(`${label} actually starts an opacity transition`, events.some(event => event.event === 'transitionrun'), true);
|
||||
check(`${label} completes the unchanged 500 ms transition`, events.some(event =>
|
||||
event.event === 'transitionend' && Math.abs(event.elapsed - 0.5) < 0.01), true);
|
||||
}
|
||||
};
|
||||
await fadeSet('on');
|
||||
await page.waitForTimeout(750);
|
||||
const fadeOn = await fadeSnapshot();
|
||||
checkFadeEvents('on', fadeOn);
|
||||
check('#789: all four fields finish on at full opacity', fadeOn.nodes.every(nodes =>
|
||||
nodes.every(node => node?.opacity === 1 && !node.leaving)), true);
|
||||
// Observe the real bundled call graph, without wrapping private methods or
|
||||
// inferring render-pass wiring from source text. Coverage is not timing data.
|
||||
const coverageSession = await page.context().newCDPSession(page);
|
||||
try {
|
||||
await coverageSession.send('Profiler.enable');
|
||||
await coverageSession.send('Profiler.startPreciseCoverage', { callCount: true, detailed: true });
|
||||
for (const pass of [1, 2]) {
|
||||
await coverageSession.send('Profiler.takePreciseCoverage');
|
||||
await evaluate(async () => {
|
||||
const card = window.__card;
|
||||
card.requestUpdate();
|
||||
for (let attempt = 0; attempt < 10; attempt++) {
|
||||
const complete = await card.updateComplete;
|
||||
if (complete !== false && !card.isUpdatePending) return;
|
||||
}
|
||||
throw new Error('#789: observed main render did not settle');
|
||||
});
|
||||
const result = await coverageSession.send('Profiler.takePreciseCoverage');
|
||||
const functions = result.result.flatMap(script => script.functions);
|
||||
const calls = name => functions.filter(fn => fn.functionName === name)
|
||||
.reduce((sum, fn) => sum + (fn.ranges[0]?.count || 0), 0);
|
||||
const counts = { render: calls('_renderLightPass'), reads: calls('_lightBarriers'), builds: calls('_resolveLightBarriers') };
|
||||
check(`#789: observed main render pass ${pass} executes`, counts.render > 0, true);
|
||||
check(`#789: Glow and LED share barrier reads in pass ${pass}`, counts.reads >= 2 * counts.render, true);
|
||||
check(`#789: pass ${pass} resolves barriers exactly once per render, never across renders`, counts.builds, counts.render);
|
||||
console.log('#789 render-pass coverage', JSON.stringify({ pass, ...counts }));
|
||||
}
|
||||
} finally {
|
||||
await coverageSession.send('Profiler.stopPreciseCoverage');
|
||||
await coverageSession.send('Profiler.disable');
|
||||
await coverageSession.detach();
|
||||
}
|
||||
await fadeSet('off');
|
||||
check('#789: normal off retains all four nodes for their fade', await evaluate(() =>
|
||||
window.__ledFadeProof.nodes().every(nodes => nodes.every(node => node?.classList.contains('is-leaving')))), true);
|
||||
await page.waitForTimeout(750);
|
||||
const fadeOff = await fadeSnapshot();
|
||||
checkFadeEvents('off', fadeOff);
|
||||
check('#789: normal off removes all four fields after fading', fadeOff.nodes.every(nodes => nodes.every(node => !node)), true);
|
||||
|
||||
await page.emulateMedia({ reducedMotion: 'reduce' });
|
||||
await fadeSet('on');
|
||||
await page.waitForTimeout(200);
|
||||
const reducedOn = await fadeSnapshot();
|
||||
check('#789: reduced motion shows LED and ordinary immediately on both surfaces', reducedOn.nodes.every(nodes =>
|
||||
nodes.every(node => node?.opacity === 1)), true);
|
||||
check('#789: reduced on starts no CSS opacity animation', reducedOn.log.length, 0);
|
||||
await fadeSet('off');
|
||||
await page.waitForTimeout(200);
|
||||
const reducedOff = await fadeSnapshot();
|
||||
check('#789: reduced off is immediately dark on both surfaces', reducedOff.nodes.every(nodes =>
|
||||
nodes.every(node => node?.opacity === 0)), true);
|
||||
check('#789: reduced off starts no CSS opacity animation', reducedOff.log.length, 0);
|
||||
await page.waitForTimeout(450);
|
||||
check('#789: reduced off still removes every field', await evaluate(() =>
|
||||
window.__ledFadeProof.nodes().every(nodes => nodes.every(node => !node))), true);
|
||||
|
||||
await page.emulateMedia({ reducedMotion: 'no-preference' });
|
||||
await fadeSet('on');
|
||||
await page.waitForTimeout(750);
|
||||
await evaluate(() => { window.__ledFadeProof.savedNodes = window.__ledFadeProof.nodes(); });
|
||||
await fadeSet('off');
|
||||
await page.waitForTimeout(100);
|
||||
await fadeSet('on');
|
||||
await page.waitForTimeout(750); // beyond the cancelled off timer as well as the returning fade
|
||||
check('#789: rapid off/on retains every DOM node beyond the stale fade deadline', await evaluate(() => {
|
||||
const proof = window.__ledFadeProof;
|
||||
return proof.nodes().every((nodes, card) => nodes.every((node, kind) => node
|
||||
&& node === proof.savedNodes[card][kind] && !node.classList.contains('is-leaving')
|
||||
&& Number(getComputedStyle(node).opacity) === 1));
|
||||
}), true);
|
||||
|
||||
await fadeSet('off');
|
||||
await page.waitForTimeout(650);
|
||||
await evaluate(async () => {
|
||||
// Both Lit commits finish in microtasks, before a browser entering-frame.
|
||||
await window.__ledFadeProof.set('on'); await window.__ledFadeProof.set('off');
|
||||
});
|
||||
await page.waitForTimeout(650);
|
||||
check('#789: off before the entry frame cannot resurrect a field', await evaluate(() =>
|
||||
window.__ledFadeProof.nodes().every(nodes => nodes.every(node => !node))), true);
|
||||
|
||||
await evaluate(async () => {
|
||||
const proof = window.__ledFadeProof;
|
||||
await proof.set('on');
|
||||
// Lit's updated() can enqueue a header/summary follow-up. Drain only its
|
||||
// microtasks before observing disconnect: entry rAFs must still be pending.
|
||||
const drainUpdates = async phase => {
|
||||
for (let attempt = 0; attempt < 10; attempt++) {
|
||||
const complete = await Promise.all(proof.cards.map(card => card.updateComplete));
|
||||
if (complete.every(result => result !== false) && proof.cards.every(card => !card.isUpdatePending)) return;
|
||||
}
|
||||
throw new Error(`#789: pending Lit microtasks did not settle ${phase} disconnect`);
|
||||
};
|
||||
await drainUpdates('before');
|
||||
if (!proof.nodes().every(nodes => nodes.every(node => node?.classList.contains('is-entering')))) {
|
||||
throw new Error('#789: disconnect witness must precede every entering frame');
|
||||
}
|
||||
for (const [index, card] of proof.cards.entries()) {
|
||||
proof.descriptors[index] = Object.getOwnPropertyDescriptor(card, 'requestUpdate');
|
||||
const original = card.requestUpdate;
|
||||
card.requestUpdate = function (...args) {
|
||||
proof.updates[index]++;
|
||||
if (!this.isConnected) proof.late.push({ card: index, stack: new Error().stack });
|
||||
return original.apply(this, args);
|
||||
};
|
||||
}
|
||||
// Disconnect in this microtask, with entry rAFs/feather timers still pending.
|
||||
for (const card of proof.cards) card.remove();
|
||||
// Teardown itself mutates reactive header/summary state. Finish that same
|
||||
// microtask chain before measuring late callbacks; no rAF/timer can run yet.
|
||||
await drainUpdates('after');
|
||||
proof.updates = [0, 0];
|
||||
proof.late = [];
|
||||
});
|
||||
await page.waitForTimeout(750);
|
||||
check('#789: disconnected main/static owners receive no late transition updates', await evaluate(() =>
|
||||
window.__ledFadeProof.updates), [0, 0]);
|
||||
const late = await evaluate(() => window.__ledFadeProof.late);
|
||||
if (late.length) console.log('#789 late update diagnostic', JSON.stringify(late, null, 2));
|
||||
await evaluate(async () => {
|
||||
const proof = window.__ledFadeProof; proof.log = [];
|
||||
proof.parent.insertBefore(proof.cards[0], proof.next?.parentNode === proof.parent ? proof.next : null);
|
||||
proof.host.appendChild(proof.cards[1]);
|
||||
await Promise.all(proof.cards.map(card => card.updateComplete));
|
||||
});
|
||||
await page.waitForTimeout(750);
|
||||
const reconnected = await fadeSnapshot();
|
||||
checkFadeEvents('reconnected', reconnected);
|
||||
check('#789: reconnect starts clean lifecycles and returns all four fields', reconnected.nodes.every(nodes =>
|
||||
nodes.every(node => node?.opacity === 1 && !node.leaving)), true);
|
||||
for (const exit of [
|
||||
{ label: 'disabled pools', config: { space: 'f1', light_pools: false } },
|
||||
{ label: 'another space', config: { space: 'garden', light_pools: true } },
|
||||
]) {
|
||||
await fadeSet('off');
|
||||
await page.waitForTimeout(650);
|
||||
await evaluate(async config => {
|
||||
const proof = window.__ledFadeProof;
|
||||
await proof.set('on');
|
||||
if (!proof.nodes()[1].every(node => node?.classList.contains('is-entering'))) {
|
||||
throw new Error('#789: config exit must precede the static entering frame');
|
||||
}
|
||||
proof.cards[1].setConfig({ type: 'custom:houseplan-space-card', live_states: true, ...config });
|
||||
await proof.cards[1].updateComplete;
|
||||
}, exit.config);
|
||||
await page.waitForTimeout(750);
|
||||
check(`#789: ${exit.label} removes previous static fields beyond their old callbacks`, await evaluate(() =>
|
||||
window.__ledFadeProof.nodes()[1].every(node => !node)), true);
|
||||
await evaluate(async () => {
|
||||
const proof = window.__ledFadeProof; proof.log = [];
|
||||
proof.cards[1].setConfig({ type: 'custom:houseplan-space-card', space: 'f1', light_pools: true, live_states: true });
|
||||
await proof.cards[1].updateComplete;
|
||||
});
|
||||
await page.waitForTimeout(750);
|
||||
const returned = await fadeSnapshot();
|
||||
checkFadeEvents(`return from ${exit.label}`, returned, [1]);
|
||||
check(`#789: return from ${exit.label} starts fresh static fields`, returned.nodes[1].every(node =>
|
||||
node?.opacity === 1 && !node.leaving), true);
|
||||
}
|
||||
await fadeSet('off');
|
||||
await page.waitForTimeout(650);
|
||||
await evaluate(async () => {
|
||||
const proof = window.__ledFadeProof;
|
||||
await proof.set('on');
|
||||
if (!proof.nodes()[0].every(node => node?.classList.contains('is-entering'))) {
|
||||
throw new Error('#789: main navigation must start before the entering frame');
|
||||
}
|
||||
await window.__hpTest.switchSpace('garden');
|
||||
});
|
||||
await page.waitForTimeout(750);
|
||||
check('#789: main LED-to-empty navigation removes both former sources beyond stale callbacks', await evaluate(() =>
|
||||
window.__ledFadeProof.nodes()[0].every(node => !node)), true);
|
||||
await evaluate(async () => {
|
||||
window.__ledFadeProof.log = [];
|
||||
await window.__hpTest.switchSpace('f1');
|
||||
});
|
||||
await page.waitForTimeout(750);
|
||||
const mainReturned = await fadeSnapshot();
|
||||
checkFadeEvents('return from LED-free space', mainReturned, [0], [0]);
|
||||
// Main ordinary Glow has retained its per-space appearance without replay on
|
||||
// navigation since before #789. Preserve that UX; only LED starts a fresh
|
||||
// lifecycle here. The real on/off fade assertions above still cover both.
|
||||
check('#789: main navigation back restores the LED field at full opacity',
|
||||
mainReturned.nodes[0][0]?.opacity === 1 && !mainReturned.nodes[0][0].leaving, true);
|
||||
const retainedOrdinary = mainReturned.nodes[0][1];
|
||||
check('#789: main navigation preserves steady ordinary Glow without replay',
|
||||
retainedOrdinary?.opacity === 1 && !retainedOrdinary.entering && !retainedOrdinary.leaving, true);
|
||||
|
||||
// Keep the same mounted static card while its server-side space vanishes.
|
||||
// This exercises renderSpaceStatic's !space exit, not setConfig/disconnect.
|
||||
await fadeSet('off');
|
||||
await page.waitForTimeout(650);
|
||||
await evaluate(async () => {
|
||||
const proof = window.__ledFadeProof;
|
||||
await proof.set('on');
|
||||
if (!proof.nodes()[1].every(node => node?.classList.contains('is-entering'))) {
|
||||
throw new Error('#789: server removal must start before the static entering frame');
|
||||
}
|
||||
await window.__hpTest.setServerConfig(cfg => {
|
||||
proof.savedConfig = structuredClone(cfg);
|
||||
cfg.spaces = cfg.spaces.filter(space => space.id !== 'f1');
|
||||
cfg.markers = (cfg.markers || []).filter(marker => marker.space !== 'f1');
|
||||
});
|
||||
});
|
||||
await page.waitForFunction(() => !window.__ledFadeProof.cards[1].shadowRoot.querySelector('[data-led-strip]'));
|
||||
await page.waitForTimeout(750);
|
||||
check('#789: a missing server space clears both fields without disconnecting the static card', await evaluate(() => {
|
||||
const proof = window.__ledFadeProof;
|
||||
return proof.cards[1].isConnected && proof.nodes()[1].every(node => !node);
|
||||
}), true);
|
||||
await evaluate(async () => {
|
||||
const proof = window.__ledFadeProof; proof.log = [];
|
||||
await window.__hpTest.setServerConfig(proof.savedConfig);
|
||||
delete proof.savedConfig;
|
||||
});
|
||||
await page.waitForTimeout(750);
|
||||
const restoredSpace = await fadeSnapshot();
|
||||
checkFadeEvents('restored server space', restoredSpace, [1]);
|
||||
check('#789: restoring a server space starts fresh static LED and ordinary fields', restoredSpace.nodes[1].every(node =>
|
||||
node?.opacity === 1 && !node.leaving), true);
|
||||
await evaluate(() => window.__hpTest.switchSpace('f1'));
|
||||
await page.waitForTimeout(750);
|
||||
} finally {
|
||||
await page.emulateMedia({ reducedMotion: 'no-preference' });
|
||||
await evaluate(async () => {
|
||||
const proof = window.__ledFadeProof;
|
||||
if (proof.savedConfig) {
|
||||
await window.__hpTest.setServerConfig(proof.savedConfig);
|
||||
delete proof.savedConfig;
|
||||
}
|
||||
for (const [index, card] of proof.cards.entries()) {
|
||||
for (const name of ['transitionrun', 'transitionend']) card.shadowRoot.removeEventListener(name, proof.listeners[index]);
|
||||
if (index < proof.descriptors.length) {
|
||||
if (proof.descriptors[index]) Object.defineProperty(card, 'requestUpdate', proof.descriptors[index]);
|
||||
else delete card.requestUpdate;
|
||||
}
|
||||
}
|
||||
const main = proof.cards[0];
|
||||
if (!main.isConnected) proof.parent.insertBefore(main, proof.next?.parentNode === proof.parent ? proof.next : null);
|
||||
main.hass = proof.savedHass;
|
||||
proof.cards[1].remove(); proof.host.remove();
|
||||
delete window.__ledFadeProof;
|
||||
await main.updateComplete;
|
||||
});
|
||||
await page.waitForFunction(() => window.__card.shadowRoot.querySelector('[data-led-strip]'));
|
||||
await settle();
|
||||
}
|
||||
|
||||
for (const mode of ['plan', 'decor']) {
|
||||
await evaluate(mode => window.__hpTest.setMode(mode), mode);
|
||||
check(`#790: ${mode} keeps the coloured core passive and translucent`, await evaluate(() => {
|
||||
@@ -401,4 +717,65 @@ check('the press loads the tool and draws for the same marker', await evaluate((
|
||||
return !!led?.tool && led.chain?.convert === 'd_tv' && !window.__card._markerDialog;
|
||||
}));
|
||||
|
||||
// A fresh document makes both lazy imports genuinely cold. Hold each network
|
||||
// response, disconnect its requesting static card, then let the actual module
|
||||
// finish loading: no private runtime hooks or synthetic callback.
|
||||
await evaluate(() => localStorage.clear()); // isolated demo browser storage only
|
||||
ledRequests.length = 0;
|
||||
await page.reload();
|
||||
await page.waitForFunction(() => window.__card?._loadOk);
|
||||
await installHpTestOnPage(page);
|
||||
await evaluate(async () => {
|
||||
window.__card.setConfig({ type: 'custom:houseplan-card', floor: 'garden' });
|
||||
await window.__card.updateComplete;
|
||||
});
|
||||
await setStrips(ceilingStrip, { glow_enabled: true });
|
||||
check('#789: fixed non-LED main card leaves both LED imports cold', ledRequests.filter(url =>
|
||||
/^led-strip-(runtime|field)-/.test(url)).length, 0);
|
||||
for (const kind of ['runtime', 'field']) {
|
||||
let releaseImport;
|
||||
const barrier = new Promise(resolve => { releaseImport = resolve; });
|
||||
const pattern = new RegExp(`/led-strip-${kind}-[^/]+\\.js(?:\\?.*)?$`);
|
||||
const holdImport = async route => { await barrier; await route.fallback(); };
|
||||
await page.route(pattern, holdImport);
|
||||
try {
|
||||
const requested = page.waitForRequest(pattern);
|
||||
await evaluate(async () => {
|
||||
const card = document.createElement('houseplan-space-card');
|
||||
card.setConfig({ type: 'custom:houseplan-space-card', space: 'f1', light_pools: true, live_states: true });
|
||||
card.hass = window.__card.hass;
|
||||
document.body.appendChild(card);
|
||||
window.__ledColdProof = { card, updates: 0, original: card.requestUpdate };
|
||||
await card.updateComplete;
|
||||
});
|
||||
await requested;
|
||||
check(`#789: cold static ${kind} import has not painted its content yet`, await evaluate(kind =>
|
||||
!window.__ledColdProof.card.shadowRoot.querySelector(kind === 'runtime' ? '[data-led-strip]' : '[data-led-field]'), kind), true);
|
||||
await evaluate(async () => {
|
||||
const proof = window.__ledColdProof, card = proof.card;
|
||||
card.remove();
|
||||
let settled = false;
|
||||
for (let attempt = 0; attempt < 10; attempt++) {
|
||||
const complete = await card.updateComplete;
|
||||
if (complete !== false && !card.isUpdatePending) { settled = true; break; }
|
||||
}
|
||||
if (!settled) throw new Error('#789: cold static teardown did not settle');
|
||||
card.requestUpdate = function (...args) { proof.updates++; return proof.original.apply(this, args); };
|
||||
});
|
||||
const arrived = page.waitForResponse(pattern);
|
||||
releaseImport();
|
||||
await (await arrived).finished();
|
||||
await page.waitForTimeout(750);
|
||||
check(`#789: a cold ${kind} arriving after static disconnect requests no update`, await evaluate(() =>
|
||||
window.__ledColdProof.updates), 0);
|
||||
} finally {
|
||||
releaseImport();
|
||||
await page.unroute(pattern, holdImport);
|
||||
await evaluate(() => {
|
||||
const proof = window.__ledColdProof;
|
||||
if (proof) { delete proof.card.requestUpdate; proof.card.remove(); delete window.__ledColdProof; }
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
await finish(browser);
|
||||
|
||||
@@ -520,7 +520,10 @@ ignores `#space=`.
|
||||
visual layer with one selection/transform/history pipeline; image bytes live
|
||||
only in the asset store: [DECOR-EDITOR](DECOR-EDITOR.md), [FURNITURE](FURNITURE.md).
|
||||
- **Light and Glow** — one visibility region per source (#71); Glow is an overlay
|
||||
independent of the data fill (#55): [LIGHT](LIGHT.md). **Room fill** —
|
||||
independent of the data fill (#55): [LIGHT](LIGHT.md). Shared source entry
|
||||
scheduling is owner-local; `LightBarrierPass` shares revisions only inside
|
||||
the main card's synchronous render, never across subsequent edits (#789).
|
||||
**Room fill** —
|
||||
`resolveEffectiveRoomFill()` is the single projection for room floors,
|
||||
clean-floor holes and opening tunnels; `room_color` styles only borders and
|
||||
names; custom colour and legacy tokens (#56, #581):
|
||||
|
||||
@@ -2,6 +2,11 @@
|
||||
|
||||
## Unreleased
|
||||
|
||||
- Removed redundant card updates when many lights appear together and repeated
|
||||
light-barrier calculations within a frame; the light geometry and fades are
|
||||
unchanged. Pending LED updates are also cleared when leaving their space or
|
||||
disconnecting a card ([#789](https://github.com/Matysh/houseplan-card/issues/789)).
|
||||
|
||||
- An on LED strip now shows its light colour inside the outline even when
|
||||
Glow is enabled; off strips stay white
|
||||
([#790](https://github.com/Matysh/houseplan-card/issues/790)).
|
||||
|
||||
@@ -8,6 +8,12 @@
|
||||
|
||||
## Не выпущено
|
||||
|
||||
- Убраны лишние обновления карточки при одновременном появлении множества
|
||||
источников света и повторные расчёты препятствий в одном кадре. Форма света
|
||||
и плавность переходов сохранены. Отложенные обновления LED также очищаются
|
||||
при уходе с пространства или отключении карточки
|
||||
([#789](https://github.com/Matysh/houseplan-card/issues/789)).
|
||||
|
||||
- Ядро включённой LED-ленты внутри обводки теперь показывает цвет её свечения
|
||||
и при включённом Glow; выключенная лента остаётся белой
|
||||
([#790](https://github.com/Matysh/houseplan-card/issues/790)).
|
||||
|
||||
@@ -183,6 +183,15 @@ new `size-pack` with 467.63 MiB; the accepted upper bound is 487.63 MiB.
|
||||
|
||||
- Frontend: `npm test` — compiles src/logic.ts+rules.ts (tsconfig.test.json) and runs node:test
|
||||
(test/*.test.mjs). Strict typing: `npm run typecheck` (tsc --noEmit, part of `npm run build`).
|
||||
- Shared Glow scheduling and render-local barrier reuse (#789) have separate
|
||||
scheduler/memo unit witnesses. `smoke_led_strip_glow` observes real opacity
|
||||
transition events on main/static LED and ordinary pools, without a forced
|
||||
style read between insertion and the entering frame. A declared 500 ms CSS
|
||||
duration alone is not evidence that a fade actually runs. Main-card ordinary
|
||||
Glow retains its previous no-replay behavior on return to a visited space;
|
||||
that navigation witness checks its steady state, not a newly invented fade.
|
||||
The complete
|
||||
`led-strips-v1` performance gate remains separate from these correctness checks.
|
||||
- Pure backend on native Windows (with no HA plugin autoload): use the explicit
|
||||
`python -Arguments @(...)` invocation above after setting
|
||||
`$env:PYTEST_DISABLE_PLUGIN_AUTOLOAD='1'`.
|
||||
|
||||
@@ -429,6 +429,23 @@ only the geometry differs:
|
||||
luminance bands of the shared `GLOW_FALLOFF` in one mask, so corners and the
|
||||
closing of a loop neither seam nor double the brightness. Intensity and the 500 ms fade
|
||||
are the shared `glowAlpha` / `GLOW_FADE_MS`.
|
||||
- **Scheduling.** Sources first seen by one synchronous render share one
|
||||
entering-frame callback per owner/runtime, not one complete update per
|
||||
source (#789). A microtask seals the batch so a later rAF cannot bring a
|
||||
newly inserted source into an earlier entry frame. Forget/disconnect cancels
|
||||
empty batches; identity checks reject late callbacks. Normal 500 ms fade and
|
||||
reduced-motion behavior remain shared with ordinary Glow.
|
||||
- **Render-local scene reuse.** The main card shares a resolved barrier scene
|
||||
between its Glow/LED consumers within one synchronous `render()` only
|
||||
(`LightBarrierPass`). `finally` clears the memo even on early return or error.
|
||||
The next render, callbacks and handlers still resolve the content-based
|
||||
revision; this is not a cross-frame cache keyed only by object identity.
|
||||
- **Owner teardown.** Main and static cards release LED state when their next
|
||||
space has no visible strips, not only when another field renders. The static
|
||||
card also releases it on disconnect, space change or disabling light pools;
|
||||
switching off live states releases the field while retaining the neutral tube.
|
||||
Both lazy runtime and field readiness callbacks check the actual owner's
|
||||
connection before requesting an update. Reconnect creates a fresh lifecycle.
|
||||
- **Visibility.** Classify and sample the full polyline at radius/4 or finer,
|
||||
retaining every actual vertex and both ends; never thin a short final run
|
||||
or an acute corner. The continuous field is clipped to the union of the
|
||||
|
||||
@@ -31,6 +31,7 @@ Everything computable from the tree and git; regenerate, never edit by hand
|
||||
| Branches | `main` carries stable releases only; pre-release tags point at `dev`. Work lands on `dev`, which is equal to or ahead of `main`, never behind. |
|
||||
| LED regression acceptance | #788 covers mixed face/free and fractional-coordinate joins, winding-safe field unions, retained endpoints/acute vertices and circle–wall transition events. The actual owner export is exercised locally; public fixtures are synthetic. Pixel oracles, not just SVG counts, protect the visible falloff. |
|
||||
| LED core colour | #790 makes the on core follow the resolved light colour with or without Glow. Off, unavailable and non-live static strips keep their neutral presentation; geometry and field brightness are unchanged. |
|
||||
| LED performance work | #789 batches entering callbacks per owner and reuses light-barrier revisions within one synchronous render. No change to bands, emitters, clips or raster precision. The complete 50×50 performance acceptance remains mandatory; these structural changes alone do not certify the camera budget. |
|
||||
| Warm remount | Delayed editor restoration preserves the original View camera, reserves pending chrome space, and yields synchronously to mode/space navigation (#762). Header/stage dimensions are published as one settled pair. Canonical: `docs/WARM-REMOUNT.md`. |
|
||||
| 2.5D View | Public since #649: the installation-wide General settings switch `settings.volumetric_view` (Display). Flat stays the default and byte-for-byte unchanged; editors and `houseplan-space-card` stay Flat. Canonical: `docs/ISOMETRIC.md`. |
|
||||
| Input support | Owner's rule since 2026-08-08: View and kiosk are fully supported and release-blocking on touch; the three editors are desktop-first, touch editing is best effort. Canonical: `docs/TOUCH-SUPPORT.md`. |
|
||||
|
||||
@@ -14,11 +14,11 @@ to prove that the Node witness actually kills it.
|
||||
| --- | ---: | --- |
|
||||
| Performance threshold | 4 | The witness measures real browser wall-time or frame work; a pure assertion cannot prove the budget. |
|
||||
| Browser harness integrity | 4 | The mutation breaks page-error, round-trip or page-registration observation in the browser harness itself. |
|
||||
| Paint, cascade and layer composition | 36 | The invariant depends on computed CSS, SVG paint, clipping, stacking or pixels produced by Chromium. |
|
||||
| Paint, cascade and layer composition | 37 | The invariant depends on computed CSS, SVG paint, clipping, stacking or pixels produced by Chromium. |
|
||||
| Pointer geometry and trusted interaction | 47 | The invariant depends on hit testing, pointer capture, touch/keyboard dispatch or live DOM geometry. |
|
||||
| Responsive DOM layout | 38 | The invariant depends on measured element boxes, responsive breakpoints, native/HA dialog shells or focusable target size. |
|
||||
| Custom-element and HA browser lifecycle | 101 | The invariant crosses Lit/custom-element lifecycle, browser storage/events, lazy loading or a complete HA-card state transition. |
|
||||
| **Total** | **230 / 200** | Above the guideline `mutation-gate --check` warns rather than fails (#699); each guard above it is held by its own reason in this inventory and its `because`. |
|
||||
| Custom-element and HA browser lifecycle | 103 | The invariant crosses Lit/custom-element lifecycle, browser storage/events, lazy loading or a complete HA-card state transition. |
|
||||
| **Total** | **233 / 200** | Above the guideline `mutation-gate --check` warns rather than fails (#699); each guard above it is held by its own reason in this inventory and its `because`. |
|
||||
|
||||
## Measured effect
|
||||
|
||||
@@ -81,6 +81,7 @@ The invariant depends on computed CSS, SVG paint, clipping, stacking or pixels p
|
||||
- `device-keyboard-bypasses-click-path`
|
||||
- `device-long-value-ellipsis-restored`
|
||||
- `device-unavailable-hover-restored`
|
||||
- `glow-entry-initial-opacity-skipped`
|
||||
- `golden-filled-tunnel-removed`
|
||||
- `golden-lamp-out-of-reach`
|
||||
- `hatch-static-renderer-untouched`
|
||||
@@ -250,6 +251,8 @@ The invariant crosses Lit/custom-element lifecycle, browser storage/events, lazy
|
||||
- `floor-geometry-key-global-epoch`
|
||||
- `floor-geometry-key-ignores-content`
|
||||
- `french-locale-wrong-dictionary`
|
||||
- `glow-barrier-render-pass-wiring-skipped`
|
||||
- `glow-static-led-release-skipped`
|
||||
- `household-enter-stops-acting`
|
||||
- `household-marker-drops-keyboard-reach`
|
||||
- `hp-dialog-escape-does-not-close`
|
||||
|
||||
@@ -7387,7 +7387,7 @@ const MUTANT_DEFINITIONS = [
|
||||
find: ' ${passageGlowTunnels}\n'
|
||||
+ ' <g class="decorlayer" pointer-events="none">${decorImages}</g>\n'
|
||||
+ ' <g class="hp-stairs-layer" pointer-events="none">${stairShapes}</g>\n'
|
||||
+ ' ${glowPools}\n ${ledVisible(',
|
||||
+ ' ${glowPools}\n ${showLed ?',
|
||||
replace: ' ${passageGlowTunnels}\n'
|
||||
+ ' ${!space.bg && !disp.showNames ? svg`<g class="room-svg-labels" pointer-events="none">${space.rooms.map((room) => {\n'
|
||||
+ ' const center = roomCenter(room);\n'
|
||||
@@ -7397,7 +7397,7 @@ const MUTANT_DEFINITIONS = [
|
||||
+ ' <g class="decorlayer" pointer-events="none">${decorImages}</g>\n'
|
||||
+ ' <g class="hp-stairs-layer" pointer-events="none">${stairShapes}</g>\n'
|
||||
+ ' ${glowPools}\n'
|
||||
+ ' ${ledVisible(',
|
||||
+ ' ${showLed ?',
|
||||
}],
|
||||
},
|
||||
{
|
||||
@@ -14234,6 +14234,61 @@ const MUTANT_DEFINITIONS = [
|
||||
replace: " ? body.slice(0, at).replace(/\\s+$/, '')",
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'glow-barrier-render-pass-wiring-skipped',
|
||||
guard: 'node demo/smoke_led_strip_glow.mjs',
|
||||
because: '#789 AC4: precise coverage of the real main-card Glow plus LED render must resolve barriers '
|
||||
+ 'once per pass; only this bundled custom-element path proves the pass cache is actually wired to its consumers',
|
||||
patches: [{
|
||||
file: 'src/houseplan-card.ts',
|
||||
find: ' return this._lightBarrierPass.read(space, () => this._resolveLightBarriers(space));',
|
||||
replace: ' return this._resolveLightBarriers(space);',
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'glow-barrier-render-pass-reuse-skipped',
|
||||
guard: 'node --test test/light-barrier-pass.test.mjs',
|
||||
because: '#789 AC4: a synchronous render pass must fingerprint/build each space once while still '
|
||||
+ 'invalidating across passes; disabling its local read cache repeats expensive work without changing pixels',
|
||||
patches: [{
|
||||
file: 'src/glow-scene.ts',
|
||||
find: ' const retained = this.scenes?.get(space);',
|
||||
replace: ' const retained = undefined;',
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'glow-entry-initial-opacity-skipped',
|
||||
guard: 'node demo/smoke_led_strip_glow.mjs',
|
||||
because: '#789 AC2: only browser CSS paint and real opacity transitionrun/transitionend events '
|
||||
+ 'prove that LED and ordinary pools on both surfaces paint their initial entering frame without a forced style flush',
|
||||
patches: [{
|
||||
file: 'src/glow-scene.ts',
|
||||
find: ' state.enteringSources.add(key);',
|
||||
replace: ' // mutant: render the new source fully visible before its entry frame',
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'glow-static-led-release-skipped',
|
||||
guard: 'node demo/smoke_led_strip_glow.mjs',
|
||||
because: '#789 AC4: disconnecting and reconnecting the actual static custom element must start a fresh '
|
||||
+ 'LED entering transition; a host connection guard alone cannot release the retained field lifecycle',
|
||||
patches: [{
|
||||
file: 'src/space-card.ts',
|
||||
find: ' ledRelease(this._glowRuntimeState);\n this._continuityHistory =',
|
||||
replace: ' // mutant: retain the static LED field lifecycle across disconnect\n this._continuityHistory =',
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'glow-entry-per-source-frame',
|
||||
guard: 'node --test test/glow-entry-batch.test.mjs',
|
||||
because: '#789 AC1: the controlled scheduler must see one frame and one update for 50 simultaneous '
|
||||
+ 'sources; disabling batch reuse restores the former linear callback fan-out without needing a browser',
|
||||
patches: [{
|
||||
file: 'src/glow-scene.ts',
|
||||
find: ' let batch = state.collectingEntryBatch;',
|
||||
replace: ' let batch: GlowEntryBatch | null = null;',
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'led-icon-not-suppressed',
|
||||
guard: 'node demo/smoke_led_strip_glow.mjs',
|
||||
|
||||
+71
-19
@@ -88,6 +88,31 @@ export interface LightBarrierScene {
|
||||
opaqueBodies: number[][][];
|
||||
}
|
||||
|
||||
/**
|
||||
* One synchronous render reads the same scene for ordinary Glow and LED.
|
||||
* Share that read, including its content fingerprint, only inside the render.
|
||||
* Handlers, subsequent renders and exception paths never retain the memo:
|
||||
* in-place edits and changed opening states must still rebuild the revision.
|
||||
*/
|
||||
export class LightBarrierPass {
|
||||
private scenes: Map<SpaceModel, LightBarrierScene> | null = null;
|
||||
|
||||
run<T>(render: () => T): T {
|
||||
const previous = this.scenes;
|
||||
this.scenes = new Map();
|
||||
try { return render(); }
|
||||
finally { this.scenes.clear(); this.scenes = previous; }
|
||||
}
|
||||
|
||||
read(space: SpaceModel, build: () => LightBarrierScene): LightBarrierScene {
|
||||
const retained = this.scenes?.get(space);
|
||||
if (retained) return retained;
|
||||
const scene = build();
|
||||
this.scenes?.set(space, scene);
|
||||
return scene;
|
||||
}
|
||||
}
|
||||
|
||||
export interface LightZeroWalls {
|
||||
contour: number[][];
|
||||
barriers: number[][];
|
||||
@@ -101,6 +126,8 @@ export interface GlowRuntimeHost {
|
||||
reducedMotion: () => boolean;
|
||||
}
|
||||
|
||||
type GlowEntryBatch = { keys: Set<string>; raf: number };
|
||||
|
||||
export interface GlowRuntimeState {
|
||||
clipCache: Map<string, GlowClipGeometry | null>;
|
||||
geometryWarnings: Set<string>;
|
||||
@@ -108,7 +135,8 @@ export interface GlowRuntimeState {
|
||||
renderedSources: Map<string, number>;
|
||||
lastAppearance: Map<string, { c: string; alpha: number }>;
|
||||
enteringSources: Set<string>;
|
||||
enterRafs: Map<string, number>;
|
||||
enterBatches: Set<GlowEntryBatch>;
|
||||
collectingEntryBatch: GlowEntryBatch | null;
|
||||
fadeTimers: Map<string, number>;
|
||||
featherSuspendUntil: number;
|
||||
featherResumeTimer: number;
|
||||
@@ -129,7 +157,8 @@ export function createGlowRuntimeState(): GlowRuntimeState {
|
||||
renderedSources: new Map(),
|
||||
lastAppearance: new Map(),
|
||||
enteringSources: new Set(),
|
||||
enterRafs: new Map(),
|
||||
enterBatches: new Set(),
|
||||
collectingEntryBatch: null,
|
||||
fadeTimers: new Map(),
|
||||
featherSuspendUntil: 0,
|
||||
featherResumeTimer: 0,
|
||||
@@ -435,6 +464,40 @@ function suspendGlowFeather(
|
||||
state.featherResumeTimer = win.setTimeout(resume, delay);
|
||||
}
|
||||
|
||||
/** All sources discovered by one render enter in one frame, not N renders. */
|
||||
function scheduleGlowEntry(state: GlowRuntimeState, host: GlowRuntimeHost, key: string): void {
|
||||
let batch = state.collectingEntryBatch;
|
||||
if (!batch) {
|
||||
const next: GlowEntryBatch = { keys: new Set(), raf: 0 };
|
||||
state.collectingEntryBatch = next;
|
||||
state.enterBatches.add(next);
|
||||
next.raf = host.window().requestAnimationFrame(() => {
|
||||
if (!state.enterBatches.delete(next)) return;
|
||||
if (state.collectingEntryBatch === next) state.collectingEntryBatch = null;
|
||||
for (const source of next.keys) state.enteringSources.delete(source);
|
||||
if (next.keys.size && host.isConnected()) host.requestUpdate();
|
||||
});
|
||||
// Seal this synchronous render before other rAF callbacks can discover
|
||||
// new sources: those need their own next frame, not this earlier batch.
|
||||
queueMicrotask(() => {
|
||||
if (state.collectingEntryBatch === next) state.collectingEntryBatch = null;
|
||||
});
|
||||
batch = next;
|
||||
}
|
||||
batch.keys.add(key);
|
||||
}
|
||||
|
||||
function forgetGlowEntry(state: GlowRuntimeState, host: GlowRuntimeHost, key: string): void {
|
||||
state.enteringSources.delete(key);
|
||||
for (const batch of state.enterBatches) {
|
||||
batch.keys.delete(key);
|
||||
if (batch.keys.size) continue;
|
||||
host.window().cancelAnimationFrame(batch.raf);
|
||||
state.enterBatches.delete(batch);
|
||||
if (state.collectingEntryBatch === batch) state.collectingEntryBatch = null;
|
||||
}
|
||||
}
|
||||
|
||||
export function transitionGlowSource(
|
||||
state: GlowRuntimeState,
|
||||
host: GlowRuntimeHost,
|
||||
@@ -454,21 +517,12 @@ export function transitionGlowSource(
|
||||
domId = ++state.sourceSeq;
|
||||
state.renderedSources.set(key, domId);
|
||||
state.enteringSources.add(key);
|
||||
const raf = win.requestAnimationFrame(() => {
|
||||
if (state.enterRafs.get(key) !== raf) return;
|
||||
state.enterRafs.delete(key);
|
||||
state.enteringSources.delete(key);
|
||||
if (host.isConnected()) host.requestUpdate();
|
||||
});
|
||||
state.enterRafs.set(key, raf);
|
||||
scheduleGlowEntry(state, host, key);
|
||||
}
|
||||
return { domId, entering: state.enteringSources.has(key), leaving: false };
|
||||
}
|
||||
if (domId == null) return null;
|
||||
const enterRaf = state.enterRafs.get(key);
|
||||
if (enterRaf != null) win.cancelAnimationFrame(enterRaf);
|
||||
state.enterRafs.delete(key);
|
||||
state.enteringSources.delete(key);
|
||||
forgetGlowEntry(state, host, key);
|
||||
if (!state.fadeTimers.has(key)) {
|
||||
suspendGlowFeather(state, host);
|
||||
const timer = win.setTimeout(() => {
|
||||
@@ -488,12 +542,9 @@ export function forgetGlowSource(
|
||||
): void {
|
||||
const win = host.window();
|
||||
const timer = state.fadeTimers.get(key);
|
||||
const raf = state.enterRafs.get(key);
|
||||
if (timer != null) win.clearTimeout(timer);
|
||||
if (raf != null) win.cancelAnimationFrame(raf);
|
||||
state.fadeTimers.delete(key);
|
||||
state.enterRafs.delete(key);
|
||||
state.enteringSources.delete(key);
|
||||
forgetGlowEntry(state, host, key);
|
||||
state.renderedSources.delete(key);
|
||||
state.lastAppearance.delete(key);
|
||||
}
|
||||
@@ -524,12 +575,13 @@ export function disposeGlowRuntime(
|
||||
): void {
|
||||
const win = host.window();
|
||||
for (const timer of state.fadeTimers.values()) win.clearTimeout(timer);
|
||||
for (const raf of state.enterRafs.values()) win.cancelAnimationFrame(raf);
|
||||
for (const batch of state.enterBatches) win.cancelAnimationFrame(batch.raf);
|
||||
win.clearTimeout(state.featherResumeTimer);
|
||||
state.clipCache.clear();
|
||||
state.geometryWarnings.clear();
|
||||
state.fadeTimers.clear();
|
||||
state.enterRafs.clear();
|
||||
state.enterBatches.clear();
|
||||
state.collectingEntryBatch = null;
|
||||
state.enteringSources.clear();
|
||||
state.renderedSources.clear();
|
||||
state.lastAppearance.clear();
|
||||
|
||||
+17
-3
@@ -180,7 +180,7 @@ import {
|
||||
buildGlowClipGeometry, buildLightBarrierScene, createGlowRuntimeState,
|
||||
disposeGlowRuntime, forgetGlowSource, forgetGlowSpace, glowSourceInOpaqueBody,
|
||||
pruneGlowSources, readGlowClip, renderGlowPools, resolveGlowCandidates, resolveGlowFeather,
|
||||
lightGeometryFingerprint, resolveLightBarrierRevision, transitionGlowSource,
|
||||
LightBarrierPass, lightGeometryFingerprint, resolveLightBarrierRevision, transitionGlowSource,
|
||||
warnGlowGeometryFallback, writeGlowClip,
|
||||
type GlowRuntimeHost, type GlowRuntimeState, type GlowSpot,
|
||||
} from './glow-scene';
|
||||
@@ -1804,6 +1804,7 @@ export class HouseplanCard extends LitElement {
|
||||
private _innerContourCache = new Map<string, number[][] | null>();
|
||||
private readonly _floorKey = floorGeometryKeyReader(this as unknown as FloorKeySource); // #744: key of the four caches above
|
||||
private readonly _glowRuntimeState: GlowRuntimeState = createGlowRuntimeState();
|
||||
private readonly _lightBarrierPass = new LightBarrierPass();
|
||||
private readonly _glowRuntimeHost: GlowRuntimeHost = {
|
||||
window: () => this.ownerDocument.defaultView || window,
|
||||
isConnected: () => this.isConnected,
|
||||
@@ -10325,6 +10326,10 @@ export class HouseplanCard extends LitElement {
|
||||
occluders: LightSegment[]; floor: number[][][]; fingerprint: string;
|
||||
masonryGeometry: any; opaqueBodies: number[][][];
|
||||
} {
|
||||
return this._lightBarrierPass.read(space, () => this._resolveLightBarriers(space));
|
||||
}
|
||||
|
||||
private _resolveLightBarriers(space: SpaceModel) {
|
||||
const raw = this._curSpaceCfg;
|
||||
const revision = resolveLightBarrierRevision({
|
||||
rawSpaceConfig: raw,
|
||||
@@ -10511,8 +10516,13 @@ export class HouseplanCard extends LitElement {
|
||||
/** #780: the lazy LED chunk when the space shows a strip (ТЗ §13.1). */
|
||||
private _ledRt(space: SpaceModel) {
|
||||
// A disconnected card never applies the chunk (a late import, r1 M5).
|
||||
return this.isConnected && ledVisible(space, this._renderDevices, this._mode === 'devices' && this._showAll, this._serverCfg?.markers)
|
||||
? ledRuntime(space.id, () => this.isConnected && this.requestUpdate()) : null;
|
||||
if (!this.isConnected || !ledVisible(space, this._renderDevices, this._mode === 'devices' && this._showAll, this._serverCfg?.markers)) {
|
||||
// The next space may have no strips, so no field render can prune the
|
||||
// previous owner's entry frames, fade timers or cached geometry.
|
||||
ledRelease(this);
|
||||
return null;
|
||||
}
|
||||
return ledRuntime(space.id, () => this.isConnected && this.requestUpdate());
|
||||
}
|
||||
|
||||
/** #780: the Devices-editor LED tool, its own lazy chunk (src/led-strip-card.ts). */
|
||||
@@ -10617,6 +10627,10 @@ export class HouseplanCard extends LitElement {
|
||||
}
|
||||
|
||||
protected render(): TemplateResult | typeof nothing | typeof noChange {
|
||||
return this._lightBarrierPass.run(() => this._renderLightPass());
|
||||
}
|
||||
|
||||
private _renderLightPass(): TemplateResult | typeof nothing | typeof noChange {
|
||||
try {
|
||||
const body = this._renderBody();
|
||||
// `nothing` is the only root that has no decision surface. `noChange` is
|
||||
|
||||
@@ -613,6 +613,7 @@ export interface StaticLedInput {
|
||||
owner: object;
|
||||
/** Re-render when the field chunk lands. */
|
||||
ready: () => void;
|
||||
isConnected: () => boolean;
|
||||
}
|
||||
|
||||
export function renderStaticLed(input: StaticLedInput): TemplateResult {
|
||||
@@ -642,10 +643,11 @@ export function renderStaticLed(input: StaticLedInput): TemplateResult {
|
||||
occluders: [], floor: [], fingerprint: '',
|
||||
masonryGeometry: input.bodies.masonryGeometry, opaqueBodies: input.bodies.opaqueBodies,
|
||||
}, (LED_EPSILON_CM / input.cellCm) * input.gridPitch);
|
||||
const module = input.scene && input.live && (fieldWanted(views) || field?.hasLedField(input.owner))
|
||||
? ledField(input.space.id, input.ready) : null;
|
||||
const module = input.isConnected() && input.scene && input.live && (fieldWanted(views) || field?.hasLedField(input.owner))
|
||||
? ledField(input.space.id, () => input.isConnected() && input.ready()) : null;
|
||||
if (!module) field?.releaseLedField(input.owner);
|
||||
return svg`${module ? module.renderLedField({
|
||||
views, scene: input.scene as LightBarrierScene, polygons, faces, spaceId: input.space.id, owner: input.owner,
|
||||
requestUpdate: input.ready, isConnected: () => true,
|
||||
requestUpdate: input.ready, isConnected: input.isConnected,
|
||||
}) : nothing}${renderLedStripes({ views, d: frame.d, faces, perUnit: input.perUnit, handlers: null })}` as unknown as TemplateResult;
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ import { ContentSigner } from './signing';
|
||||
import { normalizeDeviceDisplay, openingEntityReferences, referencedContentUrls } from './logic';
|
||||
import { acquireHaRegistries, activeRegistryHass, haRegistrySnapshot } from './ha-binding-status';
|
||||
import { resolvedLightSources } from './devices';
|
||||
import { ledRelease } from './led-strip-gate';
|
||||
import {
|
||||
activitySourceSignature, resolveDevicePresentation, resolvePresentationSources,
|
||||
} from './device-presentation';
|
||||
@@ -297,6 +298,10 @@ class HouseplanSpaceCard extends LitElement {
|
||||
if (!config || !config.space) {
|
||||
throw new Error('houseplan-space-card: "space" is required');
|
||||
}
|
||||
if (this._config?.space !== config.space) {
|
||||
disposeGlowRuntime(this._glowRuntimeState, this._glowRuntimeHost);
|
||||
ledRelease(this._glowRuntimeState);
|
||||
}
|
||||
this._config = {
|
||||
show_button: true, button_target: '/plan-doma',
|
||||
live_states: true, show_temperature: true, show_signal: true,
|
||||
@@ -309,6 +314,7 @@ class HouseplanSpaceCard extends LitElement {
|
||||
// and a blend probe alive.
|
||||
if (this._config.light_pools !== true) {
|
||||
disposeGlowRuntime(this._glowRuntimeState, this._glowRuntimeHost);
|
||||
ledRelease(this._glowRuntimeState);
|
||||
} else if (this.isConnected) {
|
||||
this._resolveGlowBlend();
|
||||
}
|
||||
@@ -369,6 +375,7 @@ class HouseplanSpaceCard extends LitElement {
|
||||
for (const runtime of this._activityRuntime.values()) window.clearTimeout(runtime.timer);
|
||||
this._activityRuntime.clear();
|
||||
disposeGlowRuntime(this._glowRuntimeState, this._glowRuntimeHost);
|
||||
ledRelease(this._glowRuntimeState);
|
||||
this._continuityHistory = [...this._continuityHistory, ...this._continuity.trace].slice(-80);
|
||||
this._continuity.dispose();
|
||||
this._continuityDisposed = true;
|
||||
|
||||
+16
-5
@@ -59,14 +59,14 @@ import {
|
||||
type Layout, type ContentItem, type SpaceCardFit,
|
||||
} from './space-geometry';
|
||||
import { resolveZeroWalls } from './zero-walls';
|
||||
import { ledAnchor, ledRuntime, ledStripsByMarker, ledVisible } from './led-strip-gate';
|
||||
import { ledAnchor, ledRelease, ledRuntime, ledStripsByMarker, ledVisible } from './led-strip-gate';
|
||||
import { geometryOpenings } from './plan-geometry-preflight';
|
||||
import { resolveDeviceAreaRelocations } from './device-area-relocation';
|
||||
import { projectDecorImage } from './decor-assets';
|
||||
import type { DecorShape } from './editors/decor/types';
|
||||
import { cachedStairRenderGeometry, stairOutline, stairStyleVars } from './stairs';
|
||||
import {
|
||||
buildGlowClipGeometry, buildLightBarrierScene, forgetGlowSource, forgetGlowSpace,
|
||||
buildGlowClipGeometry, buildLightBarrierScene, disposeGlowRuntime, forgetGlowSource, forgetGlowSpace,
|
||||
glowSourceInOpaqueBody, pruneGlowSources, readGlowClip, renderGlowPools,
|
||||
resolveGlowCandidates, resolveGlowFeather, lightGeometryFingerprint,
|
||||
resolveLightBarrierRevision, transitionGlowSource, warnGlowGeometryFallback, writeGlowClip,
|
||||
@@ -282,7 +282,13 @@ export function buildSpaceDevices(o: StaticDeviceBuildOpts): DevItem[] {
|
||||
export function renderSpaceStatic(o: StaticRenderOpts): TemplateResult | null {
|
||||
const models = spaceModels(o.cfg);
|
||||
const space = models.find((s) => s.id === o.spaceId);
|
||||
if (!space) return null;
|
||||
if (!space) {
|
||||
if (o.glowRuntime) {
|
||||
disposeGlowRuntime(o.glowRuntime.state, o.glowRuntime.host);
|
||||
ledRelease(o.glowRuntime.state);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
const disp = spaceDisplayOf(o.cfg.spaces.find((s: any) => s.id === o.spaceId));
|
||||
const colors = fillColorsOf(o.cfg.settings);
|
||||
const storedDecor = (o.cfg.settings as { decor_default_style?: Record<string, unknown> })
|
||||
@@ -310,6 +316,10 @@ export function renderSpaceStatic(o: StaticRenderOpts): TemplateResult | null {
|
||||
// the same room show different Zigbee health on the two cards.
|
||||
const spaceDevs = all.filter((d) => d.space === o.spaceId);
|
||||
const devs = spaceDevs.filter((d) => !d.hidden);
|
||||
const showLed = ledVisible(space, devs, false, o.cfg.markers);
|
||||
const ledConnected = o.glowRuntime?.host.isConnected ?? (() => false);
|
||||
const ledReady = () => { if (ledConnected()) o.moonHost?.requestUpdate(); };
|
||||
if (!showLed && o.glowRuntime) ledRelease(o.glowRuntime.state);
|
||||
// #780: a marker shown as an LED strip draws no icon here and takes no slot.
|
||||
const leds = ledStripsByMarker(space);
|
||||
const iconDevs = leds.size ? devs.filter((d) => !leds.has(d.id)) : devs;
|
||||
@@ -1001,13 +1011,14 @@ export function renderSpaceStatic(o: StaticRenderOpts): TemplateResult | null {
|
||||
<g class="decorlayer" pointer-events="none">${decorImages}</g>
|
||||
<g class="hp-stairs-layer" pointer-events="none">${stairShapes}</g>
|
||||
${glowPools}
|
||||
${ledVisible(space, devs, false, o.cfg.markers) ? ledRuntime(space.id, () => o.moonHost?.requestUpdate())?.renderStaticLed({
|
||||
${showLed ? ledRuntime(space.id, ledReady)?.renderStaticLed({
|
||||
space, devices: spaceDevs, hass: planHass, virtualLights: o.virtualLights,
|
||||
defaultColor: colors.glow_light.c, paletteAlpha: colors.glow_light.a, cellCm,
|
||||
gridPitch: GRID_PITCH, iconPct, glowFor: (room) => !!o.lightPools && roomGlowOf(disp.glow, room),
|
||||
inRoom: (point, room) => pointInPolygon(point, roomPoly(room) || []), live: o.liveStates !== false,
|
||||
scene: lightScene, perUnit: pxPerUnit, owner: o.glowRuntime?.state || space,
|
||||
ready: () => o.moonHost?.requestUpdate(),
|
||||
ready: ledReady,
|
||||
isConnected: ledConnected,
|
||||
bodies: { masonryGeometry: canonicalWallGeometry?.components.flatMap((c) => c.geom) || [], opaqueBodies: extras },
|
||||
}) ?? nothing : nothing}
|
||||
${wallUnion
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
createGlowRuntimeState, disposeGlowRuntime, forgetGlowSource, forgetGlowSpace,
|
||||
GLOW_FADE_MS, pruneGlowSources, transitionGlowSource,
|
||||
} from '../test-build/glow-scene.js';
|
||||
|
||||
function runtime(reducedMotion = false) {
|
||||
let seq = 0, updates = 0, connected = true;
|
||||
const frames = new Map(), timers = new Map();
|
||||
const win = {
|
||||
requestAnimationFrame(cb) { const id = ++seq; frames.set(id, cb); return id; },
|
||||
cancelAnimationFrame(id) { frames.delete(id); },
|
||||
setTimeout(cb, delay) { const id = ++seq; timers.set(id, { cb, delay }); return id; },
|
||||
clearTimeout(id) { timers.delete(id); },
|
||||
};
|
||||
const host = { window: () => win, reducedMotion: () => reducedMotion,
|
||||
isConnected: () => connected, requestUpdate: () => { updates++; } };
|
||||
const state = createGlowRuntimeState();
|
||||
return { state, host, frames, timers,
|
||||
get updates() { return updates; },
|
||||
disconnect() { connected = false; },
|
||||
enter(key) { return transitionGlowSource(state, host, key, true); },
|
||||
off(key) { return transitionGlowSource(state, host, key, false); },
|
||||
frame() { for (const [id, cb] of [...frames]) { frames.delete(id); cb(0); } },
|
||||
dispose() { disposeGlowRuntime(state, host); },
|
||||
};
|
||||
}
|
||||
|
||||
for (const reduced of [false, true]) {
|
||||
test(`50 Glow/LED entries share one frame and one update, reduced motion=${reduced}`, () => {
|
||||
const r = runtime(reduced);
|
||||
for (let i = 0; i < 50; i++) {
|
||||
const result = r.enter(`floor|${i % 2 ? 'lamp' : 'led'}-${i}`);
|
||||
assert.equal(result.entering, true);
|
||||
}
|
||||
assert.equal(r.frames.size, 1, 'old per-source scheduler produces 50 callbacks here');
|
||||
assert.equal(r.updates, 0, 'entering state is painted before its next frame');
|
||||
assert.equal(r.state.enteringSources.size, 50);
|
||||
r.frame();
|
||||
assert.equal(r.updates, 1);
|
||||
assert.equal(r.state.enteringSources.size, 0);
|
||||
assert.equal(r.frames.size, 0);
|
||||
for (const key of r.state.renderedSources.keys()) assert.equal(r.enter(key).entering, false);
|
||||
assert.equal(r.frames.size, 0, 'steady renders do not schedule entry again');
|
||||
r.dispose();
|
||||
assert.equal(r.timers.size, 0);
|
||||
});
|
||||
}
|
||||
|
||||
test('owners do not share their scheduler or their entering sources', () => {
|
||||
const a = runtime(), b = runtime();
|
||||
a.enter('floor|same'); b.enter('floor|same');
|
||||
a.frame();
|
||||
assert.equal(a.updates, 1); assert.equal(b.updates, 0);
|
||||
assert.equal(b.state.enteringSources.size, 1);
|
||||
b.frame();
|
||||
assert.equal(b.updates, 1);
|
||||
a.dispose(); b.dispose();
|
||||
});
|
||||
|
||||
test('off before the entry frame preserves its peers and fade-out duration', () => {
|
||||
const r = runtime();
|
||||
const first = r.enter('s|a'); r.enter('s|b');
|
||||
const off = r.off('s|a');
|
||||
assert.deepEqual(off, { domId: first.domId, entering: false, leaving: true });
|
||||
assert.equal(r.frames.size, 1, 'cancelling one member must not cancel another');
|
||||
const timer = r.state.fadeTimers.get('s|a');
|
||||
assert.equal(r.timers.get(timer).delay, GLOW_FADE_MS + 34);
|
||||
r.frame();
|
||||
assert.equal(r.updates, 1);
|
||||
assert.equal(r.state.renderedSources.has('s|a'), true, 'off node survives through fade');
|
||||
r.timers.get(timer).cb();
|
||||
assert.equal(r.state.renderedSources.has('s|a'), false);
|
||||
assert.equal(r.state.renderedSources.has('s|b'), true);
|
||||
r.dispose();
|
||||
});
|
||||
|
||||
test('rapid off/on keeps DOM identity and rejects its stale fade callback', () => {
|
||||
const r = runtime();
|
||||
const first = r.enter('s|a');
|
||||
r.off('s|a');
|
||||
const stale = r.timers.get(r.state.fadeTimers.get('s|a')).cb;
|
||||
assert.equal(r.frames.size, 0, 'last departing entry cancels the empty batch');
|
||||
assert.deepEqual(r.enter('s|a'), { domId: first.domId, entering: false, leaving: false });
|
||||
stale();
|
||||
assert.equal(r.state.renderedSources.get('s|a'), first.domId);
|
||||
assert.equal(r.updates, 0);
|
||||
r.dispose();
|
||||
});
|
||||
|
||||
test('prune/space switch remove only their sources; cancelled old frame cannot consume the new batch', () => {
|
||||
const r = runtime();
|
||||
r.enter('old|a'); r.enter('old|b');
|
||||
const stale = [...r.frames.values()][0];
|
||||
pruneGlowSources(r.state, r.host, 'old', new Set(['old|b']));
|
||||
assert.equal(r.frames.size, 1);
|
||||
forgetGlowSpace(r.state, r.host, 'old');
|
||||
assert.equal(r.frames.size, 0);
|
||||
assert.equal(r.state.renderedSources.size, 0);
|
||||
r.enter('new|c');
|
||||
stale();
|
||||
assert.equal(r.state.enteringSources.has('new|c'), true);
|
||||
assert.equal(r.updates, 0);
|
||||
r.frame();
|
||||
assert.equal(r.updates, 1);
|
||||
r.dispose();
|
||||
});
|
||||
|
||||
test('disconnect/dispose prevent queued entry and fade callbacks from updating their owner', () => {
|
||||
const r = runtime();
|
||||
r.enter('s|a');
|
||||
const staleFrame = [...r.frames.values()][0];
|
||||
r.off('s|a');
|
||||
const staleFade = r.timers.get(r.state.fadeTimers.get('s|a')).cb;
|
||||
r.enter('s|b'); r.disconnect();
|
||||
r.frame();
|
||||
assert.equal(r.updates, 0);
|
||||
r.dispose();
|
||||
staleFrame(); staleFade();
|
||||
assert.equal(r.updates, 0);
|
||||
assert.equal(r.frames.size, 0); assert.equal(r.timers.size, 0);
|
||||
assert.equal(r.state.enteringSources.size, 0);
|
||||
assert.equal(r.state.renderedSources.size, 0);
|
||||
assert.equal(r.state.enterBatches.size, 0);
|
||||
assert.equal(r.state.collectingEntryBatch, null);
|
||||
});
|
||||
|
||||
test('forget the last entry cancels its frame without affecting an already visible source', () => {
|
||||
const r = runtime();
|
||||
r.enter('s|visible'); r.frame();
|
||||
r.enter('s|new');
|
||||
forgetGlowSource(r.state, r.host, 's|new');
|
||||
assert.equal(r.frames.size, 0);
|
||||
assert.equal(r.state.renderedSources.has('s|visible'), true);
|
||||
assert.equal(r.updates, 1);
|
||||
r.dispose();
|
||||
});
|
||||
|
||||
test('a source discovered by an earlier rAF is not consumed by an older pending batch', async () => {
|
||||
const r = runtime();
|
||||
r.host.window().requestAnimationFrame(() => r.enter('s|later'));
|
||||
r.enter('s|first');
|
||||
await Promise.resolve(); // finish the synchronous render before the browser frame
|
||||
r.frame(); // earlier external callback, then the old entry batch
|
||||
assert.equal(r.state.enteringSources.has('s|first'), false);
|
||||
assert.equal(r.state.enteringSources.has('s|later'), true,
|
||||
'the newly rendered source needs its own initial entering frame');
|
||||
assert.equal(r.frames.size, 1);
|
||||
assert.equal(r.updates, 1);
|
||||
await Promise.resolve();
|
||||
r.frame();
|
||||
assert.equal(r.state.enteringSources.size, 0);
|
||||
assert.equal(r.updates, 2);
|
||||
r.dispose();
|
||||
});
|
||||
|
||||
test('forget/re-add of the same key cannot let an earlier batch consume its new entry', async () => {
|
||||
const r = runtime();
|
||||
r.enter('s|a'); r.enter('s|peer');
|
||||
await Promise.resolve();
|
||||
forgetGlowSource(r.state, r.host, 's|a');
|
||||
const oldFrame = [...r.frames.values()][0];
|
||||
r.enter('s|a');
|
||||
oldFrame();
|
||||
assert.equal(r.state.enteringSources.has('s|a'), true);
|
||||
assert.equal(r.state.enteringSources.has('s|peer'), false);
|
||||
r.frame();
|
||||
assert.equal(r.state.enteringSources.size, 0);
|
||||
assert.equal(r.updates, 2);
|
||||
r.dispose();
|
||||
});
|
||||
|
||||
test('a reused runtime ignores old frames and microtasks after dispose', async () => {
|
||||
const r = runtime();
|
||||
r.enter('s|same');
|
||||
const oldFrame = [...r.frames.values()][0];
|
||||
r.dispose();
|
||||
// Runs between the old seal-microtask and the new one. A stale seal must
|
||||
// not clear the new collecting batch and split this peer into a second rAF.
|
||||
queueMicrotask(() => r.enter('s|peer'));
|
||||
r.enter('s|same');
|
||||
oldFrame();
|
||||
await Promise.resolve();
|
||||
assert.equal(r.state.enteringSources.has('s|same'), true);
|
||||
assert.equal(r.state.enteringSources.has('s|peer'), true);
|
||||
assert.equal(r.frames.size, 1);
|
||||
assert.equal(r.updates, 0);
|
||||
r.frame();
|
||||
assert.equal(r.updates, 1);
|
||||
assert.equal(r.state.enteringSources.size, 0);
|
||||
r.dispose();
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { LightBarrierPass } from '../test-build/glow-scene.js';
|
||||
|
||||
test('ordinary Glow and LED share one revision within a synchronous render only', () => {
|
||||
const pass = new LightBarrierPass(), space = { id: 'floor' };
|
||||
let builds = 0;
|
||||
const build = () => ({ fingerprint: String(++builds) });
|
||||
pass.run(() => {
|
||||
const first = pass.read(space, build);
|
||||
assert.equal(pass.read(space, build), first);
|
||||
assert.equal(pass.read(space, build), first);
|
||||
assert.equal(builds, 1);
|
||||
});
|
||||
assert.equal(pass.read(space, build).fingerprint, '2');
|
||||
assert.equal(pass.read(space, build).fingerprint, '3');
|
||||
pass.run(() => assert.equal(pass.read(space, build).fingerprint, '4'));
|
||||
});
|
||||
|
||||
test('in-place plan and opening changes are fingerprinted again next render', () => {
|
||||
const pass = new LightBarrierPass(), space = { id: 'floor', wall: 10, opening: 0 };
|
||||
const build = () => ({ fingerprint: `${space.wall}/${space.opening}` });
|
||||
const render = () => pass.run(() => pass.read(space, build));
|
||||
assert.equal(render().fingerprint, '10/0');
|
||||
space.wall = 12;
|
||||
assert.equal(render().fingerprint, '12/0');
|
||||
space.opening = 1;
|
||||
assert.equal(render().fingerprint, '12/1');
|
||||
});
|
||||
|
||||
test('owners and different space objects never share a memo, even with the same id', () => {
|
||||
const a = new LightBarrierPass(), b = new LightBarrierPass();
|
||||
const one = { id: 'floor' }, two = { id: 'floor' };
|
||||
let n = 0;
|
||||
const build = () => ({ fingerprint: String(++n) });
|
||||
a.run(() => b.run(() => {
|
||||
assert.equal(a.read(one, build).fingerprint, '1');
|
||||
assert.equal(a.read(two, build).fingerprint, '2');
|
||||
assert.equal(b.read(one, build).fingerprint, '3');
|
||||
assert.equal(a.read(one, build).fingerprint, '1');
|
||||
}));
|
||||
});
|
||||
|
||||
test('throwing renders and failed builds cannot retain or poison the next read', () => {
|
||||
const pass = new LightBarrierPass(), space = { id: 'floor' };
|
||||
const failure = new Error('render failed');
|
||||
assert.throws(() => pass.run(() => {
|
||||
assert.throws(() => pass.read(space, () => { throw failure; }), failure);
|
||||
pass.read(space, () => ({ fingerprint: 'old' }));
|
||||
throw failure;
|
||||
}), failure);
|
||||
assert.equal(pass.read(space, () => ({ fingerprint: 'new' })).fingerprint, 'new');
|
||||
});
|
||||
|
||||
test('nested synchronous passes restore the outer memo without leaking the inner one', () => {
|
||||
const pass = new LightBarrierPass(), space = { id: 'floor' };
|
||||
const outer = { fingerprint: 'outer' }, inner = { fingerprint: 'inner' };
|
||||
pass.run(() => {
|
||||
assert.equal(pass.read(space, () => outer), outer);
|
||||
pass.run(() => assert.equal(pass.read(space, () => inner), inner));
|
||||
assert.equal(pass.read(space, () => { throw Error('outer memo missing'); }), outer);
|
||||
});
|
||||
assert.equal(pass.read(space, () => inner), inner);
|
||||
});
|
||||
Reference in New Issue
Block a user