diff --git a/demo/smoke_lazy_editor_chunk.mjs b/demo/smoke_lazy_editor_chunk.mjs index 5d0e7808..6297ffe8 100644 --- a/demo/smoke_lazy_editor_chunk.mjs +++ b/demo/smoke_lazy_editor_chunk.mjs @@ -154,6 +154,145 @@ out.fingerprintMismatchKeepsView = await mismatch.page.evaluate(() => window.__card._mode === 'view' && !window.__card._editorRuntime && window.__card._toast.includes(window.__card._t('editor.refresh_advice'))); +// #757: a render is not an intent. Surfaces the core opens without the +// runtime — the kiosk scale dialog after a 3 s hold, the floor import wizard +// on an empty plan, a dialog a warm remount revives — keep asking for it on +// every repaint. One non-terminal failure is one cycle and one notice; the +// loader then waits for the next explicit intent, which still heals. +const QUIET_MS = 8000; +const chunkRequests = async (session, name, plain = 'abort') => { + const seen = []; + session.page.on('request', (request) => { + const url = new URL(request.url()); + if (url.pathname.endsWith(`/${name}`)) seen.push(url.search || 'plain'); + }); + const net = { plain, retry: 'abort' }; + await session.page.route(`**/${name}*`, (route) => { + const verdict = new URL(route.request().url()).search ? net.retry : net.plain; + return verdict === 'abort' ? route.abort('failed') : route.fallback(); + }); + return { seen, net }; +}; +/** Toast nodes as the user sees them: each appearance is one notice. */ +const installNoticeCounter = (page) => page.evaluate(() => { + window.__hpWatchNotices = (card) => { + window.__hpNotices = 0; + new MutationObserver((records) => { + for (const record of records) { + for (const node of record.addedNodes) { + if (node.nodeType === 1 && node.matches('[data-hp="toast"]')) window.__hpNotices += 1; + } + } + }).observe(card.shadowRoot || card.renderRoot, { childList: true, subtree: true }); + }; +}); +const watchToasts = async (page, selector) => { + await installNoticeCounter(page); + await page.evaluate((cardSelector) => window.__hpWatchNotices(document.querySelector(cardSelector)), selector); +}; +const notices = (page) => page.evaluate(() => window.__hpNotices); +/** `true`, or what actually happened — the count is the evidence. */ +const exactly = (expected, actual, what) => actual === expected || `${actual} ${what} instead of ${expected}`; + +// (a) Kiosk: a 3 s hold on the empty scene opens the per-screen size dialog. +const kiosk = await launchColdView(); +const kioskChunk = await chunkRequests(kiosk, runtimeName); +await kiosk.page.evaluate(async () => { + const card = document.createElement('houseplan-card'); + card.id = 'hp-kiosk'; + card.setConfig({ type: 'custom:houseplan-card', kiosk: true, cycle: 0 }); + card.style.cssText = 'position:fixed;left:0;top:0;width:900px;height:700px;z-index:99'; + card.hass = window.__card.hass; + document.body.appendChild(card); +}); +await kiosk.page.waitForFunction(() => { + const card = document.querySelector('#hp-kiosk'); + return !card._booting && (card.shadowRoot || card.renderRoot).querySelector('.stage'); +}); +await watchToasts(kiosk.page, '#hp-kiosk'); +await kiosk.page.evaluate(() => { + const card = document.querySelector('#hp-kiosk'); + const stage = (card.shadowRoot || card.renderRoot).querySelector('.stage'); + const box = stage.getBoundingClientRect(); + stage.dispatchEvent(new PointerEvent('pointerdown', { + bubbles: true, composed: true, cancelable: true, pointerId: 31, pointerType: 'touch', + isPrimary: true, button: 0, buttons: 1, + clientX: box.left + box.width / 2, clientY: box.top + box.height / 2, + })); +}); +await kiosk.page.waitForFunction(() => document.querySelector('#hp-kiosk')._kioskDialog === true, null, { timeout: 6000 }); +await kiosk.page.waitForTimeout(QUIET_MS); +out.kioskDialogFailureIsOneCycle = exactly(2, kioskChunk.seen.length, 'chunk requests'); +out.kioskDialogFailureIsOneNotice = exactly(1, await notices(kiosk.page), 'notices'); +out.kioskDialogLoaderWaitsForIntent = await kiosk.page.evaluate(() => { + const card = document.querySelector('#hp-kiosk'); + return card._editorRuntimeLoader.state === 'idle' && card._kioskDialog === true + && !!(card.shadowRoot || card.renderRoot).querySelector('hp-dialog input[type="range"]'); +}); + +// (b) Import wizard: an empty plan with HA floors opens it for an admin. +const wizard = await launchColdView(); +const wizardChunk = await chunkRequests(wizard, onboardingName); +await watchToasts(wizard.page, 'houseplan-card'); +await wizard.page.evaluate(() => window.__hpTest.setServerConfig((cfg) => ({ ...cfg, spaces: [] }))); +await wizard.page.waitForFunction(() => !!window.__card._importDialog); +await wizard.page.waitForTimeout(QUIET_MS); +out.importWizardFailureIsOneCycle = exactly(2, wizardChunk.seen.length, 'chunk requests'); +out.importWizardFailureIsOneNotice = exactly(1, await notices(wizard.page), 'notices'); +out.importWizardLoaderWaitsForIntent = await wizard.page.evaluate(() => + window.__card._onboardingRuntimeLoader.state === 'idle' && !!window.__card._importDialog); +wizardChunk.net.retry = 'serve'; +await wizard.page.evaluate(() => { + const card = window.__card; + (card.shadowRoot || card.renderRoot).querySelector('[data-hp="create-space"]')?.click(); +}); +await wizard.page.waitForFunction(() => window.__card._onboardingRuntimeLoader.state === 'ready'); +out.createSpacePressAfterFailureHeals = true; + +// (c) Warm remount: General settings revive on the new instance, offline. The +// plain chunk URL failed earlier in this page, so a cycle is one request — +// the cache-busting retry. +const warm = await launchColdView(); +const warmChunk = await chunkRequests(warm, runtimeName); +warmChunk.net.retry = 'serve'; +await warm.page.evaluate(() => { + const card = window.__card; + (card.shadowRoot || card.renderRoot).querySelector('[data-hp="settings"]')?.click(); +}); +await warm.page.waitForFunction(() => !!window.__card._settingsDialog && !!window.__card._editorRuntime); +await warm.page.waitForTimeout(300); +warmChunk.net.retry = 'abort'; +const warmBefore = warmChunk.seen.length; +await installNoticeCounter(warm.page); +await warm.page.evaluate(() => { + const old = window.__card; + const host = old.parentNode; + const card = document.createElement('houseplan-card'); + card.setConfig({ type: 'custom:houseplan-card', title: 'House Plan', icon_size: 3.4 }); + card.hass = old.hass; + old.remove(); + host.appendChild(card); + window.__hpWatchNotices(card); + window.__card = card; +}); +await warm.page.waitForFunction(() => !!window.__card._settingsDialog); +await warm.page.waitForTimeout(QUIET_MS); +out.warmReviveFailureIsOneCycle = exactly(1, warmChunk.seen.length - warmBefore, 'chunk requests'); +out.warmReviveFailureIsOneNotice = exactly(1, await notices(warm.page), 'notices'); +out.warmReviveLoaderWaitsForIntent = await warm.page.evaluate(() => + window.__card._editorRuntimeLoader.state === 'idle' && !!window.__card._settingsDialog); +warmChunk.net.retry = 'serve'; +await warm.page.evaluate(() => { + const card = window.__card; + (card.shadowRoot || card.renderRoot).querySelector('[data-hp="mode-tab"][data-mode="plan"]')?.click(); +}); +await warm.page.waitForFunction(() => + window.__card._editorRuntimeLoader.state === 'ready' && window.__card._mode === 'plan'); +out.planTabAfterFailureHeals = true; + +await kiosk.browser.close(); +await wizard.browser.close(); +await warm.browser.close(); await failed.browser.close(); await mismatch.browser.close(); await onboarding.browser.close(); diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 2bece24a..54e0e67b 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -20,6 +20,9 @@ slide. A real colour change on the same floor — a light turning on, a new temperature, hover — still fades smoothly ([#742](https://github.com/Matysh/houseplan-card/issues/742)). +- When the editor fails to load (e.g. offline), the card no longer retries on + its own and repeats the notice every few seconds; the next press retries + ([#757](https://github.com/Matysh/houseplan-card/issues/757)). ## v1.79.0-beta.1 — 2026-09-30 diff --git a/docs/CHANGELOG.ru.md b/docs/CHANGELOG.ru.md index 75fba41b..b55dd7c1 100755 --- a/docs/CHANGELOG.ru.md +++ b/docs/CHANGELOG.ru.md @@ -27,6 +27,10 @@ сдвигом плана. Настоящая смена цвета на том же этаже — включился свет, сменилась температура, наведение — по-прежнему идёт плавно ([#742](https://github.com/Matysh/houseplan-card/issues/742)). +- Если редактор не загрузился, например без сети, карточка больше не пробует + снова сама и не повторяет уведомление каждые несколько секунд: повтор — по + следующему нажатию + ([#757](https://github.com/Matysh/houseplan-card/issues/757)). ## v1.79.0-beta.1 — 2026-09-30 diff --git a/docs/testing-notes/mutation-browser-guards.md b/docs/testing-notes/mutation-browser-guards.md index ee28e86c..9599f738 100644 --- a/docs/testing-notes/mutation-browser-guards.md +++ b/docs/testing-notes/mutation-browser-guards.md @@ -16,8 +16,8 @@ to prove that the Node witness actually kills it. | Paint, cascade and layer composition | 26 | The invariant depends on computed CSS, SVG paint, clipping, stacking or pixels produced by Chromium. | | Pointer geometry and trusted interaction | 45 | The invariant depends on hit testing, pointer capture, touch/keyboard dispatch or live DOM geometry. | | Responsive DOM layout | 36 | The invariant depends on measured element boxes, responsive breakpoints, native/HA dialog shells or focusable target size. | -| Custom-element and HA browser lifecycle | 88 | The invariant crosses Lit/custom-element lifecycle, browser storage/events, lazy loading or a complete HA-card state transition. | -| **Total** | **203 / 200** | Growth above the cap fails `mutation-gate --check`. | +| Custom-element and HA browser lifecycle | 89 | The invariant crosses Lit/custom-element lifecycle, browser storage/events, lazy loading or a complete HA-card state transition. | +| **Total** | **204 / 200** | Growth above the cap fails `mutation-gate --check`. | ## Measured effect @@ -256,6 +256,7 @@ The invariant crosses Lit/custom-element lifecycle, browser storage/events, lazy - `post-write-tail-runs-on-refused-gate` - `readonly-view-syncs-new-devices` - `render-invalidation-renders-irrelevant-ha` +- `render-reconcile-restarts-editor-runtime-cycle` - `reopened-room-from-registry-space` - `room-accept-leaves-coincident-partitions` - `room-gear-second-touch-keeps-drag` diff --git a/scripts/mutation-registry.mjs b/scripts/mutation-registry.mjs index f08e6119..468b5beb 100644 --- a/scripts/mutation-registry.mjs +++ b/scripts/mutation-registry.mjs @@ -13522,6 +13522,19 @@ const MUTANT_DEFINITIONS = [ replace: ' // mutant: the visible draft revives without its dirty baseline', }], }, + { + id: 'render-reconcile-restarts-editor-runtime-cycle', + guard: 'node demo/smoke_lazy_editor_chunk.mjs', + because: '#757 AC1: _renderBody asks for the editor runtime on every repaint while a ' + + 'surface the core opened (kiosk size dialog, warm-revived dialog) waits for it; ' + + 'calling it as an intent turns one network failure into a new cycle and a new ' + + 'notice every few seconds — only the production card render shows this', + patches: [{ + file: 'src/houseplan-card.ts', + find: " if (editorRuntimeRequested && !this._editorRuntime) void this._editorRuntimeLoader.ensure('reconcile');", + replace: ' if (editorRuntimeRequested && !this._editorRuntime) void this._editorRuntimeLoader.ensure(); // mutant: a render is an intent', + }], + }, { id: 'marker-discard-without-asking', guard: 'node demo/smoke_device_settings_form.mjs', diff --git a/src/editor-runtime-loader.ts b/src/editor-runtime-loader.ts index 3fd29a20..ca9a9bb8 100644 --- a/src/editor-runtime-loader.ts +++ b/src/editor-runtime-loader.ts @@ -68,10 +68,19 @@ class FingerprintMismatchError extends Error {} * keeps returning `false` without importing again. Any other failure (network, * parse) reports via `failed` and returns the loader to `idle`, so the NEXT * explicit user intent starts a fresh cycle. There are no background retries. + * + * A render that finds a surface waiting for the runtime is not an intent + * (#757): `ensure('reconcile')` starts the first cycle such a surface needs, + * but after a non-terminal failure it returns `false` without loading until + * an explicit `ensure()` has started a new cycle. Otherwise every repaint — + * the loader's own state change, the failure toast and its expiry, each + * `hass` tick — would start another cycle and another toast. */ export class EditorRuntimeLoader { private _state: EditorRuntimeLoaderState = 'idle'; private _inFlight: Promise | null = null; + /** A non-terminal failure waits for the next explicit intent (#757). */ + private _awaitingIntent = false; public constructor(private readonly options: EditorRuntimeLoaderOptions) {} @@ -79,10 +88,14 @@ export class EditorRuntimeLoader { return this._state; } - public ensure(): Promise { + /** `intent`: `explicit` — a user action or an opening; `reconcile` — a render + * that finds a surface waiting for the runtime. */ + public ensure(intent: 'explicit' | 'reconcile' = 'explicit'): Promise { if (this._state === 'ready') return Promise.resolve(true); if (this._state === 'failed') return Promise.resolve(false); if (this._inFlight) return this._inFlight; + if (intent === 'reconcile' && this._awaitingIntent) return Promise.resolve(false); + this._awaitingIntent = false; this._setState('loading'); this._inFlight = this._loadWithRetry().finally(() => { this._inFlight = null; @@ -110,6 +123,7 @@ export class EditorRuntimeLoader { if (error instanceof FingerprintMismatchError) sawMismatch = true; } } + this._awaitingIntent = !sawMismatch; this._setState(sawMismatch ? 'failed' : 'idle'); this.options.failed?.(lastError, { terminal: sawMismatch }); return false; diff --git a/src/houseplan-card.ts b/src/houseplan-card.ts index 3cf10877..448b17c9 100755 --- a/src/houseplan-card.ts +++ b/src/houseplan-card.ts @@ -10613,10 +10613,10 @@ export class HouseplanCard extends LitElement { || this._markerDialog || this._rulesDialog || this._settingsDialog || this._supportDialog || this._alignDialog || this._backupExportDialog || this._backupImportDialog || this._kioskDialog || this._vacFit || this._vacCalConfirm); - if (onboardingRuntimeRequested && !this._onboardingRuntime) { - void this._ensureOnboardingRuntime(); + if (onboardingRuntimeRequested && !this._onboardingRuntime) { // a render reconciles; it is not an intent (#757) + void this._onboardingRuntimeLoader.ensure('reconcile'); } - if (editorRuntimeRequested && !this._editorRuntime) void this._ensureEditorRuntime(); + if (editorRuntimeRequested && !this._editorRuntime) void this._editorRuntimeLoader.ensure('reconcile'); const model = this._model; const diagnostics = this._renderLife.diagnostics( this.hass, this._markers, (binding) => this._bindingStatus(binding), diff --git a/test/editor-runtime-loader.test.mjs b/test/editor-runtime-loader.test.mjs index 5c98b6ae..6706a579 100644 --- a/test/editor-runtime-loader.test.mjs +++ b/test/editor-runtime-loader.test.mjs @@ -187,6 +187,58 @@ test('network failure re-arms the loader for the next explicit intent (#353 AC1) assert.equal(failures.length, 2); }); +test('a render reconcile never starts another cycle after a network failure (#757 AC3)', async () => { + let cycles = 0; + let online = false; + const failures = []; + const loader = new EditorRuntimeLoader({ + expectedFingerprint: 'same', + load: async (attempt) => { + if (attempt === 0) cycles++; + if (!online) throw new Error('net::ERR_FAILED'); + return { fingerprint: 'same', create: () => 'runtime' }; + }, + install: () => {}, + failed: (error, info) => failures.push(info.terminal), + }); + + assert.equal(await loader.ensure('reconcile'), false, 'a surface waiting for the runtime starts the first cycle'); + assert.equal(cycles, 1); + assert.equal(loader.state, 'idle', 'the failure stays non-terminal'); + for (let render = 0; render < 5; render++) assert.equal(await loader.ensure('reconcile'), false); + online = true; + assert.equal(await loader.ensure('reconcile'), false, 'a render after the network is back is still not an intent'); + assert.equal(cycles, 1, 'no render started a cycle of its own'); + assert.deepEqual(failures, [false], 'one failure, one notice'); + + online = false; + assert.equal(await loader.ensure(), false, 'an explicit intent starts a fresh cycle'); + assert.equal(cycles, 2); + assert.equal(await loader.ensure('reconcile'), false, 'and its failure waits for the next intent again'); + assert.equal(cycles, 2); + + online = true; + const explicit = loader.ensure(); + assert.equal(loader.ensure('reconcile'), explicit, 'a render joins the explicit cycle in flight'); + assert.equal(await explicit, true); + assert.equal(cycles, 3); + assert.equal(await loader.ensure('reconcile'), true, 'ready answers a render too'); +}); + +test('a render reconcile after a terminal failure stays terminal (#757 AC3)', async () => { + let loads = 0; + const loader = new EditorRuntimeLoader({ + expectedFingerprint: 'entry', + load: async () => { loads++; return { fingerprint: 'other', create: () => 'foreign' }; }, + install: () => {}, + }); + assert.equal(await loader.ensure('reconcile'), false); + assert.equal(loader.state, 'failed'); + assert.equal(await loader.ensure('reconcile'), false); + assert.equal(await loader.ensure(), false); + assert.equal(loads, 2, 'one cycle of two attempts, never another import'); +}); + test('fingerprint mismatch on either attempt is terminal (#353 AC2)', async () => { const attempts = []; const failures = [];