Files
houseplan-card/demo/smoke_ws_resilience.mjs
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

81 lines
3.3 KiB
JavaScript

// DEV-B703-02: temporary WS failures must NEVER blank a plan that was already
// shown. On the build before the fix _loadFromServer's catch nulled
// _serverCfg after 8 failed tries — the plan disappeared even though a valid
// config (the LS snapshot) was on screen. Stale-while-revalidate: the last
// valid config lives until a successful reload replaces it, and once the
// socket recovers the card revalidates QUIETLY on its own retry clock
// (willUpdate stops driving loads after 8 tries).
// planSurvivesOutage + reloadedAfterRecovery FAIL on the pre-fix build.
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?.();
// the LS snapshot is in place (the page's main card cached it): the new
// instance renders instantly from it — a wall tablet rejoining wifi
out.lsCachePresent = !!localStorage.getItem('houseplan_card_cfg_v1');
let fail = true;
let failedCalls = 0;
const base = window.__mkHass();
const mkFlaky = () => ({
...base,
callWS: async (m) => {
if (fail) { failedCalls++; throw new Error('ws down'); }
return base.callWS(m);
},
});
const c = document.createElement('houseplan-card');
c.setConfig({ type: 'custom:houseplan-card' });
c.hass = mkFlaky();
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);
await sleep(50);
out.rendersFromCache = !!c._serverCfg;
// hass keeps ticking while the socket is down — drive past the 8-try budget
for (let i = 0; i < 12; i++) {
c.hass = mkFlaky();
await sleep(60);
}
out.wsFailedEnough = failedCalls >= 8 ? true : `only ${failedCalls} failed calls`;
out.planSurvivesOutage = c._serverCfg ? true : '_serverCfg was cleared by the outage';
out.stillServerMode = c._serverStorage === true;
const sr = () => c.shadowRoot || c.renderRoot;
const stage = sr().querySelector('.stage');
out.roomsStillRendered = !!stage && stage.querySelectorAll('.room').length > 0;
// ---- the socket comes back: quiet self-driven revalidation ----
fail = false;
const t0 = performance.now();
while (!c._loadOk && performance.now() - t0 < 12000) await sleep(100);
out.reloadedAfterRecovery = c._loadOk === true ? true : 'never revalidated after recovery';
out.cfgValidAfterRecovery = !!c._serverCfg && Array.isArray(c._serverCfg.spaces)
&& c._serverCfg.spaces.length > 0;
wrap.remove();
return out;
});
for (const [k, v] of Object.entries(res)) check(k, v);
// ---- a LIVE instance: a failing config reload keeps the last config ----
const live = await page.evaluate(async () => {
const out = {};
const c = window.__card;
const before = c._serverCfg;
const origHass = c.hass;
c.hass = { ...c.hass, callWS: async () => { throw new Error('ws down'); } };
await c._reloadConfigOnly(true);
out.liveReloadFailKeepsCfg = c._serverCfg === before
? true : 'a failed reload replaced/cleared the live config';
c.hass = origHass;
return out;
});
for (const [k, v] of Object.entries(live)) check(k, v);
await finish(browser, { ...res, ...live });