diff --git a/demo/smoke_led_strip_glow.mjs b/demo/smoke_led_strip_glow.mjs
index 94640573..9ae5f11e 100644
--- a/demo/smoke_led_strip_glow.mjs
+++ b/demo/smoke_led_strip_glow.mjs
@@ -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);
diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md
index a337d4ce..f45088e7 100644
--- a/docs/ARCHITECTURE.md
+++ b/docs/ARCHITECTURE.md
@@ -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):
diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md
index 1123c0e0..8c4bcff2 100644
--- a/docs/CHANGELOG.md
+++ b/docs/CHANGELOG.md
@@ -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)).
diff --git a/docs/CHANGELOG.ru.md b/docs/CHANGELOG.ru.md
index 1bbb19e0..7fabcb75 100755
--- a/docs/CHANGELOG.ru.md
+++ b/docs/CHANGELOG.ru.md
@@ -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)).
diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md
index 519cb157..bf87dabc 100644
--- a/docs/DEVELOPMENT.md
+++ b/docs/DEVELOPMENT.md
@@ -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'`.
diff --git a/docs/LIGHT.md b/docs/LIGHT.md
index 6ab6d043..953247a1 100644
--- a/docs/LIGHT.md
+++ b/docs/LIGHT.md
@@ -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
diff --git a/docs/STATUS.md b/docs/STATUS.md
index 469f0609..e0710595 100644
--- a/docs/STATUS.md
+++ b/docs/STATUS.md
@@ -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`. |
diff --git a/docs/testing-notes/mutation-browser-guards.md b/docs/testing-notes/mutation-browser-guards.md
index 21656911..e32cef7a 100644
--- a/docs/testing-notes/mutation-browser-guards.md
+++ b/docs/testing-notes/mutation-browser-guards.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`
diff --git a/scripts/mutation-registry.mjs b/scripts/mutation-registry.mjs
index af3b6fa2..33fc4ec8 100644
--- a/scripts/mutation-registry.mjs
+++ b/scripts/mutation-registry.mjs
@@ -7387,7 +7387,7 @@ const MUTANT_DEFINITIONS = [
find: ' ${passageGlowTunnels}\n'
+ ' ${decorImages}\n'
+ ' ${stairShapes}\n'
- + ' ${glowPools}\n ${ledVisible(',
+ + ' ${glowPools}\n ${showLed ?',
replace: ' ${passageGlowTunnels}\n'
+ ' ${!space.bg && !disp.showNames ? svg`${space.rooms.map((room) => {\n'
+ ' const center = roomCenter(room);\n'
@@ -7397,7 +7397,7 @@ const MUTANT_DEFINITIONS = [
+ ' ${decorImages}\n'
+ ' ${stairShapes}\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',
diff --git a/src/glow-scene.ts b/src/glow-scene.ts
index 1476f928..791ccc68 100644
--- a/src/glow-scene.ts
+++ b/src/glow-scene.ts
@@ -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 | null = null;
+
+ run(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; raf: number };
+
export interface GlowRuntimeState {
clipCache: Map;
geometryWarnings: Set;
@@ -108,7 +135,8 @@ export interface GlowRuntimeState {
renderedSources: Map;
lastAppearance: Map;
enteringSources: Set;
- enterRafs: Map;
+ enterBatches: Set;
+ collectingEntryBatch: GlowEntryBatch | null;
fadeTimers: Map;
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();
diff --git a/src/houseplan-card.ts b/src/houseplan-card.ts
index 6a4bb89e..3af19a35 100755
--- a/src/houseplan-card.ts
+++ b/src/houseplan-card.ts
@@ -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();
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
diff --git a/src/led-strip-runtime.ts b/src/led-strip-runtime.ts
index 76895886..149533b1 100755
--- a/src/led-strip-runtime.ts
+++ b/src/led-strip-runtime.ts
@@ -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;
}
diff --git a/src/space-card.ts b/src/space-card.ts
index 70323a0e..f4e26204 100644
--- a/src/space-card.ts
+++ b/src/space-card.ts
@@ -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;
diff --git a/src/space-render.ts b/src/space-render.ts
index 75649768..2ff70318 100644
--- a/src/space-render.ts
+++ b/src/space-render.ts
@@ -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 })
@@ -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 {
${decorImages}
${stairShapes}
${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
diff --git a/test/glow-entry-batch.test.mjs b/test/glow-entry-batch.test.mjs
new file mode 100644
index 00000000..4f6b85a7
--- /dev/null
+++ b/test/glow-entry-batch.test.mjs
@@ -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();
+});
diff --git a/test/light-barrier-pass.test.mjs b/test/light-barrier-pass.test.mjs
new file mode 100644
index 00000000..71eadd4b
--- /dev/null
+++ b/test/light-barrier-pass.test.mjs
@@ -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);
+});