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.
This commit is contained in:
Matysh
2026-08-03 15:36:32 +03:00
parent 9be44dab1e
commit db19753bbf
8 changed files with 366 additions and 51 deletions
File diff suppressed because one or more lines are too long
+2
View File
@@ -13,6 +13,7 @@ const { page, browser } = await launch({ width: 820, height: 760 });
// ---------- phase 1: normal mode, HA panel lands late ----------
const res = await page.evaluate(async () => {
const out = {};
customElements.get('houseplan-card')?._warmBootReset?.(); // DEV-B703: this scenario simulates a COLD first open — forget the page's warm re-mount memo
const spacer = document.createElement('div');
spacer.style.cssText = 'height:0px';
const c = document.createElement('houseplan-card');
@@ -119,6 +120,7 @@ check('kioskPlanNeverHidden', kiosk.kioskPlanNeverHidden);
// ---------- phase 3: prefers-reduced-motion → the house does not pulse ----------
await page.emulateMedia({ reducedMotion: 'reduce' });
const reduced = await page.evaluate(async () => {
customElements.get('houseplan-card')?._warmBootReset?.(); // DEV-B703: this scenario simulates a COLD first open — forget the page's warm re-mount memo
const c = document.createElement('houseplan-card');
c.setConfig({ type: 'custom:houseplan-card' });
c.hass = window.__card.hass;
+4
View File
@@ -22,6 +22,7 @@ const { page, browser } = await launch({ width: 820, height: 760 });
const rec = await page.evaluate(async () => {
const out = {};
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
customElements.get('houseplan-card')?._warmBootReset?.(); // DEV-B703: this scenario simulates a COLD first open — forget the page's warm re-mount memo
const c = document.createElement('houseplan-card');
c.setConfig({ type: 'custom:houseplan-card' });
c.hass = window.__card.hass;
@@ -48,6 +49,7 @@ const rec = await page.evaluate(async () => {
out.veilGone = !sr().querySelector('.bootveil');
// A2: a second detach DURING the fade must not leave a zombie veil either
customElements.get('houseplan-card')?._warmBootReset?.(); // DEV-B703: this scenario simulates a COLD first open — forget the page's warm re-mount memo
const c2 = document.createElement('houseplan-card');
c2.setConfig({ type: 'custom:houseplan-card' });
c2.hass = window.__card.hass;
@@ -72,6 +74,7 @@ const outB = {};
for (const shiftMs of shifts) {
const res = await page.evaluate(async (shiftAt) => {
const out = {};
customElements.get('houseplan-card')?._warmBootReset?.(); // DEV-B703: this scenario simulates a COLD first open — forget the page's warm re-mount memo
const spacer = document.createElement('div');
spacer.style.cssText = 'height:0px';
const c = document.createElement('houseplan-card');
@@ -133,6 +136,7 @@ for (const shiftMs of shifts) {
const late = await page.evaluate(async () => {
const out = {};
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
customElements.get('houseplan-card')?._warmBootReset?.(); // DEV-B703: this scenario simulates a COLD first open — forget the page's warm re-mount memo
const spacer = document.createElement('div');
spacer.style.cssText = 'height:0px';
const c = document.createElement('houseplan-card');
+119
View File
@@ -0,0 +1,119 @@
// 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 });
+80
View File
@@ -0,0 +1,80 @@
// 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 });
File diff suppressed because one or more lines are too long
+16 -16
View File
File diff suppressed because one or more lines are too long
+113 -3
View File
@@ -65,6 +65,23 @@ const BOOT_MAX_MS = 1200;
/** AUD-1552-02: post-reveal grace during which late chrome shifts glide
* (CSS height transition on the stage) instead of snapping. */
const BOOT_SOFT_MS = 1500;
/** DEV-B703-01: warm re-mount memo — MODULE scope, so it lives with the loaded
* PAGE, not with any card instance. 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 + BOOT_MIN_MS + quiescence),
* which reads as «план перезагрузился», even though the page (and HA's
* chrome) never went anywhere. Within one loaded page the chrome IS already
* settled, so the geometry the previous instance settled at is still valid:
* remember it per (viewport size × card config) and let the next instance
* open instantly in the final geometry, no veil at all. A window resize
* between instances changes the key → miss → the full protective boot (the
* only case where the chrome may genuinely re-settle). The config part of
* the key keeps two DIFFERENT cards on one page from adopting each other's
* header height (same config twice on one view is indistinguishable — and
* then the heights match anyway). */
const warmBoot = new Map<string, { hdrH: number; stageH: number }>();
const warmBootKey = (config: unknown): string =>
`${window.innerWidth}x${window.innerHeight}|${JSON.stringify(config ?? {})}`;
const LS_KEY = 'houseplan_card_layout_v1';
const LS_CFG = 'houseplan_card_cfg_v1'; // cache of the server config+layout for instant rendering
const LS_ZOOM = 'houseplan_card_zoom_v1';
@@ -573,6 +590,9 @@ class HouseplanCard extends LitElement {
clearTimeout(this._bootSoftTimer);
this._bootSoftTimer = window.setTimeout(() => { this._bootSoft = false; }, BOOT_SOFT_MS);
}
// DEV-B703-02: a reattach mid-outage must keep revalidating — the retry
// timer died in disconnectedCallback
if (!this._loadOk && this._serverCfg && this.hass) this._scheduleLoadRetry();
}
public disconnectedCallback(): void {
@@ -584,6 +604,10 @@ class HouseplanCard extends LitElement {
clearTimeout(this._kioskDotsTimer);
clearTimeout(this._kioskHoldTimer);
clearTimeout(this._reloadRetry);
clearTimeout(this._loadRetryTimer);
this._loadRetryTimer = undefined; // a cleared id must not block a reschedule
this._connHooked?.removeEventListener?.('ready', this._onConnReady);
this._connHooked = null;
this._signer.dispose();
clearTimeout(this._toastTimer);
clearTimeout(this._slideTimer);
@@ -723,9 +747,32 @@ class HouseplanCard extends LitElement {
return { type: 'custom:houseplan-card' };
}
/** Test hook (smokes): forget the warm re-mount memo — a cold page again. */
public static _warmBootReset(): void {
warmBoot.clear();
}
public setConfig(config: CardConfig): void {
this._config = { icon_size: 2.5, show_temperature: true, live_states: true, show_signal: true, ...config };
if (this._config.kiosk) { this._booting = false; this._bootFading = false; } // kiosk: 100dvh, nothing to settle
else {
// DEV-B703-01: this page already booted an identical card at this
// viewport — adopt its settled header height and skip the veil
// entirely: the card reveals synchronously in the final geometry (the
// saved zoom is armed below, HP-1551). _bootSoft covers any residual
// chrome drift with a glide instead of a snap.
const warm = warmBoot.get(warmBootKey(this._config));
if (warm) {
this._booting = false;
this._bootFading = false;
this._hdrH = warm.hdrH;
this._bootSoft = true; // timer armed in connectedCallback...
if (this.isConnected) { // ...unless setConfig re-runs while attached
clearTimeout(this._bootSoftTimer);
this._bootSoftTimer = window.setTimeout(() => { this._bootSoft = false; }, BOOT_SOFT_MS);
}
}
}
if (config.default_floor) this._space = config.default_floor;
try {
this._zoomBySpace = JSON.parse(localStorage.getItem(LS_ZOOM) || '{}') || {};
@@ -954,6 +1001,7 @@ class HouseplanCard extends LitElement {
protected willUpdate(changed: PropertyValues): void {
if (changed?.has?.('hass')) { this._vacTick(); this._senseTick(); }
if (changed.has('hass') && this.hass) {
this._hookConnection();
if (!this._loadOk && !this._loading && this._loadTries < 8) {
this._loadFromServer();
}
@@ -986,6 +1034,12 @@ class HouseplanCard extends LitElement {
const above = Math.min(Math.max(card.getBoundingClientRect().top, 0), 120);
const t = Math.round(own + above);
if (t >= 0 && Math.abs(t - this._hdrH) > 1) this._hdrH = t;
// DEV-B703-01: chrome that lands after the settle (or a window
// resize with a live card) must not poison the next warm mount —
// the memo follows the live settled geometry.
if (t >= 0 && !this._booting && !this._config?.kiosk && stage.clientHeight > 0) {
warmBoot.set(warmBootKey(this._config), { hdrH: t, stageH: stage.clientHeight });
}
};
// a frame later: setting state straight from the observer callback makes
// the browser report "ResizeObserver loop completed with undelivered
@@ -1083,16 +1137,26 @@ class HouseplanCard extends LitElement {
this._cacheSnapshot();
this._restoreZoom();
} catch (e) {
// not the last attempt — silently wait for the next hass update (WS warm-up)
if (this._loadTries >= 8) {
if (this._serverCfg) {
// DEV-B703-02: this instance already RENDERS a valid config (the LS
// snapshot, or an earlier successful load). A failing socket is a
// transient condition — nulling _serverCfg here blanked the plan on
// every reconnect that took more than 8 hass ticks. Stale-while-
// revalidate: the last valid config stays on screen until a
// successful reload replaces it, and revalidation keeps running on
// our own clock (willUpdate stops driving loads after 8 tries).
this._scheduleLoadRetry();
} else if (this._loadTries >= 8) {
// nothing was ever shown — genuine no-backend: local-only fallback
this._serverStorage = false;
this._serverCfg = null;
try {
this._layout = JSON.parse(localStorage.getItem(LS_KEY) || '{}') || {};
} catch {
this._layout = {};
}
}
// fewer than 8 tries with nothing shown yet: silently wait for the
// next hass update (WS warm-up)
} finally {
this._loading = false;
}
@@ -1134,6 +1198,46 @@ class HouseplanCard extends LitElement {
}
private _reloadRetry?: number;
/** DEV-B703-02: self-driven revalidation once willUpdate's 8-try budget is
* spent — without it a card whose socket died at mount would show the
* cached plan forever and never revalidate (hass ticks stop driving loads
* after 8 tries). Exponential backoff capped at 8 s; single timer;
* cleared on disconnect and on a connection 'ready'. */
private _loadRetryTimer?: number;
private _scheduleLoadRetry(): void {
if (this._loadRetryTimer !== undefined) return;
const delay = Math.min(8000, 500 * 2 ** Math.min(4, Math.max(1, this._loadTries - 7)));
this._loadRetryTimer = window.setTimeout(() => {
this._loadRetryTimer = undefined;
if (!this._loadOk && !this._loading && this.hass) this._loadFromServer();
}, delay);
}
/** DEV-B703-02: revalidate the moment the socket comes back. The event
* subscriptions (houseplan_config_updated / layout / trail) survive a
* reconnect on their own — home-assistant-js-websocket keeps the
* Connection object alive and replays its subscription commands on
* 'ready' — but a LOAD that burned its retry budget while the socket was
* down would never run again. 'ready' fires on every (re)connect: reset
* the budget and quietly re-read the config. */
private _connHooked: { removeEventListener?: (t: string, cb: () => void) => void } | null = null;
private _onConnReady = (): void => {
this._loadTries = 0;
clearTimeout(this._loadRetryTimer);
this._loadRetryTimer = undefined;
if (this._loading) return;
// a subscribe lost mid-load leaves _loadOk=true without _unsubCfg — the
// full load path repairs both (every subscribe in it is guarded)
if (!this._loadOk || !this._unsubCfg) this._loadFromServer();
else this._reloadConfigOnly();
};
private _hookConnection(): void {
const conn = (this.hass as any)?.connection;
if (!conn || conn === this._connHooked) return;
this._connHooked?.removeEventListener?.('ready', this._onConnReady);
conn.addEventListener?.('ready', this._onConnReady);
this._connHooked = conn;
}
/**
* Signed urls for the content endpoint (audit follow-up B1 regression).
* A browser cannot authenticate an <image href> or an <a href>: HA takes a
@@ -1731,6 +1835,12 @@ class HouseplanCard extends LitElement {
if (!this._booting) return;
this._refitView(); // reveal in the final geometry, never mid-jump
this._booting = false;
// DEV-B703-01: the page has settled once — the next instance with the
// same config at the same viewport opens warm (no veil, no wait).
const settledH = this._stageEl?.clientHeight ?? 0;
if (!this._config?.kiosk && settledH > 0) {
warmBoot.set(warmBootKey(this._config), { hdrH: this._hdrH, stageH: settledH });
}
this._bootFading = true; // one soft opacity-out, then out of the DOM
this._bootTimer = window.setTimeout(() => { this._bootFading = false; }, 220);
// AUD-1552-02: chrome that lands after the cap (device slower than