mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-01 12:18:51 +00:00
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:
File diff suppressed because one or more lines are too long
@@ -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;
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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 });
|
||||
@@ -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
Vendored
+16
-16
File diff suppressed because one or more lines are too long
+113
-3
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user