diff --git a/demo/smoke_warm_dialogs.mjs b/demo/smoke_warm_dialogs.mjs index 943799db..f8a90c39 100644 --- a/demo/smoke_warm_dialogs.mjs +++ b/demo/smoke_warm_dialogs.mjs @@ -7,8 +7,11 @@ // 2) открытый диалог пережил пересоздание вместе с черновиком; // 3) осознанно закрытый (Esc) — НЕ воскресает; // 4) подтверждение «Выровнять всё» — НЕ воскресает никогда; -// 5) воскрешение одноразовое: третий экземпляр диалога уже не видит. -// ПАДАЕТ на сборке до DEV-B703-03 (вид рецентрировался, диалоги терялись). +// 5) воскрешение одноразовое: третий экземпляр диалога уже не видит; +// 6) (#756, раздел H) то же — когда право записи приходит позже вставки: +// hass после вставки или через такт, не-админ с `can_write` сервера. +// ПАДАЕТ на сборке до DEV-B703-03 (вид рецентрировался, диалоги терялись); +// раздел H — на сборке до #756 (черновик терялся, флаг ожидания висел). import { launch, check, finish } from './serve.mjs'; const { page, browser } = await launch({ width: 820, height: 760 }); @@ -23,11 +26,15 @@ const res = await page.evaluate(async () => { 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 = () => { + /** `order` (#756): `before` — hass до вставки; `after` — сразу после + * (порядок самого демо); `task` — через такт. */ + const mk = (order = 'before', mkHass = window.__mkHass) => { const c = document.createElement('houseplan-card'); c.setConfig({ type: 'custom:houseplan-card' }); // Lovelace: setConfig ДО вставки - c.hass = window.__mkHass(); + if (order === 'before') c.hass = mkHass(); wrap.appendChild(c); + if (order === 'after') c.hass = mkHass(); + if (order === 'task') setTimeout(() => { c.hass = mkHass(); }, 0); return c; }; const settle = async (c) => { @@ -249,6 +256,80 @@ const res = await page.evaluate(async () => { out.gWarmReturnKeepsOnlySpace = c._space === returnSpace && c._mode === 'view' && !c._markerDialog; + // ================= H. право записи приходит позже вставки (#756) ========= + // Новый экземпляр не может сразу вернуть редактор: hass ещё нет, или + // не-админ ждёт `can_write` сервера. Режим ждёт в `_pendingNavMode`, и + // черновик обязан вернуться вместе с ним — бит-в-бит, одноразово. Источник + // каждый раз холодный, а вход в редактор, диалог и ввод — через UI. + const nonAdmin = (configDelay = 0) => () => { + const hass = window.__mkHass(); + const callWS = hass.callWS; + return { + ...hass, user: { id: 'u', name: 'User', is_admin: false }, + callWS: async (m) => { + if (configDelay && m.type === 'houseplan/config/get') await sleep(configDelay); + return callWS(m); + }, + }; + }; + const root = (card) => card.shadowRoot || card.renderRoot; + const markerName = (card) => root(card).querySelector('hp-dialog[data-kind="marker"] #marker-name'); + const devicesDraft = async (name) => { + c.remove(); await sleep(20); + customElements.get('houseplan-card')?._warmBootReset?.(); + c = mk(); await settle(c); + root(c).querySelector('[data-hp="mode-tab"][data-mode="devices"]')?.click(); + // источник сам выходит из мягкой фазы бута: до неё высота сцены ещё не своя + await waitFor(() => c._mode === 'devices' && !c._modeTransitionBusy && !c._bootSoft, 4000); + c._applyView(3.4, 430, 380); c.requestUpdate(); await c.updateComplete; + await waitForStableView(c); + const ref = { zoom: c._zoom, view: rect(c) }; + root(c).querySelector('[data-hp="device"][data-id]')?.click(); + await waitFor(() => !!markerName(c)); + if (markerName(c)) await window.__hpTest.input(markerName(c), name); + await waitForStableView(c); + return ref; + }; + for (const [key, order, mkHass] of [ + ['hAfterInsert', 'after'], ['hNextTask', 'task'], ['hNonAdminCanWrite', 'before', nonAdmin()], + ]) { + const ref = await devicesDraft(`ЧЕРНОВИК-${key}`); + out[`${key}Source`] = c._markerDialog?.name === `ЧЕРНОВИК-${key}` && ref.view[0] > 1 && ref.view[1] > 1; + c.remove(); await sleep(20); + c = mk(order, mkHass); + out[`${key}ViewBitExact`] = await watchView(c, ref.zoom, ref.view, 1500); + await waitFor(() => !!c._markerDialog); + await c.updateComplete; + out[`${key}ModeRestored`] = c._mode === 'devices'; + out[`${key}DraftSurvived`] = c._markerDialog?.name === `ЧЕРНОВИК-${key}`; + out[`${key}DirtyBaselineSurvived`] = dialogSave(c, 'marker')?.disabled === false; + out[`${key}ReviveSettled`] = c._warmRevivePending === false; + if (key !== 'hAfterInsert') continue; + // цепочка: черновик, набранный уже в этом экземпляре, — следующий ре-маунт + // вернёт его, а не черновик предшественника + if (markerName(c)) await window.__hpTest.input(markerName(c), 'ЧЕРНОВИК-ЦЕПОЧКА'); + await sleep(80); + c.remove(); await sleep(20); + c = mk(); + await waitFor(() => c._mode === 'devices' && !!c._markerDialog); + out.hChainCarriesOwnDraft = c._markerDialog?.name === 'ЧЕРНОВИК-ЦЕПОЧКА'; + } + // явная навигация в окне ожидания: другое пространство — черновик съеден, + // не открыт, и флаг ожидания не висит. Сервер отвечает через 400 мс, чтобы + // окно ожидания было, а не гонка. + await devicesDraft('ЧЕРНОВИК-ДРУГОЕ-ПРОСТРАНСТВО'); + const draftSpace = c._space; + c.remove(); await sleep(20); + c = mk('before', nonAdmin(400)); + await c.updateComplete; await sleep(50); + const otherSpace = c._model.find((sp) => sp.id !== draftSpace)?.id; + out.hOtherSpaceFixture = !!otherSpace && c._pendingNavMode === 'devices'; + root(c).querySelector(`[data-hp="space-tab"][data-id="${otherSpace}"]`)?.click(); + await waitFor(() => c._mode === 'devices' && c._space === otherSpace && !c._warmRevivePending, 3000); + await sleep(300); await c.updateComplete; + out.hSpaceSwitchNotRevived = c._space === otherSpace && !c._markerDialog; + out.hSpaceSwitchReviveSettled = c._warmRevivePending === false && c._warmSlot?.dlg === null; + c.remove(); wrap.remove(); return out; }); diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 2bece24a..4214bdfa 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)). +- An editor dialog with unsaved input now survives a card re-creation when + write access is confirmed late + ([#756](https://github.com/Matysh/houseplan-card/issues/756)). ## v1.79.0-beta.1 — 2026-09-30 diff --git a/docs/CHANGELOG.ru.md b/docs/CHANGELOG.ru.md index 75fba41b..915ab85f 100755 --- a/docs/CHANGELOG.ru.md +++ b/docs/CHANGELOG.ru.md @@ -27,6 +27,9 @@ сдвигом плана. Настоящая смена цвета на том же этаже — включился свет, сменилась температура, наведение — по-прежнему идёт плавно ([#742](https://github.com/Matysh/houseplan-card/issues/742)). +- После пересоздания карточки открытый в редакторе диалог с несохранённым + вводом больше не пропадает, если права на запись подтверждаются с задержкой + ([#756](https://github.com/Matysh/houseplan-card/issues/756)). ## v1.79.0-beta.1 — 2026-09-30 diff --git a/docs/WARM-REMOUNT.md b/docs/WARM-REMOUNT.md index f68ed0bb..47b5517d 100644 --- a/docs/WARM-REMOUNT.md +++ b/docs/WARM-REMOUNT.md @@ -152,6 +152,19 @@ backend уточняет `can_write`, старый DOM редактора мож видимого крестика, сначала отменяет этот pending: поздний ответ сервера не имеет права снова открыть уже закрытый редактор (#95). +Отложенный редактор — та же тёплая адопция, только позже (#756). Когда право +записи известно, `_resumePendingNavMode` входит в редактор через `_setMode`, а +`src/warm-mode-adoption.ts` доводит адопцию тем же хвостом, что и немедленная: +черновик из слота воскрешается один раз, refit удерживается до оседания +редакторской панели. Вход через `_setMode` снимает мягкую фазу бута и +подгоняет вид под шапку, измеренную ещё без панели, поэтому камера, которую +окно ожидания не трогало, возвращается бит-в-бит. Камера, уже ушедшая сама +(рефит Просмотра после мягкой фазы, пан, другое пространство), остаётся +обычному рефиту, а черновик проверяется по §3 п. 4. Явная команда в окне +ожидания снимает отложенный редактор вместе с черновиком; режим, который так и +не зафиксировался, черновик съедает. Ни в одном исходе `_warmRevivePending` не +остаётся поднятым. + Диплинк `#space=` — явная навигация и по-прежнему сильнее памятки. Он выбирает пространство, но, как и обычный возврат на карточку, не включает редактор. diff --git a/docs/testing-notes/mutation-browser-guards.md b/docs/testing-notes/mutation-browser-guards.md index ee28e86c..162018f5 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 @@ -274,6 +274,7 @@ The invariant crosses Lit/custom-element lifecycle, browser storage/events, lazy - `view-current-space-aria-removed` - `wall-face-apply-skips-overlap-guard` - `wallthick-hit-narrowed` +- `warm-pending-mode-leaves-revive-waiting` - `writer-history-skips-finished-chain-normalization` - `zigbee-topology-endpoint-cleanup-skipped` - `zigbee-topology-endpoint-elevation-removed` diff --git a/scripts/mutation-registry.mjs b/scripts/mutation-registry.mjs index f08e6119..43f4de0b 100644 --- a/scripts/mutation-registry.mjs +++ b/scripts/mutation-registry.mjs @@ -13522,6 +13522,18 @@ const MUTANT_DEFINITIONS = [ replace: ' // mutant: the visible draft revives without its dirty baseline', }], }, + { + id: 'warm-pending-mode-leaves-revive-waiting', + guard: 'node demo/smoke_warm_dialogs.mjs', + because: '#756 AC1: an editor that waited for hass or the server can_write is entered by ' + + '_resumePendingNavMode; if that branch only commits the mode, the revival keeps waiting ' + + 'forever, the draft never opens and the stuck flag later hands the predecessor draft on', + patches: [{ + file: 'src/warm-mode-adoption.ts', + find: ' commit();\n if (!host._warmRevivePending) return;\n', + replace: ' commit();\n return; // mutant: the pending branch leaves the revival waiting\n', + }], + }, { id: 'marker-discard-without-asking', guard: 'node demo/smoke_device_settings_form.mjs', diff --git a/scripts/smoke-links.mjs b/scripts/smoke-links.mjs index adc0fb35..fe3543ae 100644 --- a/scripts/smoke-links.mjs +++ b/scripts/smoke-links.mjs @@ -602,6 +602,16 @@ export const SMOKE_LINKS = [ + 'отрезка решает, где кладка меняет глубину, а смок проверяет кладку, ' + 'а не толщину, и ни одного имени из резолвера не называет', }, + { + // #756: хвост тёплой адопции редактора. Смоки видят его только как + // воскрешённый черновик и покадрово неизменный `_view` после ре-маунта. + symbols: ['finishWarmModeAdoption', 'resumeWarmMode', '_holdWarmRefit', '_releaseWarmRefit'], + smokes: ['smoke_warm_dialogs.mjs', 'smoke_warm_remount.mjs', 'smoke_nav_persist.mjs'], + because: '#756: the warm editor tail — draft revival once the mode is committed and the ' + + 'refit hold on the adopted camera — is observed only as the revived draft, the ' + + 'frame-by-frame viewport after a remount (immediate or after can_write) and the ' + + 'pending mode entering through _setMode; no smoke names these helpers', + }, ]; /** Смоки, связанные с изменёнными символами через реестр. */ diff --git a/src/houseplan-card.ts b/src/houseplan-card.ts index 3cf10877..e4c2e87a 100755 --- a/src/houseplan-card.ts +++ b/src/houseplan-card.ts @@ -242,6 +242,7 @@ import { type WarmEntry, type WarmViewport, } from './card-runtime'; import { restoreWarmDialogBaseline, warmDialogBaseline } from './editors/dialog-baseline'; +import { finishWarmModeAdoption, resumeWarmMode, type WarmModeHost } from './warm-mode-adoption'; // Chromium records a FAILED module in the page module map permanently — a // retry of the same URL resolves from that map without touching the network. @@ -731,44 +732,37 @@ export class HouseplanCard extends LitElement { this.requestUpdate(); } + /** `adopt`: `true` — a warm remount that may edit at once; `'resume'` — a + * pending warm editor once write access is known (#756, src/warm-mode-adoption.ts). */ public async _requestMode( mode: 'view' | 'plan' | 'devices' | 'decor', animate = true, - adopt = false, + adopt: boolean | 'resume' = false, ): Promise { const request = ++this._editorModeRequest; - if (adopt) { - this._warmModeRequest = request; - if (this._refitRaf) { cancelAnimationFrame(this._refitRaf); this._refitRaf = 0; } - this._pendingRefitSize = null; - } - if (mode !== 'view' && !(await this._ensureEditorRuntime())) { + if (adopt === true) this._holdWarmRefit(request); + const ready = mode === 'view' || await this._ensureEditorRuntime(); + const current = ready && request === this._editorModeRequest && this.isConnected; + if (adopt === 'resume') { + resumeWarmMode(this as unknown as WarmModeHost, mode, () => { if (current) this._setMode(mode, animate); }); + } else if (!current) { if (this._warmModeRequest === request) this._warmModeRequest = 0; - return; - } - if (request !== this._editorModeRequest || !this.isConnected) { - if (this._warmModeRequest === request) this._warmModeRequest = 0; - return; - } - if (adopt) { + } else if (adopt) { this._adoptMode(mode); - if (this._warmRevivePending) { - clearTimeout(this._warmReviveTimer); - this._warmReviveTimer = undefined; - this._warmReviveDialog(); - } - this.requestUpdate(); - void this.updateComplete.then(() => requestAnimationFrame(() => requestAnimationFrame(() => { - if (this._warmModeRequest !== request || request !== this._editorModeRequest) return; - const stage = this._stageEl; - this._lastValidStageSize = stage && stage.clientWidth > 0 && stage.clientHeight > 0 - ? [stage.clientWidth, stage.clientHeight] : null; - this._pendingRefitSize = null; - this._warmModeRequest = 0; - }))); - return; - } - this._setMode(mode, animate); + finishWarmModeAdoption(this as unknown as WarmModeHost, request); + } else this._setMode(mode, animate); + } + /** A warm request owns the stage refit until its restored mode has settled (#756). */ + public _holdWarmRefit(request: number): void { + this._warmModeRequest = request; this._pendingRefitSize = null; + if (this._refitRaf) { cancelAnimationFrame(this._refitRaf); this._refitRaf = 0; } + } + /** Then the settled stage becomes the refit baseline; the camera stays. */ + public _releaseWarmRefit(request: number): void { + if (this._warmModeRequest !== request || request !== this._editorModeRequest) return; + const stage = this._stageEl; this._pendingRefitSize = null; this._warmModeRequest = 0; + this._lastValidStageSize = stage && stage.clientWidth > 0 && stage.clientHeight > 0 + ? [stage.clientWidth, stage.clientHeight] : null; } public hass?: any; public panelHost = false; public layout: string | null = null; public narrow: boolean | null = null; @@ -3471,21 +3465,21 @@ export class HouseplanCard extends LitElement { * dialog must not be stolen. One task later the previous instance has * detached (`freed`) and the snapshot is ours to consume — exactly once. */ - private _warmReviveDialog(): void { + private _warmReviveDialog(settle = false): void { // settle (#756): the editor is not coming — no more waiting const e = this._warmSlot; // AUD-159B1-01: OUR slot, never a neighbour's - this._warmReviveTimer = undefined; + clearTimeout(this._warmReviveTimer); this._warmReviveTimer = undefined; if (!e || !e.dlg) { this._warmRevivePending = false; return; } const d = e.dlg; const freed = e.freed; - if (d.mode !== this._mode && d.mode !== 'view' && this._warmVp?.mode === d.mode + if (!settle && d.mode !== this._mode && d.mode !== 'view' && this._warmVp?.mode === d.mode && !this._editorRuntime) { - // The dialog belongs to the editor viewport that is currently waiting - // for its lazy runtime. Do not consume it against the temporary View; - // `_requestMode(..., adopt=true)` revives it immediately after the mode - // can be committed safely. + // The dialog belongs to the editor viewport that is waiting for its lazy + // runtime or for write access. Do not consume it against the temporary + // View; the warm tail (src/warm-mode-adoption.ts) revives it right after + // the mode is committed — at once or from `_resumePendingNavMode`. this._warmRevivePending = true; return; } @@ -4165,12 +4159,13 @@ export class HouseplanCard extends LitElement { /** Resume only a same-route warm editor intent after permissions arrive. * Always enter through _setMode: it owns transition state, contextual tray * cleanup and navigation persistence. Direct assignment leaves those - * surfaces in mutually inconsistent modes. */ + * surfaces in mutually inconsistent modes. The waiting draft follows (#756). */ private _resumePendingNavMode(): boolean { if (!this._pendingNavMode || !this._canEdit || this._config?.kiosk) return false; const pendingMode = this._pendingNavMode; this._pendingNavMode = null; - this._setMode(pendingMode, false); + if (!this._editorRuntime) void this._requestMode(pendingMode, false, 'resume'); + else resumeWarmMode(this as unknown as WarmModeHost, pendingMode, () => this._setMode(pendingMode, false)); return true; } @@ -7370,7 +7365,7 @@ export class HouseplanCard extends LitElement { // disconnect us before its first measured frame. Commit View atomically so // the warm tombstone never records an editor camera under `mode: view`. if (this._mode !== 'view') this._setMode('view', false); - this._pendingNavMode = null; + this._pendingNavMode = null; this._warmRevivePending = false; // the slot is sealed below this._geometryHistory.clear(); this._activeWallChainId = null; this._wallChainSegmentCms = []; @@ -7441,6 +7436,7 @@ export class HouseplanCard extends LitElement { void this._requestMode(mode, animate); return; } + if (this._pendingNavMode && this._warmRevivePending) this._warmReviveDialog(true); // #95: the draft goes with the editor this._editorRuntime.stairs.beforeModeChange(mode); this._editorRuntime._setMode(mode, animate); this._editorRuntime.stairs.afterModeChange(); this._stairsView.clearGesture(); } diff --git a/src/warm-mode-adoption.ts b/src/warm-mode-adoption.ts new file mode 100644 index 00000000..3e05876a --- /dev/null +++ b/src/warm-mode-adoption.ts @@ -0,0 +1,64 @@ +/** + * The tail of a warm editor restore (docs/WARM-REMOUNT.md §2, §3). + * + * A same-route re-mount brings back the editor in one of two ways: at once, + * when the new instance may already edit (`_requestMode(..., adopt=true)`), + * or later, when the editor waited in `_pendingNavMode` for `hass` or for the + * server's `can_write` (`_resumePendingNavMode`). Both end the same way: the + * draft the dead instance left in the slot is revived exactly once, and the + * adopted camera is not refitted by the editor chrome that lands with the + * mode (#756). + */ +import type { HouseplanMode } from './mode-transition'; + +type ViewRect = { x: number; y: number; w: number; h: number }; + +export interface WarmModeHost { + readonly updateComplete: Promise; + readonly _mode: HouseplanMode; + readonly _warmVp: { view: ViewRect | null } | null; + readonly _warmRevivePending: boolean; + _view: ViewRect | null; + _editorModeRequest: number; + _holdWarmRefit(request: number): void; + _releaseWarmRefit(request: number): void; + _warmReviveDialog(settle?: boolean): void; + _liveVp(): void; + requestUpdate(): void; +} + +/** The mode is committed under a held refit: revive the waiting draft once, + * then release the refit two frames after the restored mode has rendered. */ +export function finishWarmModeAdoption(host: WarmModeHost, request: number): void { + if (host._warmRevivePending) host._warmReviveDialog(); + host.requestUpdate(); + void host.updateComplete.then(() => requestAnimationFrame(() => requestAnimationFrame( + () => host._releaseWarmRefit(request), + ))); +} + +const sameView = (a: ViewRect | null, b: ViewRect | null | undefined): boolean => !!a && !!b + && a.x === b.x && a.y === b.y && a.w === b.w && a.h === b.h; + +/** + * Commit a pending warm mode through `commit` (`_setMode`, the transition + * authority — smoke_nav_persist), then settle the waiting draft revival. + * + * Entering the editor this way ends the passive boot grace and refits the + * camera to the header measured BEFORE the editor chrome rendered. While the + * pending window still shows the adopted memo camera, that camera is put + * back and held exactly as an immediate adoption holds it. A camera that has + * already moved on (a View refit, the user's own pan, another space) is left + * to the ordinary refit. A mode that did not commit eats the draft (§3 p. 4). + */ +export function resumeWarmMode(host: WarmModeHost, mode: HouseplanMode, commit: () => void): void { + const view = host._view; + const kept = sameView(view, host._warmVp?.view); + commit(); + if (!host._warmRevivePending) return; + if (host._mode !== mode || !kept) { host._warmReviveDialog(true); return; } + const request = ++host._editorModeRequest; + host._holdWarmRefit(request); + host._view = view; host._liveVp(); + finishWarmModeAdoption(host, request); +}