Files
houseplan-card/demo/smoke_warm_remount.mjs
T
Matysh db19753bbf DEV-B703: warm re-mount without the veil + WS outages never blank the plan
Owner's report: «план перезагружается при возврате на вкладку, хотя страница
жива». Diagnosis confirmed: Lovelace re-creates the card element when the
websocket reconnects after a long-backgrounded tab, and the fresh instance ran
the FULL first-open boot — veil + BOOT_MIN_MS + quiescence — reading as a plan
reload. On top of that, _loadFromServer's catch nulled _serverCfg after 8
failed tries, so a slow reconnect could genuinely blank an already-shown plan.

DEV-B703-01 warm re-mount: module-scoped memo (lives with the PAGE, not the
instance) of the settled header height, keyed by viewport size × card config.
A repeat instance adopts the settled geometry in setConfig and skips the veil
entirely — synchronous reveal at the saved zoom (HP-1551); a window resize
between instances changes the key and brings the full protective boot back.
The memo follows the live geometry (updated()'s measure) and is written on
every _bootSettled. Test hook: static _warmBootReset().

DEV-B703-02 stale-while-revalidate: an instance that already renders a valid
config (LS snapshot or a successful load) NEVER clears it on WS failures —
the local-only fallback is reserved for a card that never had a backend. A
self-driven retry (backoff, cap 8 s) keeps revalidating after willUpdate's
8-try budget is spent, and a connection 'ready' hook resets the budget and
quietly re-reads the config the moment the socket is back (the event
subscriptions re-subscribe on their own inside home-assistant-js-websocket).

Smokes: smoke_warm_remount (fails pre-fix: veil + hidden plan on re-mount;
resize invalidation stays cold), smoke_ws_resilience (fails pre-fix:
_serverCfg cleared after 8 tries, no revalidation after recovery); the
preloader smokes now reset the warm memo — they simulate a COLD first open.
2026-08-03 15:36:32 +03:00

120 lines
5.0 KiB
JavaScript

// DEV-B703-01: Lovelace re-creates card elements when the websocket reconnects
// after a long-backgrounded tab; the fresh instance used to run the whole
// first-open boot (veil + 700 ms + quiescence) — the owner's «план
// перезагружается при возврате на вкладку». Within one loaded page a repeat
// instance must open WARM: no veil, no wait, not a single frame at a stale
// stage height or a default zoom. A window resize between instances
// invalidates the memo → the full protective boot is back.
// FAILS on the build before DEV-B703-01 (every instance booted cold).
import { launch, check, finish } from './serve.mjs';
const { page, browser } = await launch({ width: 820, height: 760 });
const res = await page.evaluate(async () => {
const out = {};
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
customElements.get('houseplan-card')?._warmBootReset?.(); // deterministic: the page starts cold
localStorage.setItem('houseplan_card_zoom_v1', JSON.stringify({ f1: 1.8 }));
localStorage.removeItem('houseplan_card_nav_v1');
const wrap = document.createElement('div');
wrap.style.cssText = 'position:fixed;left:0;top:0;width:800px;z-index:99;background:#000';
document.body.appendChild(wrap);
const mk = () => {
const c = document.createElement('houseplan-card');
c.setConfig({ type: 'custom:houseplan-card' });
c.hass = window.__mkHass();
wrap.appendChild(c);
return c;
};
// ---- cold boot: the first instance pays the full protective window ----
const c1 = mk();
out.coldBoots = c1._booting === true;
const t0 = performance.now();
let veilSeen = false;
while (c1._booting && performance.now() - t0 < 2500) {
if ((c1.shadowRoot || c1.renderRoot).querySelector('.bootveil')) veilSeen = true;
await sleep(30);
}
out.coldVeilSeen = veilSeen;
out.coldSettled = c1._booting === false;
await sleep(300); // fade out + soft grace start
const stage1 = (c1.shadowRoot || c1.renderRoot).querySelector('.stage');
const hFinal = stage1.clientHeight;
const zoomSaved = c1._zoom;
out.zoomArmed = Math.abs(zoomSaved - 1.8) < 0.01;
c1.remove(); // Lovelace throws the old element away…
await sleep(30);
// ---- …and creates a new one: same page, same viewport → WARM mount ----
const c2 = mk();
out.warmNoBootFlag = c2._booting === false; // decided synchronously in setConfig
const sr2 = () => c2.shadowRoot || c2.renderRoot;
const frames = [];
const t1 = performance.now();
await new Promise((done) => {
const sample = () => {
const stage = sr2().querySelector('.stage');
if (stage) {
const zw = stage.querySelector(':scope > .zoomwrap');
frames.push({
t: Math.round(performance.now() - t1),
veil: !!sr2().querySelector('.bootveil'),
planVisible: !!zw && getComputedStyle(zw).visibility === 'visible',
rooms: stage.querySelectorAll('.room').length,
stageH: stage.clientHeight,
zoom: c2._zoom,
});
}
if (performance.now() - t1 < 900) requestAnimationFrame(sample);
else done();
};
requestAnimationFrame(sample);
});
out.framesSampled = frames.length > 20;
out.warmNoVeilEver = !frames.some((f) => f.veil);
out.warmPlanVisibleEveryFrame = frames.length > 0 && frames.every((f) => f.planVisible)
? true : 'a frame hid the plan';
const badH = frames.filter((f) => Math.abs(f.stageH - hFinal) > 2);
out.warmNoStaleHeightFrame = badH.length === 0
? true : `stage h=${badH[0].stageH} vs final ${hFinal} at t=${badH[0].t}ms`;
out.warmZoomEveryFrame = frames.every((f) => Math.abs(f.zoom - zoomSaved) < 0.01)
? true : 'a frame at a non-saved zoom';
// the server config reload on a warm mount must not blank the plan
out.warmNeverEmptyPlan = frames.every((f) => f.rooms > 0) ? true : 'a frame rendered no rooms';
c2.remove();
wrap.remove();
return out;
});
for (const [k, v] of Object.entries(res)) check(k, v);
// ---- a window resize between instances → memo stale → the full boot ----
await page.setViewportSize({ width: 900, height: 680 });
const rez = await page.evaluate(async () => {
const out = {};
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const c = document.createElement('houseplan-card');
c.setConfig({ type: 'custom:houseplan-card' });
c.hass = window.__mkHass();
const wrap = document.createElement('div');
wrap.style.cssText = 'position:fixed;left:0;top:0;width:800px;z-index:99;background:#000';
wrap.appendChild(c);
document.body.appendChild(wrap);
out.resizedBootsCold = c._booting === true;
const sr = () => c.shadowRoot || c.renderRoot;
const t0 = performance.now();
let veil = false;
while (c._booting && performance.now() - t0 < 2500) {
if (sr().querySelector('.bootveil')) veil = true;
await sleep(30);
}
out.resizedVeilShown = veil;
out.resizedSettles = c._booting === false;
wrap.remove();
return out;
});
for (const [k, v] of Object.entries(rez)) check(k, v);
await finish(browser, { ...res, ...rez });