From 6d5f78eebf8b6b63257915efa1458539d858b16a Mon Sep 17 00:00:00 2001 From: Paul Bakaus Date: Thu, 3 Sep 2026 23:52:56 -0700 Subject: [PATCH 1/6] Live: the loader now hands off when the resume is the arrival The overlay could sit in its generating shader over a DOM that already held all three variants, and only a page refresh cleared it (#719). The server's generation preflight runs live-wrap with --defer-source-write, so the wrapper and every variant reach the DOM in a single HMR batch. The deferred-wrapper scout is constructed at init and the variant MutationObserver at Go; observer callbacks run in construction order, so on that batch the scout resumes first and resumeSession, not the observer, is the transition into CYCLING. It set the state and the bar but never called hideShaderOverlay(), so the frozen capture of the original stayed painted over the variants. It also reported browser_resumed, which does not count as publication progress, and then disconnected and re-created the observer, dropping the records that observer had already queued for the same batch, so variants_ready never fired at all. resumeSession now finishes the same transition the observer does (shader down, inline edit off, insert session finalized, params panel rebuilt) and reports variants_ready when it already holds every variant. The deferred scout names itself in the journal as browser_resumed_deferred_wrapper, so the two resume paths are no longer indistinguishable. Wrapper resolution goes through findVariantsWrapper, which prefers a wrapper that actually holds non-original variants. A target inside a .map() renders one wrapper per item, and an agent that relocates the wrapper out of the shared primitive live-wrap scaffolded leaves an empty one behind; first match could pin either and strand the session at 0/N. With zero or one match this is the querySelector it replaces. Tests: waitForCycling now asserts the generating shader is gone once the bar cycles, across every runtime fixture (it failed on vite8-react-plain before this change and passes after), marked no-retry so the reload recovery cannot hide it. Source-shape tests pin the transition, the variants_ready report, and the wrapper preference. Co-Authored-By: Claude Code Claude-Session: https://claude.ai/code/session_01Vau2X53xGTjjTCXWMVBoNY --- skill/scripts/live-browser.js | 70 +++++++++++++++++++++++++----- tests/live-browser-source.test.mjs | 62 ++++++++++++++++++++++++-- tests/live-e2e/preactions.mjs | 5 ++- tests/live-e2e/ui.mjs | 30 +++++++++++++ 4 files changed, 151 insertions(+), 16 deletions(-) diff --git a/skill/scripts/live-browser.js b/skill/scripts/live-browser.js index c32101b12..10353bc95 100644 --- a/skill/scripts/live-browser.js +++ b/skill/scripts/live-browser.js @@ -6907,6 +6907,27 @@ // MutationObserver for progressive variant reveal // + // A session id can have more than one wrapper in the DOM: the target may sit + // inside a `.map()` callback (the wrapper renders once per item), or the + // agent may have relocated the wrapper out of the shared primitive live-wrap + // scaffolded into. A plain first match can then pin an empty scaffold while + // the real variants sit in a later wrapper, which strands the session at + // 0/N. Prefer a wrapper that actually holds variants. With zero or one match + // this is exactly the querySelector it replaces. + function findVariantsWrapper(sessionId) { + const selector = sessionId + ? '[data-impeccable-variants="' + sessionId + '"]' + : '[data-impeccable-variants]'; + const matches = document.querySelectorAll(selector); + if (matches.length < 2) return matches[0] || null; + for (const candidate of matches) { + if (candidate.querySelector('[data-impeccable-variant]:not([data-impeccable-variant="original"])')) { + return candidate; + } + } + return matches[0]; + } + function startVariantObserver(sessionId) { let updating = false; // re-entrancy guard @@ -6936,7 +6957,7 @@ } if (!dominated) return; - const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]'); + const wrapper = findVariantsWrapper(sessionId); if (!wrapper) return; const variants = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])'); @@ -9059,7 +9080,7 @@ void main() { } function restoreFromActiveSessions(activeSessions, reason) { - const wrapper = document.querySelector('[data-impeccable-variants]'); + const wrapper = findVariantsWrapper(null); if (wrapper && !isFrameworkComponentPreviewMode(wrapper.dataset.impeccablePreview)) return false; if (svelteComponentSession?.sessionId === currentSessionId) return false; return restoreSessionWithoutWrapper(reason || 'sse_connected', activeSessions); @@ -9400,8 +9421,13 @@ void main() { return restoreSessionWithoutWrapper('browser_resumed_over_handled_wrapper'); } - function resumeSession(recoveryRevision = liveInteractionRevision) { - const wrapper = document.querySelector('[data-impeccable-variants]'); + function resumeSession(recoveryRevision = liveInteractionRevision, opts = {}) { + // Which path resumed matters in the journal: an init resume is a fresh + // page load, the deferred-wrapper scout is a mid-page-load arrival. Both + // used to log the same `browser_resumed`, which made issue #719 take a + // DOM reconstruction to diagnose. + const resumeReason = opts.reason || 'browser_resumed'; + const wrapper = findVariantsWrapper(null); const runtimeWrapper = wrapper || document.querySelector('[data-impeccable-carbonize]'); if (restoreSessionSupersedingHandledWrapper(runtimeWrapper)) return true; if (scheduleHandledRuntimeWrapperReload(runtimeWrapper, recoveryRevision)) return false; @@ -9500,16 +9526,38 @@ void main() { showBar(state === 'CYCLING' ? 'cycling' : 'generating'); startScrollTracking(); - // Build the params panel for the restored visible variant. Previously - // this was missed on page-reload resume: showVariantInDOM above fires - // refreshParamsPanel, but state was still IDLE at that moment so it - // hid. Now that state is CYCLING, re-fire. - if (state === 'CYCLING') refreshParamsPanel(); + // A resume can BE the arrival, not just a re-entry after one. The server's + // generation preflight runs live-wrap with --defer-source-write, so the + // wrapper and every variant reach the DOM in one HMR batch, and the + // deferred-wrapper scout (constructed at init) runs before the variant + // MutationObserver (constructed at Go) on that batch. Finish the same + // transition the observer would have finished. Without hideShaderOverlay + // the generating shader stays frozen over the target and the session looks + // stuck at GENERATING while the bar already cycles (issue #719). + if (state === 'CYCLING') { + recoveryWaitingForAnchor = false; + hideShaderOverlay(); + if (isInsert) finalizeInsertSession(); + disableInlineEdit(); + // Build the params panel for the restored visible variant. Previously + // this was missed on page-reload resume: showVariantInDOM above fires + // refreshParamsPanel, but state was still IDLE at that moment so it + // hid. Now that state is CYCLING, re-fire. + refreshParamsPanel(); + } saveSession(); if (arrivedVariants > 0 && arrivedVariants < expectedVariants) { sendCheckpoint('variants_progress'); } else { - queueCheckpoint('browser_resumed'); + queueCheckpoint(resumeReason); + // Only variants_progress and variants_ready count as publication + // progress. When the resume is the arrival, the observer never gets to + // report it (this function disconnects and re-creates it below, which + // drops the records it had already queued for this same batch), so + // without this the server never learns the variants were published. + if (arrivedVariants > 0 && arrivedVariants >= expectedVariants && expectedVariants > 0) { + sendCheckpoint('variants_ready'); + } } // Start observing for more variants AFTER initial setup @@ -12831,7 +12879,7 @@ void main() { const wrapper = document.querySelector('[data-impeccable-variants],[data-impeccable-carbonize]'); if (!wrapper) return; scout.disconnect(); - if (resumeSession(deferredResumeRevision)) { + if (resumeSession(deferredResumeRevision, { reason: 'browser_resumed_deferred_wrapper' })) { console.log('[impeccable] Resumed deferred session ' + currentSessionId + ' (post-hydration).'); } }); diff --git a/tests/live-browser-source.test.mjs b/tests/live-browser-source.test.mjs index 0617806f8..f2aaa0b46 100644 --- a/tests/live-browser-source.test.mjs +++ b/tests/live-browser-source.test.mjs @@ -470,7 +470,7 @@ describe('live-browser source contracts', () => { assert.match(recovery, /if \(staleWrapper\) location\.reload\(\);/); assert.match( SOURCE, - /function resumeSession\(recoveryRevision = liveInteractionRevision\)[\s\S]{0,250}?\[data-impeccable-carbonize\][\s\S]{0,180}?scheduleHandledRuntimeWrapperReload\(runtimeWrapper, recoveryRevision\)/, + /function resumeSession\(recoveryRevision = liveInteractionRevision, opts = \{\}\)[\s\S]{0,700}?\[data-impeccable-carbonize\][\s\S]{0,180}?scheduleHandledRuntimeWrapperReload\(runtimeWrapper, recoveryRevision\)/, 'resume must inspect handled carbonize wrappers before clearing handled state', ); assert.match( @@ -526,11 +526,67 @@ describe('live-browser source contracts', () => { ); assert.match( SOURCE, - /const deferredResumeRevision = liveInteractionRevision;[\s\S]{0,350}?const scout = new MutationObserver[\s\S]{0,350}?resumeSession\(deferredResumeRevision\)/, - 'the deferred-wrapper scout must retain its originating interaction revision', + /const deferredResumeRevision = liveInteractionRevision;[\s\S]{0,350}?const scout = new MutationObserver[\s\S]{0,400}?resumeSession\(deferredResumeRevision, \{ reason: 'browser_resumed_deferred_wrapper' \}\)/, + 'the deferred-wrapper scout must retain its originating interaction revision and name itself in the journal', ); }); + it('finishes the cycling transition when the resume is the arrival (#719)', () => { + // The server's generation preflight runs live-wrap with + // --defer-source-write, so the wrapper and every variant reach the DOM in + // one HMR batch. The deferred-wrapper scout is constructed at init, the + // variant MutationObserver at Go, and observer callbacks run in + // construction order, so on that batch the scout resumes first and + // resumeSession IS the transition into CYCLING. It has to finish the same + // transition the observer would have: leaving the generating shader up + // paints a frozen capture of the original over a DOM that already holds + // the variants, which is the stuck loader from issue #719. + const resumeStart = SOURCE.indexOf('function resumeSession('); + const resumeEnd = SOURCE.indexOf('\n //', resumeStart); + const resume = SOURCE.slice(resumeStart, resumeEnd); + assert.match( + resume, + /if \(state === 'CYCLING'\) \{[\s\S]{0,200}?hideShaderOverlay\(\);/, + 'a resume into CYCLING must take the generating shader down', + ); + assert.match( + resume, + /if \(state === 'CYCLING'\) \{[\s\S]{0,700}?refreshParamsPanel\(\);/, + 'a resume into CYCLING must still rebuild the params panel', + ); + // Only variants_progress|variants_ready count as publication progress, so + // a resume that already holds every variant has to report one of them or + // the server never learns the generation was published. + assert.match( + resume, + /queueCheckpoint\(resumeReason\);[\s\S]{0,500}?sendCheckpoint\('variants_ready'\)/, + 'a complete resume must report variants_ready, not only browser_resumed', + ); + }); + + it('prefers the wrapper that actually holds variants over the first match (#719)', () => { + // A target inside a `.map()` renders one wrapper per item, and an agent + // that relocates the wrapper out of the shared primitive live-wrap + // scaffolded leaves an empty one behind. First match can then pin a + // scaffold with no variants and strand the session at 0/N. + const start = SOURCE.indexOf('function findVariantsWrapper(sessionId)'); + assert.ok(start > 0, 'findVariantsWrapper must exist'); + const helper = SOURCE.slice(start, SOURCE.indexOf('\n function startVariantObserver(', start)); + assert.match(helper, /if \(matches\.length < 2\) return matches\[0\] \|\| null;/); + assert.match( + helper, + /candidate\.querySelector\('\[data-impeccable-variant\]:not\(\[data-impeccable-variant="original"\]\)'\)[\s\S]{0,80}?return candidate;/, + 'the preferred wrapper is the one holding non-original variants', + ); + assert.match(helper, /return matches\[0\];/, 'with no populated wrapper the old first match still wins'); + for (const caller of [ + 'const wrapper = findVariantsWrapper(sessionId);', + 'const wrapper = findVariantsWrapper(null);', + ]) { + assert.ok(SOURCE.includes(caller), `${caller} should be how the resolvers look a wrapper up`); + } + }); + it('invalidates nullable deferred recovery as soon as a replacement edit starts configuring', () => { assert.match( SOURCE, diff --git a/tests/live-e2e/preactions.mjs b/tests/live-e2e/preactions.mjs index 87ddd76c7..9c7a7af9f 100644 --- a/tests/live-e2e/preactions.mjs +++ b/tests/live-e2e/preactions.mjs @@ -93,7 +93,8 @@ export async function waitForCyclingRobust(page, expectedCount, opts = {}) { try { await waitForCycling(page, expectedCount, { timeout: firstPassTimeoutMs }); return; - } catch { + } catch (preErr) { + if (preErr?.impeccableNoRetry) throw preErr; log(`Cycling not reached in ${firstPassTimeoutMs}ms — retracing preActions`); await runPreActions(page, preActions); } @@ -106,7 +107,7 @@ export async function waitForCyclingRobust(page, expectedCount, opts = {}) { if (process.env.IMPECCABLE_E2E_DEBUG) { firstErr.message += '\n\n--- live UI snapshot ---\n' + JSON.stringify(await liveUiSnapshot(page), null, 2); } - if (agentMode !== 'llm') throw firstErr; + if (agentMode !== 'llm' || firstErr?.impeccableNoRetry) throw firstErr; } log('Cycling not reached after LLM generate — reloading to pick up HMR'); diff --git a/tests/live-e2e/ui.mjs b/tests/live-e2e/ui.mjs index be02bd669..7b2bc259f 100644 --- a/tests/live-e2e/ui.mjs +++ b/tests/live-e2e/ui.mjs @@ -605,6 +605,35 @@ export async function clickGo(page) { throw lastErr || new Error('Go click did not leave configure mode'); } +/** + * The generating shader is the visible loader: a frozen capture of the + * original painted over the target while variants are being written. Every + * transition into CYCLING has to take it down, so a cycling bar with the + * shader still up is the stuck loader from issue #719, not a cosmetic + * detail. The transition is synchronous, so a short window is generous. + */ +async function assertGeneratingShaderCleared(page) { + const stillUp = await page + .waitForFunction( + () => !(window.__impeccableLiveQuery || document.querySelector.bind(document))('#impeccable-live-shader'), + undefined, + { timeout: 5_000 }, + ) + .then(() => false) + .catch(() => true); + if (stillUp) { + const err = new Error( + 'CYCLING reached but the generating shader overlay (#impeccable-live-shader) is still up: ' + + 'the loader never handed off to the variant cycler', + ); + // Cycling was reached, so the recovery paths in waitForCyclingRobust + // (retrace preActions, reload) have nothing to recover and a reload would + // hide the defect by taking the shader down with the page. + err.impeccableNoRetry = true; + throw err; + } +} + /** * Wait for the bar to enter CYCLING state — happens after the agent's * variants land in the DOM via HMR and the MutationObserver counts them. @@ -636,6 +665,7 @@ export async function waitForCycling(page, expectedCount, { timeout = 30_000 } = { barSel: BAR_ID, expected: expectedCount }, { timeout }, ); + await assertGeneratingShaderCleared(page); } catch (err) { if (process.env.IMPECCABLE_E2E_DEBUG) { const snapshot = await page.evaluate((barSel) => { From f7c92d9eb949d8f16462b30e8cfe0a94ac0ffee9 Mon Sep 17 00:00:00 2001 From: Paul Bakaus Date: Fri, 4 Sep 2026 00:26:08 -0700 Subject: [PATCH 2/6] Live: the shader teardown can no longer race its own construction The new cycling assertion caught a real defect on CI: vite8-react-insert reached CYCLING with #impeccable-live-shader still painted over the page. showShaderOverlay is async. It appends its canvas synchronously, then awaits createImageBitmap and finishes the GL setup before it publishes shaderState. hideShaderOverlay returned early on a null shaderState, so a teardown that landed inside that window did nothing, and the construction then published itself over a session that had already left GENERATING, with no teardown left to run. The scroll tick kept repositioning it, which is why the CI page.html shows the canvas sized from the capture rect but styled to the cycling anchor. Every teardown now bumps a shader epoch before it does anything else, and a construction pins the epoch it owns and abandons its canvas (releasing the GL context) at every point past an await and before any publish, including both bitmap-fallback publishes. A teardown also drops a shader node that no shaderState owns, so an already-orphaned canvas cannot survive one. Reproduced by widening the append-to-publish window: with a 400ms delay after uiAppend, vite8-react-insert failed with the CI error and the probe showed the teardown arriving at CYCLING with shaderState still null. The same run passes with this change, as does a 1500ms window on insert and plain. Locally that window is about 4ms, which is why it only showed on a slower runner. The four remaining setLiveState('CYCLING') sites that did not lower the loader now do: the SSE done handler (the one route that can reach CYCLING from GENERATING), the Svelte republish remount, and the two accept failure recoveries. The e2e assertion already waits up to 5s for the shader to clear, so it was never racing a legitimate teardown; it is left as it is. Co-Authored-By: Claude Code Claude-Session: https://claude.ai/code/session_01Vau2X53xGTjjTCXWMVBoNY --- skill/scripts/live-browser.js | 47 ++++++++++++++++++++++- tests/live-browser-source.test.mjs | 61 ++++++++++++++++++++++++++++++ 2 files changed, 107 insertions(+), 1 deletion(-) diff --git a/skill/scripts/live-browser.js b/skill/scripts/live-browser.js index 10353bc95..cbbf3a7ce 100644 --- a/skill/scripts/live-browser.js +++ b/skill/scripts/live-browser.js @@ -5820,6 +5820,7 @@ return; } setLiveState('CYCLING'); + hideShaderOverlay(); showOrUpdateCyclingBar(); saveSession(); completeParameterGenerationIfReady(); @@ -7166,6 +7167,7 @@ if (arrivedVariants >= expectedVariants && expectedVariants > 0) { if (state === 'GENERATING') { setLiveState('CYCLING'); + hideShaderOverlay(); showOrUpdateCyclingBar(); disableInlineEdit(); refreshParamsPanel(); @@ -7226,6 +7228,7 @@ pendingAcceptedSession = null; awaitingAcceptResult = null; setLiveState('CYCLING'); + hideShaderOverlay(); updateBarContent('cycling'); showToast('Could not complete accept cleanup. Try Accept again.', 5000); break; @@ -8328,6 +8331,15 @@ void main() { // matches the original off-white risograph paper. const SHADER_PAPER_FALLBACK = [0.975, 0.965, 0.955]; let shaderState = null; // { canvas, gl, program, texture, rafId, startTime } + // showShaderOverlay is async: it appends its canvas, then awaits + // createImageBitmap and the GL setup before it publishes shaderState. A + // teardown that landed inside that window found shaderState still null, + // returned, and then watched the construction publish itself over a session + // that had already left GENERATING, with no teardown left to run. That is + // the generating loader frozen over a page that already cycles (issue #719). + // Every teardown bumps this epoch; a construction abandons its own canvas as + // soon as it sees the epoch move. + let shaderEpoch = 0; // The element's effective background tone, used as the uniform halftone // ground so content dissolves into dots over it. Unlike resolveCanvasBackground @@ -8474,14 +8486,28 @@ void main() { }); } + /** Drop a shader node no shaderState owns (an abandoned construction). */ + function removeStrayShaderNode() { + const stray = uiGetById(PREFIX + '-shader'); + if (stray) stray.remove(); + } + function hideShaderOverlay() { - if (!shaderState) return; + // Bump first, unconditionally: this is what tells an in-flight + // showShaderOverlay to abandon itself rather than publish over a session + // that has already moved on. + shaderEpoch += 1; + if (!shaderState) { + removeStrayShaderNode(); + return; + } if (shaderState.rafId) cancelAnimationFrame(shaderState.rafId); if (shaderState.canvas) shaderState.canvas.remove(); if (shaderState.objectUrl) URL.revokeObjectURL(shaderState.objectUrl); const lose = shaderState.gl?.getExtension?.('WEBGL_lose_context'); try { lose?.loseContext(); } catch {} shaderState = null; + removeStrayShaderNode(); } function showShaderBitmapFallback(canvas, blob) { @@ -8506,6 +8532,16 @@ void main() { async function showShaderOverlay(el, blob, rect, paper) { hideShaderOverlay(); if (!blob || !el) return; + // hideShaderOverlay just bumped the epoch, so this run owns it until the + // next teardown. Every step past an await re-checks before it publishes. + const epoch = shaderEpoch; + const abandoned = (node, gl) => { + if (epoch === shaderEpoch) return false; + node.remove(); + const lose = gl?.getExtension?.('WEBGL_lose_context'); + try { lose?.loseContext(); } catch {} + return true; + }; const canvas = document.createElement('canvas'); canvas.id = PREFIX + '-shader'; const dpr = Math.min(window.devicePixelRatio || 1, 2); @@ -8528,6 +8564,7 @@ void main() { if (!gl) { // WebGL unavailable: use the captured bitmap as a background overlay so // the user still sees something meaningful during generation. + if (abandoned(canvas, null)) return; showShaderBitmapFallback(canvas, blob); return; } @@ -8567,16 +8604,22 @@ void main() { } // Upload the screenshot as a texture + if (abandoned(canvas, gl)) return; let bitmap; try { bitmap = await createImageBitmap(blob); } catch (err) { console.warn('[impeccable] shader bitmap decode failed:', err); + if (abandoned(canvas, gl)) return; const lose = gl.getExtension?.('WEBGL_lose_context'); try { lose?.loseContext(); } catch {} showShaderBitmapFallback(canvas, blob); return; } + if (abandoned(canvas, gl)) { + if (bitmap.close) bitmap.close(); + return; + } texture = gl.createTexture(); gl.bindTexture(gl.TEXTURE_2D, texture); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); @@ -8595,6 +8638,7 @@ void main() { const paperRgb = paper || resolvePaperRgb(el); const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches; + if (abandoned(canvas, gl)) return; shaderState = { canvas, gl, program, texture, rafId: 0, startTime: performance.now(), reduced }; function frame() { if (!shaderState) return; @@ -8674,6 +8718,7 @@ void main() { .catch(() => { if (pendingAcceptedSession?.id === acceptedSessionId) pendingAcceptedSession = null; setLiveState('CYCLING'); + hideShaderOverlay(); showOrUpdateCyclingBar(); showToast('Could not confirm accept with the live server. Session kept for recovery; try Accept again.', 5000); }); diff --git a/tests/live-browser-source.test.mjs b/tests/live-browser-source.test.mjs index f2aaa0b46..c87db08f6 100644 --- a/tests/live-browser-source.test.mjs +++ b/tests/live-browser-source.test.mjs @@ -564,6 +564,67 @@ describe('live-browser source contracts', () => { ); }); + it('never leaves a shader behind when the teardown races its construction (#719)', () => { + // showShaderOverlay appends its canvas, then awaits createImageBitmap and + // the GL setup before it publishes shaderState. A teardown inside that + // window found shaderState null, returned, and then watched the + // construction publish itself over a session that had already reached + // CYCLING, with no teardown left to run. On a slow runner that is the + // generating loader frozen over a page that already cycles. + const hideStart = SOURCE.indexOf('function hideShaderOverlay()'); + const hide = SOURCE.slice(hideStart, SOURCE.indexOf('\n function ', hideStart + 10)); + assert.match( + hide, + /shaderEpoch \+= 1;[\s\S]{0,120}?if \(!shaderState\) \{/, + 'the epoch must be bumped before the no-state early return, or an in-flight construction never hears about the teardown', + ); + assert.match(hide, /removeStrayShaderNode\(\);/, 'a teardown must also drop a shader node no state owns'); + + const showStart = SOURCE.indexOf('async function showShaderOverlay('); + const show = SOURCE.slice(showStart, SOURCE.indexOf('\n async function handleAccept', showStart)); + assert.match(show, /const epoch = shaderEpoch;/, 'the construction must pin the epoch it owns'); + assert.match( + show, + /const abandoned = \(node, gl\) => \{[\s\S]{0,80}?if \(epoch === shaderEpoch\) return false;[\s\S]{0,200}?return true;/, + 'abandoning must remove the canvas and release the GL context', + ); + assert.match( + show, + /if \(abandoned\(canvas, gl\)\) return;\n shaderState = \{ canvas, gl, program, texture,/, + 'the publish must be guarded by the epoch it pinned', + ); + const awaitIdx = show.indexOf('await createImageBitmap(blob)'); + assert.ok(awaitIdx > 0, 'createImageBitmap is the await this guards'); + assert.ok( + show.indexOf('if (abandoned(canvas, gl))', awaitIdx) > awaitIdx, + 'the bitmap await must be followed by an abandonment check', + ); + for (const call of ['showShaderBitmapFallback(canvas, blob);']) { + let at = show.indexOf(call); + assert.ok(at > 0, call); + while (at > 0) { + const before = show.slice(Math.max(0, at - 220), at); + assert.match(before, /abandoned\(canvas, (?:gl|null)\)/, 'every fallback publish must be epoch guarded'); + at = show.indexOf(call, at + 1); + } + } + }); + + it('lowers the shader on every route that sets CYCLING (#719)', () => { + // resumeSession reaches CYCLING through setLiveState(resumedState), which + // its own block covers; every literal site has to lower the loader too. + const sites = [...SOURCE.matchAll(/setLiveState\('CYCLING'\);/g)].map((m) => m.index); + assert.ok(sites.length >= 8, `expected the known CYCLING sites, saw ${sites.length}`); + for (const at of sites) { + const after = SOURCE.slice(at, at + 260); + assert.match( + after, + /hideShaderOverlay\(\);/, + `a setLiveState('CYCLING') at offset ${at} does not lower the generating shader`, + ); + } + }); + it('prefers the wrapper that actually holds variants over the first match (#719)', () => { // A target inside a `.map()` renders one wrapper per item, and an agent // that relocates the wrapper out of the shared primitive live-wrap From f240348cc55755f059c14e2133a38ac7689fed8a Mon Sep 17 00:00:00 2001 From: Paul Bakaus Date: Fri, 4 Sep 2026 00:31:35 -0700 Subject: [PATCH 3/6] Live: every active-session wrapper lookup goes through the resolver Cursor Bugbot on #720: findVariantsWrapper alone was not enough. resolveBarAnchor, the visible-variant element, mountedParameterCount, readVisibleVariantFromDOM, showVariantInDOM, the source injection, and the whole accept path still took the first [data-impeccable-variants] match, so in the relocated-wrapper case Tune never bound and the bar kept anchoring to the empty scaffold even after the resume reached CYCLING. Thirteen call sites now resolve through findVariantsWrapper. The resolver split in two so a missing id cannot silently widen the lookup to any session: findVariantsWrapper(sessionId) returns null without an id, and findAnyVariantsWrapper() is the entry point for the two resume paths that have no id yet. Both share pickPopulatedVariantsWrapper, which is the old querySelector whenever there are fewer than two matches. Discard cleanup now hides every duplicate wrapper rather than the first, since a target inside a `.map()` renders one per item and hiding one left the rest of the discarded variants on screen. What still takes a raw first match is deliberate: bare existence checks, selector strings for stylesheets and observers (which want to cover every match), querySelectorAll sweeps, the parsed source document, and the Svelte component wrapper, which holds no variant children at all. The source-shape test pins that exact set by name, so a new raw lookup fails until it is either routed through the resolver or justified there. Co-Authored-By: Claude Code Claude-Session: https://claude.ai/code/session_01Vau2X53xGTjjTCXWMVBoNY --- skill/scripts/live-browser.js | 66 +++++++++++++++++---------- tests/live-browser-source.test.mjs | 73 +++++++++++++++++++++++++++--- 2 files changed, 109 insertions(+), 30 deletions(-) diff --git a/skill/scripts/live-browser.js b/skill/scripts/live-browser.js index cbbf3a7ce..dc4e751e2 100644 --- a/skill/scripts/live-browser.js +++ b/skill/scripts/live-browser.js @@ -2060,7 +2060,7 @@ if (anchor) return anchor; } if (currentSessionId && (state === 'GENERATING' || state === 'CYCLING')) { - const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]'); + const wrapper = findVariantsWrapper(currentSessionId); if (wrapper) { const variantCount = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])').length; if (variantCount > 0 && visibleVariant > 0) { @@ -2131,14 +2131,14 @@ function isInsertGeneratingSession() { if (state !== 'GENERATING' || !currentSessionId) return false; - const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]'); + const wrapper = findVariantsWrapper(currentSessionId); return !!wrapper && wrapper.dataset.impeccableMode === 'insert'; } /** Recreate the dotted placeholder if Astro/Vite HMR removed it mid-generation. */ function ensureInsertPlaceholder() { if (!isInsertGeneratingSession()) return placeholderElement; - const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]'); + const wrapper = findVariantsWrapper(currentSessionId); const variantCount = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])').length; if (variantCount > 0) return placeholderElement; if (placeholderElement && document.body.contains(placeholderElement)) return placeholderElement; @@ -3156,7 +3156,7 @@ || svelteComponentSession.wrapperEl || null; } - const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]'); + const wrapper = findVariantsWrapper(currentSessionId); if (!wrapper) return null; return wrapper.querySelector('[data-impeccable-variant="' + visibleVariant + '"]'); } @@ -4900,7 +4900,7 @@ return Object.values(svelteComponentSession.paramsByVariant || {}) .reduce((total, params) => total + (Array.isArray(params) ? params.length : 0), 0); } - const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]'); + const wrapper = findVariantsWrapper(currentSessionId); if (!wrapper) return 0; return [...wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])')] .reduce((total, variant) => total + parseVariantParams(variant).length, 0); @@ -5004,7 +5004,7 @@ scheduleCyclingBarSync(sessionId, num); return true; } - const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]'); + const wrapper = findVariantsWrapper(sessionId); if (!wrapper) return false; updateVariantStateStylesheet(sessionId, num); // Unconditional refresh - covers first-reveal (no-op if state isn't @@ -6362,7 +6362,7 @@ } rememberSessionFileMeta({ file: filePath }); if (isJsxSourceFile(filePath)) { - const liveWrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]'); + const liveWrapper = findVariantsWrapper(sessionId); if (liveWrapper && liveWrapper.querySelector('[data-impeccable-variant]:not([data-impeccable-variant="original"])')) { completeSourceInjection(liveWrapper, sessionId, { ...opts, filePath }); return; @@ -6434,7 +6434,7 @@ return; } - const existingWrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]'); + const existingWrapper = findVariantsWrapper(sessionId); if (existingWrapper) { const wrapper = srcWrapper.cloneNode(true); existingWrapper.parentElement.replaceChild(wrapper, existingWrapper); @@ -6591,7 +6591,7 @@ if (anchor && !anchor.__impeccableFrozenAnchor) selectedElement = anchor; return; } - const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]'); + const wrapper = findVariantsWrapper(currentSessionId); if (!wrapper) return; const visEl = pickVariantContent(wrapper, visibleVariant); if (visEl) selectedElement = visEl; @@ -6601,7 +6601,7 @@ if (svelteComponentSession?.sessionId === sessionId && svelteComponentSession.mountedVariant > 0) { return svelteComponentSession.mountedVariant; } - const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]'); + const wrapper = findVariantsWrapper(sessionId); if (!wrapper) return 0; const variants = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])'); for (const variant of variants) { @@ -6913,12 +6913,16 @@ // agent may have relocated the wrapper out of the shared primitive live-wrap // scaffolded into. A plain first match can then pin an empty scaffold while // the real variants sit in a later wrapper, which strands the session at - // 0/N. Prefer a wrapper that actually holds variants. With zero or one match - // this is exactly the querySelector it replaces. - function findVariantsWrapper(sessionId) { - const selector = sessionId - ? '[data-impeccable-variants="' + sessionId + '"]' - : '[data-impeccable-variants]'; + // 0/N and leaves the bar, the params panel, and accept all reading the + // wrong element. Prefer a wrapper that actually holds variants. With zero + // or one match this is exactly the querySelector it replaces. + // + // Every lookup of the ACTIVE session's wrapper goes through here. The + // remaining raw `[data-impeccable-variants=...]` uses are deliberate: bare + // existence checks, selector strings for stylesheets and observers (which + // want to cover every match), `querySelectorAll` sweeps, and the parsed + // source document, which is not this document. + function pickPopulatedVariantsWrapper(selector) { const matches = document.querySelectorAll(selector); if (matches.length < 2) return matches[0] || null; for (const candidate of matches) { @@ -6929,6 +6933,17 @@ return matches[0]; } + /** The wrapper holding `sessionId`'s variants, or null without an id. */ + function findVariantsWrapper(sessionId) { + if (!sessionId) return null; + return pickPopulatedVariantsWrapper('[data-impeccable-variants="' + sessionId + '"]'); + } + + /** Any live variant wrapper, for the resume paths that have no id yet. */ + function findAnyVariantsWrapper() { + return pickPopulatedVariantsWrapper('[data-impeccable-variants]'); + } + function startVariantObserver(sessionId) { let updating = false; // re-entrancy guard @@ -8675,7 +8690,7 @@ void main() { clientSentAt: Date.now(), }; if (!currentSessionId || arrivedVariants === 0) return; - const acceptWrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]'); + const acceptWrapper = findVariantsWrapper(currentSessionId); if (Object.keys(paramsCurrentValues).length > 0) { acceptPayload.paramValues = { ...paramsCurrentValues }; } @@ -8770,7 +8785,7 @@ void main() { } function snapshotAcceptedVariantDom(sessionId, variantId) { - const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]'); + const wrapper = findVariantsWrapper(sessionId); const accepted = wrapper?.querySelector?.('[data-impeccable-variant="' + variantId + '"]'); const root = accepted?.firstElementChild || null; return { @@ -8897,7 +8912,7 @@ void main() { } function commitAcceptedVariantToDom(sessionId, variantId) { - const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]'); + const wrapper = findVariantsWrapper(sessionId); if (!wrapper) return false; const accepted = wrapper.querySelector('[data-impeccable-variant="' + variantId + '"]'); if (!accepted || !accepted.firstElementChild) return false; @@ -9125,7 +9140,7 @@ void main() { } function restoreFromActiveSessions(activeSessions, reason) { - const wrapper = findVariantsWrapper(null); + const wrapper = findAnyVariantsWrapper(); if (wrapper && !isFrameworkComponentPreviewMode(wrapper.dataset.impeccablePreview)) return false; if (svelteComponentSession?.sessionId === currentSessionId) return false; return restoreSessionWithoutWrapper(reason || 'sse_connected', activeSessions); @@ -9238,10 +9253,13 @@ void main() { // reconciler later tries to remove a wrapper we already removed. // Schedule a 2s fallback that does the manual swap only if HMR hasn't // replaced the wrapper by then (keeps static-server / no-HMR flows alive). - const wrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]'); - if (wrapper) { + // Every match, not the first: a target inside a `.map()` renders one + // wrapper per item, and hiding only one leaves the rest of the + // discarded variants on screen. + const discardWrappers = [...document.querySelectorAll('[data-impeccable-variants="' + cleanupSessionId + '"]')]; + if (discardWrappers.length > 0) { if (restoreOriginal) showOriginalDuringDiscard(cleanupSessionId); - else wrapper.style.display = 'none'; + else for (const discardWrapper of discardWrappers) discardWrapper.style.display = 'none'; } setTimeout(function() { const recoverySuperseded = deferredRecoverySuperseded(cleanupSessionId, cleanupRevision); @@ -9472,7 +9490,7 @@ void main() { // used to log the same `browser_resumed`, which made issue #719 take a // DOM reconstruction to diagnose. const resumeReason = opts.reason || 'browser_resumed'; - const wrapper = findVariantsWrapper(null); + const wrapper = findAnyVariantsWrapper(); const runtimeWrapper = wrapper || document.querySelector('[data-impeccable-carbonize]'); if (restoreSessionSupersedingHandledWrapper(runtimeWrapper)) return true; if (scheduleHandledRuntimeWrapperReload(runtimeWrapper, recoveryRevision)) return false; diff --git a/tests/live-browser-source.test.mjs b/tests/live-browser-source.test.mjs index c87db08f6..14feecafd 100644 --- a/tests/live-browser-source.test.mjs +++ b/tests/live-browser-source.test.mjs @@ -630,8 +630,8 @@ describe('live-browser source contracts', () => { // that relocates the wrapper out of the shared primitive live-wrap // scaffolded leaves an empty one behind. First match can then pin a // scaffold with no variants and strand the session at 0/N. - const start = SOURCE.indexOf('function findVariantsWrapper(sessionId)'); - assert.ok(start > 0, 'findVariantsWrapper must exist'); + const start = SOURCE.indexOf('function pickPopulatedVariantsWrapper(selector)'); + assert.ok(start > 0, 'pickPopulatedVariantsWrapper must exist'); const helper = SOURCE.slice(start, SOURCE.indexOf('\n function startVariantObserver(', start)); assert.match(helper, /if \(matches\.length < 2\) return matches\[0\] \|\| null;/); assert.match( @@ -640,12 +640,73 @@ describe('live-browser source contracts', () => { 'the preferred wrapper is the one holding non-original variants', ); assert.match(helper, /return matches\[0\];/, 'with no populated wrapper the old first match still wins'); - for (const caller of [ - 'const wrapper = findVariantsWrapper(sessionId);', - 'const wrapper = findVariantsWrapper(null);', + assert.match( + helper, + /function findVariantsWrapper\(sessionId\) \{\n if \(!sessionId\) return null;/, + 'a missing id must not silently widen the lookup to any session', + ); + assert.match( + helper, + /function findAnyVariantsWrapper\(\) \{[\s\S]{0,120}?'\[data-impeccable-variants\]'/, + 'the resume paths that have no id yet need their own entry point', + ); + }); + + it('routes every active-session wrapper lookup through the resolver (#719)', () => { + // Bugbot on #720: findVariantsWrapper alone is not enough while the bar + // anchor, the visible-variant element, the params count, and accept still + // take the first match, because in the relocated-wrapper case Tune never + // binds and the bar keeps anchoring to the empty scaffold. + for (const fn of [ + 'function resolveBarAnchor()', + 'function isInsertGeneratingSession()', + 'function ensureInsertPlaceholder()', + 'function mountedParameterCount()', + 'function readVisibleVariantFromDOM(sessionId)', + 'function snapshotAcceptedVariantDom(sessionId, variantId)', + 'function commitAcceptedVariantToDom(sessionId, variantId)', ]) { - assert.ok(SOURCE.includes(caller), `${caller} should be how the resolvers look a wrapper up`); + const at = SOURCE.indexOf(fn); + assert.ok(at > 0, `${fn} should exist`); + const body = SOURCE.slice(at, SOURCE.indexOf('\n }', at)); + assert.doesNotMatch( + body, + /document\.querySelector\('\[data-impeccable-variants="'/, + `${fn} must resolve the session wrapper through findVariantsWrapper`, + ); } + + // Anything still taking a raw first match is a deliberate existence check + // or a cleanup sweep. Pinning the exact set means a new raw lookup has to + // justify itself here rather than quietly reintroducing the bug. + const rawSites = [...SOURCE.matchAll( + /(?:const (\w+) = (?:!!)?|(if) \()[^\n]{0,40}?document\.querySelector\('\[data-impeccable-variants="' \+ [\w.?]+ \+ '"\]'\)/g, + )].map((m) => m[1] || m[2]); + assert.deepEqual( + [...rawSites].sort(), + [ + // existence only, inside the variant-anchor retry observer + 'wrapperLanded', + // svelte component republish: an identity check next to + // svelteComponentSession, and a component wrapper holds no variants + 'existingWrapper', + // orphan removal in abortSvelteComponentInjection + 'orphan', + // orphan removal in resetSvelteComponentSession + 'orphan', + // pendingAcceptedSession existence guard + 'if', + // discard cleanup, both bounded retries + 'lateWrapper', + 'staleWrapper', + ].sort(), + 'a new raw [data-impeccable-variants=...] first-match lookup appeared; route it through findVariantsWrapper, or add it here with the reason it may take the first match', + ); + assert.equal( + rawSites.length, + SOURCE.split(`document.querySelector('[data-impeccable-variants="'`).length - 1, + 'every raw session-wrapper lookup must be shaped so this guard can see it', + ); }); it('invalidates nullable deferred recovery as soon as a replacement edit starts configuring', () => { From 524fb8c9500e16a00fa2d55b5ee2d8ca787e2b36 Mon Sep 17 00:00:00 2001 From: Paul Bakaus Date: Fri, 4 Sep 2026 00:50:54 -0700 Subject: [PATCH 4/6] Live: a discard releases every wrapper it hid Bugbot on #720: the non-restoreOriginal discard now hides every matching wrapper, but the delayed fallback still released only the first querySelector hit. A target inside a `.map()` renders one wrapper per item, so the rest stayed at display:none and their original content never came back on the static and missed-HMR flows that fallback exists for. The hide, the existence checks, and the release now all speak about the same set. discardedWrappers(sessionId) is the one place that collects it; releaseDiscardedStaticWrappers takes the stylesheet down once and releases each wrapper; releaseDiscardedStaticWrapper drops its sessionId argument and just unwinds the node it is given. The HMR-ownership decision still reads the first wrapper, which is fair: duplicates all render from one source element, so ownership is uniform across them. The reload branch is unchanged because a reload restores every original at once. Covered by a source-shape test rather than an e2e scenario: hasFrameworkHmrOwnership is true for every React, Vue, and Svelte runtime fixture, so all of them take the watcher path and none can reach the static release. The existing framework-ownership guards in the same file move to the new shape and keep their intent, including the one that says only non-discard cleanup may blank the wrapper while waiting for HMR. Co-Authored-By: Claude Code Claude-Session: https://claude.ai/code/session_01Vau2X53xGTjjTCXWMVBoNY --- skill/scripts/live-browser.js | 46 +++++++++++++++----- tests/live-browser-source.test.mjs | 67 ++++++++++++++++++++++++++---- 2 files changed, 95 insertions(+), 18 deletions(-) diff --git a/skill/scripts/live-browser.js b/skill/scripts/live-browser.js index dc4e751e2..277c60e2c 100644 --- a/skill/scripts/live-browser.js +++ b/skill/scripts/live-browser.js @@ -6716,8 +6716,17 @@ document.getElementById(discardStateStyleId(sessionId))?.remove(); } - function releaseDiscardedStaticWrapper(wrapper, sessionId) { - removeDiscardStateStylesheet(sessionId); + /** + * Every wrapper a discard has to unwind. A target inside a `.map()` renders + * one wrapper per item, so the hide, the release, and the existence checks + * all have to speak about the same set. + */ + function discardedWrappers(sessionId) { + if (!sessionId) return []; + return [...document.querySelectorAll('[data-impeccable-variants="' + sessionId + '"]')]; + } + + function releaseDiscardedStaticWrapper(wrapper) { if (!wrapper) return; const orig = wrapper.querySelector('[data-impeccable-variant="original"]'); const content = orig?.firstElementChild; @@ -6728,6 +6737,18 @@ wrapper.remove(); } + /** + * Undo the discard hide on every wrapper it covered. Releasing only the + * first match left the other mapped items sitting at display:none with + * their original content never restored, on exactly the static and + * missed-HMR flows this fallback exists for. + */ + function releaseDiscardedStaticWrappers(sessionId, wrappers) { + removeDiscardStateStylesheet(sessionId); + const set = wrappers && wrappers.length ? wrappers : discardedWrappers(sessionId); + for (const wrapper of set) releaseDiscardedStaticWrapper(wrapper); + } + function watchForDiscardedFrameworkWrapperRemoval(sessionId) { if (!sessionId || !document.body) return; if (discardedFrameworkWrapperWatchers.has(sessionId)) return; @@ -9256,7 +9277,7 @@ void main() { // Every match, not the first: a target inside a `.map()` renders one // wrapper per item, and hiding only one leaves the rest of the // discarded variants on screen. - const discardWrappers = [...document.querySelectorAll('[data-impeccable-variants="' + cleanupSessionId + '"]')]; + const discardWrappers = discardedWrappers(cleanupSessionId); if (discardWrappers.length > 0) { if (restoreOriginal) showOriginalDuringDiscard(cleanupSessionId); else for (const discardWrapper of discardWrappers) discardWrapper.style.display = 'none'; @@ -9267,16 +9288,19 @@ void main() { removeDiscardStateStylesheet(); return; } - const lateWrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]'); - if (!lateWrapper) { + const lateWrappers = discardedWrappers(cleanupSessionId); + if (lateWrappers.length === 0) { removeDiscardStateStylesheet(cleanupSessionId); return; } + // Duplicates all render from one source element, so HMR ownership is + // uniform across them; the first is a fair witness for the set. + const lateWrapper = lateWrappers[0]; if (recoverySuperseded) { if (hasFrameworkHmrOwnership(lateWrapper)) { watchForDiscardedFrameworkWrapperRemoval(cleanupSessionId); } else { - releaseDiscardedStaticWrapper(lateWrapper, cleanupSessionId); + releaseDiscardedStaticWrappers(cleanupSessionId, lateWrappers); } return; } @@ -9285,18 +9309,20 @@ void main() { // the final source rewrite, reload once after a grace window so the // discarded source becomes authoritative without a reconciler race. setTimeout(function() { - const staleWrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]'); + const staleWrappers = discardedWrappers(cleanupSessionId); if (deferredRecoverySuperseded(cleanupSessionId, cleanupRevision)) { - if (!staleWrapper) removeDiscardStateStylesheet(cleanupSessionId); + if (staleWrappers.length === 0) removeDiscardStateStylesheet(cleanupSessionId); else watchForDiscardedFrameworkWrapperRemoval(cleanupSessionId); return; } removeDiscardStateStylesheet(cleanupSessionId); - if (staleWrapper) location.reload(); + // A reload restores every wrapper's original at once, so there is + // nothing per-wrapper to do here. + if (staleWrappers.length > 0) location.reload(); }, 2000); return; } - releaseDiscardedStaticWrapper(lateWrapper, cleanupSessionId); + releaseDiscardedStaticWrappers(cleanupSessionId, lateWrappers); }, 2000); } hideBar(instantChrome); diff --git a/tests/live-browser-source.test.mjs b/tests/live-browser-source.test.mjs index 14feecafd..1148a5542 100644 --- a/tests/live-browser-source.test.mjs +++ b/tests/live-browser-source.test.mjs @@ -406,22 +406,22 @@ describe('live-browser source contracts', () => { ); assert.match( SOURCE, - /if \(hasFrameworkHmrOwnership\(lateWrapper\)\) \{[\s\S]{0,900}?location\.reload\(\);[\s\S]{0,100}?return;[\s\S]{0,150}?releaseDiscardedStaticWrapper\(lateWrapper, cleanupSessionId\)/, + /if \(hasFrameworkHmrOwnership\(lateWrapper\)\) \{[\s\S]{0,1100}?location\.reload\(\);[\s\S]{0,100}?return;[\s\S]{0,150}?releaseDiscardedStaticWrappers\(cleanupSessionId, lateWrappers\)/, 'discard cleanup must use a reload grace fallback before replacing a framework-owned wrapper', ); assert.match( SOURCE, - /function releaseDiscardedStaticWrapper\(wrapper, sessionId\)[\s\S]{0,400}?replaceChild\(content, wrapper\)/, + /function releaseDiscardedStaticWrapper\(wrapper\)[\s\S]{0,400}?replaceChild\(content, wrapper\)/, 'only the static-wrapper release helper may structurally restore discarded DOM', ); assert.match( SOURCE, - /if \(hasFrameworkHmrOwnership\(lateWrapper\)\) \{[\s\S]{0,700}?removeDiscardStateStylesheet\(cleanupSessionId\);[\s\S]{0,120}?location\.reload\(\);/, + /if \(hasFrameworkHmrOwnership\(lateWrapper\)\) \{[\s\S]{0,900}?removeDiscardStateStylesheet\(cleanupSessionId\);[\s\S]{0,250}?location\.reload\(\);/, 'discard must keep its original-visibility stylesheet until the HMR grace window ends', ); assert.match( SOURCE, - /const recoverySuperseded = deferredRecoverySuperseded\(cleanupSessionId, cleanupRevision\);[\s\S]{0,500}?if \(recoverySuperseded\) \{[\s\S]{0,250}?watchForDiscardedFrameworkWrapperRemoval\(cleanupSessionId\)[\s\S]{0,150}?releaseDiscardedStaticWrapper\(lateWrapper, cleanupSessionId\)[\s\S]{0,80}?return;/, + /const recoverySuperseded = deferredRecoverySuperseded\(cleanupSessionId, cleanupRevision\);[\s\S]{0,700}?if \(recoverySuperseded\) \{[\s\S]{0,250}?watchForDiscardedFrameworkWrapperRemoval\(cleanupSessionId\)[\s\S]{0,150}?releaseDiscardedStaticWrappers\(cleanupSessionId, lateWrappers\)[\s\S]{0,80}?return;/, 'discard cleanup and its reload grace callback must yield to a newer Live session', ); assert.match( @@ -436,7 +436,7 @@ describe('live-browser source contracts', () => { ); assert.match( SOURCE, - /setTimeout\(function\(\) \{[\s\S]{0,300}?const staleWrapper = document\.querySelector[\s\S]{0,250}?deferredRecoverySuperseded\(cleanupSessionId, cleanupRevision\)[\s\S]{0,250}?watchForDiscardedFrameworkWrapperRemoval\(cleanupSessionId\)[\s\S]{0,100}?return;[\s\S]{0,100}?removeDiscardStateStylesheet\(cleanupSessionId\);[\s\S]{0,100}?location\.reload\(\);/, + /setTimeout\(function\(\) \{[\s\S]{0,300}?const staleWrappers = discardedWrappers\(cleanupSessionId\);[\s\S]{0,250}?deferredRecoverySuperseded\(cleanupSessionId, cleanupRevision\)[\s\S]{0,250}?watchForDiscardedFrameworkWrapperRemoval\(cleanupSessionId\)[\s\S]{0,100}?return;[\s\S]{0,100}?removeDiscardStateStylesheet\(cleanupSessionId\);[\s\S]{0,250}?location\.reload\(\);/, 'framework discard recovery may observe safe HMR cleanup but must not reload replacement work', ); assert.match( @@ -564,6 +564,60 @@ describe('live-browser source contracts', () => { ); }); + it('unwinds every wrapper a discard hid, not just the first (#719)', () => { + // Bugbot on #720: the non-restoreOriginal discard hides every matching + // wrapper, so the delayed fallback has to release the same set. Releasing + // the first match left the other mapped items at display:none with their + // original content never restored, on exactly the static and missed-HMR + // flows the fallback exists for. The e2e fixtures cannot cover this: + // hasFrameworkHmrOwnership is true for every React, Vue, and Svelte + // fixture, so they all take the watcher path instead. + const cleanupAt = SOURCE.indexOf('function cleanup(options)'); + assert.ok(cleanupAt > 0, 'cleanup must exist'); + const cleanup = SOURCE.slice(cleanupAt, SOURCE.indexOf('\n //', cleanupAt)); + + assert.match( + cleanup, + /const discardWrappers = discardedWrappers\(cleanupSessionId\);[\s\S]{0,260}?for \(const discardWrapper of discardWrappers\) discardWrapper\.style\.display = 'none';/, + 'the hide must cover every wrapper for the session', + ); + assert.match( + cleanup, + /const lateWrappers = discardedWrappers\(cleanupSessionId\);[\s\S]{0,120}?if \(lateWrappers\.length === 0\)/, + 'the fallback must look at the same set the hide covered', + ); + assert.doesNotMatch( + cleanup, + /releaseDiscardedStaticWrapper\(/, + 'cleanup must go through the plural release so every hidden wrapper is unwound', + ); + for (const call of [...cleanup.matchAll(/releaseDiscardedStaticWrappers\([^)]*\)/g)].map((m) => m[0])) { + assert.match(call, /lateWrappers/, `${call} must release the captured set`); + } + assert.ok( + [...cleanup.matchAll(/releaseDiscardedStaticWrappers\(/g)].length === 2, + 'both the superseded and the plain static branch must release', + ); + + const pluralAt = SOURCE.indexOf('function releaseDiscardedStaticWrappers(sessionId, wrappers)'); + assert.ok(pluralAt > 0, 'releaseDiscardedStaticWrappers must exist'); + const plural = SOURCE.slice(pluralAt, SOURCE.indexOf('\n }', pluralAt)); + assert.match(plural, /removeDiscardStateStylesheet\(sessionId\);/, 'the stylesheet comes down once'); + assert.match( + plural, + /for \(const wrapper of set\) releaseDiscardedStaticWrapper\(wrapper\);/, + 'every wrapper in the set is released', + ); + + // Intent of main's original guard, kept: discard must not blank the + // original, and must not animate stale chrome while waiting for HMR. + assert.match( + cleanup, + /if \(restoreOriginal\) showOriginalDuringDiscard\(cleanupSessionId\);/, + 'only non-discard cleanup may blank the wrapper while waiting for HMR', + ); + }); + it('never leaves a shader behind when the teardown races its construction (#719)', () => { // showShaderOverlay appends its canvas, then awaits createImageBitmap and // the GL setup before it publishes shaderState. A teardown inside that @@ -696,9 +750,6 @@ describe('live-browser source contracts', () => { 'orphan', // pendingAcceptedSession existence guard 'if', - // discard cleanup, both bounded retries - 'lateWrapper', - 'staleWrapper', ].sort(), 'a new raw [data-impeccable-variants=...] first-match lookup appeared; route it through findVariantsWrapper, or add it here with the reason it may take the first match', ); From 3b0f46798ad34eef55570c19ed22b5817189f27f Mon Sep 17 00:00:00 2001 From: Paul Bakaus Date: Fri, 4 Sep 2026 00:52:16 -0700 Subject: [PATCH 5/6] Tests: pin the all-wrappers discard shape in the regression guards "discards variants without hiding the original or animating stale chrome" asserted the literal `else wrapper.style.display = 'none'`, which the all-wrappers discard replaced. The guard keeps its intent and its message, now over the loop, and gains the other half of the same invariant: a target inside a `.map()` renders one wrapper per item, so the blanking and the release that undoes it have to cover the same set, and releasing only the first match leaves the extra items blanked with their original never restored. This file lives on main only, so it was not updated when the shape changed on the fix branch. The rest of it passes as is: the shader fallback guard still matches through the new epoch check, and the CYCLING, resumedState, and variants_ready guards are untouched by these commits. Co-Authored-By: Claude Code Claude-Session: https://claude.ai/code/session_01Vau2X53xGTjjTCXWMVBoNY --- tests/live-browser-regression.test.mjs | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/tests/live-browser-regression.test.mjs b/tests/live-browser-regression.test.mjs index ae583eb32..355447dbf 100644 --- a/tests/live-browser-regression.test.mjs +++ b/tests/live-browser-regression.test.mjs @@ -154,9 +154,27 @@ describe('live-browser.js regression guards', () => { assert.match(SOURCE, /if \(instant\) barEl\.style\.display = 'none'/); assert.match( SOURCE, - /if \(restoreOriginal\) showOriginalDuringDiscard\(cleanupSessionId\);\s*else wrapper\.style\.display = 'none';/, + /if \(restoreOriginal\) showOriginalDuringDiscard\(cleanupSessionId\);\s*else for \(const discardWrapper of discardWrappers\) discardWrapper\.style\.display = 'none';/, 'only non-discard cleanup may blank the wrapper while waiting for HMR', ); + // Same intent, one wrapper or many: a target inside a `.map()` renders one + // wrapper per item, so the blanking and the release that undoes it have to + // cover the same set or the extra items never get their original back. + assert.match( + SOURCE, + /const discardWrappers = discardedWrappers\(cleanupSessionId\);/, + 'the discard blanking must collect every wrapper for the session', + ); + assert.match( + SOURCE, + /function releaseDiscardedStaticWrappers\(sessionId, wrappers\)[\s\S]{0,400}?for \(const wrapper of set\) releaseDiscardedStaticWrapper\(wrapper\);/, + 'the delayed release must unwind every wrapper the blanking covered', + ); + assert.doesNotMatch( + SOURCE, + /releaseDiscardedStaticWrapper\(lateWrapper/, + 'releasing only the first match leaves the other mapped items blanked forever', + ); }); it('stores live state off the document root and preserves the selected anchor top', () => { From 695df68a5860da4d25cd629fc3727ec8f3c0991b Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 4 Sep 2026 09:31:58 +0000 Subject: [PATCH 6/6] Sync generated provider output --- .../skills/impeccable/scripts/live-browser.js | 211 +++++++++++++++--- .../skills/impeccable/scripts/live-browser.js | 211 +++++++++++++++--- .../skills/impeccable/scripts/live-browser.js | 211 +++++++++++++++--- .../skills/impeccable/scripts/live-browser.js | 211 +++++++++++++++--- .../skills/impeccable/scripts/live-browser.js | 211 +++++++++++++++--- .../skills/impeccable/scripts/live-browser.js | 211 +++++++++++++++--- .../skills/impeccable/scripts/live-browser.js | 211 +++++++++++++++--- .../skills/impeccable/scripts/live-browser.js | 211 +++++++++++++++--- .../skills/impeccable/scripts/live-browser.js | 211 +++++++++++++++--- .pi/skills/impeccable/scripts/live-browser.js | 211 +++++++++++++++--- .../skills/impeccable/scripts/live-browser.js | 211 +++++++++++++++--- .../skills/impeccable/scripts/live-browser.js | 211 +++++++++++++++--- .../skills/impeccable/scripts/live-browser.js | 211 +++++++++++++++--- .../skills/impeccable/scripts/live-browser.js | 211 +++++++++++++++--- .../skills/impeccable/scripts/live-browser.js | 211 +++++++++++++++--- .../skills/impeccable/scripts/live-browser.js | 211 +++++++++++++++--- 16 files changed, 2784 insertions(+), 592 deletions(-) diff --git a/.agents/skills/impeccable/scripts/live-browser.js b/.agents/skills/impeccable/scripts/live-browser.js index c32101b12..277c60e2c 100644 --- a/.agents/skills/impeccable/scripts/live-browser.js +++ b/.agents/skills/impeccable/scripts/live-browser.js @@ -2060,7 +2060,7 @@ if (anchor) return anchor; } if (currentSessionId && (state === 'GENERATING' || state === 'CYCLING')) { - const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]'); + const wrapper = findVariantsWrapper(currentSessionId); if (wrapper) { const variantCount = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])').length; if (variantCount > 0 && visibleVariant > 0) { @@ -2131,14 +2131,14 @@ function isInsertGeneratingSession() { if (state !== 'GENERATING' || !currentSessionId) return false; - const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]'); + const wrapper = findVariantsWrapper(currentSessionId); return !!wrapper && wrapper.dataset.impeccableMode === 'insert'; } /** Recreate the dotted placeholder if Astro/Vite HMR removed it mid-generation. */ function ensureInsertPlaceholder() { if (!isInsertGeneratingSession()) return placeholderElement; - const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]'); + const wrapper = findVariantsWrapper(currentSessionId); const variantCount = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])').length; if (variantCount > 0) return placeholderElement; if (placeholderElement && document.body.contains(placeholderElement)) return placeholderElement; @@ -3156,7 +3156,7 @@ || svelteComponentSession.wrapperEl || null; } - const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]'); + const wrapper = findVariantsWrapper(currentSessionId); if (!wrapper) return null; return wrapper.querySelector('[data-impeccable-variant="' + visibleVariant + '"]'); } @@ -4900,7 +4900,7 @@ return Object.values(svelteComponentSession.paramsByVariant || {}) .reduce((total, params) => total + (Array.isArray(params) ? params.length : 0), 0); } - const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]'); + const wrapper = findVariantsWrapper(currentSessionId); if (!wrapper) return 0; return [...wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])')] .reduce((total, variant) => total + parseVariantParams(variant).length, 0); @@ -5004,7 +5004,7 @@ scheduleCyclingBarSync(sessionId, num); return true; } - const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]'); + const wrapper = findVariantsWrapper(sessionId); if (!wrapper) return false; updateVariantStateStylesheet(sessionId, num); // Unconditional refresh - covers first-reveal (no-op if state isn't @@ -5820,6 +5820,7 @@ return; } setLiveState('CYCLING'); + hideShaderOverlay(); showOrUpdateCyclingBar(); saveSession(); completeParameterGenerationIfReady(); @@ -6361,7 +6362,7 @@ } rememberSessionFileMeta({ file: filePath }); if (isJsxSourceFile(filePath)) { - const liveWrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]'); + const liveWrapper = findVariantsWrapper(sessionId); if (liveWrapper && liveWrapper.querySelector('[data-impeccable-variant]:not([data-impeccable-variant="original"])')) { completeSourceInjection(liveWrapper, sessionId, { ...opts, filePath }); return; @@ -6433,7 +6434,7 @@ return; } - const existingWrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]'); + const existingWrapper = findVariantsWrapper(sessionId); if (existingWrapper) { const wrapper = srcWrapper.cloneNode(true); existingWrapper.parentElement.replaceChild(wrapper, existingWrapper); @@ -6590,7 +6591,7 @@ if (anchor && !anchor.__impeccableFrozenAnchor) selectedElement = anchor; return; } - const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]'); + const wrapper = findVariantsWrapper(currentSessionId); if (!wrapper) return; const visEl = pickVariantContent(wrapper, visibleVariant); if (visEl) selectedElement = visEl; @@ -6600,7 +6601,7 @@ if (svelteComponentSession?.sessionId === sessionId && svelteComponentSession.mountedVariant > 0) { return svelteComponentSession.mountedVariant; } - const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]'); + const wrapper = findVariantsWrapper(sessionId); if (!wrapper) return 0; const variants = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])'); for (const variant of variants) { @@ -6715,8 +6716,17 @@ document.getElementById(discardStateStyleId(sessionId))?.remove(); } - function releaseDiscardedStaticWrapper(wrapper, sessionId) { - removeDiscardStateStylesheet(sessionId); + /** + * Every wrapper a discard has to unwind. A target inside a `.map()` renders + * one wrapper per item, so the hide, the release, and the existence checks + * all have to speak about the same set. + */ + function discardedWrappers(sessionId) { + if (!sessionId) return []; + return [...document.querySelectorAll('[data-impeccable-variants="' + sessionId + '"]')]; + } + + function releaseDiscardedStaticWrapper(wrapper) { if (!wrapper) return; const orig = wrapper.querySelector('[data-impeccable-variant="original"]'); const content = orig?.firstElementChild; @@ -6727,6 +6737,18 @@ wrapper.remove(); } + /** + * Undo the discard hide on every wrapper it covered. Releasing only the + * first match left the other mapped items sitting at display:none with + * their original content never restored, on exactly the static and + * missed-HMR flows this fallback exists for. + */ + function releaseDiscardedStaticWrappers(sessionId, wrappers) { + removeDiscardStateStylesheet(sessionId); + const set = wrappers && wrappers.length ? wrappers : discardedWrappers(sessionId); + for (const wrapper of set) releaseDiscardedStaticWrapper(wrapper); + } + function watchForDiscardedFrameworkWrapperRemoval(sessionId) { if (!sessionId || !document.body) return; if (discardedFrameworkWrapperWatchers.has(sessionId)) return; @@ -6907,6 +6929,42 @@ // MutationObserver for progressive variant reveal // + // A session id can have more than one wrapper in the DOM: the target may sit + // inside a `.map()` callback (the wrapper renders once per item), or the + // agent may have relocated the wrapper out of the shared primitive live-wrap + // scaffolded into. A plain first match can then pin an empty scaffold while + // the real variants sit in a later wrapper, which strands the session at + // 0/N and leaves the bar, the params panel, and accept all reading the + // wrong element. Prefer a wrapper that actually holds variants. With zero + // or one match this is exactly the querySelector it replaces. + // + // Every lookup of the ACTIVE session's wrapper goes through here. The + // remaining raw `[data-impeccable-variants=...]` uses are deliberate: bare + // existence checks, selector strings for stylesheets and observers (which + // want to cover every match), `querySelectorAll` sweeps, and the parsed + // source document, which is not this document. + function pickPopulatedVariantsWrapper(selector) { + const matches = document.querySelectorAll(selector); + if (matches.length < 2) return matches[0] || null; + for (const candidate of matches) { + if (candidate.querySelector('[data-impeccable-variant]:not([data-impeccable-variant="original"])')) { + return candidate; + } + } + return matches[0]; + } + + /** The wrapper holding `sessionId`'s variants, or null without an id. */ + function findVariantsWrapper(sessionId) { + if (!sessionId) return null; + return pickPopulatedVariantsWrapper('[data-impeccable-variants="' + sessionId + '"]'); + } + + /** Any live variant wrapper, for the resume paths that have no id yet. */ + function findAnyVariantsWrapper() { + return pickPopulatedVariantsWrapper('[data-impeccable-variants]'); + } + function startVariantObserver(sessionId) { let updating = false; // re-entrancy guard @@ -6936,7 +6994,7 @@ } if (!dominated) return; - const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]'); + const wrapper = findVariantsWrapper(sessionId); if (!wrapper) return; const variants = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])'); @@ -7145,6 +7203,7 @@ if (arrivedVariants >= expectedVariants && expectedVariants > 0) { if (state === 'GENERATING') { setLiveState('CYCLING'); + hideShaderOverlay(); showOrUpdateCyclingBar(); disableInlineEdit(); refreshParamsPanel(); @@ -7205,6 +7264,7 @@ pendingAcceptedSession = null; awaitingAcceptResult = null; setLiveState('CYCLING'); + hideShaderOverlay(); updateBarContent('cycling'); showToast('Could not complete accept cleanup. Try Accept again.', 5000); break; @@ -8307,6 +8367,15 @@ void main() { // matches the original off-white risograph paper. const SHADER_PAPER_FALLBACK = [0.975, 0.965, 0.955]; let shaderState = null; // { canvas, gl, program, texture, rafId, startTime } + // showShaderOverlay is async: it appends its canvas, then awaits + // createImageBitmap and the GL setup before it publishes shaderState. A + // teardown that landed inside that window found shaderState still null, + // returned, and then watched the construction publish itself over a session + // that had already left GENERATING, with no teardown left to run. That is + // the generating loader frozen over a page that already cycles (issue #719). + // Every teardown bumps this epoch; a construction abandons its own canvas as + // soon as it sees the epoch move. + let shaderEpoch = 0; // The element's effective background tone, used as the uniform halftone // ground so content dissolves into dots over it. Unlike resolveCanvasBackground @@ -8453,14 +8522,28 @@ void main() { }); } + /** Drop a shader node no shaderState owns (an abandoned construction). */ + function removeStrayShaderNode() { + const stray = uiGetById(PREFIX + '-shader'); + if (stray) stray.remove(); + } + function hideShaderOverlay() { - if (!shaderState) return; + // Bump first, unconditionally: this is what tells an in-flight + // showShaderOverlay to abandon itself rather than publish over a session + // that has already moved on. + shaderEpoch += 1; + if (!shaderState) { + removeStrayShaderNode(); + return; + } if (shaderState.rafId) cancelAnimationFrame(shaderState.rafId); if (shaderState.canvas) shaderState.canvas.remove(); if (shaderState.objectUrl) URL.revokeObjectURL(shaderState.objectUrl); const lose = shaderState.gl?.getExtension?.('WEBGL_lose_context'); try { lose?.loseContext(); } catch {} shaderState = null; + removeStrayShaderNode(); } function showShaderBitmapFallback(canvas, blob) { @@ -8485,6 +8568,16 @@ void main() { async function showShaderOverlay(el, blob, rect, paper) { hideShaderOverlay(); if (!blob || !el) return; + // hideShaderOverlay just bumped the epoch, so this run owns it until the + // next teardown. Every step past an await re-checks before it publishes. + const epoch = shaderEpoch; + const abandoned = (node, gl) => { + if (epoch === shaderEpoch) return false; + node.remove(); + const lose = gl?.getExtension?.('WEBGL_lose_context'); + try { lose?.loseContext(); } catch {} + return true; + }; const canvas = document.createElement('canvas'); canvas.id = PREFIX + '-shader'; const dpr = Math.min(window.devicePixelRatio || 1, 2); @@ -8507,6 +8600,7 @@ void main() { if (!gl) { // WebGL unavailable: use the captured bitmap as a background overlay so // the user still sees something meaningful during generation. + if (abandoned(canvas, null)) return; showShaderBitmapFallback(canvas, blob); return; } @@ -8546,16 +8640,22 @@ void main() { } // Upload the screenshot as a texture + if (abandoned(canvas, gl)) return; let bitmap; try { bitmap = await createImageBitmap(blob); } catch (err) { console.warn('[impeccable] shader bitmap decode failed:', err); + if (abandoned(canvas, gl)) return; const lose = gl.getExtension?.('WEBGL_lose_context'); try { lose?.loseContext(); } catch {} showShaderBitmapFallback(canvas, blob); return; } + if (abandoned(canvas, gl)) { + if (bitmap.close) bitmap.close(); + return; + } texture = gl.createTexture(); gl.bindTexture(gl.TEXTURE_2D, texture); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); @@ -8574,6 +8674,7 @@ void main() { const paperRgb = paper || resolvePaperRgb(el); const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches; + if (abandoned(canvas, gl)) return; shaderState = { canvas, gl, program, texture, rafId: 0, startTime: performance.now(), reduced }; function frame() { if (!shaderState) return; @@ -8610,7 +8711,7 @@ void main() { clientSentAt: Date.now(), }; if (!currentSessionId || arrivedVariants === 0) return; - const acceptWrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]'); + const acceptWrapper = findVariantsWrapper(currentSessionId); if (Object.keys(paramsCurrentValues).length > 0) { acceptPayload.paramValues = { ...paramsCurrentValues }; } @@ -8653,6 +8754,7 @@ void main() { .catch(() => { if (pendingAcceptedSession?.id === acceptedSessionId) pendingAcceptedSession = null; setLiveState('CYCLING'); + hideShaderOverlay(); showOrUpdateCyclingBar(); showToast('Could not confirm accept with the live server. Session kept for recovery; try Accept again.', 5000); }); @@ -8704,7 +8806,7 @@ void main() { } function snapshotAcceptedVariantDom(sessionId, variantId) { - const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]'); + const wrapper = findVariantsWrapper(sessionId); const accepted = wrapper?.querySelector?.('[data-impeccable-variant="' + variantId + '"]'); const root = accepted?.firstElementChild || null; return { @@ -8831,7 +8933,7 @@ void main() { } function commitAcceptedVariantToDom(sessionId, variantId) { - const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]'); + const wrapper = findVariantsWrapper(sessionId); if (!wrapper) return false; const accepted = wrapper.querySelector('[data-impeccable-variant="' + variantId + '"]'); if (!accepted || !accepted.firstElementChild) return false; @@ -9059,7 +9161,7 @@ void main() { } function restoreFromActiveSessions(activeSessions, reason) { - const wrapper = document.querySelector('[data-impeccable-variants]'); + const wrapper = findAnyVariantsWrapper(); if (wrapper && !isFrameworkComponentPreviewMode(wrapper.dataset.impeccablePreview)) return false; if (svelteComponentSession?.sessionId === currentSessionId) return false; return restoreSessionWithoutWrapper(reason || 'sse_connected', activeSessions); @@ -9172,10 +9274,13 @@ void main() { // reconciler later tries to remove a wrapper we already removed. // Schedule a 2s fallback that does the manual swap only if HMR hasn't // replaced the wrapper by then (keeps static-server / no-HMR flows alive). - const wrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]'); - if (wrapper) { + // Every match, not the first: a target inside a `.map()` renders one + // wrapper per item, and hiding only one leaves the rest of the + // discarded variants on screen. + const discardWrappers = discardedWrappers(cleanupSessionId); + if (discardWrappers.length > 0) { if (restoreOriginal) showOriginalDuringDiscard(cleanupSessionId); - else wrapper.style.display = 'none'; + else for (const discardWrapper of discardWrappers) discardWrapper.style.display = 'none'; } setTimeout(function() { const recoverySuperseded = deferredRecoverySuperseded(cleanupSessionId, cleanupRevision); @@ -9183,16 +9288,19 @@ void main() { removeDiscardStateStylesheet(); return; } - const lateWrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]'); - if (!lateWrapper) { + const lateWrappers = discardedWrappers(cleanupSessionId); + if (lateWrappers.length === 0) { removeDiscardStateStylesheet(cleanupSessionId); return; } + // Duplicates all render from one source element, so HMR ownership is + // uniform across them; the first is a fair witness for the set. + const lateWrapper = lateWrappers[0]; if (recoverySuperseded) { if (hasFrameworkHmrOwnership(lateWrapper)) { watchForDiscardedFrameworkWrapperRemoval(cleanupSessionId); } else { - releaseDiscardedStaticWrapper(lateWrapper, cleanupSessionId); + releaseDiscardedStaticWrappers(cleanupSessionId, lateWrappers); } return; } @@ -9201,18 +9309,20 @@ void main() { // the final source rewrite, reload once after a grace window so the // discarded source becomes authoritative without a reconciler race. setTimeout(function() { - const staleWrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]'); + const staleWrappers = discardedWrappers(cleanupSessionId); if (deferredRecoverySuperseded(cleanupSessionId, cleanupRevision)) { - if (!staleWrapper) removeDiscardStateStylesheet(cleanupSessionId); + if (staleWrappers.length === 0) removeDiscardStateStylesheet(cleanupSessionId); else watchForDiscardedFrameworkWrapperRemoval(cleanupSessionId); return; } removeDiscardStateStylesheet(cleanupSessionId); - if (staleWrapper) location.reload(); + // A reload restores every wrapper's original at once, so there is + // nothing per-wrapper to do here. + if (staleWrappers.length > 0) location.reload(); }, 2000); return; } - releaseDiscardedStaticWrapper(lateWrapper, cleanupSessionId); + releaseDiscardedStaticWrappers(cleanupSessionId, lateWrappers); }, 2000); } hideBar(instantChrome); @@ -9400,8 +9510,13 @@ void main() { return restoreSessionWithoutWrapper('browser_resumed_over_handled_wrapper'); } - function resumeSession(recoveryRevision = liveInteractionRevision) { - const wrapper = document.querySelector('[data-impeccable-variants]'); + function resumeSession(recoveryRevision = liveInteractionRevision, opts = {}) { + // Which path resumed matters in the journal: an init resume is a fresh + // page load, the deferred-wrapper scout is a mid-page-load arrival. Both + // used to log the same `browser_resumed`, which made issue #719 take a + // DOM reconstruction to diagnose. + const resumeReason = opts.reason || 'browser_resumed'; + const wrapper = findAnyVariantsWrapper(); const runtimeWrapper = wrapper || document.querySelector('[data-impeccable-carbonize]'); if (restoreSessionSupersedingHandledWrapper(runtimeWrapper)) return true; if (scheduleHandledRuntimeWrapperReload(runtimeWrapper, recoveryRevision)) return false; @@ -9500,16 +9615,38 @@ void main() { showBar(state === 'CYCLING' ? 'cycling' : 'generating'); startScrollTracking(); - // Build the params panel for the restored visible variant. Previously - // this was missed on page-reload resume: showVariantInDOM above fires - // refreshParamsPanel, but state was still IDLE at that moment so it - // hid. Now that state is CYCLING, re-fire. - if (state === 'CYCLING') refreshParamsPanel(); + // A resume can BE the arrival, not just a re-entry after one. The server's + // generation preflight runs live-wrap with --defer-source-write, so the + // wrapper and every variant reach the DOM in one HMR batch, and the + // deferred-wrapper scout (constructed at init) runs before the variant + // MutationObserver (constructed at Go) on that batch. Finish the same + // transition the observer would have finished. Without hideShaderOverlay + // the generating shader stays frozen over the target and the session looks + // stuck at GENERATING while the bar already cycles (issue #719). + if (state === 'CYCLING') { + recoveryWaitingForAnchor = false; + hideShaderOverlay(); + if (isInsert) finalizeInsertSession(); + disableInlineEdit(); + // Build the params panel for the restored visible variant. Previously + // this was missed on page-reload resume: showVariantInDOM above fires + // refreshParamsPanel, but state was still IDLE at that moment so it + // hid. Now that state is CYCLING, re-fire. + refreshParamsPanel(); + } saveSession(); if (arrivedVariants > 0 && arrivedVariants < expectedVariants) { sendCheckpoint('variants_progress'); } else { - queueCheckpoint('browser_resumed'); + queueCheckpoint(resumeReason); + // Only variants_progress and variants_ready count as publication + // progress. When the resume is the arrival, the observer never gets to + // report it (this function disconnects and re-creates it below, which + // drops the records it had already queued for this same batch), so + // without this the server never learns the variants were published. + if (arrivedVariants > 0 && arrivedVariants >= expectedVariants && expectedVariants > 0) { + sendCheckpoint('variants_ready'); + } } // Start observing for more variants AFTER initial setup @@ -12831,7 +12968,7 @@ void main() { const wrapper = document.querySelector('[data-impeccable-variants],[data-impeccable-carbonize]'); if (!wrapper) return; scout.disconnect(); - if (resumeSession(deferredResumeRevision)) { + if (resumeSession(deferredResumeRevision, { reason: 'browser_resumed_deferred_wrapper' })) { console.log('[impeccable] Resumed deferred session ' + currentSessionId + ' (post-hydration).'); } }); diff --git a/.claude/skills/impeccable/scripts/live-browser.js b/.claude/skills/impeccable/scripts/live-browser.js index c32101b12..277c60e2c 100644 --- a/.claude/skills/impeccable/scripts/live-browser.js +++ b/.claude/skills/impeccable/scripts/live-browser.js @@ -2060,7 +2060,7 @@ if (anchor) return anchor; } if (currentSessionId && (state === 'GENERATING' || state === 'CYCLING')) { - const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]'); + const wrapper = findVariantsWrapper(currentSessionId); if (wrapper) { const variantCount = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])').length; if (variantCount > 0 && visibleVariant > 0) { @@ -2131,14 +2131,14 @@ function isInsertGeneratingSession() { if (state !== 'GENERATING' || !currentSessionId) return false; - const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]'); + const wrapper = findVariantsWrapper(currentSessionId); return !!wrapper && wrapper.dataset.impeccableMode === 'insert'; } /** Recreate the dotted placeholder if Astro/Vite HMR removed it mid-generation. */ function ensureInsertPlaceholder() { if (!isInsertGeneratingSession()) return placeholderElement; - const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]'); + const wrapper = findVariantsWrapper(currentSessionId); const variantCount = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])').length; if (variantCount > 0) return placeholderElement; if (placeholderElement && document.body.contains(placeholderElement)) return placeholderElement; @@ -3156,7 +3156,7 @@ || svelteComponentSession.wrapperEl || null; } - const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]'); + const wrapper = findVariantsWrapper(currentSessionId); if (!wrapper) return null; return wrapper.querySelector('[data-impeccable-variant="' + visibleVariant + '"]'); } @@ -4900,7 +4900,7 @@ return Object.values(svelteComponentSession.paramsByVariant || {}) .reduce((total, params) => total + (Array.isArray(params) ? params.length : 0), 0); } - const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]'); + const wrapper = findVariantsWrapper(currentSessionId); if (!wrapper) return 0; return [...wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])')] .reduce((total, variant) => total + parseVariantParams(variant).length, 0); @@ -5004,7 +5004,7 @@ scheduleCyclingBarSync(sessionId, num); return true; } - const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]'); + const wrapper = findVariantsWrapper(sessionId); if (!wrapper) return false; updateVariantStateStylesheet(sessionId, num); // Unconditional refresh - covers first-reveal (no-op if state isn't @@ -5820,6 +5820,7 @@ return; } setLiveState('CYCLING'); + hideShaderOverlay(); showOrUpdateCyclingBar(); saveSession(); completeParameterGenerationIfReady(); @@ -6361,7 +6362,7 @@ } rememberSessionFileMeta({ file: filePath }); if (isJsxSourceFile(filePath)) { - const liveWrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]'); + const liveWrapper = findVariantsWrapper(sessionId); if (liveWrapper && liveWrapper.querySelector('[data-impeccable-variant]:not([data-impeccable-variant="original"])')) { completeSourceInjection(liveWrapper, sessionId, { ...opts, filePath }); return; @@ -6433,7 +6434,7 @@ return; } - const existingWrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]'); + const existingWrapper = findVariantsWrapper(sessionId); if (existingWrapper) { const wrapper = srcWrapper.cloneNode(true); existingWrapper.parentElement.replaceChild(wrapper, existingWrapper); @@ -6590,7 +6591,7 @@ if (anchor && !anchor.__impeccableFrozenAnchor) selectedElement = anchor; return; } - const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]'); + const wrapper = findVariantsWrapper(currentSessionId); if (!wrapper) return; const visEl = pickVariantContent(wrapper, visibleVariant); if (visEl) selectedElement = visEl; @@ -6600,7 +6601,7 @@ if (svelteComponentSession?.sessionId === sessionId && svelteComponentSession.mountedVariant > 0) { return svelteComponentSession.mountedVariant; } - const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]'); + const wrapper = findVariantsWrapper(sessionId); if (!wrapper) return 0; const variants = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])'); for (const variant of variants) { @@ -6715,8 +6716,17 @@ document.getElementById(discardStateStyleId(sessionId))?.remove(); } - function releaseDiscardedStaticWrapper(wrapper, sessionId) { - removeDiscardStateStylesheet(sessionId); + /** + * Every wrapper a discard has to unwind. A target inside a `.map()` renders + * one wrapper per item, so the hide, the release, and the existence checks + * all have to speak about the same set. + */ + function discardedWrappers(sessionId) { + if (!sessionId) return []; + return [...document.querySelectorAll('[data-impeccable-variants="' + sessionId + '"]')]; + } + + function releaseDiscardedStaticWrapper(wrapper) { if (!wrapper) return; const orig = wrapper.querySelector('[data-impeccable-variant="original"]'); const content = orig?.firstElementChild; @@ -6727,6 +6737,18 @@ wrapper.remove(); } + /** + * Undo the discard hide on every wrapper it covered. Releasing only the + * first match left the other mapped items sitting at display:none with + * their original content never restored, on exactly the static and + * missed-HMR flows this fallback exists for. + */ + function releaseDiscardedStaticWrappers(sessionId, wrappers) { + removeDiscardStateStylesheet(sessionId); + const set = wrappers && wrappers.length ? wrappers : discardedWrappers(sessionId); + for (const wrapper of set) releaseDiscardedStaticWrapper(wrapper); + } + function watchForDiscardedFrameworkWrapperRemoval(sessionId) { if (!sessionId || !document.body) return; if (discardedFrameworkWrapperWatchers.has(sessionId)) return; @@ -6907,6 +6929,42 @@ // MutationObserver for progressive variant reveal // + // A session id can have more than one wrapper in the DOM: the target may sit + // inside a `.map()` callback (the wrapper renders once per item), or the + // agent may have relocated the wrapper out of the shared primitive live-wrap + // scaffolded into. A plain first match can then pin an empty scaffold while + // the real variants sit in a later wrapper, which strands the session at + // 0/N and leaves the bar, the params panel, and accept all reading the + // wrong element. Prefer a wrapper that actually holds variants. With zero + // or one match this is exactly the querySelector it replaces. + // + // Every lookup of the ACTIVE session's wrapper goes through here. The + // remaining raw `[data-impeccable-variants=...]` uses are deliberate: bare + // existence checks, selector strings for stylesheets and observers (which + // want to cover every match), `querySelectorAll` sweeps, and the parsed + // source document, which is not this document. + function pickPopulatedVariantsWrapper(selector) { + const matches = document.querySelectorAll(selector); + if (matches.length < 2) return matches[0] || null; + for (const candidate of matches) { + if (candidate.querySelector('[data-impeccable-variant]:not([data-impeccable-variant="original"])')) { + return candidate; + } + } + return matches[0]; + } + + /** The wrapper holding `sessionId`'s variants, or null without an id. */ + function findVariantsWrapper(sessionId) { + if (!sessionId) return null; + return pickPopulatedVariantsWrapper('[data-impeccable-variants="' + sessionId + '"]'); + } + + /** Any live variant wrapper, for the resume paths that have no id yet. */ + function findAnyVariantsWrapper() { + return pickPopulatedVariantsWrapper('[data-impeccable-variants]'); + } + function startVariantObserver(sessionId) { let updating = false; // re-entrancy guard @@ -6936,7 +6994,7 @@ } if (!dominated) return; - const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]'); + const wrapper = findVariantsWrapper(sessionId); if (!wrapper) return; const variants = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])'); @@ -7145,6 +7203,7 @@ if (arrivedVariants >= expectedVariants && expectedVariants > 0) { if (state === 'GENERATING') { setLiveState('CYCLING'); + hideShaderOverlay(); showOrUpdateCyclingBar(); disableInlineEdit(); refreshParamsPanel(); @@ -7205,6 +7264,7 @@ pendingAcceptedSession = null; awaitingAcceptResult = null; setLiveState('CYCLING'); + hideShaderOverlay(); updateBarContent('cycling'); showToast('Could not complete accept cleanup. Try Accept again.', 5000); break; @@ -8307,6 +8367,15 @@ void main() { // matches the original off-white risograph paper. const SHADER_PAPER_FALLBACK = [0.975, 0.965, 0.955]; let shaderState = null; // { canvas, gl, program, texture, rafId, startTime } + // showShaderOverlay is async: it appends its canvas, then awaits + // createImageBitmap and the GL setup before it publishes shaderState. A + // teardown that landed inside that window found shaderState still null, + // returned, and then watched the construction publish itself over a session + // that had already left GENERATING, with no teardown left to run. That is + // the generating loader frozen over a page that already cycles (issue #719). + // Every teardown bumps this epoch; a construction abandons its own canvas as + // soon as it sees the epoch move. + let shaderEpoch = 0; // The element's effective background tone, used as the uniform halftone // ground so content dissolves into dots over it. Unlike resolveCanvasBackground @@ -8453,14 +8522,28 @@ void main() { }); } + /** Drop a shader node no shaderState owns (an abandoned construction). */ + function removeStrayShaderNode() { + const stray = uiGetById(PREFIX + '-shader'); + if (stray) stray.remove(); + } + function hideShaderOverlay() { - if (!shaderState) return; + // Bump first, unconditionally: this is what tells an in-flight + // showShaderOverlay to abandon itself rather than publish over a session + // that has already moved on. + shaderEpoch += 1; + if (!shaderState) { + removeStrayShaderNode(); + return; + } if (shaderState.rafId) cancelAnimationFrame(shaderState.rafId); if (shaderState.canvas) shaderState.canvas.remove(); if (shaderState.objectUrl) URL.revokeObjectURL(shaderState.objectUrl); const lose = shaderState.gl?.getExtension?.('WEBGL_lose_context'); try { lose?.loseContext(); } catch {} shaderState = null; + removeStrayShaderNode(); } function showShaderBitmapFallback(canvas, blob) { @@ -8485,6 +8568,16 @@ void main() { async function showShaderOverlay(el, blob, rect, paper) { hideShaderOverlay(); if (!blob || !el) return; + // hideShaderOverlay just bumped the epoch, so this run owns it until the + // next teardown. Every step past an await re-checks before it publishes. + const epoch = shaderEpoch; + const abandoned = (node, gl) => { + if (epoch === shaderEpoch) return false; + node.remove(); + const lose = gl?.getExtension?.('WEBGL_lose_context'); + try { lose?.loseContext(); } catch {} + return true; + }; const canvas = document.createElement('canvas'); canvas.id = PREFIX + '-shader'; const dpr = Math.min(window.devicePixelRatio || 1, 2); @@ -8507,6 +8600,7 @@ void main() { if (!gl) { // WebGL unavailable: use the captured bitmap as a background overlay so // the user still sees something meaningful during generation. + if (abandoned(canvas, null)) return; showShaderBitmapFallback(canvas, blob); return; } @@ -8546,16 +8640,22 @@ void main() { } // Upload the screenshot as a texture + if (abandoned(canvas, gl)) return; let bitmap; try { bitmap = await createImageBitmap(blob); } catch (err) { console.warn('[impeccable] shader bitmap decode failed:', err); + if (abandoned(canvas, gl)) return; const lose = gl.getExtension?.('WEBGL_lose_context'); try { lose?.loseContext(); } catch {} showShaderBitmapFallback(canvas, blob); return; } + if (abandoned(canvas, gl)) { + if (bitmap.close) bitmap.close(); + return; + } texture = gl.createTexture(); gl.bindTexture(gl.TEXTURE_2D, texture); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); @@ -8574,6 +8674,7 @@ void main() { const paperRgb = paper || resolvePaperRgb(el); const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches; + if (abandoned(canvas, gl)) return; shaderState = { canvas, gl, program, texture, rafId: 0, startTime: performance.now(), reduced }; function frame() { if (!shaderState) return; @@ -8610,7 +8711,7 @@ void main() { clientSentAt: Date.now(), }; if (!currentSessionId || arrivedVariants === 0) return; - const acceptWrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]'); + const acceptWrapper = findVariantsWrapper(currentSessionId); if (Object.keys(paramsCurrentValues).length > 0) { acceptPayload.paramValues = { ...paramsCurrentValues }; } @@ -8653,6 +8754,7 @@ void main() { .catch(() => { if (pendingAcceptedSession?.id === acceptedSessionId) pendingAcceptedSession = null; setLiveState('CYCLING'); + hideShaderOverlay(); showOrUpdateCyclingBar(); showToast('Could not confirm accept with the live server. Session kept for recovery; try Accept again.', 5000); }); @@ -8704,7 +8806,7 @@ void main() { } function snapshotAcceptedVariantDom(sessionId, variantId) { - const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]'); + const wrapper = findVariantsWrapper(sessionId); const accepted = wrapper?.querySelector?.('[data-impeccable-variant="' + variantId + '"]'); const root = accepted?.firstElementChild || null; return { @@ -8831,7 +8933,7 @@ void main() { } function commitAcceptedVariantToDom(sessionId, variantId) { - const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]'); + const wrapper = findVariantsWrapper(sessionId); if (!wrapper) return false; const accepted = wrapper.querySelector('[data-impeccable-variant="' + variantId + '"]'); if (!accepted || !accepted.firstElementChild) return false; @@ -9059,7 +9161,7 @@ void main() { } function restoreFromActiveSessions(activeSessions, reason) { - const wrapper = document.querySelector('[data-impeccable-variants]'); + const wrapper = findAnyVariantsWrapper(); if (wrapper && !isFrameworkComponentPreviewMode(wrapper.dataset.impeccablePreview)) return false; if (svelteComponentSession?.sessionId === currentSessionId) return false; return restoreSessionWithoutWrapper(reason || 'sse_connected', activeSessions); @@ -9172,10 +9274,13 @@ void main() { // reconciler later tries to remove a wrapper we already removed. // Schedule a 2s fallback that does the manual swap only if HMR hasn't // replaced the wrapper by then (keeps static-server / no-HMR flows alive). - const wrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]'); - if (wrapper) { + // Every match, not the first: a target inside a `.map()` renders one + // wrapper per item, and hiding only one leaves the rest of the + // discarded variants on screen. + const discardWrappers = discardedWrappers(cleanupSessionId); + if (discardWrappers.length > 0) { if (restoreOriginal) showOriginalDuringDiscard(cleanupSessionId); - else wrapper.style.display = 'none'; + else for (const discardWrapper of discardWrappers) discardWrapper.style.display = 'none'; } setTimeout(function() { const recoverySuperseded = deferredRecoverySuperseded(cleanupSessionId, cleanupRevision); @@ -9183,16 +9288,19 @@ void main() { removeDiscardStateStylesheet(); return; } - const lateWrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]'); - if (!lateWrapper) { + const lateWrappers = discardedWrappers(cleanupSessionId); + if (lateWrappers.length === 0) { removeDiscardStateStylesheet(cleanupSessionId); return; } + // Duplicates all render from one source element, so HMR ownership is + // uniform across them; the first is a fair witness for the set. + const lateWrapper = lateWrappers[0]; if (recoverySuperseded) { if (hasFrameworkHmrOwnership(lateWrapper)) { watchForDiscardedFrameworkWrapperRemoval(cleanupSessionId); } else { - releaseDiscardedStaticWrapper(lateWrapper, cleanupSessionId); + releaseDiscardedStaticWrappers(cleanupSessionId, lateWrappers); } return; } @@ -9201,18 +9309,20 @@ void main() { // the final source rewrite, reload once after a grace window so the // discarded source becomes authoritative without a reconciler race. setTimeout(function() { - const staleWrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]'); + const staleWrappers = discardedWrappers(cleanupSessionId); if (deferredRecoverySuperseded(cleanupSessionId, cleanupRevision)) { - if (!staleWrapper) removeDiscardStateStylesheet(cleanupSessionId); + if (staleWrappers.length === 0) removeDiscardStateStylesheet(cleanupSessionId); else watchForDiscardedFrameworkWrapperRemoval(cleanupSessionId); return; } removeDiscardStateStylesheet(cleanupSessionId); - if (staleWrapper) location.reload(); + // A reload restores every wrapper's original at once, so there is + // nothing per-wrapper to do here. + if (staleWrappers.length > 0) location.reload(); }, 2000); return; } - releaseDiscardedStaticWrapper(lateWrapper, cleanupSessionId); + releaseDiscardedStaticWrappers(cleanupSessionId, lateWrappers); }, 2000); } hideBar(instantChrome); @@ -9400,8 +9510,13 @@ void main() { return restoreSessionWithoutWrapper('browser_resumed_over_handled_wrapper'); } - function resumeSession(recoveryRevision = liveInteractionRevision) { - const wrapper = document.querySelector('[data-impeccable-variants]'); + function resumeSession(recoveryRevision = liveInteractionRevision, opts = {}) { + // Which path resumed matters in the journal: an init resume is a fresh + // page load, the deferred-wrapper scout is a mid-page-load arrival. Both + // used to log the same `browser_resumed`, which made issue #719 take a + // DOM reconstruction to diagnose. + const resumeReason = opts.reason || 'browser_resumed'; + const wrapper = findAnyVariantsWrapper(); const runtimeWrapper = wrapper || document.querySelector('[data-impeccable-carbonize]'); if (restoreSessionSupersedingHandledWrapper(runtimeWrapper)) return true; if (scheduleHandledRuntimeWrapperReload(runtimeWrapper, recoveryRevision)) return false; @@ -9500,16 +9615,38 @@ void main() { showBar(state === 'CYCLING' ? 'cycling' : 'generating'); startScrollTracking(); - // Build the params panel for the restored visible variant. Previously - // this was missed on page-reload resume: showVariantInDOM above fires - // refreshParamsPanel, but state was still IDLE at that moment so it - // hid. Now that state is CYCLING, re-fire. - if (state === 'CYCLING') refreshParamsPanel(); + // A resume can BE the arrival, not just a re-entry after one. The server's + // generation preflight runs live-wrap with --defer-source-write, so the + // wrapper and every variant reach the DOM in one HMR batch, and the + // deferred-wrapper scout (constructed at init) runs before the variant + // MutationObserver (constructed at Go) on that batch. Finish the same + // transition the observer would have finished. Without hideShaderOverlay + // the generating shader stays frozen over the target and the session looks + // stuck at GENERATING while the bar already cycles (issue #719). + if (state === 'CYCLING') { + recoveryWaitingForAnchor = false; + hideShaderOverlay(); + if (isInsert) finalizeInsertSession(); + disableInlineEdit(); + // Build the params panel for the restored visible variant. Previously + // this was missed on page-reload resume: showVariantInDOM above fires + // refreshParamsPanel, but state was still IDLE at that moment so it + // hid. Now that state is CYCLING, re-fire. + refreshParamsPanel(); + } saveSession(); if (arrivedVariants > 0 && arrivedVariants < expectedVariants) { sendCheckpoint('variants_progress'); } else { - queueCheckpoint('browser_resumed'); + queueCheckpoint(resumeReason); + // Only variants_progress and variants_ready count as publication + // progress. When the resume is the arrival, the observer never gets to + // report it (this function disconnects and re-creates it below, which + // drops the records it had already queued for this same batch), so + // without this the server never learns the variants were published. + if (arrivedVariants > 0 && arrivedVariants >= expectedVariants && expectedVariants > 0) { + sendCheckpoint('variants_ready'); + } } // Start observing for more variants AFTER initial setup @@ -12831,7 +12968,7 @@ void main() { const wrapper = document.querySelector('[data-impeccable-variants],[data-impeccable-carbonize]'); if (!wrapper) return; scout.disconnect(); - if (resumeSession(deferredResumeRevision)) { + if (resumeSession(deferredResumeRevision, { reason: 'browser_resumed_deferred_wrapper' })) { console.log('[impeccable] Resumed deferred session ' + currentSessionId + ' (post-hydration).'); } }); diff --git a/.cursor/skills/impeccable/scripts/live-browser.js b/.cursor/skills/impeccable/scripts/live-browser.js index c32101b12..277c60e2c 100644 --- a/.cursor/skills/impeccable/scripts/live-browser.js +++ b/.cursor/skills/impeccable/scripts/live-browser.js @@ -2060,7 +2060,7 @@ if (anchor) return anchor; } if (currentSessionId && (state === 'GENERATING' || state === 'CYCLING')) { - const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]'); + const wrapper = findVariantsWrapper(currentSessionId); if (wrapper) { const variantCount = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])').length; if (variantCount > 0 && visibleVariant > 0) { @@ -2131,14 +2131,14 @@ function isInsertGeneratingSession() { if (state !== 'GENERATING' || !currentSessionId) return false; - const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]'); + const wrapper = findVariantsWrapper(currentSessionId); return !!wrapper && wrapper.dataset.impeccableMode === 'insert'; } /** Recreate the dotted placeholder if Astro/Vite HMR removed it mid-generation. */ function ensureInsertPlaceholder() { if (!isInsertGeneratingSession()) return placeholderElement; - const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]'); + const wrapper = findVariantsWrapper(currentSessionId); const variantCount = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])').length; if (variantCount > 0) return placeholderElement; if (placeholderElement && document.body.contains(placeholderElement)) return placeholderElement; @@ -3156,7 +3156,7 @@ || svelteComponentSession.wrapperEl || null; } - const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]'); + const wrapper = findVariantsWrapper(currentSessionId); if (!wrapper) return null; return wrapper.querySelector('[data-impeccable-variant="' + visibleVariant + '"]'); } @@ -4900,7 +4900,7 @@ return Object.values(svelteComponentSession.paramsByVariant || {}) .reduce((total, params) => total + (Array.isArray(params) ? params.length : 0), 0); } - const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]'); + const wrapper = findVariantsWrapper(currentSessionId); if (!wrapper) return 0; return [...wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])')] .reduce((total, variant) => total + parseVariantParams(variant).length, 0); @@ -5004,7 +5004,7 @@ scheduleCyclingBarSync(sessionId, num); return true; } - const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]'); + const wrapper = findVariantsWrapper(sessionId); if (!wrapper) return false; updateVariantStateStylesheet(sessionId, num); // Unconditional refresh - covers first-reveal (no-op if state isn't @@ -5820,6 +5820,7 @@ return; } setLiveState('CYCLING'); + hideShaderOverlay(); showOrUpdateCyclingBar(); saveSession(); completeParameterGenerationIfReady(); @@ -6361,7 +6362,7 @@ } rememberSessionFileMeta({ file: filePath }); if (isJsxSourceFile(filePath)) { - const liveWrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]'); + const liveWrapper = findVariantsWrapper(sessionId); if (liveWrapper && liveWrapper.querySelector('[data-impeccable-variant]:not([data-impeccable-variant="original"])')) { completeSourceInjection(liveWrapper, sessionId, { ...opts, filePath }); return; @@ -6433,7 +6434,7 @@ return; } - const existingWrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]'); + const existingWrapper = findVariantsWrapper(sessionId); if (existingWrapper) { const wrapper = srcWrapper.cloneNode(true); existingWrapper.parentElement.replaceChild(wrapper, existingWrapper); @@ -6590,7 +6591,7 @@ if (anchor && !anchor.__impeccableFrozenAnchor) selectedElement = anchor; return; } - const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]'); + const wrapper = findVariantsWrapper(currentSessionId); if (!wrapper) return; const visEl = pickVariantContent(wrapper, visibleVariant); if (visEl) selectedElement = visEl; @@ -6600,7 +6601,7 @@ if (svelteComponentSession?.sessionId === sessionId && svelteComponentSession.mountedVariant > 0) { return svelteComponentSession.mountedVariant; } - const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]'); + const wrapper = findVariantsWrapper(sessionId); if (!wrapper) return 0; const variants = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])'); for (const variant of variants) { @@ -6715,8 +6716,17 @@ document.getElementById(discardStateStyleId(sessionId))?.remove(); } - function releaseDiscardedStaticWrapper(wrapper, sessionId) { - removeDiscardStateStylesheet(sessionId); + /** + * Every wrapper a discard has to unwind. A target inside a `.map()` renders + * one wrapper per item, so the hide, the release, and the existence checks + * all have to speak about the same set. + */ + function discardedWrappers(sessionId) { + if (!sessionId) return []; + return [...document.querySelectorAll('[data-impeccable-variants="' + sessionId + '"]')]; + } + + function releaseDiscardedStaticWrapper(wrapper) { if (!wrapper) return; const orig = wrapper.querySelector('[data-impeccable-variant="original"]'); const content = orig?.firstElementChild; @@ -6727,6 +6737,18 @@ wrapper.remove(); } + /** + * Undo the discard hide on every wrapper it covered. Releasing only the + * first match left the other mapped items sitting at display:none with + * their original content never restored, on exactly the static and + * missed-HMR flows this fallback exists for. + */ + function releaseDiscardedStaticWrappers(sessionId, wrappers) { + removeDiscardStateStylesheet(sessionId); + const set = wrappers && wrappers.length ? wrappers : discardedWrappers(sessionId); + for (const wrapper of set) releaseDiscardedStaticWrapper(wrapper); + } + function watchForDiscardedFrameworkWrapperRemoval(sessionId) { if (!sessionId || !document.body) return; if (discardedFrameworkWrapperWatchers.has(sessionId)) return; @@ -6907,6 +6929,42 @@ // MutationObserver for progressive variant reveal // + // A session id can have more than one wrapper in the DOM: the target may sit + // inside a `.map()` callback (the wrapper renders once per item), or the + // agent may have relocated the wrapper out of the shared primitive live-wrap + // scaffolded into. A plain first match can then pin an empty scaffold while + // the real variants sit in a later wrapper, which strands the session at + // 0/N and leaves the bar, the params panel, and accept all reading the + // wrong element. Prefer a wrapper that actually holds variants. With zero + // or one match this is exactly the querySelector it replaces. + // + // Every lookup of the ACTIVE session's wrapper goes through here. The + // remaining raw `[data-impeccable-variants=...]` uses are deliberate: bare + // existence checks, selector strings for stylesheets and observers (which + // want to cover every match), `querySelectorAll` sweeps, and the parsed + // source document, which is not this document. + function pickPopulatedVariantsWrapper(selector) { + const matches = document.querySelectorAll(selector); + if (matches.length < 2) return matches[0] || null; + for (const candidate of matches) { + if (candidate.querySelector('[data-impeccable-variant]:not([data-impeccable-variant="original"])')) { + return candidate; + } + } + return matches[0]; + } + + /** The wrapper holding `sessionId`'s variants, or null without an id. */ + function findVariantsWrapper(sessionId) { + if (!sessionId) return null; + return pickPopulatedVariantsWrapper('[data-impeccable-variants="' + sessionId + '"]'); + } + + /** Any live variant wrapper, for the resume paths that have no id yet. */ + function findAnyVariantsWrapper() { + return pickPopulatedVariantsWrapper('[data-impeccable-variants]'); + } + function startVariantObserver(sessionId) { let updating = false; // re-entrancy guard @@ -6936,7 +6994,7 @@ } if (!dominated) return; - const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]'); + const wrapper = findVariantsWrapper(sessionId); if (!wrapper) return; const variants = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])'); @@ -7145,6 +7203,7 @@ if (arrivedVariants >= expectedVariants && expectedVariants > 0) { if (state === 'GENERATING') { setLiveState('CYCLING'); + hideShaderOverlay(); showOrUpdateCyclingBar(); disableInlineEdit(); refreshParamsPanel(); @@ -7205,6 +7264,7 @@ pendingAcceptedSession = null; awaitingAcceptResult = null; setLiveState('CYCLING'); + hideShaderOverlay(); updateBarContent('cycling'); showToast('Could not complete accept cleanup. Try Accept again.', 5000); break; @@ -8307,6 +8367,15 @@ void main() { // matches the original off-white risograph paper. const SHADER_PAPER_FALLBACK = [0.975, 0.965, 0.955]; let shaderState = null; // { canvas, gl, program, texture, rafId, startTime } + // showShaderOverlay is async: it appends its canvas, then awaits + // createImageBitmap and the GL setup before it publishes shaderState. A + // teardown that landed inside that window found shaderState still null, + // returned, and then watched the construction publish itself over a session + // that had already left GENERATING, with no teardown left to run. That is + // the generating loader frozen over a page that already cycles (issue #719). + // Every teardown bumps this epoch; a construction abandons its own canvas as + // soon as it sees the epoch move. + let shaderEpoch = 0; // The element's effective background tone, used as the uniform halftone // ground so content dissolves into dots over it. Unlike resolveCanvasBackground @@ -8453,14 +8522,28 @@ void main() { }); } + /** Drop a shader node no shaderState owns (an abandoned construction). */ + function removeStrayShaderNode() { + const stray = uiGetById(PREFIX + '-shader'); + if (stray) stray.remove(); + } + function hideShaderOverlay() { - if (!shaderState) return; + // Bump first, unconditionally: this is what tells an in-flight + // showShaderOverlay to abandon itself rather than publish over a session + // that has already moved on. + shaderEpoch += 1; + if (!shaderState) { + removeStrayShaderNode(); + return; + } if (shaderState.rafId) cancelAnimationFrame(shaderState.rafId); if (shaderState.canvas) shaderState.canvas.remove(); if (shaderState.objectUrl) URL.revokeObjectURL(shaderState.objectUrl); const lose = shaderState.gl?.getExtension?.('WEBGL_lose_context'); try { lose?.loseContext(); } catch {} shaderState = null; + removeStrayShaderNode(); } function showShaderBitmapFallback(canvas, blob) { @@ -8485,6 +8568,16 @@ void main() { async function showShaderOverlay(el, blob, rect, paper) { hideShaderOverlay(); if (!blob || !el) return; + // hideShaderOverlay just bumped the epoch, so this run owns it until the + // next teardown. Every step past an await re-checks before it publishes. + const epoch = shaderEpoch; + const abandoned = (node, gl) => { + if (epoch === shaderEpoch) return false; + node.remove(); + const lose = gl?.getExtension?.('WEBGL_lose_context'); + try { lose?.loseContext(); } catch {} + return true; + }; const canvas = document.createElement('canvas'); canvas.id = PREFIX + '-shader'; const dpr = Math.min(window.devicePixelRatio || 1, 2); @@ -8507,6 +8600,7 @@ void main() { if (!gl) { // WebGL unavailable: use the captured bitmap as a background overlay so // the user still sees something meaningful during generation. + if (abandoned(canvas, null)) return; showShaderBitmapFallback(canvas, blob); return; } @@ -8546,16 +8640,22 @@ void main() { } // Upload the screenshot as a texture + if (abandoned(canvas, gl)) return; let bitmap; try { bitmap = await createImageBitmap(blob); } catch (err) { console.warn('[impeccable] shader bitmap decode failed:', err); + if (abandoned(canvas, gl)) return; const lose = gl.getExtension?.('WEBGL_lose_context'); try { lose?.loseContext(); } catch {} showShaderBitmapFallback(canvas, blob); return; } + if (abandoned(canvas, gl)) { + if (bitmap.close) bitmap.close(); + return; + } texture = gl.createTexture(); gl.bindTexture(gl.TEXTURE_2D, texture); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); @@ -8574,6 +8674,7 @@ void main() { const paperRgb = paper || resolvePaperRgb(el); const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches; + if (abandoned(canvas, gl)) return; shaderState = { canvas, gl, program, texture, rafId: 0, startTime: performance.now(), reduced }; function frame() { if (!shaderState) return; @@ -8610,7 +8711,7 @@ void main() { clientSentAt: Date.now(), }; if (!currentSessionId || arrivedVariants === 0) return; - const acceptWrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]'); + const acceptWrapper = findVariantsWrapper(currentSessionId); if (Object.keys(paramsCurrentValues).length > 0) { acceptPayload.paramValues = { ...paramsCurrentValues }; } @@ -8653,6 +8754,7 @@ void main() { .catch(() => { if (pendingAcceptedSession?.id === acceptedSessionId) pendingAcceptedSession = null; setLiveState('CYCLING'); + hideShaderOverlay(); showOrUpdateCyclingBar(); showToast('Could not confirm accept with the live server. Session kept for recovery; try Accept again.', 5000); }); @@ -8704,7 +8806,7 @@ void main() { } function snapshotAcceptedVariantDom(sessionId, variantId) { - const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]'); + const wrapper = findVariantsWrapper(sessionId); const accepted = wrapper?.querySelector?.('[data-impeccable-variant="' + variantId + '"]'); const root = accepted?.firstElementChild || null; return { @@ -8831,7 +8933,7 @@ void main() { } function commitAcceptedVariantToDom(sessionId, variantId) { - const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]'); + const wrapper = findVariantsWrapper(sessionId); if (!wrapper) return false; const accepted = wrapper.querySelector('[data-impeccable-variant="' + variantId + '"]'); if (!accepted || !accepted.firstElementChild) return false; @@ -9059,7 +9161,7 @@ void main() { } function restoreFromActiveSessions(activeSessions, reason) { - const wrapper = document.querySelector('[data-impeccable-variants]'); + const wrapper = findAnyVariantsWrapper(); if (wrapper && !isFrameworkComponentPreviewMode(wrapper.dataset.impeccablePreview)) return false; if (svelteComponentSession?.sessionId === currentSessionId) return false; return restoreSessionWithoutWrapper(reason || 'sse_connected', activeSessions); @@ -9172,10 +9274,13 @@ void main() { // reconciler later tries to remove a wrapper we already removed. // Schedule a 2s fallback that does the manual swap only if HMR hasn't // replaced the wrapper by then (keeps static-server / no-HMR flows alive). - const wrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]'); - if (wrapper) { + // Every match, not the first: a target inside a `.map()` renders one + // wrapper per item, and hiding only one leaves the rest of the + // discarded variants on screen. + const discardWrappers = discardedWrappers(cleanupSessionId); + if (discardWrappers.length > 0) { if (restoreOriginal) showOriginalDuringDiscard(cleanupSessionId); - else wrapper.style.display = 'none'; + else for (const discardWrapper of discardWrappers) discardWrapper.style.display = 'none'; } setTimeout(function() { const recoverySuperseded = deferredRecoverySuperseded(cleanupSessionId, cleanupRevision); @@ -9183,16 +9288,19 @@ void main() { removeDiscardStateStylesheet(); return; } - const lateWrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]'); - if (!lateWrapper) { + const lateWrappers = discardedWrappers(cleanupSessionId); + if (lateWrappers.length === 0) { removeDiscardStateStylesheet(cleanupSessionId); return; } + // Duplicates all render from one source element, so HMR ownership is + // uniform across them; the first is a fair witness for the set. + const lateWrapper = lateWrappers[0]; if (recoverySuperseded) { if (hasFrameworkHmrOwnership(lateWrapper)) { watchForDiscardedFrameworkWrapperRemoval(cleanupSessionId); } else { - releaseDiscardedStaticWrapper(lateWrapper, cleanupSessionId); + releaseDiscardedStaticWrappers(cleanupSessionId, lateWrappers); } return; } @@ -9201,18 +9309,20 @@ void main() { // the final source rewrite, reload once after a grace window so the // discarded source becomes authoritative without a reconciler race. setTimeout(function() { - const staleWrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]'); + const staleWrappers = discardedWrappers(cleanupSessionId); if (deferredRecoverySuperseded(cleanupSessionId, cleanupRevision)) { - if (!staleWrapper) removeDiscardStateStylesheet(cleanupSessionId); + if (staleWrappers.length === 0) removeDiscardStateStylesheet(cleanupSessionId); else watchForDiscardedFrameworkWrapperRemoval(cleanupSessionId); return; } removeDiscardStateStylesheet(cleanupSessionId); - if (staleWrapper) location.reload(); + // A reload restores every wrapper's original at once, so there is + // nothing per-wrapper to do here. + if (staleWrappers.length > 0) location.reload(); }, 2000); return; } - releaseDiscardedStaticWrapper(lateWrapper, cleanupSessionId); + releaseDiscardedStaticWrappers(cleanupSessionId, lateWrappers); }, 2000); } hideBar(instantChrome); @@ -9400,8 +9510,13 @@ void main() { return restoreSessionWithoutWrapper('browser_resumed_over_handled_wrapper'); } - function resumeSession(recoveryRevision = liveInteractionRevision) { - const wrapper = document.querySelector('[data-impeccable-variants]'); + function resumeSession(recoveryRevision = liveInteractionRevision, opts = {}) { + // Which path resumed matters in the journal: an init resume is a fresh + // page load, the deferred-wrapper scout is a mid-page-load arrival. Both + // used to log the same `browser_resumed`, which made issue #719 take a + // DOM reconstruction to diagnose. + const resumeReason = opts.reason || 'browser_resumed'; + const wrapper = findAnyVariantsWrapper(); const runtimeWrapper = wrapper || document.querySelector('[data-impeccable-carbonize]'); if (restoreSessionSupersedingHandledWrapper(runtimeWrapper)) return true; if (scheduleHandledRuntimeWrapperReload(runtimeWrapper, recoveryRevision)) return false; @@ -9500,16 +9615,38 @@ void main() { showBar(state === 'CYCLING' ? 'cycling' : 'generating'); startScrollTracking(); - // Build the params panel for the restored visible variant. Previously - // this was missed on page-reload resume: showVariantInDOM above fires - // refreshParamsPanel, but state was still IDLE at that moment so it - // hid. Now that state is CYCLING, re-fire. - if (state === 'CYCLING') refreshParamsPanel(); + // A resume can BE the arrival, not just a re-entry after one. The server's + // generation preflight runs live-wrap with --defer-source-write, so the + // wrapper and every variant reach the DOM in one HMR batch, and the + // deferred-wrapper scout (constructed at init) runs before the variant + // MutationObserver (constructed at Go) on that batch. Finish the same + // transition the observer would have finished. Without hideShaderOverlay + // the generating shader stays frozen over the target and the session looks + // stuck at GENERATING while the bar already cycles (issue #719). + if (state === 'CYCLING') { + recoveryWaitingForAnchor = false; + hideShaderOverlay(); + if (isInsert) finalizeInsertSession(); + disableInlineEdit(); + // Build the params panel for the restored visible variant. Previously + // this was missed on page-reload resume: showVariantInDOM above fires + // refreshParamsPanel, but state was still IDLE at that moment so it + // hid. Now that state is CYCLING, re-fire. + refreshParamsPanel(); + } saveSession(); if (arrivedVariants > 0 && arrivedVariants < expectedVariants) { sendCheckpoint('variants_progress'); } else { - queueCheckpoint('browser_resumed'); + queueCheckpoint(resumeReason); + // Only variants_progress and variants_ready count as publication + // progress. When the resume is the arrival, the observer never gets to + // report it (this function disconnects and re-creates it below, which + // drops the records it had already queued for this same batch), so + // without this the server never learns the variants were published. + if (arrivedVariants > 0 && arrivedVariants >= expectedVariants && expectedVariants > 0) { + sendCheckpoint('variants_ready'); + } } // Start observing for more variants AFTER initial setup @@ -12831,7 +12968,7 @@ void main() { const wrapper = document.querySelector('[data-impeccable-variants],[data-impeccable-carbonize]'); if (!wrapper) return; scout.disconnect(); - if (resumeSession(deferredResumeRevision)) { + if (resumeSession(deferredResumeRevision, { reason: 'browser_resumed_deferred_wrapper' })) { console.log('[impeccable] Resumed deferred session ' + currentSessionId + ' (post-hydration).'); } }); diff --git a/.gemini/skills/impeccable/scripts/live-browser.js b/.gemini/skills/impeccable/scripts/live-browser.js index c32101b12..277c60e2c 100644 --- a/.gemini/skills/impeccable/scripts/live-browser.js +++ b/.gemini/skills/impeccable/scripts/live-browser.js @@ -2060,7 +2060,7 @@ if (anchor) return anchor; } if (currentSessionId && (state === 'GENERATING' || state === 'CYCLING')) { - const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]'); + const wrapper = findVariantsWrapper(currentSessionId); if (wrapper) { const variantCount = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])').length; if (variantCount > 0 && visibleVariant > 0) { @@ -2131,14 +2131,14 @@ function isInsertGeneratingSession() { if (state !== 'GENERATING' || !currentSessionId) return false; - const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]'); + const wrapper = findVariantsWrapper(currentSessionId); return !!wrapper && wrapper.dataset.impeccableMode === 'insert'; } /** Recreate the dotted placeholder if Astro/Vite HMR removed it mid-generation. */ function ensureInsertPlaceholder() { if (!isInsertGeneratingSession()) return placeholderElement; - const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]'); + const wrapper = findVariantsWrapper(currentSessionId); const variantCount = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])').length; if (variantCount > 0) return placeholderElement; if (placeholderElement && document.body.contains(placeholderElement)) return placeholderElement; @@ -3156,7 +3156,7 @@ || svelteComponentSession.wrapperEl || null; } - const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]'); + const wrapper = findVariantsWrapper(currentSessionId); if (!wrapper) return null; return wrapper.querySelector('[data-impeccable-variant="' + visibleVariant + '"]'); } @@ -4900,7 +4900,7 @@ return Object.values(svelteComponentSession.paramsByVariant || {}) .reduce((total, params) => total + (Array.isArray(params) ? params.length : 0), 0); } - const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]'); + const wrapper = findVariantsWrapper(currentSessionId); if (!wrapper) return 0; return [...wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])')] .reduce((total, variant) => total + parseVariantParams(variant).length, 0); @@ -5004,7 +5004,7 @@ scheduleCyclingBarSync(sessionId, num); return true; } - const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]'); + const wrapper = findVariantsWrapper(sessionId); if (!wrapper) return false; updateVariantStateStylesheet(sessionId, num); // Unconditional refresh - covers first-reveal (no-op if state isn't @@ -5820,6 +5820,7 @@ return; } setLiveState('CYCLING'); + hideShaderOverlay(); showOrUpdateCyclingBar(); saveSession(); completeParameterGenerationIfReady(); @@ -6361,7 +6362,7 @@ } rememberSessionFileMeta({ file: filePath }); if (isJsxSourceFile(filePath)) { - const liveWrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]'); + const liveWrapper = findVariantsWrapper(sessionId); if (liveWrapper && liveWrapper.querySelector('[data-impeccable-variant]:not([data-impeccable-variant="original"])')) { completeSourceInjection(liveWrapper, sessionId, { ...opts, filePath }); return; @@ -6433,7 +6434,7 @@ return; } - const existingWrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]'); + const existingWrapper = findVariantsWrapper(sessionId); if (existingWrapper) { const wrapper = srcWrapper.cloneNode(true); existingWrapper.parentElement.replaceChild(wrapper, existingWrapper); @@ -6590,7 +6591,7 @@ if (anchor && !anchor.__impeccableFrozenAnchor) selectedElement = anchor; return; } - const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]'); + const wrapper = findVariantsWrapper(currentSessionId); if (!wrapper) return; const visEl = pickVariantContent(wrapper, visibleVariant); if (visEl) selectedElement = visEl; @@ -6600,7 +6601,7 @@ if (svelteComponentSession?.sessionId === sessionId && svelteComponentSession.mountedVariant > 0) { return svelteComponentSession.mountedVariant; } - const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]'); + const wrapper = findVariantsWrapper(sessionId); if (!wrapper) return 0; const variants = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])'); for (const variant of variants) { @@ -6715,8 +6716,17 @@ document.getElementById(discardStateStyleId(sessionId))?.remove(); } - function releaseDiscardedStaticWrapper(wrapper, sessionId) { - removeDiscardStateStylesheet(sessionId); + /** + * Every wrapper a discard has to unwind. A target inside a `.map()` renders + * one wrapper per item, so the hide, the release, and the existence checks + * all have to speak about the same set. + */ + function discardedWrappers(sessionId) { + if (!sessionId) return []; + return [...document.querySelectorAll('[data-impeccable-variants="' + sessionId + '"]')]; + } + + function releaseDiscardedStaticWrapper(wrapper) { if (!wrapper) return; const orig = wrapper.querySelector('[data-impeccable-variant="original"]'); const content = orig?.firstElementChild; @@ -6727,6 +6737,18 @@ wrapper.remove(); } + /** + * Undo the discard hide on every wrapper it covered. Releasing only the + * first match left the other mapped items sitting at display:none with + * their original content never restored, on exactly the static and + * missed-HMR flows this fallback exists for. + */ + function releaseDiscardedStaticWrappers(sessionId, wrappers) { + removeDiscardStateStylesheet(sessionId); + const set = wrappers && wrappers.length ? wrappers : discardedWrappers(sessionId); + for (const wrapper of set) releaseDiscardedStaticWrapper(wrapper); + } + function watchForDiscardedFrameworkWrapperRemoval(sessionId) { if (!sessionId || !document.body) return; if (discardedFrameworkWrapperWatchers.has(sessionId)) return; @@ -6907,6 +6929,42 @@ // MutationObserver for progressive variant reveal // + // A session id can have more than one wrapper in the DOM: the target may sit + // inside a `.map()` callback (the wrapper renders once per item), or the + // agent may have relocated the wrapper out of the shared primitive live-wrap + // scaffolded into. A plain first match can then pin an empty scaffold while + // the real variants sit in a later wrapper, which strands the session at + // 0/N and leaves the bar, the params panel, and accept all reading the + // wrong element. Prefer a wrapper that actually holds variants. With zero + // or one match this is exactly the querySelector it replaces. + // + // Every lookup of the ACTIVE session's wrapper goes through here. The + // remaining raw `[data-impeccable-variants=...]` uses are deliberate: bare + // existence checks, selector strings for stylesheets and observers (which + // want to cover every match), `querySelectorAll` sweeps, and the parsed + // source document, which is not this document. + function pickPopulatedVariantsWrapper(selector) { + const matches = document.querySelectorAll(selector); + if (matches.length < 2) return matches[0] || null; + for (const candidate of matches) { + if (candidate.querySelector('[data-impeccable-variant]:not([data-impeccable-variant="original"])')) { + return candidate; + } + } + return matches[0]; + } + + /** The wrapper holding `sessionId`'s variants, or null without an id. */ + function findVariantsWrapper(sessionId) { + if (!sessionId) return null; + return pickPopulatedVariantsWrapper('[data-impeccable-variants="' + sessionId + '"]'); + } + + /** Any live variant wrapper, for the resume paths that have no id yet. */ + function findAnyVariantsWrapper() { + return pickPopulatedVariantsWrapper('[data-impeccable-variants]'); + } + function startVariantObserver(sessionId) { let updating = false; // re-entrancy guard @@ -6936,7 +6994,7 @@ } if (!dominated) return; - const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]'); + const wrapper = findVariantsWrapper(sessionId); if (!wrapper) return; const variants = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])'); @@ -7145,6 +7203,7 @@ if (arrivedVariants >= expectedVariants && expectedVariants > 0) { if (state === 'GENERATING') { setLiveState('CYCLING'); + hideShaderOverlay(); showOrUpdateCyclingBar(); disableInlineEdit(); refreshParamsPanel(); @@ -7205,6 +7264,7 @@ pendingAcceptedSession = null; awaitingAcceptResult = null; setLiveState('CYCLING'); + hideShaderOverlay(); updateBarContent('cycling'); showToast('Could not complete accept cleanup. Try Accept again.', 5000); break; @@ -8307,6 +8367,15 @@ void main() { // matches the original off-white risograph paper. const SHADER_PAPER_FALLBACK = [0.975, 0.965, 0.955]; let shaderState = null; // { canvas, gl, program, texture, rafId, startTime } + // showShaderOverlay is async: it appends its canvas, then awaits + // createImageBitmap and the GL setup before it publishes shaderState. A + // teardown that landed inside that window found shaderState still null, + // returned, and then watched the construction publish itself over a session + // that had already left GENERATING, with no teardown left to run. That is + // the generating loader frozen over a page that already cycles (issue #719). + // Every teardown bumps this epoch; a construction abandons its own canvas as + // soon as it sees the epoch move. + let shaderEpoch = 0; // The element's effective background tone, used as the uniform halftone // ground so content dissolves into dots over it. Unlike resolveCanvasBackground @@ -8453,14 +8522,28 @@ void main() { }); } + /** Drop a shader node no shaderState owns (an abandoned construction). */ + function removeStrayShaderNode() { + const stray = uiGetById(PREFIX + '-shader'); + if (stray) stray.remove(); + } + function hideShaderOverlay() { - if (!shaderState) return; + // Bump first, unconditionally: this is what tells an in-flight + // showShaderOverlay to abandon itself rather than publish over a session + // that has already moved on. + shaderEpoch += 1; + if (!shaderState) { + removeStrayShaderNode(); + return; + } if (shaderState.rafId) cancelAnimationFrame(shaderState.rafId); if (shaderState.canvas) shaderState.canvas.remove(); if (shaderState.objectUrl) URL.revokeObjectURL(shaderState.objectUrl); const lose = shaderState.gl?.getExtension?.('WEBGL_lose_context'); try { lose?.loseContext(); } catch {} shaderState = null; + removeStrayShaderNode(); } function showShaderBitmapFallback(canvas, blob) { @@ -8485,6 +8568,16 @@ void main() { async function showShaderOverlay(el, blob, rect, paper) { hideShaderOverlay(); if (!blob || !el) return; + // hideShaderOverlay just bumped the epoch, so this run owns it until the + // next teardown. Every step past an await re-checks before it publishes. + const epoch = shaderEpoch; + const abandoned = (node, gl) => { + if (epoch === shaderEpoch) return false; + node.remove(); + const lose = gl?.getExtension?.('WEBGL_lose_context'); + try { lose?.loseContext(); } catch {} + return true; + }; const canvas = document.createElement('canvas'); canvas.id = PREFIX + '-shader'; const dpr = Math.min(window.devicePixelRatio || 1, 2); @@ -8507,6 +8600,7 @@ void main() { if (!gl) { // WebGL unavailable: use the captured bitmap as a background overlay so // the user still sees something meaningful during generation. + if (abandoned(canvas, null)) return; showShaderBitmapFallback(canvas, blob); return; } @@ -8546,16 +8640,22 @@ void main() { } // Upload the screenshot as a texture + if (abandoned(canvas, gl)) return; let bitmap; try { bitmap = await createImageBitmap(blob); } catch (err) { console.warn('[impeccable] shader bitmap decode failed:', err); + if (abandoned(canvas, gl)) return; const lose = gl.getExtension?.('WEBGL_lose_context'); try { lose?.loseContext(); } catch {} showShaderBitmapFallback(canvas, blob); return; } + if (abandoned(canvas, gl)) { + if (bitmap.close) bitmap.close(); + return; + } texture = gl.createTexture(); gl.bindTexture(gl.TEXTURE_2D, texture); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); @@ -8574,6 +8674,7 @@ void main() { const paperRgb = paper || resolvePaperRgb(el); const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches; + if (abandoned(canvas, gl)) return; shaderState = { canvas, gl, program, texture, rafId: 0, startTime: performance.now(), reduced }; function frame() { if (!shaderState) return; @@ -8610,7 +8711,7 @@ void main() { clientSentAt: Date.now(), }; if (!currentSessionId || arrivedVariants === 0) return; - const acceptWrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]'); + const acceptWrapper = findVariantsWrapper(currentSessionId); if (Object.keys(paramsCurrentValues).length > 0) { acceptPayload.paramValues = { ...paramsCurrentValues }; } @@ -8653,6 +8754,7 @@ void main() { .catch(() => { if (pendingAcceptedSession?.id === acceptedSessionId) pendingAcceptedSession = null; setLiveState('CYCLING'); + hideShaderOverlay(); showOrUpdateCyclingBar(); showToast('Could not confirm accept with the live server. Session kept for recovery; try Accept again.', 5000); }); @@ -8704,7 +8806,7 @@ void main() { } function snapshotAcceptedVariantDom(sessionId, variantId) { - const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]'); + const wrapper = findVariantsWrapper(sessionId); const accepted = wrapper?.querySelector?.('[data-impeccable-variant="' + variantId + '"]'); const root = accepted?.firstElementChild || null; return { @@ -8831,7 +8933,7 @@ void main() { } function commitAcceptedVariantToDom(sessionId, variantId) { - const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]'); + const wrapper = findVariantsWrapper(sessionId); if (!wrapper) return false; const accepted = wrapper.querySelector('[data-impeccable-variant="' + variantId + '"]'); if (!accepted || !accepted.firstElementChild) return false; @@ -9059,7 +9161,7 @@ void main() { } function restoreFromActiveSessions(activeSessions, reason) { - const wrapper = document.querySelector('[data-impeccable-variants]'); + const wrapper = findAnyVariantsWrapper(); if (wrapper && !isFrameworkComponentPreviewMode(wrapper.dataset.impeccablePreview)) return false; if (svelteComponentSession?.sessionId === currentSessionId) return false; return restoreSessionWithoutWrapper(reason || 'sse_connected', activeSessions); @@ -9172,10 +9274,13 @@ void main() { // reconciler later tries to remove a wrapper we already removed. // Schedule a 2s fallback that does the manual swap only if HMR hasn't // replaced the wrapper by then (keeps static-server / no-HMR flows alive). - const wrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]'); - if (wrapper) { + // Every match, not the first: a target inside a `.map()` renders one + // wrapper per item, and hiding only one leaves the rest of the + // discarded variants on screen. + const discardWrappers = discardedWrappers(cleanupSessionId); + if (discardWrappers.length > 0) { if (restoreOriginal) showOriginalDuringDiscard(cleanupSessionId); - else wrapper.style.display = 'none'; + else for (const discardWrapper of discardWrappers) discardWrapper.style.display = 'none'; } setTimeout(function() { const recoverySuperseded = deferredRecoverySuperseded(cleanupSessionId, cleanupRevision); @@ -9183,16 +9288,19 @@ void main() { removeDiscardStateStylesheet(); return; } - const lateWrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]'); - if (!lateWrapper) { + const lateWrappers = discardedWrappers(cleanupSessionId); + if (lateWrappers.length === 0) { removeDiscardStateStylesheet(cleanupSessionId); return; } + // Duplicates all render from one source element, so HMR ownership is + // uniform across them; the first is a fair witness for the set. + const lateWrapper = lateWrappers[0]; if (recoverySuperseded) { if (hasFrameworkHmrOwnership(lateWrapper)) { watchForDiscardedFrameworkWrapperRemoval(cleanupSessionId); } else { - releaseDiscardedStaticWrapper(lateWrapper, cleanupSessionId); + releaseDiscardedStaticWrappers(cleanupSessionId, lateWrappers); } return; } @@ -9201,18 +9309,20 @@ void main() { // the final source rewrite, reload once after a grace window so the // discarded source becomes authoritative without a reconciler race. setTimeout(function() { - const staleWrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]'); + const staleWrappers = discardedWrappers(cleanupSessionId); if (deferredRecoverySuperseded(cleanupSessionId, cleanupRevision)) { - if (!staleWrapper) removeDiscardStateStylesheet(cleanupSessionId); + if (staleWrappers.length === 0) removeDiscardStateStylesheet(cleanupSessionId); else watchForDiscardedFrameworkWrapperRemoval(cleanupSessionId); return; } removeDiscardStateStylesheet(cleanupSessionId); - if (staleWrapper) location.reload(); + // A reload restores every wrapper's original at once, so there is + // nothing per-wrapper to do here. + if (staleWrappers.length > 0) location.reload(); }, 2000); return; } - releaseDiscardedStaticWrapper(lateWrapper, cleanupSessionId); + releaseDiscardedStaticWrappers(cleanupSessionId, lateWrappers); }, 2000); } hideBar(instantChrome); @@ -9400,8 +9510,13 @@ void main() { return restoreSessionWithoutWrapper('browser_resumed_over_handled_wrapper'); } - function resumeSession(recoveryRevision = liveInteractionRevision) { - const wrapper = document.querySelector('[data-impeccable-variants]'); + function resumeSession(recoveryRevision = liveInteractionRevision, opts = {}) { + // Which path resumed matters in the journal: an init resume is a fresh + // page load, the deferred-wrapper scout is a mid-page-load arrival. Both + // used to log the same `browser_resumed`, which made issue #719 take a + // DOM reconstruction to diagnose. + const resumeReason = opts.reason || 'browser_resumed'; + const wrapper = findAnyVariantsWrapper(); const runtimeWrapper = wrapper || document.querySelector('[data-impeccable-carbonize]'); if (restoreSessionSupersedingHandledWrapper(runtimeWrapper)) return true; if (scheduleHandledRuntimeWrapperReload(runtimeWrapper, recoveryRevision)) return false; @@ -9500,16 +9615,38 @@ void main() { showBar(state === 'CYCLING' ? 'cycling' : 'generating'); startScrollTracking(); - // Build the params panel for the restored visible variant. Previously - // this was missed on page-reload resume: showVariantInDOM above fires - // refreshParamsPanel, but state was still IDLE at that moment so it - // hid. Now that state is CYCLING, re-fire. - if (state === 'CYCLING') refreshParamsPanel(); + // A resume can BE the arrival, not just a re-entry after one. The server's + // generation preflight runs live-wrap with --defer-source-write, so the + // wrapper and every variant reach the DOM in one HMR batch, and the + // deferred-wrapper scout (constructed at init) runs before the variant + // MutationObserver (constructed at Go) on that batch. Finish the same + // transition the observer would have finished. Without hideShaderOverlay + // the generating shader stays frozen over the target and the session looks + // stuck at GENERATING while the bar already cycles (issue #719). + if (state === 'CYCLING') { + recoveryWaitingForAnchor = false; + hideShaderOverlay(); + if (isInsert) finalizeInsertSession(); + disableInlineEdit(); + // Build the params panel for the restored visible variant. Previously + // this was missed on page-reload resume: showVariantInDOM above fires + // refreshParamsPanel, but state was still IDLE at that moment so it + // hid. Now that state is CYCLING, re-fire. + refreshParamsPanel(); + } saveSession(); if (arrivedVariants > 0 && arrivedVariants < expectedVariants) { sendCheckpoint('variants_progress'); } else { - queueCheckpoint('browser_resumed'); + queueCheckpoint(resumeReason); + // Only variants_progress and variants_ready count as publication + // progress. When the resume is the arrival, the observer never gets to + // report it (this function disconnects and re-creates it below, which + // drops the records it had already queued for this same batch), so + // without this the server never learns the variants were published. + if (arrivedVariants > 0 && arrivedVariants >= expectedVariants && expectedVariants > 0) { + sendCheckpoint('variants_ready'); + } } // Start observing for more variants AFTER initial setup @@ -12831,7 +12968,7 @@ void main() { const wrapper = document.querySelector('[data-impeccable-variants],[data-impeccable-carbonize]'); if (!wrapper) return; scout.disconnect(); - if (resumeSession(deferredResumeRevision)) { + if (resumeSession(deferredResumeRevision, { reason: 'browser_resumed_deferred_wrapper' })) { console.log('[impeccable] Resumed deferred session ' + currentSessionId + ' (post-hydration).'); } }); diff --git a/.github/skills/impeccable/scripts/live-browser.js b/.github/skills/impeccable/scripts/live-browser.js index c32101b12..277c60e2c 100644 --- a/.github/skills/impeccable/scripts/live-browser.js +++ b/.github/skills/impeccable/scripts/live-browser.js @@ -2060,7 +2060,7 @@ if (anchor) return anchor; } if (currentSessionId && (state === 'GENERATING' || state === 'CYCLING')) { - const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]'); + const wrapper = findVariantsWrapper(currentSessionId); if (wrapper) { const variantCount = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])').length; if (variantCount > 0 && visibleVariant > 0) { @@ -2131,14 +2131,14 @@ function isInsertGeneratingSession() { if (state !== 'GENERATING' || !currentSessionId) return false; - const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]'); + const wrapper = findVariantsWrapper(currentSessionId); return !!wrapper && wrapper.dataset.impeccableMode === 'insert'; } /** Recreate the dotted placeholder if Astro/Vite HMR removed it mid-generation. */ function ensureInsertPlaceholder() { if (!isInsertGeneratingSession()) return placeholderElement; - const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]'); + const wrapper = findVariantsWrapper(currentSessionId); const variantCount = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])').length; if (variantCount > 0) return placeholderElement; if (placeholderElement && document.body.contains(placeholderElement)) return placeholderElement; @@ -3156,7 +3156,7 @@ || svelteComponentSession.wrapperEl || null; } - const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]'); + const wrapper = findVariantsWrapper(currentSessionId); if (!wrapper) return null; return wrapper.querySelector('[data-impeccable-variant="' + visibleVariant + '"]'); } @@ -4900,7 +4900,7 @@ return Object.values(svelteComponentSession.paramsByVariant || {}) .reduce((total, params) => total + (Array.isArray(params) ? params.length : 0), 0); } - const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]'); + const wrapper = findVariantsWrapper(currentSessionId); if (!wrapper) return 0; return [...wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])')] .reduce((total, variant) => total + parseVariantParams(variant).length, 0); @@ -5004,7 +5004,7 @@ scheduleCyclingBarSync(sessionId, num); return true; } - const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]'); + const wrapper = findVariantsWrapper(sessionId); if (!wrapper) return false; updateVariantStateStylesheet(sessionId, num); // Unconditional refresh - covers first-reveal (no-op if state isn't @@ -5820,6 +5820,7 @@ return; } setLiveState('CYCLING'); + hideShaderOverlay(); showOrUpdateCyclingBar(); saveSession(); completeParameterGenerationIfReady(); @@ -6361,7 +6362,7 @@ } rememberSessionFileMeta({ file: filePath }); if (isJsxSourceFile(filePath)) { - const liveWrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]'); + const liveWrapper = findVariantsWrapper(sessionId); if (liveWrapper && liveWrapper.querySelector('[data-impeccable-variant]:not([data-impeccable-variant="original"])')) { completeSourceInjection(liveWrapper, sessionId, { ...opts, filePath }); return; @@ -6433,7 +6434,7 @@ return; } - const existingWrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]'); + const existingWrapper = findVariantsWrapper(sessionId); if (existingWrapper) { const wrapper = srcWrapper.cloneNode(true); existingWrapper.parentElement.replaceChild(wrapper, existingWrapper); @@ -6590,7 +6591,7 @@ if (anchor && !anchor.__impeccableFrozenAnchor) selectedElement = anchor; return; } - const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]'); + const wrapper = findVariantsWrapper(currentSessionId); if (!wrapper) return; const visEl = pickVariantContent(wrapper, visibleVariant); if (visEl) selectedElement = visEl; @@ -6600,7 +6601,7 @@ if (svelteComponentSession?.sessionId === sessionId && svelteComponentSession.mountedVariant > 0) { return svelteComponentSession.mountedVariant; } - const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]'); + const wrapper = findVariantsWrapper(sessionId); if (!wrapper) return 0; const variants = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])'); for (const variant of variants) { @@ -6715,8 +6716,17 @@ document.getElementById(discardStateStyleId(sessionId))?.remove(); } - function releaseDiscardedStaticWrapper(wrapper, sessionId) { - removeDiscardStateStylesheet(sessionId); + /** + * Every wrapper a discard has to unwind. A target inside a `.map()` renders + * one wrapper per item, so the hide, the release, and the existence checks + * all have to speak about the same set. + */ + function discardedWrappers(sessionId) { + if (!sessionId) return []; + return [...document.querySelectorAll('[data-impeccable-variants="' + sessionId + '"]')]; + } + + function releaseDiscardedStaticWrapper(wrapper) { if (!wrapper) return; const orig = wrapper.querySelector('[data-impeccable-variant="original"]'); const content = orig?.firstElementChild; @@ -6727,6 +6737,18 @@ wrapper.remove(); } + /** + * Undo the discard hide on every wrapper it covered. Releasing only the + * first match left the other mapped items sitting at display:none with + * their original content never restored, on exactly the static and + * missed-HMR flows this fallback exists for. + */ + function releaseDiscardedStaticWrappers(sessionId, wrappers) { + removeDiscardStateStylesheet(sessionId); + const set = wrappers && wrappers.length ? wrappers : discardedWrappers(sessionId); + for (const wrapper of set) releaseDiscardedStaticWrapper(wrapper); + } + function watchForDiscardedFrameworkWrapperRemoval(sessionId) { if (!sessionId || !document.body) return; if (discardedFrameworkWrapperWatchers.has(sessionId)) return; @@ -6907,6 +6929,42 @@ // MutationObserver for progressive variant reveal // + // A session id can have more than one wrapper in the DOM: the target may sit + // inside a `.map()` callback (the wrapper renders once per item), or the + // agent may have relocated the wrapper out of the shared primitive live-wrap + // scaffolded into. A plain first match can then pin an empty scaffold while + // the real variants sit in a later wrapper, which strands the session at + // 0/N and leaves the bar, the params panel, and accept all reading the + // wrong element. Prefer a wrapper that actually holds variants. With zero + // or one match this is exactly the querySelector it replaces. + // + // Every lookup of the ACTIVE session's wrapper goes through here. The + // remaining raw `[data-impeccable-variants=...]` uses are deliberate: bare + // existence checks, selector strings for stylesheets and observers (which + // want to cover every match), `querySelectorAll` sweeps, and the parsed + // source document, which is not this document. + function pickPopulatedVariantsWrapper(selector) { + const matches = document.querySelectorAll(selector); + if (matches.length < 2) return matches[0] || null; + for (const candidate of matches) { + if (candidate.querySelector('[data-impeccable-variant]:not([data-impeccable-variant="original"])')) { + return candidate; + } + } + return matches[0]; + } + + /** The wrapper holding `sessionId`'s variants, or null without an id. */ + function findVariantsWrapper(sessionId) { + if (!sessionId) return null; + return pickPopulatedVariantsWrapper('[data-impeccable-variants="' + sessionId + '"]'); + } + + /** Any live variant wrapper, for the resume paths that have no id yet. */ + function findAnyVariantsWrapper() { + return pickPopulatedVariantsWrapper('[data-impeccable-variants]'); + } + function startVariantObserver(sessionId) { let updating = false; // re-entrancy guard @@ -6936,7 +6994,7 @@ } if (!dominated) return; - const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]'); + const wrapper = findVariantsWrapper(sessionId); if (!wrapper) return; const variants = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])'); @@ -7145,6 +7203,7 @@ if (arrivedVariants >= expectedVariants && expectedVariants > 0) { if (state === 'GENERATING') { setLiveState('CYCLING'); + hideShaderOverlay(); showOrUpdateCyclingBar(); disableInlineEdit(); refreshParamsPanel(); @@ -7205,6 +7264,7 @@ pendingAcceptedSession = null; awaitingAcceptResult = null; setLiveState('CYCLING'); + hideShaderOverlay(); updateBarContent('cycling'); showToast('Could not complete accept cleanup. Try Accept again.', 5000); break; @@ -8307,6 +8367,15 @@ void main() { // matches the original off-white risograph paper. const SHADER_PAPER_FALLBACK = [0.975, 0.965, 0.955]; let shaderState = null; // { canvas, gl, program, texture, rafId, startTime } + // showShaderOverlay is async: it appends its canvas, then awaits + // createImageBitmap and the GL setup before it publishes shaderState. A + // teardown that landed inside that window found shaderState still null, + // returned, and then watched the construction publish itself over a session + // that had already left GENERATING, with no teardown left to run. That is + // the generating loader frozen over a page that already cycles (issue #719). + // Every teardown bumps this epoch; a construction abandons its own canvas as + // soon as it sees the epoch move. + let shaderEpoch = 0; // The element's effective background tone, used as the uniform halftone // ground so content dissolves into dots over it. Unlike resolveCanvasBackground @@ -8453,14 +8522,28 @@ void main() { }); } + /** Drop a shader node no shaderState owns (an abandoned construction). */ + function removeStrayShaderNode() { + const stray = uiGetById(PREFIX + '-shader'); + if (stray) stray.remove(); + } + function hideShaderOverlay() { - if (!shaderState) return; + // Bump first, unconditionally: this is what tells an in-flight + // showShaderOverlay to abandon itself rather than publish over a session + // that has already moved on. + shaderEpoch += 1; + if (!shaderState) { + removeStrayShaderNode(); + return; + } if (shaderState.rafId) cancelAnimationFrame(shaderState.rafId); if (shaderState.canvas) shaderState.canvas.remove(); if (shaderState.objectUrl) URL.revokeObjectURL(shaderState.objectUrl); const lose = shaderState.gl?.getExtension?.('WEBGL_lose_context'); try { lose?.loseContext(); } catch {} shaderState = null; + removeStrayShaderNode(); } function showShaderBitmapFallback(canvas, blob) { @@ -8485,6 +8568,16 @@ void main() { async function showShaderOverlay(el, blob, rect, paper) { hideShaderOverlay(); if (!blob || !el) return; + // hideShaderOverlay just bumped the epoch, so this run owns it until the + // next teardown. Every step past an await re-checks before it publishes. + const epoch = shaderEpoch; + const abandoned = (node, gl) => { + if (epoch === shaderEpoch) return false; + node.remove(); + const lose = gl?.getExtension?.('WEBGL_lose_context'); + try { lose?.loseContext(); } catch {} + return true; + }; const canvas = document.createElement('canvas'); canvas.id = PREFIX + '-shader'; const dpr = Math.min(window.devicePixelRatio || 1, 2); @@ -8507,6 +8600,7 @@ void main() { if (!gl) { // WebGL unavailable: use the captured bitmap as a background overlay so // the user still sees something meaningful during generation. + if (abandoned(canvas, null)) return; showShaderBitmapFallback(canvas, blob); return; } @@ -8546,16 +8640,22 @@ void main() { } // Upload the screenshot as a texture + if (abandoned(canvas, gl)) return; let bitmap; try { bitmap = await createImageBitmap(blob); } catch (err) { console.warn('[impeccable] shader bitmap decode failed:', err); + if (abandoned(canvas, gl)) return; const lose = gl.getExtension?.('WEBGL_lose_context'); try { lose?.loseContext(); } catch {} showShaderBitmapFallback(canvas, blob); return; } + if (abandoned(canvas, gl)) { + if (bitmap.close) bitmap.close(); + return; + } texture = gl.createTexture(); gl.bindTexture(gl.TEXTURE_2D, texture); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); @@ -8574,6 +8674,7 @@ void main() { const paperRgb = paper || resolvePaperRgb(el); const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches; + if (abandoned(canvas, gl)) return; shaderState = { canvas, gl, program, texture, rafId: 0, startTime: performance.now(), reduced }; function frame() { if (!shaderState) return; @@ -8610,7 +8711,7 @@ void main() { clientSentAt: Date.now(), }; if (!currentSessionId || arrivedVariants === 0) return; - const acceptWrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]'); + const acceptWrapper = findVariantsWrapper(currentSessionId); if (Object.keys(paramsCurrentValues).length > 0) { acceptPayload.paramValues = { ...paramsCurrentValues }; } @@ -8653,6 +8754,7 @@ void main() { .catch(() => { if (pendingAcceptedSession?.id === acceptedSessionId) pendingAcceptedSession = null; setLiveState('CYCLING'); + hideShaderOverlay(); showOrUpdateCyclingBar(); showToast('Could not confirm accept with the live server. Session kept for recovery; try Accept again.', 5000); }); @@ -8704,7 +8806,7 @@ void main() { } function snapshotAcceptedVariantDom(sessionId, variantId) { - const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]'); + const wrapper = findVariantsWrapper(sessionId); const accepted = wrapper?.querySelector?.('[data-impeccable-variant="' + variantId + '"]'); const root = accepted?.firstElementChild || null; return { @@ -8831,7 +8933,7 @@ void main() { } function commitAcceptedVariantToDom(sessionId, variantId) { - const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]'); + const wrapper = findVariantsWrapper(sessionId); if (!wrapper) return false; const accepted = wrapper.querySelector('[data-impeccable-variant="' + variantId + '"]'); if (!accepted || !accepted.firstElementChild) return false; @@ -9059,7 +9161,7 @@ void main() { } function restoreFromActiveSessions(activeSessions, reason) { - const wrapper = document.querySelector('[data-impeccable-variants]'); + const wrapper = findAnyVariantsWrapper(); if (wrapper && !isFrameworkComponentPreviewMode(wrapper.dataset.impeccablePreview)) return false; if (svelteComponentSession?.sessionId === currentSessionId) return false; return restoreSessionWithoutWrapper(reason || 'sse_connected', activeSessions); @@ -9172,10 +9274,13 @@ void main() { // reconciler later tries to remove a wrapper we already removed. // Schedule a 2s fallback that does the manual swap only if HMR hasn't // replaced the wrapper by then (keeps static-server / no-HMR flows alive). - const wrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]'); - if (wrapper) { + // Every match, not the first: a target inside a `.map()` renders one + // wrapper per item, and hiding only one leaves the rest of the + // discarded variants on screen. + const discardWrappers = discardedWrappers(cleanupSessionId); + if (discardWrappers.length > 0) { if (restoreOriginal) showOriginalDuringDiscard(cleanupSessionId); - else wrapper.style.display = 'none'; + else for (const discardWrapper of discardWrappers) discardWrapper.style.display = 'none'; } setTimeout(function() { const recoverySuperseded = deferredRecoverySuperseded(cleanupSessionId, cleanupRevision); @@ -9183,16 +9288,19 @@ void main() { removeDiscardStateStylesheet(); return; } - const lateWrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]'); - if (!lateWrapper) { + const lateWrappers = discardedWrappers(cleanupSessionId); + if (lateWrappers.length === 0) { removeDiscardStateStylesheet(cleanupSessionId); return; } + // Duplicates all render from one source element, so HMR ownership is + // uniform across them; the first is a fair witness for the set. + const lateWrapper = lateWrappers[0]; if (recoverySuperseded) { if (hasFrameworkHmrOwnership(lateWrapper)) { watchForDiscardedFrameworkWrapperRemoval(cleanupSessionId); } else { - releaseDiscardedStaticWrapper(lateWrapper, cleanupSessionId); + releaseDiscardedStaticWrappers(cleanupSessionId, lateWrappers); } return; } @@ -9201,18 +9309,20 @@ void main() { // the final source rewrite, reload once after a grace window so the // discarded source becomes authoritative without a reconciler race. setTimeout(function() { - const staleWrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]'); + const staleWrappers = discardedWrappers(cleanupSessionId); if (deferredRecoverySuperseded(cleanupSessionId, cleanupRevision)) { - if (!staleWrapper) removeDiscardStateStylesheet(cleanupSessionId); + if (staleWrappers.length === 0) removeDiscardStateStylesheet(cleanupSessionId); else watchForDiscardedFrameworkWrapperRemoval(cleanupSessionId); return; } removeDiscardStateStylesheet(cleanupSessionId); - if (staleWrapper) location.reload(); + // A reload restores every wrapper's original at once, so there is + // nothing per-wrapper to do here. + if (staleWrappers.length > 0) location.reload(); }, 2000); return; } - releaseDiscardedStaticWrapper(lateWrapper, cleanupSessionId); + releaseDiscardedStaticWrappers(cleanupSessionId, lateWrappers); }, 2000); } hideBar(instantChrome); @@ -9400,8 +9510,13 @@ void main() { return restoreSessionWithoutWrapper('browser_resumed_over_handled_wrapper'); } - function resumeSession(recoveryRevision = liveInteractionRevision) { - const wrapper = document.querySelector('[data-impeccable-variants]'); + function resumeSession(recoveryRevision = liveInteractionRevision, opts = {}) { + // Which path resumed matters in the journal: an init resume is a fresh + // page load, the deferred-wrapper scout is a mid-page-load arrival. Both + // used to log the same `browser_resumed`, which made issue #719 take a + // DOM reconstruction to diagnose. + const resumeReason = opts.reason || 'browser_resumed'; + const wrapper = findAnyVariantsWrapper(); const runtimeWrapper = wrapper || document.querySelector('[data-impeccable-carbonize]'); if (restoreSessionSupersedingHandledWrapper(runtimeWrapper)) return true; if (scheduleHandledRuntimeWrapperReload(runtimeWrapper, recoveryRevision)) return false; @@ -9500,16 +9615,38 @@ void main() { showBar(state === 'CYCLING' ? 'cycling' : 'generating'); startScrollTracking(); - // Build the params panel for the restored visible variant. Previously - // this was missed on page-reload resume: showVariantInDOM above fires - // refreshParamsPanel, but state was still IDLE at that moment so it - // hid. Now that state is CYCLING, re-fire. - if (state === 'CYCLING') refreshParamsPanel(); + // A resume can BE the arrival, not just a re-entry after one. The server's + // generation preflight runs live-wrap with --defer-source-write, so the + // wrapper and every variant reach the DOM in one HMR batch, and the + // deferred-wrapper scout (constructed at init) runs before the variant + // MutationObserver (constructed at Go) on that batch. Finish the same + // transition the observer would have finished. Without hideShaderOverlay + // the generating shader stays frozen over the target and the session looks + // stuck at GENERATING while the bar already cycles (issue #719). + if (state === 'CYCLING') { + recoveryWaitingForAnchor = false; + hideShaderOverlay(); + if (isInsert) finalizeInsertSession(); + disableInlineEdit(); + // Build the params panel for the restored visible variant. Previously + // this was missed on page-reload resume: showVariantInDOM above fires + // refreshParamsPanel, but state was still IDLE at that moment so it + // hid. Now that state is CYCLING, re-fire. + refreshParamsPanel(); + } saveSession(); if (arrivedVariants > 0 && arrivedVariants < expectedVariants) { sendCheckpoint('variants_progress'); } else { - queueCheckpoint('browser_resumed'); + queueCheckpoint(resumeReason); + // Only variants_progress and variants_ready count as publication + // progress. When the resume is the arrival, the observer never gets to + // report it (this function disconnects and re-creates it below, which + // drops the records it had already queued for this same batch), so + // without this the server never learns the variants were published. + if (arrivedVariants > 0 && arrivedVariants >= expectedVariants && expectedVariants > 0) { + sendCheckpoint('variants_ready'); + } } // Start observing for more variants AFTER initial setup @@ -12831,7 +12968,7 @@ void main() { const wrapper = document.querySelector('[data-impeccable-variants],[data-impeccable-carbonize]'); if (!wrapper) return; scout.disconnect(); - if (resumeSession(deferredResumeRevision)) { + if (resumeSession(deferredResumeRevision, { reason: 'browser_resumed_deferred_wrapper' })) { console.log('[impeccable] Resumed deferred session ' + currentSessionId + ' (post-hydration).'); } }); diff --git a/.grok/skills/impeccable/scripts/live-browser.js b/.grok/skills/impeccable/scripts/live-browser.js index c32101b12..277c60e2c 100644 --- a/.grok/skills/impeccable/scripts/live-browser.js +++ b/.grok/skills/impeccable/scripts/live-browser.js @@ -2060,7 +2060,7 @@ if (anchor) return anchor; } if (currentSessionId && (state === 'GENERATING' || state === 'CYCLING')) { - const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]'); + const wrapper = findVariantsWrapper(currentSessionId); if (wrapper) { const variantCount = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])').length; if (variantCount > 0 && visibleVariant > 0) { @@ -2131,14 +2131,14 @@ function isInsertGeneratingSession() { if (state !== 'GENERATING' || !currentSessionId) return false; - const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]'); + const wrapper = findVariantsWrapper(currentSessionId); return !!wrapper && wrapper.dataset.impeccableMode === 'insert'; } /** Recreate the dotted placeholder if Astro/Vite HMR removed it mid-generation. */ function ensureInsertPlaceholder() { if (!isInsertGeneratingSession()) return placeholderElement; - const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]'); + const wrapper = findVariantsWrapper(currentSessionId); const variantCount = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])').length; if (variantCount > 0) return placeholderElement; if (placeholderElement && document.body.contains(placeholderElement)) return placeholderElement; @@ -3156,7 +3156,7 @@ || svelteComponentSession.wrapperEl || null; } - const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]'); + const wrapper = findVariantsWrapper(currentSessionId); if (!wrapper) return null; return wrapper.querySelector('[data-impeccable-variant="' + visibleVariant + '"]'); } @@ -4900,7 +4900,7 @@ return Object.values(svelteComponentSession.paramsByVariant || {}) .reduce((total, params) => total + (Array.isArray(params) ? params.length : 0), 0); } - const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]'); + const wrapper = findVariantsWrapper(currentSessionId); if (!wrapper) return 0; return [...wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])')] .reduce((total, variant) => total + parseVariantParams(variant).length, 0); @@ -5004,7 +5004,7 @@ scheduleCyclingBarSync(sessionId, num); return true; } - const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]'); + const wrapper = findVariantsWrapper(sessionId); if (!wrapper) return false; updateVariantStateStylesheet(sessionId, num); // Unconditional refresh - covers first-reveal (no-op if state isn't @@ -5820,6 +5820,7 @@ return; } setLiveState('CYCLING'); + hideShaderOverlay(); showOrUpdateCyclingBar(); saveSession(); completeParameterGenerationIfReady(); @@ -6361,7 +6362,7 @@ } rememberSessionFileMeta({ file: filePath }); if (isJsxSourceFile(filePath)) { - const liveWrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]'); + const liveWrapper = findVariantsWrapper(sessionId); if (liveWrapper && liveWrapper.querySelector('[data-impeccable-variant]:not([data-impeccable-variant="original"])')) { completeSourceInjection(liveWrapper, sessionId, { ...opts, filePath }); return; @@ -6433,7 +6434,7 @@ return; } - const existingWrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]'); + const existingWrapper = findVariantsWrapper(sessionId); if (existingWrapper) { const wrapper = srcWrapper.cloneNode(true); existingWrapper.parentElement.replaceChild(wrapper, existingWrapper); @@ -6590,7 +6591,7 @@ if (anchor && !anchor.__impeccableFrozenAnchor) selectedElement = anchor; return; } - const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]'); + const wrapper = findVariantsWrapper(currentSessionId); if (!wrapper) return; const visEl = pickVariantContent(wrapper, visibleVariant); if (visEl) selectedElement = visEl; @@ -6600,7 +6601,7 @@ if (svelteComponentSession?.sessionId === sessionId && svelteComponentSession.mountedVariant > 0) { return svelteComponentSession.mountedVariant; } - const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]'); + const wrapper = findVariantsWrapper(sessionId); if (!wrapper) return 0; const variants = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])'); for (const variant of variants) { @@ -6715,8 +6716,17 @@ document.getElementById(discardStateStyleId(sessionId))?.remove(); } - function releaseDiscardedStaticWrapper(wrapper, sessionId) { - removeDiscardStateStylesheet(sessionId); + /** + * Every wrapper a discard has to unwind. A target inside a `.map()` renders + * one wrapper per item, so the hide, the release, and the existence checks + * all have to speak about the same set. + */ + function discardedWrappers(sessionId) { + if (!sessionId) return []; + return [...document.querySelectorAll('[data-impeccable-variants="' + sessionId + '"]')]; + } + + function releaseDiscardedStaticWrapper(wrapper) { if (!wrapper) return; const orig = wrapper.querySelector('[data-impeccable-variant="original"]'); const content = orig?.firstElementChild; @@ -6727,6 +6737,18 @@ wrapper.remove(); } + /** + * Undo the discard hide on every wrapper it covered. Releasing only the + * first match left the other mapped items sitting at display:none with + * their original content never restored, on exactly the static and + * missed-HMR flows this fallback exists for. + */ + function releaseDiscardedStaticWrappers(sessionId, wrappers) { + removeDiscardStateStylesheet(sessionId); + const set = wrappers && wrappers.length ? wrappers : discardedWrappers(sessionId); + for (const wrapper of set) releaseDiscardedStaticWrapper(wrapper); + } + function watchForDiscardedFrameworkWrapperRemoval(sessionId) { if (!sessionId || !document.body) return; if (discardedFrameworkWrapperWatchers.has(sessionId)) return; @@ -6907,6 +6929,42 @@ // MutationObserver for progressive variant reveal // + // A session id can have more than one wrapper in the DOM: the target may sit + // inside a `.map()` callback (the wrapper renders once per item), or the + // agent may have relocated the wrapper out of the shared primitive live-wrap + // scaffolded into. A plain first match can then pin an empty scaffold while + // the real variants sit in a later wrapper, which strands the session at + // 0/N and leaves the bar, the params panel, and accept all reading the + // wrong element. Prefer a wrapper that actually holds variants. With zero + // or one match this is exactly the querySelector it replaces. + // + // Every lookup of the ACTIVE session's wrapper goes through here. The + // remaining raw `[data-impeccable-variants=...]` uses are deliberate: bare + // existence checks, selector strings for stylesheets and observers (which + // want to cover every match), `querySelectorAll` sweeps, and the parsed + // source document, which is not this document. + function pickPopulatedVariantsWrapper(selector) { + const matches = document.querySelectorAll(selector); + if (matches.length < 2) return matches[0] || null; + for (const candidate of matches) { + if (candidate.querySelector('[data-impeccable-variant]:not([data-impeccable-variant="original"])')) { + return candidate; + } + } + return matches[0]; + } + + /** The wrapper holding `sessionId`'s variants, or null without an id. */ + function findVariantsWrapper(sessionId) { + if (!sessionId) return null; + return pickPopulatedVariantsWrapper('[data-impeccable-variants="' + sessionId + '"]'); + } + + /** Any live variant wrapper, for the resume paths that have no id yet. */ + function findAnyVariantsWrapper() { + return pickPopulatedVariantsWrapper('[data-impeccable-variants]'); + } + function startVariantObserver(sessionId) { let updating = false; // re-entrancy guard @@ -6936,7 +6994,7 @@ } if (!dominated) return; - const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]'); + const wrapper = findVariantsWrapper(sessionId); if (!wrapper) return; const variants = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])'); @@ -7145,6 +7203,7 @@ if (arrivedVariants >= expectedVariants && expectedVariants > 0) { if (state === 'GENERATING') { setLiveState('CYCLING'); + hideShaderOverlay(); showOrUpdateCyclingBar(); disableInlineEdit(); refreshParamsPanel(); @@ -7205,6 +7264,7 @@ pendingAcceptedSession = null; awaitingAcceptResult = null; setLiveState('CYCLING'); + hideShaderOverlay(); updateBarContent('cycling'); showToast('Could not complete accept cleanup. Try Accept again.', 5000); break; @@ -8307,6 +8367,15 @@ void main() { // matches the original off-white risograph paper. const SHADER_PAPER_FALLBACK = [0.975, 0.965, 0.955]; let shaderState = null; // { canvas, gl, program, texture, rafId, startTime } + // showShaderOverlay is async: it appends its canvas, then awaits + // createImageBitmap and the GL setup before it publishes shaderState. A + // teardown that landed inside that window found shaderState still null, + // returned, and then watched the construction publish itself over a session + // that had already left GENERATING, with no teardown left to run. That is + // the generating loader frozen over a page that already cycles (issue #719). + // Every teardown bumps this epoch; a construction abandons its own canvas as + // soon as it sees the epoch move. + let shaderEpoch = 0; // The element's effective background tone, used as the uniform halftone // ground so content dissolves into dots over it. Unlike resolveCanvasBackground @@ -8453,14 +8522,28 @@ void main() { }); } + /** Drop a shader node no shaderState owns (an abandoned construction). */ + function removeStrayShaderNode() { + const stray = uiGetById(PREFIX + '-shader'); + if (stray) stray.remove(); + } + function hideShaderOverlay() { - if (!shaderState) return; + // Bump first, unconditionally: this is what tells an in-flight + // showShaderOverlay to abandon itself rather than publish over a session + // that has already moved on. + shaderEpoch += 1; + if (!shaderState) { + removeStrayShaderNode(); + return; + } if (shaderState.rafId) cancelAnimationFrame(shaderState.rafId); if (shaderState.canvas) shaderState.canvas.remove(); if (shaderState.objectUrl) URL.revokeObjectURL(shaderState.objectUrl); const lose = shaderState.gl?.getExtension?.('WEBGL_lose_context'); try { lose?.loseContext(); } catch {} shaderState = null; + removeStrayShaderNode(); } function showShaderBitmapFallback(canvas, blob) { @@ -8485,6 +8568,16 @@ void main() { async function showShaderOverlay(el, blob, rect, paper) { hideShaderOverlay(); if (!blob || !el) return; + // hideShaderOverlay just bumped the epoch, so this run owns it until the + // next teardown. Every step past an await re-checks before it publishes. + const epoch = shaderEpoch; + const abandoned = (node, gl) => { + if (epoch === shaderEpoch) return false; + node.remove(); + const lose = gl?.getExtension?.('WEBGL_lose_context'); + try { lose?.loseContext(); } catch {} + return true; + }; const canvas = document.createElement('canvas'); canvas.id = PREFIX + '-shader'; const dpr = Math.min(window.devicePixelRatio || 1, 2); @@ -8507,6 +8600,7 @@ void main() { if (!gl) { // WebGL unavailable: use the captured bitmap as a background overlay so // the user still sees something meaningful during generation. + if (abandoned(canvas, null)) return; showShaderBitmapFallback(canvas, blob); return; } @@ -8546,16 +8640,22 @@ void main() { } // Upload the screenshot as a texture + if (abandoned(canvas, gl)) return; let bitmap; try { bitmap = await createImageBitmap(blob); } catch (err) { console.warn('[impeccable] shader bitmap decode failed:', err); + if (abandoned(canvas, gl)) return; const lose = gl.getExtension?.('WEBGL_lose_context'); try { lose?.loseContext(); } catch {} showShaderBitmapFallback(canvas, blob); return; } + if (abandoned(canvas, gl)) { + if (bitmap.close) bitmap.close(); + return; + } texture = gl.createTexture(); gl.bindTexture(gl.TEXTURE_2D, texture); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); @@ -8574,6 +8674,7 @@ void main() { const paperRgb = paper || resolvePaperRgb(el); const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches; + if (abandoned(canvas, gl)) return; shaderState = { canvas, gl, program, texture, rafId: 0, startTime: performance.now(), reduced }; function frame() { if (!shaderState) return; @@ -8610,7 +8711,7 @@ void main() { clientSentAt: Date.now(), }; if (!currentSessionId || arrivedVariants === 0) return; - const acceptWrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]'); + const acceptWrapper = findVariantsWrapper(currentSessionId); if (Object.keys(paramsCurrentValues).length > 0) { acceptPayload.paramValues = { ...paramsCurrentValues }; } @@ -8653,6 +8754,7 @@ void main() { .catch(() => { if (pendingAcceptedSession?.id === acceptedSessionId) pendingAcceptedSession = null; setLiveState('CYCLING'); + hideShaderOverlay(); showOrUpdateCyclingBar(); showToast('Could not confirm accept with the live server. Session kept for recovery; try Accept again.', 5000); }); @@ -8704,7 +8806,7 @@ void main() { } function snapshotAcceptedVariantDom(sessionId, variantId) { - const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]'); + const wrapper = findVariantsWrapper(sessionId); const accepted = wrapper?.querySelector?.('[data-impeccable-variant="' + variantId + '"]'); const root = accepted?.firstElementChild || null; return { @@ -8831,7 +8933,7 @@ void main() { } function commitAcceptedVariantToDom(sessionId, variantId) { - const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]'); + const wrapper = findVariantsWrapper(sessionId); if (!wrapper) return false; const accepted = wrapper.querySelector('[data-impeccable-variant="' + variantId + '"]'); if (!accepted || !accepted.firstElementChild) return false; @@ -9059,7 +9161,7 @@ void main() { } function restoreFromActiveSessions(activeSessions, reason) { - const wrapper = document.querySelector('[data-impeccable-variants]'); + const wrapper = findAnyVariantsWrapper(); if (wrapper && !isFrameworkComponentPreviewMode(wrapper.dataset.impeccablePreview)) return false; if (svelteComponentSession?.sessionId === currentSessionId) return false; return restoreSessionWithoutWrapper(reason || 'sse_connected', activeSessions); @@ -9172,10 +9274,13 @@ void main() { // reconciler later tries to remove a wrapper we already removed. // Schedule a 2s fallback that does the manual swap only if HMR hasn't // replaced the wrapper by then (keeps static-server / no-HMR flows alive). - const wrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]'); - if (wrapper) { + // Every match, not the first: a target inside a `.map()` renders one + // wrapper per item, and hiding only one leaves the rest of the + // discarded variants on screen. + const discardWrappers = discardedWrappers(cleanupSessionId); + if (discardWrappers.length > 0) { if (restoreOriginal) showOriginalDuringDiscard(cleanupSessionId); - else wrapper.style.display = 'none'; + else for (const discardWrapper of discardWrappers) discardWrapper.style.display = 'none'; } setTimeout(function() { const recoverySuperseded = deferredRecoverySuperseded(cleanupSessionId, cleanupRevision); @@ -9183,16 +9288,19 @@ void main() { removeDiscardStateStylesheet(); return; } - const lateWrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]'); - if (!lateWrapper) { + const lateWrappers = discardedWrappers(cleanupSessionId); + if (lateWrappers.length === 0) { removeDiscardStateStylesheet(cleanupSessionId); return; } + // Duplicates all render from one source element, so HMR ownership is + // uniform across them; the first is a fair witness for the set. + const lateWrapper = lateWrappers[0]; if (recoverySuperseded) { if (hasFrameworkHmrOwnership(lateWrapper)) { watchForDiscardedFrameworkWrapperRemoval(cleanupSessionId); } else { - releaseDiscardedStaticWrapper(lateWrapper, cleanupSessionId); + releaseDiscardedStaticWrappers(cleanupSessionId, lateWrappers); } return; } @@ -9201,18 +9309,20 @@ void main() { // the final source rewrite, reload once after a grace window so the // discarded source becomes authoritative without a reconciler race. setTimeout(function() { - const staleWrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]'); + const staleWrappers = discardedWrappers(cleanupSessionId); if (deferredRecoverySuperseded(cleanupSessionId, cleanupRevision)) { - if (!staleWrapper) removeDiscardStateStylesheet(cleanupSessionId); + if (staleWrappers.length === 0) removeDiscardStateStylesheet(cleanupSessionId); else watchForDiscardedFrameworkWrapperRemoval(cleanupSessionId); return; } removeDiscardStateStylesheet(cleanupSessionId); - if (staleWrapper) location.reload(); + // A reload restores every wrapper's original at once, so there is + // nothing per-wrapper to do here. + if (staleWrappers.length > 0) location.reload(); }, 2000); return; } - releaseDiscardedStaticWrapper(lateWrapper, cleanupSessionId); + releaseDiscardedStaticWrappers(cleanupSessionId, lateWrappers); }, 2000); } hideBar(instantChrome); @@ -9400,8 +9510,13 @@ void main() { return restoreSessionWithoutWrapper('browser_resumed_over_handled_wrapper'); } - function resumeSession(recoveryRevision = liveInteractionRevision) { - const wrapper = document.querySelector('[data-impeccable-variants]'); + function resumeSession(recoveryRevision = liveInteractionRevision, opts = {}) { + // Which path resumed matters in the journal: an init resume is a fresh + // page load, the deferred-wrapper scout is a mid-page-load arrival. Both + // used to log the same `browser_resumed`, which made issue #719 take a + // DOM reconstruction to diagnose. + const resumeReason = opts.reason || 'browser_resumed'; + const wrapper = findAnyVariantsWrapper(); const runtimeWrapper = wrapper || document.querySelector('[data-impeccable-carbonize]'); if (restoreSessionSupersedingHandledWrapper(runtimeWrapper)) return true; if (scheduleHandledRuntimeWrapperReload(runtimeWrapper, recoveryRevision)) return false; @@ -9500,16 +9615,38 @@ void main() { showBar(state === 'CYCLING' ? 'cycling' : 'generating'); startScrollTracking(); - // Build the params panel for the restored visible variant. Previously - // this was missed on page-reload resume: showVariantInDOM above fires - // refreshParamsPanel, but state was still IDLE at that moment so it - // hid. Now that state is CYCLING, re-fire. - if (state === 'CYCLING') refreshParamsPanel(); + // A resume can BE the arrival, not just a re-entry after one. The server's + // generation preflight runs live-wrap with --defer-source-write, so the + // wrapper and every variant reach the DOM in one HMR batch, and the + // deferred-wrapper scout (constructed at init) runs before the variant + // MutationObserver (constructed at Go) on that batch. Finish the same + // transition the observer would have finished. Without hideShaderOverlay + // the generating shader stays frozen over the target and the session looks + // stuck at GENERATING while the bar already cycles (issue #719). + if (state === 'CYCLING') { + recoveryWaitingForAnchor = false; + hideShaderOverlay(); + if (isInsert) finalizeInsertSession(); + disableInlineEdit(); + // Build the params panel for the restored visible variant. Previously + // this was missed on page-reload resume: showVariantInDOM above fires + // refreshParamsPanel, but state was still IDLE at that moment so it + // hid. Now that state is CYCLING, re-fire. + refreshParamsPanel(); + } saveSession(); if (arrivedVariants > 0 && arrivedVariants < expectedVariants) { sendCheckpoint('variants_progress'); } else { - queueCheckpoint('browser_resumed'); + queueCheckpoint(resumeReason); + // Only variants_progress and variants_ready count as publication + // progress. When the resume is the arrival, the observer never gets to + // report it (this function disconnects and re-creates it below, which + // drops the records it had already queued for this same batch), so + // without this the server never learns the variants were published. + if (arrivedVariants > 0 && arrivedVariants >= expectedVariants && expectedVariants > 0) { + sendCheckpoint('variants_ready'); + } } // Start observing for more variants AFTER initial setup @@ -12831,7 +12968,7 @@ void main() { const wrapper = document.querySelector('[data-impeccable-variants],[data-impeccable-carbonize]'); if (!wrapper) return; scout.disconnect(); - if (resumeSession(deferredResumeRevision)) { + if (resumeSession(deferredResumeRevision, { reason: 'browser_resumed_deferred_wrapper' })) { console.log('[impeccable] Resumed deferred session ' + currentSessionId + ' (post-hydration).'); } }); diff --git a/.hermes/skills/impeccable/scripts/live-browser.js b/.hermes/skills/impeccable/scripts/live-browser.js index c32101b12..277c60e2c 100644 --- a/.hermes/skills/impeccable/scripts/live-browser.js +++ b/.hermes/skills/impeccable/scripts/live-browser.js @@ -2060,7 +2060,7 @@ if (anchor) return anchor; } if (currentSessionId && (state === 'GENERATING' || state === 'CYCLING')) { - const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]'); + const wrapper = findVariantsWrapper(currentSessionId); if (wrapper) { const variantCount = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])').length; if (variantCount > 0 && visibleVariant > 0) { @@ -2131,14 +2131,14 @@ function isInsertGeneratingSession() { if (state !== 'GENERATING' || !currentSessionId) return false; - const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]'); + const wrapper = findVariantsWrapper(currentSessionId); return !!wrapper && wrapper.dataset.impeccableMode === 'insert'; } /** Recreate the dotted placeholder if Astro/Vite HMR removed it mid-generation. */ function ensureInsertPlaceholder() { if (!isInsertGeneratingSession()) return placeholderElement; - const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]'); + const wrapper = findVariantsWrapper(currentSessionId); const variantCount = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])').length; if (variantCount > 0) return placeholderElement; if (placeholderElement && document.body.contains(placeholderElement)) return placeholderElement; @@ -3156,7 +3156,7 @@ || svelteComponentSession.wrapperEl || null; } - const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]'); + const wrapper = findVariantsWrapper(currentSessionId); if (!wrapper) return null; return wrapper.querySelector('[data-impeccable-variant="' + visibleVariant + '"]'); } @@ -4900,7 +4900,7 @@ return Object.values(svelteComponentSession.paramsByVariant || {}) .reduce((total, params) => total + (Array.isArray(params) ? params.length : 0), 0); } - const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]'); + const wrapper = findVariantsWrapper(currentSessionId); if (!wrapper) return 0; return [...wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])')] .reduce((total, variant) => total + parseVariantParams(variant).length, 0); @@ -5004,7 +5004,7 @@ scheduleCyclingBarSync(sessionId, num); return true; } - const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]'); + const wrapper = findVariantsWrapper(sessionId); if (!wrapper) return false; updateVariantStateStylesheet(sessionId, num); // Unconditional refresh - covers first-reveal (no-op if state isn't @@ -5820,6 +5820,7 @@ return; } setLiveState('CYCLING'); + hideShaderOverlay(); showOrUpdateCyclingBar(); saveSession(); completeParameterGenerationIfReady(); @@ -6361,7 +6362,7 @@ } rememberSessionFileMeta({ file: filePath }); if (isJsxSourceFile(filePath)) { - const liveWrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]'); + const liveWrapper = findVariantsWrapper(sessionId); if (liveWrapper && liveWrapper.querySelector('[data-impeccable-variant]:not([data-impeccable-variant="original"])')) { completeSourceInjection(liveWrapper, sessionId, { ...opts, filePath }); return; @@ -6433,7 +6434,7 @@ return; } - const existingWrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]'); + const existingWrapper = findVariantsWrapper(sessionId); if (existingWrapper) { const wrapper = srcWrapper.cloneNode(true); existingWrapper.parentElement.replaceChild(wrapper, existingWrapper); @@ -6590,7 +6591,7 @@ if (anchor && !anchor.__impeccableFrozenAnchor) selectedElement = anchor; return; } - const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]'); + const wrapper = findVariantsWrapper(currentSessionId); if (!wrapper) return; const visEl = pickVariantContent(wrapper, visibleVariant); if (visEl) selectedElement = visEl; @@ -6600,7 +6601,7 @@ if (svelteComponentSession?.sessionId === sessionId && svelteComponentSession.mountedVariant > 0) { return svelteComponentSession.mountedVariant; } - const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]'); + const wrapper = findVariantsWrapper(sessionId); if (!wrapper) return 0; const variants = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])'); for (const variant of variants) { @@ -6715,8 +6716,17 @@ document.getElementById(discardStateStyleId(sessionId))?.remove(); } - function releaseDiscardedStaticWrapper(wrapper, sessionId) { - removeDiscardStateStylesheet(sessionId); + /** + * Every wrapper a discard has to unwind. A target inside a `.map()` renders + * one wrapper per item, so the hide, the release, and the existence checks + * all have to speak about the same set. + */ + function discardedWrappers(sessionId) { + if (!sessionId) return []; + return [...document.querySelectorAll('[data-impeccable-variants="' + sessionId + '"]')]; + } + + function releaseDiscardedStaticWrapper(wrapper) { if (!wrapper) return; const orig = wrapper.querySelector('[data-impeccable-variant="original"]'); const content = orig?.firstElementChild; @@ -6727,6 +6737,18 @@ wrapper.remove(); } + /** + * Undo the discard hide on every wrapper it covered. Releasing only the + * first match left the other mapped items sitting at display:none with + * their original content never restored, on exactly the static and + * missed-HMR flows this fallback exists for. + */ + function releaseDiscardedStaticWrappers(sessionId, wrappers) { + removeDiscardStateStylesheet(sessionId); + const set = wrappers && wrappers.length ? wrappers : discardedWrappers(sessionId); + for (const wrapper of set) releaseDiscardedStaticWrapper(wrapper); + } + function watchForDiscardedFrameworkWrapperRemoval(sessionId) { if (!sessionId || !document.body) return; if (discardedFrameworkWrapperWatchers.has(sessionId)) return; @@ -6907,6 +6929,42 @@ // MutationObserver for progressive variant reveal // + // A session id can have more than one wrapper in the DOM: the target may sit + // inside a `.map()` callback (the wrapper renders once per item), or the + // agent may have relocated the wrapper out of the shared primitive live-wrap + // scaffolded into. A plain first match can then pin an empty scaffold while + // the real variants sit in a later wrapper, which strands the session at + // 0/N and leaves the bar, the params panel, and accept all reading the + // wrong element. Prefer a wrapper that actually holds variants. With zero + // or one match this is exactly the querySelector it replaces. + // + // Every lookup of the ACTIVE session's wrapper goes through here. The + // remaining raw `[data-impeccable-variants=...]` uses are deliberate: bare + // existence checks, selector strings for stylesheets and observers (which + // want to cover every match), `querySelectorAll` sweeps, and the parsed + // source document, which is not this document. + function pickPopulatedVariantsWrapper(selector) { + const matches = document.querySelectorAll(selector); + if (matches.length < 2) return matches[0] || null; + for (const candidate of matches) { + if (candidate.querySelector('[data-impeccable-variant]:not([data-impeccable-variant="original"])')) { + return candidate; + } + } + return matches[0]; + } + + /** The wrapper holding `sessionId`'s variants, or null without an id. */ + function findVariantsWrapper(sessionId) { + if (!sessionId) return null; + return pickPopulatedVariantsWrapper('[data-impeccable-variants="' + sessionId + '"]'); + } + + /** Any live variant wrapper, for the resume paths that have no id yet. */ + function findAnyVariantsWrapper() { + return pickPopulatedVariantsWrapper('[data-impeccable-variants]'); + } + function startVariantObserver(sessionId) { let updating = false; // re-entrancy guard @@ -6936,7 +6994,7 @@ } if (!dominated) return; - const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]'); + const wrapper = findVariantsWrapper(sessionId); if (!wrapper) return; const variants = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])'); @@ -7145,6 +7203,7 @@ if (arrivedVariants >= expectedVariants && expectedVariants > 0) { if (state === 'GENERATING') { setLiveState('CYCLING'); + hideShaderOverlay(); showOrUpdateCyclingBar(); disableInlineEdit(); refreshParamsPanel(); @@ -7205,6 +7264,7 @@ pendingAcceptedSession = null; awaitingAcceptResult = null; setLiveState('CYCLING'); + hideShaderOverlay(); updateBarContent('cycling'); showToast('Could not complete accept cleanup. Try Accept again.', 5000); break; @@ -8307,6 +8367,15 @@ void main() { // matches the original off-white risograph paper. const SHADER_PAPER_FALLBACK = [0.975, 0.965, 0.955]; let shaderState = null; // { canvas, gl, program, texture, rafId, startTime } + // showShaderOverlay is async: it appends its canvas, then awaits + // createImageBitmap and the GL setup before it publishes shaderState. A + // teardown that landed inside that window found shaderState still null, + // returned, and then watched the construction publish itself over a session + // that had already left GENERATING, with no teardown left to run. That is + // the generating loader frozen over a page that already cycles (issue #719). + // Every teardown bumps this epoch; a construction abandons its own canvas as + // soon as it sees the epoch move. + let shaderEpoch = 0; // The element's effective background tone, used as the uniform halftone // ground so content dissolves into dots over it. Unlike resolveCanvasBackground @@ -8453,14 +8522,28 @@ void main() { }); } + /** Drop a shader node no shaderState owns (an abandoned construction). */ + function removeStrayShaderNode() { + const stray = uiGetById(PREFIX + '-shader'); + if (stray) stray.remove(); + } + function hideShaderOverlay() { - if (!shaderState) return; + // Bump first, unconditionally: this is what tells an in-flight + // showShaderOverlay to abandon itself rather than publish over a session + // that has already moved on. + shaderEpoch += 1; + if (!shaderState) { + removeStrayShaderNode(); + return; + } if (shaderState.rafId) cancelAnimationFrame(shaderState.rafId); if (shaderState.canvas) shaderState.canvas.remove(); if (shaderState.objectUrl) URL.revokeObjectURL(shaderState.objectUrl); const lose = shaderState.gl?.getExtension?.('WEBGL_lose_context'); try { lose?.loseContext(); } catch {} shaderState = null; + removeStrayShaderNode(); } function showShaderBitmapFallback(canvas, blob) { @@ -8485,6 +8568,16 @@ void main() { async function showShaderOverlay(el, blob, rect, paper) { hideShaderOverlay(); if (!blob || !el) return; + // hideShaderOverlay just bumped the epoch, so this run owns it until the + // next teardown. Every step past an await re-checks before it publishes. + const epoch = shaderEpoch; + const abandoned = (node, gl) => { + if (epoch === shaderEpoch) return false; + node.remove(); + const lose = gl?.getExtension?.('WEBGL_lose_context'); + try { lose?.loseContext(); } catch {} + return true; + }; const canvas = document.createElement('canvas'); canvas.id = PREFIX + '-shader'; const dpr = Math.min(window.devicePixelRatio || 1, 2); @@ -8507,6 +8600,7 @@ void main() { if (!gl) { // WebGL unavailable: use the captured bitmap as a background overlay so // the user still sees something meaningful during generation. + if (abandoned(canvas, null)) return; showShaderBitmapFallback(canvas, blob); return; } @@ -8546,16 +8640,22 @@ void main() { } // Upload the screenshot as a texture + if (abandoned(canvas, gl)) return; let bitmap; try { bitmap = await createImageBitmap(blob); } catch (err) { console.warn('[impeccable] shader bitmap decode failed:', err); + if (abandoned(canvas, gl)) return; const lose = gl.getExtension?.('WEBGL_lose_context'); try { lose?.loseContext(); } catch {} showShaderBitmapFallback(canvas, blob); return; } + if (abandoned(canvas, gl)) { + if (bitmap.close) bitmap.close(); + return; + } texture = gl.createTexture(); gl.bindTexture(gl.TEXTURE_2D, texture); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); @@ -8574,6 +8674,7 @@ void main() { const paperRgb = paper || resolvePaperRgb(el); const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches; + if (abandoned(canvas, gl)) return; shaderState = { canvas, gl, program, texture, rafId: 0, startTime: performance.now(), reduced }; function frame() { if (!shaderState) return; @@ -8610,7 +8711,7 @@ void main() { clientSentAt: Date.now(), }; if (!currentSessionId || arrivedVariants === 0) return; - const acceptWrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]'); + const acceptWrapper = findVariantsWrapper(currentSessionId); if (Object.keys(paramsCurrentValues).length > 0) { acceptPayload.paramValues = { ...paramsCurrentValues }; } @@ -8653,6 +8754,7 @@ void main() { .catch(() => { if (pendingAcceptedSession?.id === acceptedSessionId) pendingAcceptedSession = null; setLiveState('CYCLING'); + hideShaderOverlay(); showOrUpdateCyclingBar(); showToast('Could not confirm accept with the live server. Session kept for recovery; try Accept again.', 5000); }); @@ -8704,7 +8806,7 @@ void main() { } function snapshotAcceptedVariantDom(sessionId, variantId) { - const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]'); + const wrapper = findVariantsWrapper(sessionId); const accepted = wrapper?.querySelector?.('[data-impeccable-variant="' + variantId + '"]'); const root = accepted?.firstElementChild || null; return { @@ -8831,7 +8933,7 @@ void main() { } function commitAcceptedVariantToDom(sessionId, variantId) { - const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]'); + const wrapper = findVariantsWrapper(sessionId); if (!wrapper) return false; const accepted = wrapper.querySelector('[data-impeccable-variant="' + variantId + '"]'); if (!accepted || !accepted.firstElementChild) return false; @@ -9059,7 +9161,7 @@ void main() { } function restoreFromActiveSessions(activeSessions, reason) { - const wrapper = document.querySelector('[data-impeccable-variants]'); + const wrapper = findAnyVariantsWrapper(); if (wrapper && !isFrameworkComponentPreviewMode(wrapper.dataset.impeccablePreview)) return false; if (svelteComponentSession?.sessionId === currentSessionId) return false; return restoreSessionWithoutWrapper(reason || 'sse_connected', activeSessions); @@ -9172,10 +9274,13 @@ void main() { // reconciler later tries to remove a wrapper we already removed. // Schedule a 2s fallback that does the manual swap only if HMR hasn't // replaced the wrapper by then (keeps static-server / no-HMR flows alive). - const wrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]'); - if (wrapper) { + // Every match, not the first: a target inside a `.map()` renders one + // wrapper per item, and hiding only one leaves the rest of the + // discarded variants on screen. + const discardWrappers = discardedWrappers(cleanupSessionId); + if (discardWrappers.length > 0) { if (restoreOriginal) showOriginalDuringDiscard(cleanupSessionId); - else wrapper.style.display = 'none'; + else for (const discardWrapper of discardWrappers) discardWrapper.style.display = 'none'; } setTimeout(function() { const recoverySuperseded = deferredRecoverySuperseded(cleanupSessionId, cleanupRevision); @@ -9183,16 +9288,19 @@ void main() { removeDiscardStateStylesheet(); return; } - const lateWrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]'); - if (!lateWrapper) { + const lateWrappers = discardedWrappers(cleanupSessionId); + if (lateWrappers.length === 0) { removeDiscardStateStylesheet(cleanupSessionId); return; } + // Duplicates all render from one source element, so HMR ownership is + // uniform across them; the first is a fair witness for the set. + const lateWrapper = lateWrappers[0]; if (recoverySuperseded) { if (hasFrameworkHmrOwnership(lateWrapper)) { watchForDiscardedFrameworkWrapperRemoval(cleanupSessionId); } else { - releaseDiscardedStaticWrapper(lateWrapper, cleanupSessionId); + releaseDiscardedStaticWrappers(cleanupSessionId, lateWrappers); } return; } @@ -9201,18 +9309,20 @@ void main() { // the final source rewrite, reload once after a grace window so the // discarded source becomes authoritative without a reconciler race. setTimeout(function() { - const staleWrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]'); + const staleWrappers = discardedWrappers(cleanupSessionId); if (deferredRecoverySuperseded(cleanupSessionId, cleanupRevision)) { - if (!staleWrapper) removeDiscardStateStylesheet(cleanupSessionId); + if (staleWrappers.length === 0) removeDiscardStateStylesheet(cleanupSessionId); else watchForDiscardedFrameworkWrapperRemoval(cleanupSessionId); return; } removeDiscardStateStylesheet(cleanupSessionId); - if (staleWrapper) location.reload(); + // A reload restores every wrapper's original at once, so there is + // nothing per-wrapper to do here. + if (staleWrappers.length > 0) location.reload(); }, 2000); return; } - releaseDiscardedStaticWrapper(lateWrapper, cleanupSessionId); + releaseDiscardedStaticWrappers(cleanupSessionId, lateWrappers); }, 2000); } hideBar(instantChrome); @@ -9400,8 +9510,13 @@ void main() { return restoreSessionWithoutWrapper('browser_resumed_over_handled_wrapper'); } - function resumeSession(recoveryRevision = liveInteractionRevision) { - const wrapper = document.querySelector('[data-impeccable-variants]'); + function resumeSession(recoveryRevision = liveInteractionRevision, opts = {}) { + // Which path resumed matters in the journal: an init resume is a fresh + // page load, the deferred-wrapper scout is a mid-page-load arrival. Both + // used to log the same `browser_resumed`, which made issue #719 take a + // DOM reconstruction to diagnose. + const resumeReason = opts.reason || 'browser_resumed'; + const wrapper = findAnyVariantsWrapper(); const runtimeWrapper = wrapper || document.querySelector('[data-impeccable-carbonize]'); if (restoreSessionSupersedingHandledWrapper(runtimeWrapper)) return true; if (scheduleHandledRuntimeWrapperReload(runtimeWrapper, recoveryRevision)) return false; @@ -9500,16 +9615,38 @@ void main() { showBar(state === 'CYCLING' ? 'cycling' : 'generating'); startScrollTracking(); - // Build the params panel for the restored visible variant. Previously - // this was missed on page-reload resume: showVariantInDOM above fires - // refreshParamsPanel, but state was still IDLE at that moment so it - // hid. Now that state is CYCLING, re-fire. - if (state === 'CYCLING') refreshParamsPanel(); + // A resume can BE the arrival, not just a re-entry after one. The server's + // generation preflight runs live-wrap with --defer-source-write, so the + // wrapper and every variant reach the DOM in one HMR batch, and the + // deferred-wrapper scout (constructed at init) runs before the variant + // MutationObserver (constructed at Go) on that batch. Finish the same + // transition the observer would have finished. Without hideShaderOverlay + // the generating shader stays frozen over the target and the session looks + // stuck at GENERATING while the bar already cycles (issue #719). + if (state === 'CYCLING') { + recoveryWaitingForAnchor = false; + hideShaderOverlay(); + if (isInsert) finalizeInsertSession(); + disableInlineEdit(); + // Build the params panel for the restored visible variant. Previously + // this was missed on page-reload resume: showVariantInDOM above fires + // refreshParamsPanel, but state was still IDLE at that moment so it + // hid. Now that state is CYCLING, re-fire. + refreshParamsPanel(); + } saveSession(); if (arrivedVariants > 0 && arrivedVariants < expectedVariants) { sendCheckpoint('variants_progress'); } else { - queueCheckpoint('browser_resumed'); + queueCheckpoint(resumeReason); + // Only variants_progress and variants_ready count as publication + // progress. When the resume is the arrival, the observer never gets to + // report it (this function disconnects and re-creates it below, which + // drops the records it had already queued for this same batch), so + // without this the server never learns the variants were published. + if (arrivedVariants > 0 && arrivedVariants >= expectedVariants && expectedVariants > 0) { + sendCheckpoint('variants_ready'); + } } // Start observing for more variants AFTER initial setup @@ -12831,7 +12968,7 @@ void main() { const wrapper = document.querySelector('[data-impeccable-variants],[data-impeccable-carbonize]'); if (!wrapper) return; scout.disconnect(); - if (resumeSession(deferredResumeRevision)) { + if (resumeSession(deferredResumeRevision, { reason: 'browser_resumed_deferred_wrapper' })) { console.log('[impeccable] Resumed deferred session ' + currentSessionId + ' (post-hydration).'); } }); diff --git a/.kiro/skills/impeccable/scripts/live-browser.js b/.kiro/skills/impeccable/scripts/live-browser.js index c32101b12..277c60e2c 100644 --- a/.kiro/skills/impeccable/scripts/live-browser.js +++ b/.kiro/skills/impeccable/scripts/live-browser.js @@ -2060,7 +2060,7 @@ if (anchor) return anchor; } if (currentSessionId && (state === 'GENERATING' || state === 'CYCLING')) { - const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]'); + const wrapper = findVariantsWrapper(currentSessionId); if (wrapper) { const variantCount = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])').length; if (variantCount > 0 && visibleVariant > 0) { @@ -2131,14 +2131,14 @@ function isInsertGeneratingSession() { if (state !== 'GENERATING' || !currentSessionId) return false; - const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]'); + const wrapper = findVariantsWrapper(currentSessionId); return !!wrapper && wrapper.dataset.impeccableMode === 'insert'; } /** Recreate the dotted placeholder if Astro/Vite HMR removed it mid-generation. */ function ensureInsertPlaceholder() { if (!isInsertGeneratingSession()) return placeholderElement; - const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]'); + const wrapper = findVariantsWrapper(currentSessionId); const variantCount = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])').length; if (variantCount > 0) return placeholderElement; if (placeholderElement && document.body.contains(placeholderElement)) return placeholderElement; @@ -3156,7 +3156,7 @@ || svelteComponentSession.wrapperEl || null; } - const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]'); + const wrapper = findVariantsWrapper(currentSessionId); if (!wrapper) return null; return wrapper.querySelector('[data-impeccable-variant="' + visibleVariant + '"]'); } @@ -4900,7 +4900,7 @@ return Object.values(svelteComponentSession.paramsByVariant || {}) .reduce((total, params) => total + (Array.isArray(params) ? params.length : 0), 0); } - const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]'); + const wrapper = findVariantsWrapper(currentSessionId); if (!wrapper) return 0; return [...wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])')] .reduce((total, variant) => total + parseVariantParams(variant).length, 0); @@ -5004,7 +5004,7 @@ scheduleCyclingBarSync(sessionId, num); return true; } - const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]'); + const wrapper = findVariantsWrapper(sessionId); if (!wrapper) return false; updateVariantStateStylesheet(sessionId, num); // Unconditional refresh - covers first-reveal (no-op if state isn't @@ -5820,6 +5820,7 @@ return; } setLiveState('CYCLING'); + hideShaderOverlay(); showOrUpdateCyclingBar(); saveSession(); completeParameterGenerationIfReady(); @@ -6361,7 +6362,7 @@ } rememberSessionFileMeta({ file: filePath }); if (isJsxSourceFile(filePath)) { - const liveWrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]'); + const liveWrapper = findVariantsWrapper(sessionId); if (liveWrapper && liveWrapper.querySelector('[data-impeccable-variant]:not([data-impeccable-variant="original"])')) { completeSourceInjection(liveWrapper, sessionId, { ...opts, filePath }); return; @@ -6433,7 +6434,7 @@ return; } - const existingWrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]'); + const existingWrapper = findVariantsWrapper(sessionId); if (existingWrapper) { const wrapper = srcWrapper.cloneNode(true); existingWrapper.parentElement.replaceChild(wrapper, existingWrapper); @@ -6590,7 +6591,7 @@ if (anchor && !anchor.__impeccableFrozenAnchor) selectedElement = anchor; return; } - const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]'); + const wrapper = findVariantsWrapper(currentSessionId); if (!wrapper) return; const visEl = pickVariantContent(wrapper, visibleVariant); if (visEl) selectedElement = visEl; @@ -6600,7 +6601,7 @@ if (svelteComponentSession?.sessionId === sessionId && svelteComponentSession.mountedVariant > 0) { return svelteComponentSession.mountedVariant; } - const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]'); + const wrapper = findVariantsWrapper(sessionId); if (!wrapper) return 0; const variants = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])'); for (const variant of variants) { @@ -6715,8 +6716,17 @@ document.getElementById(discardStateStyleId(sessionId))?.remove(); } - function releaseDiscardedStaticWrapper(wrapper, sessionId) { - removeDiscardStateStylesheet(sessionId); + /** + * Every wrapper a discard has to unwind. A target inside a `.map()` renders + * one wrapper per item, so the hide, the release, and the existence checks + * all have to speak about the same set. + */ + function discardedWrappers(sessionId) { + if (!sessionId) return []; + return [...document.querySelectorAll('[data-impeccable-variants="' + sessionId + '"]')]; + } + + function releaseDiscardedStaticWrapper(wrapper) { if (!wrapper) return; const orig = wrapper.querySelector('[data-impeccable-variant="original"]'); const content = orig?.firstElementChild; @@ -6727,6 +6737,18 @@ wrapper.remove(); } + /** + * Undo the discard hide on every wrapper it covered. Releasing only the + * first match left the other mapped items sitting at display:none with + * their original content never restored, on exactly the static and + * missed-HMR flows this fallback exists for. + */ + function releaseDiscardedStaticWrappers(sessionId, wrappers) { + removeDiscardStateStylesheet(sessionId); + const set = wrappers && wrappers.length ? wrappers : discardedWrappers(sessionId); + for (const wrapper of set) releaseDiscardedStaticWrapper(wrapper); + } + function watchForDiscardedFrameworkWrapperRemoval(sessionId) { if (!sessionId || !document.body) return; if (discardedFrameworkWrapperWatchers.has(sessionId)) return; @@ -6907,6 +6929,42 @@ // MutationObserver for progressive variant reveal // + // A session id can have more than one wrapper in the DOM: the target may sit + // inside a `.map()` callback (the wrapper renders once per item), or the + // agent may have relocated the wrapper out of the shared primitive live-wrap + // scaffolded into. A plain first match can then pin an empty scaffold while + // the real variants sit in a later wrapper, which strands the session at + // 0/N and leaves the bar, the params panel, and accept all reading the + // wrong element. Prefer a wrapper that actually holds variants. With zero + // or one match this is exactly the querySelector it replaces. + // + // Every lookup of the ACTIVE session's wrapper goes through here. The + // remaining raw `[data-impeccable-variants=...]` uses are deliberate: bare + // existence checks, selector strings for stylesheets and observers (which + // want to cover every match), `querySelectorAll` sweeps, and the parsed + // source document, which is not this document. + function pickPopulatedVariantsWrapper(selector) { + const matches = document.querySelectorAll(selector); + if (matches.length < 2) return matches[0] || null; + for (const candidate of matches) { + if (candidate.querySelector('[data-impeccable-variant]:not([data-impeccable-variant="original"])')) { + return candidate; + } + } + return matches[0]; + } + + /** The wrapper holding `sessionId`'s variants, or null without an id. */ + function findVariantsWrapper(sessionId) { + if (!sessionId) return null; + return pickPopulatedVariantsWrapper('[data-impeccable-variants="' + sessionId + '"]'); + } + + /** Any live variant wrapper, for the resume paths that have no id yet. */ + function findAnyVariantsWrapper() { + return pickPopulatedVariantsWrapper('[data-impeccable-variants]'); + } + function startVariantObserver(sessionId) { let updating = false; // re-entrancy guard @@ -6936,7 +6994,7 @@ } if (!dominated) return; - const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]'); + const wrapper = findVariantsWrapper(sessionId); if (!wrapper) return; const variants = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])'); @@ -7145,6 +7203,7 @@ if (arrivedVariants >= expectedVariants && expectedVariants > 0) { if (state === 'GENERATING') { setLiveState('CYCLING'); + hideShaderOverlay(); showOrUpdateCyclingBar(); disableInlineEdit(); refreshParamsPanel(); @@ -7205,6 +7264,7 @@ pendingAcceptedSession = null; awaitingAcceptResult = null; setLiveState('CYCLING'); + hideShaderOverlay(); updateBarContent('cycling'); showToast('Could not complete accept cleanup. Try Accept again.', 5000); break; @@ -8307,6 +8367,15 @@ void main() { // matches the original off-white risograph paper. const SHADER_PAPER_FALLBACK = [0.975, 0.965, 0.955]; let shaderState = null; // { canvas, gl, program, texture, rafId, startTime } + // showShaderOverlay is async: it appends its canvas, then awaits + // createImageBitmap and the GL setup before it publishes shaderState. A + // teardown that landed inside that window found shaderState still null, + // returned, and then watched the construction publish itself over a session + // that had already left GENERATING, with no teardown left to run. That is + // the generating loader frozen over a page that already cycles (issue #719). + // Every teardown bumps this epoch; a construction abandons its own canvas as + // soon as it sees the epoch move. + let shaderEpoch = 0; // The element's effective background tone, used as the uniform halftone // ground so content dissolves into dots over it. Unlike resolveCanvasBackground @@ -8453,14 +8522,28 @@ void main() { }); } + /** Drop a shader node no shaderState owns (an abandoned construction). */ + function removeStrayShaderNode() { + const stray = uiGetById(PREFIX + '-shader'); + if (stray) stray.remove(); + } + function hideShaderOverlay() { - if (!shaderState) return; + // Bump first, unconditionally: this is what tells an in-flight + // showShaderOverlay to abandon itself rather than publish over a session + // that has already moved on. + shaderEpoch += 1; + if (!shaderState) { + removeStrayShaderNode(); + return; + } if (shaderState.rafId) cancelAnimationFrame(shaderState.rafId); if (shaderState.canvas) shaderState.canvas.remove(); if (shaderState.objectUrl) URL.revokeObjectURL(shaderState.objectUrl); const lose = shaderState.gl?.getExtension?.('WEBGL_lose_context'); try { lose?.loseContext(); } catch {} shaderState = null; + removeStrayShaderNode(); } function showShaderBitmapFallback(canvas, blob) { @@ -8485,6 +8568,16 @@ void main() { async function showShaderOverlay(el, blob, rect, paper) { hideShaderOverlay(); if (!blob || !el) return; + // hideShaderOverlay just bumped the epoch, so this run owns it until the + // next teardown. Every step past an await re-checks before it publishes. + const epoch = shaderEpoch; + const abandoned = (node, gl) => { + if (epoch === shaderEpoch) return false; + node.remove(); + const lose = gl?.getExtension?.('WEBGL_lose_context'); + try { lose?.loseContext(); } catch {} + return true; + }; const canvas = document.createElement('canvas'); canvas.id = PREFIX + '-shader'; const dpr = Math.min(window.devicePixelRatio || 1, 2); @@ -8507,6 +8600,7 @@ void main() { if (!gl) { // WebGL unavailable: use the captured bitmap as a background overlay so // the user still sees something meaningful during generation. + if (abandoned(canvas, null)) return; showShaderBitmapFallback(canvas, blob); return; } @@ -8546,16 +8640,22 @@ void main() { } // Upload the screenshot as a texture + if (abandoned(canvas, gl)) return; let bitmap; try { bitmap = await createImageBitmap(blob); } catch (err) { console.warn('[impeccable] shader bitmap decode failed:', err); + if (abandoned(canvas, gl)) return; const lose = gl.getExtension?.('WEBGL_lose_context'); try { lose?.loseContext(); } catch {} showShaderBitmapFallback(canvas, blob); return; } + if (abandoned(canvas, gl)) { + if (bitmap.close) bitmap.close(); + return; + } texture = gl.createTexture(); gl.bindTexture(gl.TEXTURE_2D, texture); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); @@ -8574,6 +8674,7 @@ void main() { const paperRgb = paper || resolvePaperRgb(el); const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches; + if (abandoned(canvas, gl)) return; shaderState = { canvas, gl, program, texture, rafId: 0, startTime: performance.now(), reduced }; function frame() { if (!shaderState) return; @@ -8610,7 +8711,7 @@ void main() { clientSentAt: Date.now(), }; if (!currentSessionId || arrivedVariants === 0) return; - const acceptWrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]'); + const acceptWrapper = findVariantsWrapper(currentSessionId); if (Object.keys(paramsCurrentValues).length > 0) { acceptPayload.paramValues = { ...paramsCurrentValues }; } @@ -8653,6 +8754,7 @@ void main() { .catch(() => { if (pendingAcceptedSession?.id === acceptedSessionId) pendingAcceptedSession = null; setLiveState('CYCLING'); + hideShaderOverlay(); showOrUpdateCyclingBar(); showToast('Could not confirm accept with the live server. Session kept for recovery; try Accept again.', 5000); }); @@ -8704,7 +8806,7 @@ void main() { } function snapshotAcceptedVariantDom(sessionId, variantId) { - const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]'); + const wrapper = findVariantsWrapper(sessionId); const accepted = wrapper?.querySelector?.('[data-impeccable-variant="' + variantId + '"]'); const root = accepted?.firstElementChild || null; return { @@ -8831,7 +8933,7 @@ void main() { } function commitAcceptedVariantToDom(sessionId, variantId) { - const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]'); + const wrapper = findVariantsWrapper(sessionId); if (!wrapper) return false; const accepted = wrapper.querySelector('[data-impeccable-variant="' + variantId + '"]'); if (!accepted || !accepted.firstElementChild) return false; @@ -9059,7 +9161,7 @@ void main() { } function restoreFromActiveSessions(activeSessions, reason) { - const wrapper = document.querySelector('[data-impeccable-variants]'); + const wrapper = findAnyVariantsWrapper(); if (wrapper && !isFrameworkComponentPreviewMode(wrapper.dataset.impeccablePreview)) return false; if (svelteComponentSession?.sessionId === currentSessionId) return false; return restoreSessionWithoutWrapper(reason || 'sse_connected', activeSessions); @@ -9172,10 +9274,13 @@ void main() { // reconciler later tries to remove a wrapper we already removed. // Schedule a 2s fallback that does the manual swap only if HMR hasn't // replaced the wrapper by then (keeps static-server / no-HMR flows alive). - const wrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]'); - if (wrapper) { + // Every match, not the first: a target inside a `.map()` renders one + // wrapper per item, and hiding only one leaves the rest of the + // discarded variants on screen. + const discardWrappers = discardedWrappers(cleanupSessionId); + if (discardWrappers.length > 0) { if (restoreOriginal) showOriginalDuringDiscard(cleanupSessionId); - else wrapper.style.display = 'none'; + else for (const discardWrapper of discardWrappers) discardWrapper.style.display = 'none'; } setTimeout(function() { const recoverySuperseded = deferredRecoverySuperseded(cleanupSessionId, cleanupRevision); @@ -9183,16 +9288,19 @@ void main() { removeDiscardStateStylesheet(); return; } - const lateWrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]'); - if (!lateWrapper) { + const lateWrappers = discardedWrappers(cleanupSessionId); + if (lateWrappers.length === 0) { removeDiscardStateStylesheet(cleanupSessionId); return; } + // Duplicates all render from one source element, so HMR ownership is + // uniform across them; the first is a fair witness for the set. + const lateWrapper = lateWrappers[0]; if (recoverySuperseded) { if (hasFrameworkHmrOwnership(lateWrapper)) { watchForDiscardedFrameworkWrapperRemoval(cleanupSessionId); } else { - releaseDiscardedStaticWrapper(lateWrapper, cleanupSessionId); + releaseDiscardedStaticWrappers(cleanupSessionId, lateWrappers); } return; } @@ -9201,18 +9309,20 @@ void main() { // the final source rewrite, reload once after a grace window so the // discarded source becomes authoritative without a reconciler race. setTimeout(function() { - const staleWrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]'); + const staleWrappers = discardedWrappers(cleanupSessionId); if (deferredRecoverySuperseded(cleanupSessionId, cleanupRevision)) { - if (!staleWrapper) removeDiscardStateStylesheet(cleanupSessionId); + if (staleWrappers.length === 0) removeDiscardStateStylesheet(cleanupSessionId); else watchForDiscardedFrameworkWrapperRemoval(cleanupSessionId); return; } removeDiscardStateStylesheet(cleanupSessionId); - if (staleWrapper) location.reload(); + // A reload restores every wrapper's original at once, so there is + // nothing per-wrapper to do here. + if (staleWrappers.length > 0) location.reload(); }, 2000); return; } - releaseDiscardedStaticWrapper(lateWrapper, cleanupSessionId); + releaseDiscardedStaticWrappers(cleanupSessionId, lateWrappers); }, 2000); } hideBar(instantChrome); @@ -9400,8 +9510,13 @@ void main() { return restoreSessionWithoutWrapper('browser_resumed_over_handled_wrapper'); } - function resumeSession(recoveryRevision = liveInteractionRevision) { - const wrapper = document.querySelector('[data-impeccable-variants]'); + function resumeSession(recoveryRevision = liveInteractionRevision, opts = {}) { + // Which path resumed matters in the journal: an init resume is a fresh + // page load, the deferred-wrapper scout is a mid-page-load arrival. Both + // used to log the same `browser_resumed`, which made issue #719 take a + // DOM reconstruction to diagnose. + const resumeReason = opts.reason || 'browser_resumed'; + const wrapper = findAnyVariantsWrapper(); const runtimeWrapper = wrapper || document.querySelector('[data-impeccable-carbonize]'); if (restoreSessionSupersedingHandledWrapper(runtimeWrapper)) return true; if (scheduleHandledRuntimeWrapperReload(runtimeWrapper, recoveryRevision)) return false; @@ -9500,16 +9615,38 @@ void main() { showBar(state === 'CYCLING' ? 'cycling' : 'generating'); startScrollTracking(); - // Build the params panel for the restored visible variant. Previously - // this was missed on page-reload resume: showVariantInDOM above fires - // refreshParamsPanel, but state was still IDLE at that moment so it - // hid. Now that state is CYCLING, re-fire. - if (state === 'CYCLING') refreshParamsPanel(); + // A resume can BE the arrival, not just a re-entry after one. The server's + // generation preflight runs live-wrap with --defer-source-write, so the + // wrapper and every variant reach the DOM in one HMR batch, and the + // deferred-wrapper scout (constructed at init) runs before the variant + // MutationObserver (constructed at Go) on that batch. Finish the same + // transition the observer would have finished. Without hideShaderOverlay + // the generating shader stays frozen over the target and the session looks + // stuck at GENERATING while the bar already cycles (issue #719). + if (state === 'CYCLING') { + recoveryWaitingForAnchor = false; + hideShaderOverlay(); + if (isInsert) finalizeInsertSession(); + disableInlineEdit(); + // Build the params panel for the restored visible variant. Previously + // this was missed on page-reload resume: showVariantInDOM above fires + // refreshParamsPanel, but state was still IDLE at that moment so it + // hid. Now that state is CYCLING, re-fire. + refreshParamsPanel(); + } saveSession(); if (arrivedVariants > 0 && arrivedVariants < expectedVariants) { sendCheckpoint('variants_progress'); } else { - queueCheckpoint('browser_resumed'); + queueCheckpoint(resumeReason); + // Only variants_progress and variants_ready count as publication + // progress. When the resume is the arrival, the observer never gets to + // report it (this function disconnects and re-creates it below, which + // drops the records it had already queued for this same batch), so + // without this the server never learns the variants were published. + if (arrivedVariants > 0 && arrivedVariants >= expectedVariants && expectedVariants > 0) { + sendCheckpoint('variants_ready'); + } } // Start observing for more variants AFTER initial setup @@ -12831,7 +12968,7 @@ void main() { const wrapper = document.querySelector('[data-impeccable-variants],[data-impeccable-carbonize]'); if (!wrapper) return; scout.disconnect(); - if (resumeSession(deferredResumeRevision)) { + if (resumeSession(deferredResumeRevision, { reason: 'browser_resumed_deferred_wrapper' })) { console.log('[impeccable] Resumed deferred session ' + currentSessionId + ' (post-hydration).'); } }); diff --git a/.opencode/skills/impeccable/scripts/live-browser.js b/.opencode/skills/impeccable/scripts/live-browser.js index c32101b12..277c60e2c 100644 --- a/.opencode/skills/impeccable/scripts/live-browser.js +++ b/.opencode/skills/impeccable/scripts/live-browser.js @@ -2060,7 +2060,7 @@ if (anchor) return anchor; } if (currentSessionId && (state === 'GENERATING' || state === 'CYCLING')) { - const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]'); + const wrapper = findVariantsWrapper(currentSessionId); if (wrapper) { const variantCount = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])').length; if (variantCount > 0 && visibleVariant > 0) { @@ -2131,14 +2131,14 @@ function isInsertGeneratingSession() { if (state !== 'GENERATING' || !currentSessionId) return false; - const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]'); + const wrapper = findVariantsWrapper(currentSessionId); return !!wrapper && wrapper.dataset.impeccableMode === 'insert'; } /** Recreate the dotted placeholder if Astro/Vite HMR removed it mid-generation. */ function ensureInsertPlaceholder() { if (!isInsertGeneratingSession()) return placeholderElement; - const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]'); + const wrapper = findVariantsWrapper(currentSessionId); const variantCount = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])').length; if (variantCount > 0) return placeholderElement; if (placeholderElement && document.body.contains(placeholderElement)) return placeholderElement; @@ -3156,7 +3156,7 @@ || svelteComponentSession.wrapperEl || null; } - const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]'); + const wrapper = findVariantsWrapper(currentSessionId); if (!wrapper) return null; return wrapper.querySelector('[data-impeccable-variant="' + visibleVariant + '"]'); } @@ -4900,7 +4900,7 @@ return Object.values(svelteComponentSession.paramsByVariant || {}) .reduce((total, params) => total + (Array.isArray(params) ? params.length : 0), 0); } - const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]'); + const wrapper = findVariantsWrapper(currentSessionId); if (!wrapper) return 0; return [...wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])')] .reduce((total, variant) => total + parseVariantParams(variant).length, 0); @@ -5004,7 +5004,7 @@ scheduleCyclingBarSync(sessionId, num); return true; } - const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]'); + const wrapper = findVariantsWrapper(sessionId); if (!wrapper) return false; updateVariantStateStylesheet(sessionId, num); // Unconditional refresh - covers first-reveal (no-op if state isn't @@ -5820,6 +5820,7 @@ return; } setLiveState('CYCLING'); + hideShaderOverlay(); showOrUpdateCyclingBar(); saveSession(); completeParameterGenerationIfReady(); @@ -6361,7 +6362,7 @@ } rememberSessionFileMeta({ file: filePath }); if (isJsxSourceFile(filePath)) { - const liveWrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]'); + const liveWrapper = findVariantsWrapper(sessionId); if (liveWrapper && liveWrapper.querySelector('[data-impeccable-variant]:not([data-impeccable-variant="original"])')) { completeSourceInjection(liveWrapper, sessionId, { ...opts, filePath }); return; @@ -6433,7 +6434,7 @@ return; } - const existingWrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]'); + const existingWrapper = findVariantsWrapper(sessionId); if (existingWrapper) { const wrapper = srcWrapper.cloneNode(true); existingWrapper.parentElement.replaceChild(wrapper, existingWrapper); @@ -6590,7 +6591,7 @@ if (anchor && !anchor.__impeccableFrozenAnchor) selectedElement = anchor; return; } - const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]'); + const wrapper = findVariantsWrapper(currentSessionId); if (!wrapper) return; const visEl = pickVariantContent(wrapper, visibleVariant); if (visEl) selectedElement = visEl; @@ -6600,7 +6601,7 @@ if (svelteComponentSession?.sessionId === sessionId && svelteComponentSession.mountedVariant > 0) { return svelteComponentSession.mountedVariant; } - const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]'); + const wrapper = findVariantsWrapper(sessionId); if (!wrapper) return 0; const variants = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])'); for (const variant of variants) { @@ -6715,8 +6716,17 @@ document.getElementById(discardStateStyleId(sessionId))?.remove(); } - function releaseDiscardedStaticWrapper(wrapper, sessionId) { - removeDiscardStateStylesheet(sessionId); + /** + * Every wrapper a discard has to unwind. A target inside a `.map()` renders + * one wrapper per item, so the hide, the release, and the existence checks + * all have to speak about the same set. + */ + function discardedWrappers(sessionId) { + if (!sessionId) return []; + return [...document.querySelectorAll('[data-impeccable-variants="' + sessionId + '"]')]; + } + + function releaseDiscardedStaticWrapper(wrapper) { if (!wrapper) return; const orig = wrapper.querySelector('[data-impeccable-variant="original"]'); const content = orig?.firstElementChild; @@ -6727,6 +6737,18 @@ wrapper.remove(); } + /** + * Undo the discard hide on every wrapper it covered. Releasing only the + * first match left the other mapped items sitting at display:none with + * their original content never restored, on exactly the static and + * missed-HMR flows this fallback exists for. + */ + function releaseDiscardedStaticWrappers(sessionId, wrappers) { + removeDiscardStateStylesheet(sessionId); + const set = wrappers && wrappers.length ? wrappers : discardedWrappers(sessionId); + for (const wrapper of set) releaseDiscardedStaticWrapper(wrapper); + } + function watchForDiscardedFrameworkWrapperRemoval(sessionId) { if (!sessionId || !document.body) return; if (discardedFrameworkWrapperWatchers.has(sessionId)) return; @@ -6907,6 +6929,42 @@ // MutationObserver for progressive variant reveal // + // A session id can have more than one wrapper in the DOM: the target may sit + // inside a `.map()` callback (the wrapper renders once per item), or the + // agent may have relocated the wrapper out of the shared primitive live-wrap + // scaffolded into. A plain first match can then pin an empty scaffold while + // the real variants sit in a later wrapper, which strands the session at + // 0/N and leaves the bar, the params panel, and accept all reading the + // wrong element. Prefer a wrapper that actually holds variants. With zero + // or one match this is exactly the querySelector it replaces. + // + // Every lookup of the ACTIVE session's wrapper goes through here. The + // remaining raw `[data-impeccable-variants=...]` uses are deliberate: bare + // existence checks, selector strings for stylesheets and observers (which + // want to cover every match), `querySelectorAll` sweeps, and the parsed + // source document, which is not this document. + function pickPopulatedVariantsWrapper(selector) { + const matches = document.querySelectorAll(selector); + if (matches.length < 2) return matches[0] || null; + for (const candidate of matches) { + if (candidate.querySelector('[data-impeccable-variant]:not([data-impeccable-variant="original"])')) { + return candidate; + } + } + return matches[0]; + } + + /** The wrapper holding `sessionId`'s variants, or null without an id. */ + function findVariantsWrapper(sessionId) { + if (!sessionId) return null; + return pickPopulatedVariantsWrapper('[data-impeccable-variants="' + sessionId + '"]'); + } + + /** Any live variant wrapper, for the resume paths that have no id yet. */ + function findAnyVariantsWrapper() { + return pickPopulatedVariantsWrapper('[data-impeccable-variants]'); + } + function startVariantObserver(sessionId) { let updating = false; // re-entrancy guard @@ -6936,7 +6994,7 @@ } if (!dominated) return; - const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]'); + const wrapper = findVariantsWrapper(sessionId); if (!wrapper) return; const variants = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])'); @@ -7145,6 +7203,7 @@ if (arrivedVariants >= expectedVariants && expectedVariants > 0) { if (state === 'GENERATING') { setLiveState('CYCLING'); + hideShaderOverlay(); showOrUpdateCyclingBar(); disableInlineEdit(); refreshParamsPanel(); @@ -7205,6 +7264,7 @@ pendingAcceptedSession = null; awaitingAcceptResult = null; setLiveState('CYCLING'); + hideShaderOverlay(); updateBarContent('cycling'); showToast('Could not complete accept cleanup. Try Accept again.', 5000); break; @@ -8307,6 +8367,15 @@ void main() { // matches the original off-white risograph paper. const SHADER_PAPER_FALLBACK = [0.975, 0.965, 0.955]; let shaderState = null; // { canvas, gl, program, texture, rafId, startTime } + // showShaderOverlay is async: it appends its canvas, then awaits + // createImageBitmap and the GL setup before it publishes shaderState. A + // teardown that landed inside that window found shaderState still null, + // returned, and then watched the construction publish itself over a session + // that had already left GENERATING, with no teardown left to run. That is + // the generating loader frozen over a page that already cycles (issue #719). + // Every teardown bumps this epoch; a construction abandons its own canvas as + // soon as it sees the epoch move. + let shaderEpoch = 0; // The element's effective background tone, used as the uniform halftone // ground so content dissolves into dots over it. Unlike resolveCanvasBackground @@ -8453,14 +8522,28 @@ void main() { }); } + /** Drop a shader node no shaderState owns (an abandoned construction). */ + function removeStrayShaderNode() { + const stray = uiGetById(PREFIX + '-shader'); + if (stray) stray.remove(); + } + function hideShaderOverlay() { - if (!shaderState) return; + // Bump first, unconditionally: this is what tells an in-flight + // showShaderOverlay to abandon itself rather than publish over a session + // that has already moved on. + shaderEpoch += 1; + if (!shaderState) { + removeStrayShaderNode(); + return; + } if (shaderState.rafId) cancelAnimationFrame(shaderState.rafId); if (shaderState.canvas) shaderState.canvas.remove(); if (shaderState.objectUrl) URL.revokeObjectURL(shaderState.objectUrl); const lose = shaderState.gl?.getExtension?.('WEBGL_lose_context'); try { lose?.loseContext(); } catch {} shaderState = null; + removeStrayShaderNode(); } function showShaderBitmapFallback(canvas, blob) { @@ -8485,6 +8568,16 @@ void main() { async function showShaderOverlay(el, blob, rect, paper) { hideShaderOverlay(); if (!blob || !el) return; + // hideShaderOverlay just bumped the epoch, so this run owns it until the + // next teardown. Every step past an await re-checks before it publishes. + const epoch = shaderEpoch; + const abandoned = (node, gl) => { + if (epoch === shaderEpoch) return false; + node.remove(); + const lose = gl?.getExtension?.('WEBGL_lose_context'); + try { lose?.loseContext(); } catch {} + return true; + }; const canvas = document.createElement('canvas'); canvas.id = PREFIX + '-shader'; const dpr = Math.min(window.devicePixelRatio || 1, 2); @@ -8507,6 +8600,7 @@ void main() { if (!gl) { // WebGL unavailable: use the captured bitmap as a background overlay so // the user still sees something meaningful during generation. + if (abandoned(canvas, null)) return; showShaderBitmapFallback(canvas, blob); return; } @@ -8546,16 +8640,22 @@ void main() { } // Upload the screenshot as a texture + if (abandoned(canvas, gl)) return; let bitmap; try { bitmap = await createImageBitmap(blob); } catch (err) { console.warn('[impeccable] shader bitmap decode failed:', err); + if (abandoned(canvas, gl)) return; const lose = gl.getExtension?.('WEBGL_lose_context'); try { lose?.loseContext(); } catch {} showShaderBitmapFallback(canvas, blob); return; } + if (abandoned(canvas, gl)) { + if (bitmap.close) bitmap.close(); + return; + } texture = gl.createTexture(); gl.bindTexture(gl.TEXTURE_2D, texture); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); @@ -8574,6 +8674,7 @@ void main() { const paperRgb = paper || resolvePaperRgb(el); const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches; + if (abandoned(canvas, gl)) return; shaderState = { canvas, gl, program, texture, rafId: 0, startTime: performance.now(), reduced }; function frame() { if (!shaderState) return; @@ -8610,7 +8711,7 @@ void main() { clientSentAt: Date.now(), }; if (!currentSessionId || arrivedVariants === 0) return; - const acceptWrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]'); + const acceptWrapper = findVariantsWrapper(currentSessionId); if (Object.keys(paramsCurrentValues).length > 0) { acceptPayload.paramValues = { ...paramsCurrentValues }; } @@ -8653,6 +8754,7 @@ void main() { .catch(() => { if (pendingAcceptedSession?.id === acceptedSessionId) pendingAcceptedSession = null; setLiveState('CYCLING'); + hideShaderOverlay(); showOrUpdateCyclingBar(); showToast('Could not confirm accept with the live server. Session kept for recovery; try Accept again.', 5000); }); @@ -8704,7 +8806,7 @@ void main() { } function snapshotAcceptedVariantDom(sessionId, variantId) { - const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]'); + const wrapper = findVariantsWrapper(sessionId); const accepted = wrapper?.querySelector?.('[data-impeccable-variant="' + variantId + '"]'); const root = accepted?.firstElementChild || null; return { @@ -8831,7 +8933,7 @@ void main() { } function commitAcceptedVariantToDom(sessionId, variantId) { - const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]'); + const wrapper = findVariantsWrapper(sessionId); if (!wrapper) return false; const accepted = wrapper.querySelector('[data-impeccable-variant="' + variantId + '"]'); if (!accepted || !accepted.firstElementChild) return false; @@ -9059,7 +9161,7 @@ void main() { } function restoreFromActiveSessions(activeSessions, reason) { - const wrapper = document.querySelector('[data-impeccable-variants]'); + const wrapper = findAnyVariantsWrapper(); if (wrapper && !isFrameworkComponentPreviewMode(wrapper.dataset.impeccablePreview)) return false; if (svelteComponentSession?.sessionId === currentSessionId) return false; return restoreSessionWithoutWrapper(reason || 'sse_connected', activeSessions); @@ -9172,10 +9274,13 @@ void main() { // reconciler later tries to remove a wrapper we already removed. // Schedule a 2s fallback that does the manual swap only if HMR hasn't // replaced the wrapper by then (keeps static-server / no-HMR flows alive). - const wrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]'); - if (wrapper) { + // Every match, not the first: a target inside a `.map()` renders one + // wrapper per item, and hiding only one leaves the rest of the + // discarded variants on screen. + const discardWrappers = discardedWrappers(cleanupSessionId); + if (discardWrappers.length > 0) { if (restoreOriginal) showOriginalDuringDiscard(cleanupSessionId); - else wrapper.style.display = 'none'; + else for (const discardWrapper of discardWrappers) discardWrapper.style.display = 'none'; } setTimeout(function() { const recoverySuperseded = deferredRecoverySuperseded(cleanupSessionId, cleanupRevision); @@ -9183,16 +9288,19 @@ void main() { removeDiscardStateStylesheet(); return; } - const lateWrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]'); - if (!lateWrapper) { + const lateWrappers = discardedWrappers(cleanupSessionId); + if (lateWrappers.length === 0) { removeDiscardStateStylesheet(cleanupSessionId); return; } + // Duplicates all render from one source element, so HMR ownership is + // uniform across them; the first is a fair witness for the set. + const lateWrapper = lateWrappers[0]; if (recoverySuperseded) { if (hasFrameworkHmrOwnership(lateWrapper)) { watchForDiscardedFrameworkWrapperRemoval(cleanupSessionId); } else { - releaseDiscardedStaticWrapper(lateWrapper, cleanupSessionId); + releaseDiscardedStaticWrappers(cleanupSessionId, lateWrappers); } return; } @@ -9201,18 +9309,20 @@ void main() { // the final source rewrite, reload once after a grace window so the // discarded source becomes authoritative without a reconciler race. setTimeout(function() { - const staleWrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]'); + const staleWrappers = discardedWrappers(cleanupSessionId); if (deferredRecoverySuperseded(cleanupSessionId, cleanupRevision)) { - if (!staleWrapper) removeDiscardStateStylesheet(cleanupSessionId); + if (staleWrappers.length === 0) removeDiscardStateStylesheet(cleanupSessionId); else watchForDiscardedFrameworkWrapperRemoval(cleanupSessionId); return; } removeDiscardStateStylesheet(cleanupSessionId); - if (staleWrapper) location.reload(); + // A reload restores every wrapper's original at once, so there is + // nothing per-wrapper to do here. + if (staleWrappers.length > 0) location.reload(); }, 2000); return; } - releaseDiscardedStaticWrapper(lateWrapper, cleanupSessionId); + releaseDiscardedStaticWrappers(cleanupSessionId, lateWrappers); }, 2000); } hideBar(instantChrome); @@ -9400,8 +9510,13 @@ void main() { return restoreSessionWithoutWrapper('browser_resumed_over_handled_wrapper'); } - function resumeSession(recoveryRevision = liveInteractionRevision) { - const wrapper = document.querySelector('[data-impeccable-variants]'); + function resumeSession(recoveryRevision = liveInteractionRevision, opts = {}) { + // Which path resumed matters in the journal: an init resume is a fresh + // page load, the deferred-wrapper scout is a mid-page-load arrival. Both + // used to log the same `browser_resumed`, which made issue #719 take a + // DOM reconstruction to diagnose. + const resumeReason = opts.reason || 'browser_resumed'; + const wrapper = findAnyVariantsWrapper(); const runtimeWrapper = wrapper || document.querySelector('[data-impeccable-carbonize]'); if (restoreSessionSupersedingHandledWrapper(runtimeWrapper)) return true; if (scheduleHandledRuntimeWrapperReload(runtimeWrapper, recoveryRevision)) return false; @@ -9500,16 +9615,38 @@ void main() { showBar(state === 'CYCLING' ? 'cycling' : 'generating'); startScrollTracking(); - // Build the params panel for the restored visible variant. Previously - // this was missed on page-reload resume: showVariantInDOM above fires - // refreshParamsPanel, but state was still IDLE at that moment so it - // hid. Now that state is CYCLING, re-fire. - if (state === 'CYCLING') refreshParamsPanel(); + // A resume can BE the arrival, not just a re-entry after one. The server's + // generation preflight runs live-wrap with --defer-source-write, so the + // wrapper and every variant reach the DOM in one HMR batch, and the + // deferred-wrapper scout (constructed at init) runs before the variant + // MutationObserver (constructed at Go) on that batch. Finish the same + // transition the observer would have finished. Without hideShaderOverlay + // the generating shader stays frozen over the target and the session looks + // stuck at GENERATING while the bar already cycles (issue #719). + if (state === 'CYCLING') { + recoveryWaitingForAnchor = false; + hideShaderOverlay(); + if (isInsert) finalizeInsertSession(); + disableInlineEdit(); + // Build the params panel for the restored visible variant. Previously + // this was missed on page-reload resume: showVariantInDOM above fires + // refreshParamsPanel, but state was still IDLE at that moment so it + // hid. Now that state is CYCLING, re-fire. + refreshParamsPanel(); + } saveSession(); if (arrivedVariants > 0 && arrivedVariants < expectedVariants) { sendCheckpoint('variants_progress'); } else { - queueCheckpoint('browser_resumed'); + queueCheckpoint(resumeReason); + // Only variants_progress and variants_ready count as publication + // progress. When the resume is the arrival, the observer never gets to + // report it (this function disconnects and re-creates it below, which + // drops the records it had already queued for this same batch), so + // without this the server never learns the variants were published. + if (arrivedVariants > 0 && arrivedVariants >= expectedVariants && expectedVariants > 0) { + sendCheckpoint('variants_ready'); + } } // Start observing for more variants AFTER initial setup @@ -12831,7 +12968,7 @@ void main() { const wrapper = document.querySelector('[data-impeccable-variants],[data-impeccable-carbonize]'); if (!wrapper) return; scout.disconnect(); - if (resumeSession(deferredResumeRevision)) { + if (resumeSession(deferredResumeRevision, { reason: 'browser_resumed_deferred_wrapper' })) { console.log('[impeccable] Resumed deferred session ' + currentSessionId + ' (post-hydration).'); } }); diff --git a/.pi/skills/impeccable/scripts/live-browser.js b/.pi/skills/impeccable/scripts/live-browser.js index c32101b12..277c60e2c 100644 --- a/.pi/skills/impeccable/scripts/live-browser.js +++ b/.pi/skills/impeccable/scripts/live-browser.js @@ -2060,7 +2060,7 @@ if (anchor) return anchor; } if (currentSessionId && (state === 'GENERATING' || state === 'CYCLING')) { - const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]'); + const wrapper = findVariantsWrapper(currentSessionId); if (wrapper) { const variantCount = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])').length; if (variantCount > 0 && visibleVariant > 0) { @@ -2131,14 +2131,14 @@ function isInsertGeneratingSession() { if (state !== 'GENERATING' || !currentSessionId) return false; - const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]'); + const wrapper = findVariantsWrapper(currentSessionId); return !!wrapper && wrapper.dataset.impeccableMode === 'insert'; } /** Recreate the dotted placeholder if Astro/Vite HMR removed it mid-generation. */ function ensureInsertPlaceholder() { if (!isInsertGeneratingSession()) return placeholderElement; - const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]'); + const wrapper = findVariantsWrapper(currentSessionId); const variantCount = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])').length; if (variantCount > 0) return placeholderElement; if (placeholderElement && document.body.contains(placeholderElement)) return placeholderElement; @@ -3156,7 +3156,7 @@ || svelteComponentSession.wrapperEl || null; } - const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]'); + const wrapper = findVariantsWrapper(currentSessionId); if (!wrapper) return null; return wrapper.querySelector('[data-impeccable-variant="' + visibleVariant + '"]'); } @@ -4900,7 +4900,7 @@ return Object.values(svelteComponentSession.paramsByVariant || {}) .reduce((total, params) => total + (Array.isArray(params) ? params.length : 0), 0); } - const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]'); + const wrapper = findVariantsWrapper(currentSessionId); if (!wrapper) return 0; return [...wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])')] .reduce((total, variant) => total + parseVariantParams(variant).length, 0); @@ -5004,7 +5004,7 @@ scheduleCyclingBarSync(sessionId, num); return true; } - const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]'); + const wrapper = findVariantsWrapper(sessionId); if (!wrapper) return false; updateVariantStateStylesheet(sessionId, num); // Unconditional refresh - covers first-reveal (no-op if state isn't @@ -5820,6 +5820,7 @@ return; } setLiveState('CYCLING'); + hideShaderOverlay(); showOrUpdateCyclingBar(); saveSession(); completeParameterGenerationIfReady(); @@ -6361,7 +6362,7 @@ } rememberSessionFileMeta({ file: filePath }); if (isJsxSourceFile(filePath)) { - const liveWrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]'); + const liveWrapper = findVariantsWrapper(sessionId); if (liveWrapper && liveWrapper.querySelector('[data-impeccable-variant]:not([data-impeccable-variant="original"])')) { completeSourceInjection(liveWrapper, sessionId, { ...opts, filePath }); return; @@ -6433,7 +6434,7 @@ return; } - const existingWrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]'); + const existingWrapper = findVariantsWrapper(sessionId); if (existingWrapper) { const wrapper = srcWrapper.cloneNode(true); existingWrapper.parentElement.replaceChild(wrapper, existingWrapper); @@ -6590,7 +6591,7 @@ if (anchor && !anchor.__impeccableFrozenAnchor) selectedElement = anchor; return; } - const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]'); + const wrapper = findVariantsWrapper(currentSessionId); if (!wrapper) return; const visEl = pickVariantContent(wrapper, visibleVariant); if (visEl) selectedElement = visEl; @@ -6600,7 +6601,7 @@ if (svelteComponentSession?.sessionId === sessionId && svelteComponentSession.mountedVariant > 0) { return svelteComponentSession.mountedVariant; } - const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]'); + const wrapper = findVariantsWrapper(sessionId); if (!wrapper) return 0; const variants = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])'); for (const variant of variants) { @@ -6715,8 +6716,17 @@ document.getElementById(discardStateStyleId(sessionId))?.remove(); } - function releaseDiscardedStaticWrapper(wrapper, sessionId) { - removeDiscardStateStylesheet(sessionId); + /** + * Every wrapper a discard has to unwind. A target inside a `.map()` renders + * one wrapper per item, so the hide, the release, and the existence checks + * all have to speak about the same set. + */ + function discardedWrappers(sessionId) { + if (!sessionId) return []; + return [...document.querySelectorAll('[data-impeccable-variants="' + sessionId + '"]')]; + } + + function releaseDiscardedStaticWrapper(wrapper) { if (!wrapper) return; const orig = wrapper.querySelector('[data-impeccable-variant="original"]'); const content = orig?.firstElementChild; @@ -6727,6 +6737,18 @@ wrapper.remove(); } + /** + * Undo the discard hide on every wrapper it covered. Releasing only the + * first match left the other mapped items sitting at display:none with + * their original content never restored, on exactly the static and + * missed-HMR flows this fallback exists for. + */ + function releaseDiscardedStaticWrappers(sessionId, wrappers) { + removeDiscardStateStylesheet(sessionId); + const set = wrappers && wrappers.length ? wrappers : discardedWrappers(sessionId); + for (const wrapper of set) releaseDiscardedStaticWrapper(wrapper); + } + function watchForDiscardedFrameworkWrapperRemoval(sessionId) { if (!sessionId || !document.body) return; if (discardedFrameworkWrapperWatchers.has(sessionId)) return; @@ -6907,6 +6929,42 @@ // MutationObserver for progressive variant reveal // + // A session id can have more than one wrapper in the DOM: the target may sit + // inside a `.map()` callback (the wrapper renders once per item), or the + // agent may have relocated the wrapper out of the shared primitive live-wrap + // scaffolded into. A plain first match can then pin an empty scaffold while + // the real variants sit in a later wrapper, which strands the session at + // 0/N and leaves the bar, the params panel, and accept all reading the + // wrong element. Prefer a wrapper that actually holds variants. With zero + // or one match this is exactly the querySelector it replaces. + // + // Every lookup of the ACTIVE session's wrapper goes through here. The + // remaining raw `[data-impeccable-variants=...]` uses are deliberate: bare + // existence checks, selector strings for stylesheets and observers (which + // want to cover every match), `querySelectorAll` sweeps, and the parsed + // source document, which is not this document. + function pickPopulatedVariantsWrapper(selector) { + const matches = document.querySelectorAll(selector); + if (matches.length < 2) return matches[0] || null; + for (const candidate of matches) { + if (candidate.querySelector('[data-impeccable-variant]:not([data-impeccable-variant="original"])')) { + return candidate; + } + } + return matches[0]; + } + + /** The wrapper holding `sessionId`'s variants, or null without an id. */ + function findVariantsWrapper(sessionId) { + if (!sessionId) return null; + return pickPopulatedVariantsWrapper('[data-impeccable-variants="' + sessionId + '"]'); + } + + /** Any live variant wrapper, for the resume paths that have no id yet. */ + function findAnyVariantsWrapper() { + return pickPopulatedVariantsWrapper('[data-impeccable-variants]'); + } + function startVariantObserver(sessionId) { let updating = false; // re-entrancy guard @@ -6936,7 +6994,7 @@ } if (!dominated) return; - const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]'); + const wrapper = findVariantsWrapper(sessionId); if (!wrapper) return; const variants = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])'); @@ -7145,6 +7203,7 @@ if (arrivedVariants >= expectedVariants && expectedVariants > 0) { if (state === 'GENERATING') { setLiveState('CYCLING'); + hideShaderOverlay(); showOrUpdateCyclingBar(); disableInlineEdit(); refreshParamsPanel(); @@ -7205,6 +7264,7 @@ pendingAcceptedSession = null; awaitingAcceptResult = null; setLiveState('CYCLING'); + hideShaderOverlay(); updateBarContent('cycling'); showToast('Could not complete accept cleanup. Try Accept again.', 5000); break; @@ -8307,6 +8367,15 @@ void main() { // matches the original off-white risograph paper. const SHADER_PAPER_FALLBACK = [0.975, 0.965, 0.955]; let shaderState = null; // { canvas, gl, program, texture, rafId, startTime } + // showShaderOverlay is async: it appends its canvas, then awaits + // createImageBitmap and the GL setup before it publishes shaderState. A + // teardown that landed inside that window found shaderState still null, + // returned, and then watched the construction publish itself over a session + // that had already left GENERATING, with no teardown left to run. That is + // the generating loader frozen over a page that already cycles (issue #719). + // Every teardown bumps this epoch; a construction abandons its own canvas as + // soon as it sees the epoch move. + let shaderEpoch = 0; // The element's effective background tone, used as the uniform halftone // ground so content dissolves into dots over it. Unlike resolveCanvasBackground @@ -8453,14 +8522,28 @@ void main() { }); } + /** Drop a shader node no shaderState owns (an abandoned construction). */ + function removeStrayShaderNode() { + const stray = uiGetById(PREFIX + '-shader'); + if (stray) stray.remove(); + } + function hideShaderOverlay() { - if (!shaderState) return; + // Bump first, unconditionally: this is what tells an in-flight + // showShaderOverlay to abandon itself rather than publish over a session + // that has already moved on. + shaderEpoch += 1; + if (!shaderState) { + removeStrayShaderNode(); + return; + } if (shaderState.rafId) cancelAnimationFrame(shaderState.rafId); if (shaderState.canvas) shaderState.canvas.remove(); if (shaderState.objectUrl) URL.revokeObjectURL(shaderState.objectUrl); const lose = shaderState.gl?.getExtension?.('WEBGL_lose_context'); try { lose?.loseContext(); } catch {} shaderState = null; + removeStrayShaderNode(); } function showShaderBitmapFallback(canvas, blob) { @@ -8485,6 +8568,16 @@ void main() { async function showShaderOverlay(el, blob, rect, paper) { hideShaderOverlay(); if (!blob || !el) return; + // hideShaderOverlay just bumped the epoch, so this run owns it until the + // next teardown. Every step past an await re-checks before it publishes. + const epoch = shaderEpoch; + const abandoned = (node, gl) => { + if (epoch === shaderEpoch) return false; + node.remove(); + const lose = gl?.getExtension?.('WEBGL_lose_context'); + try { lose?.loseContext(); } catch {} + return true; + }; const canvas = document.createElement('canvas'); canvas.id = PREFIX + '-shader'; const dpr = Math.min(window.devicePixelRatio || 1, 2); @@ -8507,6 +8600,7 @@ void main() { if (!gl) { // WebGL unavailable: use the captured bitmap as a background overlay so // the user still sees something meaningful during generation. + if (abandoned(canvas, null)) return; showShaderBitmapFallback(canvas, blob); return; } @@ -8546,16 +8640,22 @@ void main() { } // Upload the screenshot as a texture + if (abandoned(canvas, gl)) return; let bitmap; try { bitmap = await createImageBitmap(blob); } catch (err) { console.warn('[impeccable] shader bitmap decode failed:', err); + if (abandoned(canvas, gl)) return; const lose = gl.getExtension?.('WEBGL_lose_context'); try { lose?.loseContext(); } catch {} showShaderBitmapFallback(canvas, blob); return; } + if (abandoned(canvas, gl)) { + if (bitmap.close) bitmap.close(); + return; + } texture = gl.createTexture(); gl.bindTexture(gl.TEXTURE_2D, texture); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); @@ -8574,6 +8674,7 @@ void main() { const paperRgb = paper || resolvePaperRgb(el); const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches; + if (abandoned(canvas, gl)) return; shaderState = { canvas, gl, program, texture, rafId: 0, startTime: performance.now(), reduced }; function frame() { if (!shaderState) return; @@ -8610,7 +8711,7 @@ void main() { clientSentAt: Date.now(), }; if (!currentSessionId || arrivedVariants === 0) return; - const acceptWrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]'); + const acceptWrapper = findVariantsWrapper(currentSessionId); if (Object.keys(paramsCurrentValues).length > 0) { acceptPayload.paramValues = { ...paramsCurrentValues }; } @@ -8653,6 +8754,7 @@ void main() { .catch(() => { if (pendingAcceptedSession?.id === acceptedSessionId) pendingAcceptedSession = null; setLiveState('CYCLING'); + hideShaderOverlay(); showOrUpdateCyclingBar(); showToast('Could not confirm accept with the live server. Session kept for recovery; try Accept again.', 5000); }); @@ -8704,7 +8806,7 @@ void main() { } function snapshotAcceptedVariantDom(sessionId, variantId) { - const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]'); + const wrapper = findVariantsWrapper(sessionId); const accepted = wrapper?.querySelector?.('[data-impeccable-variant="' + variantId + '"]'); const root = accepted?.firstElementChild || null; return { @@ -8831,7 +8933,7 @@ void main() { } function commitAcceptedVariantToDom(sessionId, variantId) { - const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]'); + const wrapper = findVariantsWrapper(sessionId); if (!wrapper) return false; const accepted = wrapper.querySelector('[data-impeccable-variant="' + variantId + '"]'); if (!accepted || !accepted.firstElementChild) return false; @@ -9059,7 +9161,7 @@ void main() { } function restoreFromActiveSessions(activeSessions, reason) { - const wrapper = document.querySelector('[data-impeccable-variants]'); + const wrapper = findAnyVariantsWrapper(); if (wrapper && !isFrameworkComponentPreviewMode(wrapper.dataset.impeccablePreview)) return false; if (svelteComponentSession?.sessionId === currentSessionId) return false; return restoreSessionWithoutWrapper(reason || 'sse_connected', activeSessions); @@ -9172,10 +9274,13 @@ void main() { // reconciler later tries to remove a wrapper we already removed. // Schedule a 2s fallback that does the manual swap only if HMR hasn't // replaced the wrapper by then (keeps static-server / no-HMR flows alive). - const wrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]'); - if (wrapper) { + // Every match, not the first: a target inside a `.map()` renders one + // wrapper per item, and hiding only one leaves the rest of the + // discarded variants on screen. + const discardWrappers = discardedWrappers(cleanupSessionId); + if (discardWrappers.length > 0) { if (restoreOriginal) showOriginalDuringDiscard(cleanupSessionId); - else wrapper.style.display = 'none'; + else for (const discardWrapper of discardWrappers) discardWrapper.style.display = 'none'; } setTimeout(function() { const recoverySuperseded = deferredRecoverySuperseded(cleanupSessionId, cleanupRevision); @@ -9183,16 +9288,19 @@ void main() { removeDiscardStateStylesheet(); return; } - const lateWrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]'); - if (!lateWrapper) { + const lateWrappers = discardedWrappers(cleanupSessionId); + if (lateWrappers.length === 0) { removeDiscardStateStylesheet(cleanupSessionId); return; } + // Duplicates all render from one source element, so HMR ownership is + // uniform across them; the first is a fair witness for the set. + const lateWrapper = lateWrappers[0]; if (recoverySuperseded) { if (hasFrameworkHmrOwnership(lateWrapper)) { watchForDiscardedFrameworkWrapperRemoval(cleanupSessionId); } else { - releaseDiscardedStaticWrapper(lateWrapper, cleanupSessionId); + releaseDiscardedStaticWrappers(cleanupSessionId, lateWrappers); } return; } @@ -9201,18 +9309,20 @@ void main() { // the final source rewrite, reload once after a grace window so the // discarded source becomes authoritative without a reconciler race. setTimeout(function() { - const staleWrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]'); + const staleWrappers = discardedWrappers(cleanupSessionId); if (deferredRecoverySuperseded(cleanupSessionId, cleanupRevision)) { - if (!staleWrapper) removeDiscardStateStylesheet(cleanupSessionId); + if (staleWrappers.length === 0) removeDiscardStateStylesheet(cleanupSessionId); else watchForDiscardedFrameworkWrapperRemoval(cleanupSessionId); return; } removeDiscardStateStylesheet(cleanupSessionId); - if (staleWrapper) location.reload(); + // A reload restores every wrapper's original at once, so there is + // nothing per-wrapper to do here. + if (staleWrappers.length > 0) location.reload(); }, 2000); return; } - releaseDiscardedStaticWrapper(lateWrapper, cleanupSessionId); + releaseDiscardedStaticWrappers(cleanupSessionId, lateWrappers); }, 2000); } hideBar(instantChrome); @@ -9400,8 +9510,13 @@ void main() { return restoreSessionWithoutWrapper('browser_resumed_over_handled_wrapper'); } - function resumeSession(recoveryRevision = liveInteractionRevision) { - const wrapper = document.querySelector('[data-impeccable-variants]'); + function resumeSession(recoveryRevision = liveInteractionRevision, opts = {}) { + // Which path resumed matters in the journal: an init resume is a fresh + // page load, the deferred-wrapper scout is a mid-page-load arrival. Both + // used to log the same `browser_resumed`, which made issue #719 take a + // DOM reconstruction to diagnose. + const resumeReason = opts.reason || 'browser_resumed'; + const wrapper = findAnyVariantsWrapper(); const runtimeWrapper = wrapper || document.querySelector('[data-impeccable-carbonize]'); if (restoreSessionSupersedingHandledWrapper(runtimeWrapper)) return true; if (scheduleHandledRuntimeWrapperReload(runtimeWrapper, recoveryRevision)) return false; @@ -9500,16 +9615,38 @@ void main() { showBar(state === 'CYCLING' ? 'cycling' : 'generating'); startScrollTracking(); - // Build the params panel for the restored visible variant. Previously - // this was missed on page-reload resume: showVariantInDOM above fires - // refreshParamsPanel, but state was still IDLE at that moment so it - // hid. Now that state is CYCLING, re-fire. - if (state === 'CYCLING') refreshParamsPanel(); + // A resume can BE the arrival, not just a re-entry after one. The server's + // generation preflight runs live-wrap with --defer-source-write, so the + // wrapper and every variant reach the DOM in one HMR batch, and the + // deferred-wrapper scout (constructed at init) runs before the variant + // MutationObserver (constructed at Go) on that batch. Finish the same + // transition the observer would have finished. Without hideShaderOverlay + // the generating shader stays frozen over the target and the session looks + // stuck at GENERATING while the bar already cycles (issue #719). + if (state === 'CYCLING') { + recoveryWaitingForAnchor = false; + hideShaderOverlay(); + if (isInsert) finalizeInsertSession(); + disableInlineEdit(); + // Build the params panel for the restored visible variant. Previously + // this was missed on page-reload resume: showVariantInDOM above fires + // refreshParamsPanel, but state was still IDLE at that moment so it + // hid. Now that state is CYCLING, re-fire. + refreshParamsPanel(); + } saveSession(); if (arrivedVariants > 0 && arrivedVariants < expectedVariants) { sendCheckpoint('variants_progress'); } else { - queueCheckpoint('browser_resumed'); + queueCheckpoint(resumeReason); + // Only variants_progress and variants_ready count as publication + // progress. When the resume is the arrival, the observer never gets to + // report it (this function disconnects and re-creates it below, which + // drops the records it had already queued for this same batch), so + // without this the server never learns the variants were published. + if (arrivedVariants > 0 && arrivedVariants >= expectedVariants && expectedVariants > 0) { + sendCheckpoint('variants_ready'); + } } // Start observing for more variants AFTER initial setup @@ -12831,7 +12968,7 @@ void main() { const wrapper = document.querySelector('[data-impeccable-variants],[data-impeccable-carbonize]'); if (!wrapper) return; scout.disconnect(); - if (resumeSession(deferredResumeRevision)) { + if (resumeSession(deferredResumeRevision, { reason: 'browser_resumed_deferred_wrapper' })) { console.log('[impeccable] Resumed deferred session ' + currentSessionId + ' (post-hydration).'); } }); diff --git a/.qoder/skills/impeccable/scripts/live-browser.js b/.qoder/skills/impeccable/scripts/live-browser.js index c32101b12..277c60e2c 100644 --- a/.qoder/skills/impeccable/scripts/live-browser.js +++ b/.qoder/skills/impeccable/scripts/live-browser.js @@ -2060,7 +2060,7 @@ if (anchor) return anchor; } if (currentSessionId && (state === 'GENERATING' || state === 'CYCLING')) { - const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]'); + const wrapper = findVariantsWrapper(currentSessionId); if (wrapper) { const variantCount = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])').length; if (variantCount > 0 && visibleVariant > 0) { @@ -2131,14 +2131,14 @@ function isInsertGeneratingSession() { if (state !== 'GENERATING' || !currentSessionId) return false; - const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]'); + const wrapper = findVariantsWrapper(currentSessionId); return !!wrapper && wrapper.dataset.impeccableMode === 'insert'; } /** Recreate the dotted placeholder if Astro/Vite HMR removed it mid-generation. */ function ensureInsertPlaceholder() { if (!isInsertGeneratingSession()) return placeholderElement; - const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]'); + const wrapper = findVariantsWrapper(currentSessionId); const variantCount = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])').length; if (variantCount > 0) return placeholderElement; if (placeholderElement && document.body.contains(placeholderElement)) return placeholderElement; @@ -3156,7 +3156,7 @@ || svelteComponentSession.wrapperEl || null; } - const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]'); + const wrapper = findVariantsWrapper(currentSessionId); if (!wrapper) return null; return wrapper.querySelector('[data-impeccable-variant="' + visibleVariant + '"]'); } @@ -4900,7 +4900,7 @@ return Object.values(svelteComponentSession.paramsByVariant || {}) .reduce((total, params) => total + (Array.isArray(params) ? params.length : 0), 0); } - const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]'); + const wrapper = findVariantsWrapper(currentSessionId); if (!wrapper) return 0; return [...wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])')] .reduce((total, variant) => total + parseVariantParams(variant).length, 0); @@ -5004,7 +5004,7 @@ scheduleCyclingBarSync(sessionId, num); return true; } - const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]'); + const wrapper = findVariantsWrapper(sessionId); if (!wrapper) return false; updateVariantStateStylesheet(sessionId, num); // Unconditional refresh - covers first-reveal (no-op if state isn't @@ -5820,6 +5820,7 @@ return; } setLiveState('CYCLING'); + hideShaderOverlay(); showOrUpdateCyclingBar(); saveSession(); completeParameterGenerationIfReady(); @@ -6361,7 +6362,7 @@ } rememberSessionFileMeta({ file: filePath }); if (isJsxSourceFile(filePath)) { - const liveWrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]'); + const liveWrapper = findVariantsWrapper(sessionId); if (liveWrapper && liveWrapper.querySelector('[data-impeccable-variant]:not([data-impeccable-variant="original"])')) { completeSourceInjection(liveWrapper, sessionId, { ...opts, filePath }); return; @@ -6433,7 +6434,7 @@ return; } - const existingWrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]'); + const existingWrapper = findVariantsWrapper(sessionId); if (existingWrapper) { const wrapper = srcWrapper.cloneNode(true); existingWrapper.parentElement.replaceChild(wrapper, existingWrapper); @@ -6590,7 +6591,7 @@ if (anchor && !anchor.__impeccableFrozenAnchor) selectedElement = anchor; return; } - const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]'); + const wrapper = findVariantsWrapper(currentSessionId); if (!wrapper) return; const visEl = pickVariantContent(wrapper, visibleVariant); if (visEl) selectedElement = visEl; @@ -6600,7 +6601,7 @@ if (svelteComponentSession?.sessionId === sessionId && svelteComponentSession.mountedVariant > 0) { return svelteComponentSession.mountedVariant; } - const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]'); + const wrapper = findVariantsWrapper(sessionId); if (!wrapper) return 0; const variants = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])'); for (const variant of variants) { @@ -6715,8 +6716,17 @@ document.getElementById(discardStateStyleId(sessionId))?.remove(); } - function releaseDiscardedStaticWrapper(wrapper, sessionId) { - removeDiscardStateStylesheet(sessionId); + /** + * Every wrapper a discard has to unwind. A target inside a `.map()` renders + * one wrapper per item, so the hide, the release, and the existence checks + * all have to speak about the same set. + */ + function discardedWrappers(sessionId) { + if (!sessionId) return []; + return [...document.querySelectorAll('[data-impeccable-variants="' + sessionId + '"]')]; + } + + function releaseDiscardedStaticWrapper(wrapper) { if (!wrapper) return; const orig = wrapper.querySelector('[data-impeccable-variant="original"]'); const content = orig?.firstElementChild; @@ -6727,6 +6737,18 @@ wrapper.remove(); } + /** + * Undo the discard hide on every wrapper it covered. Releasing only the + * first match left the other mapped items sitting at display:none with + * their original content never restored, on exactly the static and + * missed-HMR flows this fallback exists for. + */ + function releaseDiscardedStaticWrappers(sessionId, wrappers) { + removeDiscardStateStylesheet(sessionId); + const set = wrappers && wrappers.length ? wrappers : discardedWrappers(sessionId); + for (const wrapper of set) releaseDiscardedStaticWrapper(wrapper); + } + function watchForDiscardedFrameworkWrapperRemoval(sessionId) { if (!sessionId || !document.body) return; if (discardedFrameworkWrapperWatchers.has(sessionId)) return; @@ -6907,6 +6929,42 @@ // MutationObserver for progressive variant reveal // + // A session id can have more than one wrapper in the DOM: the target may sit + // inside a `.map()` callback (the wrapper renders once per item), or the + // agent may have relocated the wrapper out of the shared primitive live-wrap + // scaffolded into. A plain first match can then pin an empty scaffold while + // the real variants sit in a later wrapper, which strands the session at + // 0/N and leaves the bar, the params panel, and accept all reading the + // wrong element. Prefer a wrapper that actually holds variants. With zero + // or one match this is exactly the querySelector it replaces. + // + // Every lookup of the ACTIVE session's wrapper goes through here. The + // remaining raw `[data-impeccable-variants=...]` uses are deliberate: bare + // existence checks, selector strings for stylesheets and observers (which + // want to cover every match), `querySelectorAll` sweeps, and the parsed + // source document, which is not this document. + function pickPopulatedVariantsWrapper(selector) { + const matches = document.querySelectorAll(selector); + if (matches.length < 2) return matches[0] || null; + for (const candidate of matches) { + if (candidate.querySelector('[data-impeccable-variant]:not([data-impeccable-variant="original"])')) { + return candidate; + } + } + return matches[0]; + } + + /** The wrapper holding `sessionId`'s variants, or null without an id. */ + function findVariantsWrapper(sessionId) { + if (!sessionId) return null; + return pickPopulatedVariantsWrapper('[data-impeccable-variants="' + sessionId + '"]'); + } + + /** Any live variant wrapper, for the resume paths that have no id yet. */ + function findAnyVariantsWrapper() { + return pickPopulatedVariantsWrapper('[data-impeccable-variants]'); + } + function startVariantObserver(sessionId) { let updating = false; // re-entrancy guard @@ -6936,7 +6994,7 @@ } if (!dominated) return; - const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]'); + const wrapper = findVariantsWrapper(sessionId); if (!wrapper) return; const variants = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])'); @@ -7145,6 +7203,7 @@ if (arrivedVariants >= expectedVariants && expectedVariants > 0) { if (state === 'GENERATING') { setLiveState('CYCLING'); + hideShaderOverlay(); showOrUpdateCyclingBar(); disableInlineEdit(); refreshParamsPanel(); @@ -7205,6 +7264,7 @@ pendingAcceptedSession = null; awaitingAcceptResult = null; setLiveState('CYCLING'); + hideShaderOverlay(); updateBarContent('cycling'); showToast('Could not complete accept cleanup. Try Accept again.', 5000); break; @@ -8307,6 +8367,15 @@ void main() { // matches the original off-white risograph paper. const SHADER_PAPER_FALLBACK = [0.975, 0.965, 0.955]; let shaderState = null; // { canvas, gl, program, texture, rafId, startTime } + // showShaderOverlay is async: it appends its canvas, then awaits + // createImageBitmap and the GL setup before it publishes shaderState. A + // teardown that landed inside that window found shaderState still null, + // returned, and then watched the construction publish itself over a session + // that had already left GENERATING, with no teardown left to run. That is + // the generating loader frozen over a page that already cycles (issue #719). + // Every teardown bumps this epoch; a construction abandons its own canvas as + // soon as it sees the epoch move. + let shaderEpoch = 0; // The element's effective background tone, used as the uniform halftone // ground so content dissolves into dots over it. Unlike resolveCanvasBackground @@ -8453,14 +8522,28 @@ void main() { }); } + /** Drop a shader node no shaderState owns (an abandoned construction). */ + function removeStrayShaderNode() { + const stray = uiGetById(PREFIX + '-shader'); + if (stray) stray.remove(); + } + function hideShaderOverlay() { - if (!shaderState) return; + // Bump first, unconditionally: this is what tells an in-flight + // showShaderOverlay to abandon itself rather than publish over a session + // that has already moved on. + shaderEpoch += 1; + if (!shaderState) { + removeStrayShaderNode(); + return; + } if (shaderState.rafId) cancelAnimationFrame(shaderState.rafId); if (shaderState.canvas) shaderState.canvas.remove(); if (shaderState.objectUrl) URL.revokeObjectURL(shaderState.objectUrl); const lose = shaderState.gl?.getExtension?.('WEBGL_lose_context'); try { lose?.loseContext(); } catch {} shaderState = null; + removeStrayShaderNode(); } function showShaderBitmapFallback(canvas, blob) { @@ -8485,6 +8568,16 @@ void main() { async function showShaderOverlay(el, blob, rect, paper) { hideShaderOverlay(); if (!blob || !el) return; + // hideShaderOverlay just bumped the epoch, so this run owns it until the + // next teardown. Every step past an await re-checks before it publishes. + const epoch = shaderEpoch; + const abandoned = (node, gl) => { + if (epoch === shaderEpoch) return false; + node.remove(); + const lose = gl?.getExtension?.('WEBGL_lose_context'); + try { lose?.loseContext(); } catch {} + return true; + }; const canvas = document.createElement('canvas'); canvas.id = PREFIX + '-shader'; const dpr = Math.min(window.devicePixelRatio || 1, 2); @@ -8507,6 +8600,7 @@ void main() { if (!gl) { // WebGL unavailable: use the captured bitmap as a background overlay so // the user still sees something meaningful during generation. + if (abandoned(canvas, null)) return; showShaderBitmapFallback(canvas, blob); return; } @@ -8546,16 +8640,22 @@ void main() { } // Upload the screenshot as a texture + if (abandoned(canvas, gl)) return; let bitmap; try { bitmap = await createImageBitmap(blob); } catch (err) { console.warn('[impeccable] shader bitmap decode failed:', err); + if (abandoned(canvas, gl)) return; const lose = gl.getExtension?.('WEBGL_lose_context'); try { lose?.loseContext(); } catch {} showShaderBitmapFallback(canvas, blob); return; } + if (abandoned(canvas, gl)) { + if (bitmap.close) bitmap.close(); + return; + } texture = gl.createTexture(); gl.bindTexture(gl.TEXTURE_2D, texture); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); @@ -8574,6 +8674,7 @@ void main() { const paperRgb = paper || resolvePaperRgb(el); const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches; + if (abandoned(canvas, gl)) return; shaderState = { canvas, gl, program, texture, rafId: 0, startTime: performance.now(), reduced }; function frame() { if (!shaderState) return; @@ -8610,7 +8711,7 @@ void main() { clientSentAt: Date.now(), }; if (!currentSessionId || arrivedVariants === 0) return; - const acceptWrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]'); + const acceptWrapper = findVariantsWrapper(currentSessionId); if (Object.keys(paramsCurrentValues).length > 0) { acceptPayload.paramValues = { ...paramsCurrentValues }; } @@ -8653,6 +8754,7 @@ void main() { .catch(() => { if (pendingAcceptedSession?.id === acceptedSessionId) pendingAcceptedSession = null; setLiveState('CYCLING'); + hideShaderOverlay(); showOrUpdateCyclingBar(); showToast('Could not confirm accept with the live server. Session kept for recovery; try Accept again.', 5000); }); @@ -8704,7 +8806,7 @@ void main() { } function snapshotAcceptedVariantDom(sessionId, variantId) { - const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]'); + const wrapper = findVariantsWrapper(sessionId); const accepted = wrapper?.querySelector?.('[data-impeccable-variant="' + variantId + '"]'); const root = accepted?.firstElementChild || null; return { @@ -8831,7 +8933,7 @@ void main() { } function commitAcceptedVariantToDom(sessionId, variantId) { - const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]'); + const wrapper = findVariantsWrapper(sessionId); if (!wrapper) return false; const accepted = wrapper.querySelector('[data-impeccable-variant="' + variantId + '"]'); if (!accepted || !accepted.firstElementChild) return false; @@ -9059,7 +9161,7 @@ void main() { } function restoreFromActiveSessions(activeSessions, reason) { - const wrapper = document.querySelector('[data-impeccable-variants]'); + const wrapper = findAnyVariantsWrapper(); if (wrapper && !isFrameworkComponentPreviewMode(wrapper.dataset.impeccablePreview)) return false; if (svelteComponentSession?.sessionId === currentSessionId) return false; return restoreSessionWithoutWrapper(reason || 'sse_connected', activeSessions); @@ -9172,10 +9274,13 @@ void main() { // reconciler later tries to remove a wrapper we already removed. // Schedule a 2s fallback that does the manual swap only if HMR hasn't // replaced the wrapper by then (keeps static-server / no-HMR flows alive). - const wrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]'); - if (wrapper) { + // Every match, not the first: a target inside a `.map()` renders one + // wrapper per item, and hiding only one leaves the rest of the + // discarded variants on screen. + const discardWrappers = discardedWrappers(cleanupSessionId); + if (discardWrappers.length > 0) { if (restoreOriginal) showOriginalDuringDiscard(cleanupSessionId); - else wrapper.style.display = 'none'; + else for (const discardWrapper of discardWrappers) discardWrapper.style.display = 'none'; } setTimeout(function() { const recoverySuperseded = deferredRecoverySuperseded(cleanupSessionId, cleanupRevision); @@ -9183,16 +9288,19 @@ void main() { removeDiscardStateStylesheet(); return; } - const lateWrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]'); - if (!lateWrapper) { + const lateWrappers = discardedWrappers(cleanupSessionId); + if (lateWrappers.length === 0) { removeDiscardStateStylesheet(cleanupSessionId); return; } + // Duplicates all render from one source element, so HMR ownership is + // uniform across them; the first is a fair witness for the set. + const lateWrapper = lateWrappers[0]; if (recoverySuperseded) { if (hasFrameworkHmrOwnership(lateWrapper)) { watchForDiscardedFrameworkWrapperRemoval(cleanupSessionId); } else { - releaseDiscardedStaticWrapper(lateWrapper, cleanupSessionId); + releaseDiscardedStaticWrappers(cleanupSessionId, lateWrappers); } return; } @@ -9201,18 +9309,20 @@ void main() { // the final source rewrite, reload once after a grace window so the // discarded source becomes authoritative without a reconciler race. setTimeout(function() { - const staleWrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]'); + const staleWrappers = discardedWrappers(cleanupSessionId); if (deferredRecoverySuperseded(cleanupSessionId, cleanupRevision)) { - if (!staleWrapper) removeDiscardStateStylesheet(cleanupSessionId); + if (staleWrappers.length === 0) removeDiscardStateStylesheet(cleanupSessionId); else watchForDiscardedFrameworkWrapperRemoval(cleanupSessionId); return; } removeDiscardStateStylesheet(cleanupSessionId); - if (staleWrapper) location.reload(); + // A reload restores every wrapper's original at once, so there is + // nothing per-wrapper to do here. + if (staleWrappers.length > 0) location.reload(); }, 2000); return; } - releaseDiscardedStaticWrapper(lateWrapper, cleanupSessionId); + releaseDiscardedStaticWrappers(cleanupSessionId, lateWrappers); }, 2000); } hideBar(instantChrome); @@ -9400,8 +9510,13 @@ void main() { return restoreSessionWithoutWrapper('browser_resumed_over_handled_wrapper'); } - function resumeSession(recoveryRevision = liveInteractionRevision) { - const wrapper = document.querySelector('[data-impeccable-variants]'); + function resumeSession(recoveryRevision = liveInteractionRevision, opts = {}) { + // Which path resumed matters in the journal: an init resume is a fresh + // page load, the deferred-wrapper scout is a mid-page-load arrival. Both + // used to log the same `browser_resumed`, which made issue #719 take a + // DOM reconstruction to diagnose. + const resumeReason = opts.reason || 'browser_resumed'; + const wrapper = findAnyVariantsWrapper(); const runtimeWrapper = wrapper || document.querySelector('[data-impeccable-carbonize]'); if (restoreSessionSupersedingHandledWrapper(runtimeWrapper)) return true; if (scheduleHandledRuntimeWrapperReload(runtimeWrapper, recoveryRevision)) return false; @@ -9500,16 +9615,38 @@ void main() { showBar(state === 'CYCLING' ? 'cycling' : 'generating'); startScrollTracking(); - // Build the params panel for the restored visible variant. Previously - // this was missed on page-reload resume: showVariantInDOM above fires - // refreshParamsPanel, but state was still IDLE at that moment so it - // hid. Now that state is CYCLING, re-fire. - if (state === 'CYCLING') refreshParamsPanel(); + // A resume can BE the arrival, not just a re-entry after one. The server's + // generation preflight runs live-wrap with --defer-source-write, so the + // wrapper and every variant reach the DOM in one HMR batch, and the + // deferred-wrapper scout (constructed at init) runs before the variant + // MutationObserver (constructed at Go) on that batch. Finish the same + // transition the observer would have finished. Without hideShaderOverlay + // the generating shader stays frozen over the target and the session looks + // stuck at GENERATING while the bar already cycles (issue #719). + if (state === 'CYCLING') { + recoveryWaitingForAnchor = false; + hideShaderOverlay(); + if (isInsert) finalizeInsertSession(); + disableInlineEdit(); + // Build the params panel for the restored visible variant. Previously + // this was missed on page-reload resume: showVariantInDOM above fires + // refreshParamsPanel, but state was still IDLE at that moment so it + // hid. Now that state is CYCLING, re-fire. + refreshParamsPanel(); + } saveSession(); if (arrivedVariants > 0 && arrivedVariants < expectedVariants) { sendCheckpoint('variants_progress'); } else { - queueCheckpoint('browser_resumed'); + queueCheckpoint(resumeReason); + // Only variants_progress and variants_ready count as publication + // progress. When the resume is the arrival, the observer never gets to + // report it (this function disconnects and re-creates it below, which + // drops the records it had already queued for this same batch), so + // without this the server never learns the variants were published. + if (arrivedVariants > 0 && arrivedVariants >= expectedVariants && expectedVariants > 0) { + sendCheckpoint('variants_ready'); + } } // Start observing for more variants AFTER initial setup @@ -12831,7 +12968,7 @@ void main() { const wrapper = document.querySelector('[data-impeccable-variants],[data-impeccable-carbonize]'); if (!wrapper) return; scout.disconnect(); - if (resumeSession(deferredResumeRevision)) { + if (resumeSession(deferredResumeRevision, { reason: 'browser_resumed_deferred_wrapper' })) { console.log('[impeccable] Resumed deferred session ' + currentSessionId + ' (post-hydration).'); } }); diff --git a/.rovodev/skills/impeccable/scripts/live-browser.js b/.rovodev/skills/impeccable/scripts/live-browser.js index c32101b12..277c60e2c 100644 --- a/.rovodev/skills/impeccable/scripts/live-browser.js +++ b/.rovodev/skills/impeccable/scripts/live-browser.js @@ -2060,7 +2060,7 @@ if (anchor) return anchor; } if (currentSessionId && (state === 'GENERATING' || state === 'CYCLING')) { - const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]'); + const wrapper = findVariantsWrapper(currentSessionId); if (wrapper) { const variantCount = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])').length; if (variantCount > 0 && visibleVariant > 0) { @@ -2131,14 +2131,14 @@ function isInsertGeneratingSession() { if (state !== 'GENERATING' || !currentSessionId) return false; - const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]'); + const wrapper = findVariantsWrapper(currentSessionId); return !!wrapper && wrapper.dataset.impeccableMode === 'insert'; } /** Recreate the dotted placeholder if Astro/Vite HMR removed it mid-generation. */ function ensureInsertPlaceholder() { if (!isInsertGeneratingSession()) return placeholderElement; - const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]'); + const wrapper = findVariantsWrapper(currentSessionId); const variantCount = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])').length; if (variantCount > 0) return placeholderElement; if (placeholderElement && document.body.contains(placeholderElement)) return placeholderElement; @@ -3156,7 +3156,7 @@ || svelteComponentSession.wrapperEl || null; } - const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]'); + const wrapper = findVariantsWrapper(currentSessionId); if (!wrapper) return null; return wrapper.querySelector('[data-impeccable-variant="' + visibleVariant + '"]'); } @@ -4900,7 +4900,7 @@ return Object.values(svelteComponentSession.paramsByVariant || {}) .reduce((total, params) => total + (Array.isArray(params) ? params.length : 0), 0); } - const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]'); + const wrapper = findVariantsWrapper(currentSessionId); if (!wrapper) return 0; return [...wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])')] .reduce((total, variant) => total + parseVariantParams(variant).length, 0); @@ -5004,7 +5004,7 @@ scheduleCyclingBarSync(sessionId, num); return true; } - const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]'); + const wrapper = findVariantsWrapper(sessionId); if (!wrapper) return false; updateVariantStateStylesheet(sessionId, num); // Unconditional refresh - covers first-reveal (no-op if state isn't @@ -5820,6 +5820,7 @@ return; } setLiveState('CYCLING'); + hideShaderOverlay(); showOrUpdateCyclingBar(); saveSession(); completeParameterGenerationIfReady(); @@ -6361,7 +6362,7 @@ } rememberSessionFileMeta({ file: filePath }); if (isJsxSourceFile(filePath)) { - const liveWrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]'); + const liveWrapper = findVariantsWrapper(sessionId); if (liveWrapper && liveWrapper.querySelector('[data-impeccable-variant]:not([data-impeccable-variant="original"])')) { completeSourceInjection(liveWrapper, sessionId, { ...opts, filePath }); return; @@ -6433,7 +6434,7 @@ return; } - const existingWrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]'); + const existingWrapper = findVariantsWrapper(sessionId); if (existingWrapper) { const wrapper = srcWrapper.cloneNode(true); existingWrapper.parentElement.replaceChild(wrapper, existingWrapper); @@ -6590,7 +6591,7 @@ if (anchor && !anchor.__impeccableFrozenAnchor) selectedElement = anchor; return; } - const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]'); + const wrapper = findVariantsWrapper(currentSessionId); if (!wrapper) return; const visEl = pickVariantContent(wrapper, visibleVariant); if (visEl) selectedElement = visEl; @@ -6600,7 +6601,7 @@ if (svelteComponentSession?.sessionId === sessionId && svelteComponentSession.mountedVariant > 0) { return svelteComponentSession.mountedVariant; } - const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]'); + const wrapper = findVariantsWrapper(sessionId); if (!wrapper) return 0; const variants = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])'); for (const variant of variants) { @@ -6715,8 +6716,17 @@ document.getElementById(discardStateStyleId(sessionId))?.remove(); } - function releaseDiscardedStaticWrapper(wrapper, sessionId) { - removeDiscardStateStylesheet(sessionId); + /** + * Every wrapper a discard has to unwind. A target inside a `.map()` renders + * one wrapper per item, so the hide, the release, and the existence checks + * all have to speak about the same set. + */ + function discardedWrappers(sessionId) { + if (!sessionId) return []; + return [...document.querySelectorAll('[data-impeccable-variants="' + sessionId + '"]')]; + } + + function releaseDiscardedStaticWrapper(wrapper) { if (!wrapper) return; const orig = wrapper.querySelector('[data-impeccable-variant="original"]'); const content = orig?.firstElementChild; @@ -6727,6 +6737,18 @@ wrapper.remove(); } + /** + * Undo the discard hide on every wrapper it covered. Releasing only the + * first match left the other mapped items sitting at display:none with + * their original content never restored, on exactly the static and + * missed-HMR flows this fallback exists for. + */ + function releaseDiscardedStaticWrappers(sessionId, wrappers) { + removeDiscardStateStylesheet(sessionId); + const set = wrappers && wrappers.length ? wrappers : discardedWrappers(sessionId); + for (const wrapper of set) releaseDiscardedStaticWrapper(wrapper); + } + function watchForDiscardedFrameworkWrapperRemoval(sessionId) { if (!sessionId || !document.body) return; if (discardedFrameworkWrapperWatchers.has(sessionId)) return; @@ -6907,6 +6929,42 @@ // MutationObserver for progressive variant reveal // + // A session id can have more than one wrapper in the DOM: the target may sit + // inside a `.map()` callback (the wrapper renders once per item), or the + // agent may have relocated the wrapper out of the shared primitive live-wrap + // scaffolded into. A plain first match can then pin an empty scaffold while + // the real variants sit in a later wrapper, which strands the session at + // 0/N and leaves the bar, the params panel, and accept all reading the + // wrong element. Prefer a wrapper that actually holds variants. With zero + // or one match this is exactly the querySelector it replaces. + // + // Every lookup of the ACTIVE session's wrapper goes through here. The + // remaining raw `[data-impeccable-variants=...]` uses are deliberate: bare + // existence checks, selector strings for stylesheets and observers (which + // want to cover every match), `querySelectorAll` sweeps, and the parsed + // source document, which is not this document. + function pickPopulatedVariantsWrapper(selector) { + const matches = document.querySelectorAll(selector); + if (matches.length < 2) return matches[0] || null; + for (const candidate of matches) { + if (candidate.querySelector('[data-impeccable-variant]:not([data-impeccable-variant="original"])')) { + return candidate; + } + } + return matches[0]; + } + + /** The wrapper holding `sessionId`'s variants, or null without an id. */ + function findVariantsWrapper(sessionId) { + if (!sessionId) return null; + return pickPopulatedVariantsWrapper('[data-impeccable-variants="' + sessionId + '"]'); + } + + /** Any live variant wrapper, for the resume paths that have no id yet. */ + function findAnyVariantsWrapper() { + return pickPopulatedVariantsWrapper('[data-impeccable-variants]'); + } + function startVariantObserver(sessionId) { let updating = false; // re-entrancy guard @@ -6936,7 +6994,7 @@ } if (!dominated) return; - const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]'); + const wrapper = findVariantsWrapper(sessionId); if (!wrapper) return; const variants = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])'); @@ -7145,6 +7203,7 @@ if (arrivedVariants >= expectedVariants && expectedVariants > 0) { if (state === 'GENERATING') { setLiveState('CYCLING'); + hideShaderOverlay(); showOrUpdateCyclingBar(); disableInlineEdit(); refreshParamsPanel(); @@ -7205,6 +7264,7 @@ pendingAcceptedSession = null; awaitingAcceptResult = null; setLiveState('CYCLING'); + hideShaderOverlay(); updateBarContent('cycling'); showToast('Could not complete accept cleanup. Try Accept again.', 5000); break; @@ -8307,6 +8367,15 @@ void main() { // matches the original off-white risograph paper. const SHADER_PAPER_FALLBACK = [0.975, 0.965, 0.955]; let shaderState = null; // { canvas, gl, program, texture, rafId, startTime } + // showShaderOverlay is async: it appends its canvas, then awaits + // createImageBitmap and the GL setup before it publishes shaderState. A + // teardown that landed inside that window found shaderState still null, + // returned, and then watched the construction publish itself over a session + // that had already left GENERATING, with no teardown left to run. That is + // the generating loader frozen over a page that already cycles (issue #719). + // Every teardown bumps this epoch; a construction abandons its own canvas as + // soon as it sees the epoch move. + let shaderEpoch = 0; // The element's effective background tone, used as the uniform halftone // ground so content dissolves into dots over it. Unlike resolveCanvasBackground @@ -8453,14 +8522,28 @@ void main() { }); } + /** Drop a shader node no shaderState owns (an abandoned construction). */ + function removeStrayShaderNode() { + const stray = uiGetById(PREFIX + '-shader'); + if (stray) stray.remove(); + } + function hideShaderOverlay() { - if (!shaderState) return; + // Bump first, unconditionally: this is what tells an in-flight + // showShaderOverlay to abandon itself rather than publish over a session + // that has already moved on. + shaderEpoch += 1; + if (!shaderState) { + removeStrayShaderNode(); + return; + } if (shaderState.rafId) cancelAnimationFrame(shaderState.rafId); if (shaderState.canvas) shaderState.canvas.remove(); if (shaderState.objectUrl) URL.revokeObjectURL(shaderState.objectUrl); const lose = shaderState.gl?.getExtension?.('WEBGL_lose_context'); try { lose?.loseContext(); } catch {} shaderState = null; + removeStrayShaderNode(); } function showShaderBitmapFallback(canvas, blob) { @@ -8485,6 +8568,16 @@ void main() { async function showShaderOverlay(el, blob, rect, paper) { hideShaderOverlay(); if (!blob || !el) return; + // hideShaderOverlay just bumped the epoch, so this run owns it until the + // next teardown. Every step past an await re-checks before it publishes. + const epoch = shaderEpoch; + const abandoned = (node, gl) => { + if (epoch === shaderEpoch) return false; + node.remove(); + const lose = gl?.getExtension?.('WEBGL_lose_context'); + try { lose?.loseContext(); } catch {} + return true; + }; const canvas = document.createElement('canvas'); canvas.id = PREFIX + '-shader'; const dpr = Math.min(window.devicePixelRatio || 1, 2); @@ -8507,6 +8600,7 @@ void main() { if (!gl) { // WebGL unavailable: use the captured bitmap as a background overlay so // the user still sees something meaningful during generation. + if (abandoned(canvas, null)) return; showShaderBitmapFallback(canvas, blob); return; } @@ -8546,16 +8640,22 @@ void main() { } // Upload the screenshot as a texture + if (abandoned(canvas, gl)) return; let bitmap; try { bitmap = await createImageBitmap(blob); } catch (err) { console.warn('[impeccable] shader bitmap decode failed:', err); + if (abandoned(canvas, gl)) return; const lose = gl.getExtension?.('WEBGL_lose_context'); try { lose?.loseContext(); } catch {} showShaderBitmapFallback(canvas, blob); return; } + if (abandoned(canvas, gl)) { + if (bitmap.close) bitmap.close(); + return; + } texture = gl.createTexture(); gl.bindTexture(gl.TEXTURE_2D, texture); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); @@ -8574,6 +8674,7 @@ void main() { const paperRgb = paper || resolvePaperRgb(el); const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches; + if (abandoned(canvas, gl)) return; shaderState = { canvas, gl, program, texture, rafId: 0, startTime: performance.now(), reduced }; function frame() { if (!shaderState) return; @@ -8610,7 +8711,7 @@ void main() { clientSentAt: Date.now(), }; if (!currentSessionId || arrivedVariants === 0) return; - const acceptWrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]'); + const acceptWrapper = findVariantsWrapper(currentSessionId); if (Object.keys(paramsCurrentValues).length > 0) { acceptPayload.paramValues = { ...paramsCurrentValues }; } @@ -8653,6 +8754,7 @@ void main() { .catch(() => { if (pendingAcceptedSession?.id === acceptedSessionId) pendingAcceptedSession = null; setLiveState('CYCLING'); + hideShaderOverlay(); showOrUpdateCyclingBar(); showToast('Could not confirm accept with the live server. Session kept for recovery; try Accept again.', 5000); }); @@ -8704,7 +8806,7 @@ void main() { } function snapshotAcceptedVariantDom(sessionId, variantId) { - const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]'); + const wrapper = findVariantsWrapper(sessionId); const accepted = wrapper?.querySelector?.('[data-impeccable-variant="' + variantId + '"]'); const root = accepted?.firstElementChild || null; return { @@ -8831,7 +8933,7 @@ void main() { } function commitAcceptedVariantToDom(sessionId, variantId) { - const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]'); + const wrapper = findVariantsWrapper(sessionId); if (!wrapper) return false; const accepted = wrapper.querySelector('[data-impeccable-variant="' + variantId + '"]'); if (!accepted || !accepted.firstElementChild) return false; @@ -9059,7 +9161,7 @@ void main() { } function restoreFromActiveSessions(activeSessions, reason) { - const wrapper = document.querySelector('[data-impeccable-variants]'); + const wrapper = findAnyVariantsWrapper(); if (wrapper && !isFrameworkComponentPreviewMode(wrapper.dataset.impeccablePreview)) return false; if (svelteComponentSession?.sessionId === currentSessionId) return false; return restoreSessionWithoutWrapper(reason || 'sse_connected', activeSessions); @@ -9172,10 +9274,13 @@ void main() { // reconciler later tries to remove a wrapper we already removed. // Schedule a 2s fallback that does the manual swap only if HMR hasn't // replaced the wrapper by then (keeps static-server / no-HMR flows alive). - const wrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]'); - if (wrapper) { + // Every match, not the first: a target inside a `.map()` renders one + // wrapper per item, and hiding only one leaves the rest of the + // discarded variants on screen. + const discardWrappers = discardedWrappers(cleanupSessionId); + if (discardWrappers.length > 0) { if (restoreOriginal) showOriginalDuringDiscard(cleanupSessionId); - else wrapper.style.display = 'none'; + else for (const discardWrapper of discardWrappers) discardWrapper.style.display = 'none'; } setTimeout(function() { const recoverySuperseded = deferredRecoverySuperseded(cleanupSessionId, cleanupRevision); @@ -9183,16 +9288,19 @@ void main() { removeDiscardStateStylesheet(); return; } - const lateWrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]'); - if (!lateWrapper) { + const lateWrappers = discardedWrappers(cleanupSessionId); + if (lateWrappers.length === 0) { removeDiscardStateStylesheet(cleanupSessionId); return; } + // Duplicates all render from one source element, so HMR ownership is + // uniform across them; the first is a fair witness for the set. + const lateWrapper = lateWrappers[0]; if (recoverySuperseded) { if (hasFrameworkHmrOwnership(lateWrapper)) { watchForDiscardedFrameworkWrapperRemoval(cleanupSessionId); } else { - releaseDiscardedStaticWrapper(lateWrapper, cleanupSessionId); + releaseDiscardedStaticWrappers(cleanupSessionId, lateWrappers); } return; } @@ -9201,18 +9309,20 @@ void main() { // the final source rewrite, reload once after a grace window so the // discarded source becomes authoritative without a reconciler race. setTimeout(function() { - const staleWrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]'); + const staleWrappers = discardedWrappers(cleanupSessionId); if (deferredRecoverySuperseded(cleanupSessionId, cleanupRevision)) { - if (!staleWrapper) removeDiscardStateStylesheet(cleanupSessionId); + if (staleWrappers.length === 0) removeDiscardStateStylesheet(cleanupSessionId); else watchForDiscardedFrameworkWrapperRemoval(cleanupSessionId); return; } removeDiscardStateStylesheet(cleanupSessionId); - if (staleWrapper) location.reload(); + // A reload restores every wrapper's original at once, so there is + // nothing per-wrapper to do here. + if (staleWrappers.length > 0) location.reload(); }, 2000); return; } - releaseDiscardedStaticWrapper(lateWrapper, cleanupSessionId); + releaseDiscardedStaticWrappers(cleanupSessionId, lateWrappers); }, 2000); } hideBar(instantChrome); @@ -9400,8 +9510,13 @@ void main() { return restoreSessionWithoutWrapper('browser_resumed_over_handled_wrapper'); } - function resumeSession(recoveryRevision = liveInteractionRevision) { - const wrapper = document.querySelector('[data-impeccable-variants]'); + function resumeSession(recoveryRevision = liveInteractionRevision, opts = {}) { + // Which path resumed matters in the journal: an init resume is a fresh + // page load, the deferred-wrapper scout is a mid-page-load arrival. Both + // used to log the same `browser_resumed`, which made issue #719 take a + // DOM reconstruction to diagnose. + const resumeReason = opts.reason || 'browser_resumed'; + const wrapper = findAnyVariantsWrapper(); const runtimeWrapper = wrapper || document.querySelector('[data-impeccable-carbonize]'); if (restoreSessionSupersedingHandledWrapper(runtimeWrapper)) return true; if (scheduleHandledRuntimeWrapperReload(runtimeWrapper, recoveryRevision)) return false; @@ -9500,16 +9615,38 @@ void main() { showBar(state === 'CYCLING' ? 'cycling' : 'generating'); startScrollTracking(); - // Build the params panel for the restored visible variant. Previously - // this was missed on page-reload resume: showVariantInDOM above fires - // refreshParamsPanel, but state was still IDLE at that moment so it - // hid. Now that state is CYCLING, re-fire. - if (state === 'CYCLING') refreshParamsPanel(); + // A resume can BE the arrival, not just a re-entry after one. The server's + // generation preflight runs live-wrap with --defer-source-write, so the + // wrapper and every variant reach the DOM in one HMR batch, and the + // deferred-wrapper scout (constructed at init) runs before the variant + // MutationObserver (constructed at Go) on that batch. Finish the same + // transition the observer would have finished. Without hideShaderOverlay + // the generating shader stays frozen over the target and the session looks + // stuck at GENERATING while the bar already cycles (issue #719). + if (state === 'CYCLING') { + recoveryWaitingForAnchor = false; + hideShaderOverlay(); + if (isInsert) finalizeInsertSession(); + disableInlineEdit(); + // Build the params panel for the restored visible variant. Previously + // this was missed on page-reload resume: showVariantInDOM above fires + // refreshParamsPanel, but state was still IDLE at that moment so it + // hid. Now that state is CYCLING, re-fire. + refreshParamsPanel(); + } saveSession(); if (arrivedVariants > 0 && arrivedVariants < expectedVariants) { sendCheckpoint('variants_progress'); } else { - queueCheckpoint('browser_resumed'); + queueCheckpoint(resumeReason); + // Only variants_progress and variants_ready count as publication + // progress. When the resume is the arrival, the observer never gets to + // report it (this function disconnects and re-creates it below, which + // drops the records it had already queued for this same batch), so + // without this the server never learns the variants were published. + if (arrivedVariants > 0 && arrivedVariants >= expectedVariants && expectedVariants > 0) { + sendCheckpoint('variants_ready'); + } } // Start observing for more variants AFTER initial setup @@ -12831,7 +12968,7 @@ void main() { const wrapper = document.querySelector('[data-impeccable-variants],[data-impeccable-carbonize]'); if (!wrapper) return; scout.disconnect(); - if (resumeSession(deferredResumeRevision)) { + if (resumeSession(deferredResumeRevision, { reason: 'browser_resumed_deferred_wrapper' })) { console.log('[impeccable] Resumed deferred session ' + currentSessionId + ' (post-hydration).'); } }); diff --git a/.trae-cn/skills/impeccable/scripts/live-browser.js b/.trae-cn/skills/impeccable/scripts/live-browser.js index c32101b12..277c60e2c 100644 --- a/.trae-cn/skills/impeccable/scripts/live-browser.js +++ b/.trae-cn/skills/impeccable/scripts/live-browser.js @@ -2060,7 +2060,7 @@ if (anchor) return anchor; } if (currentSessionId && (state === 'GENERATING' || state === 'CYCLING')) { - const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]'); + const wrapper = findVariantsWrapper(currentSessionId); if (wrapper) { const variantCount = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])').length; if (variantCount > 0 && visibleVariant > 0) { @@ -2131,14 +2131,14 @@ function isInsertGeneratingSession() { if (state !== 'GENERATING' || !currentSessionId) return false; - const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]'); + const wrapper = findVariantsWrapper(currentSessionId); return !!wrapper && wrapper.dataset.impeccableMode === 'insert'; } /** Recreate the dotted placeholder if Astro/Vite HMR removed it mid-generation. */ function ensureInsertPlaceholder() { if (!isInsertGeneratingSession()) return placeholderElement; - const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]'); + const wrapper = findVariantsWrapper(currentSessionId); const variantCount = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])').length; if (variantCount > 0) return placeholderElement; if (placeholderElement && document.body.contains(placeholderElement)) return placeholderElement; @@ -3156,7 +3156,7 @@ || svelteComponentSession.wrapperEl || null; } - const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]'); + const wrapper = findVariantsWrapper(currentSessionId); if (!wrapper) return null; return wrapper.querySelector('[data-impeccable-variant="' + visibleVariant + '"]'); } @@ -4900,7 +4900,7 @@ return Object.values(svelteComponentSession.paramsByVariant || {}) .reduce((total, params) => total + (Array.isArray(params) ? params.length : 0), 0); } - const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]'); + const wrapper = findVariantsWrapper(currentSessionId); if (!wrapper) return 0; return [...wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])')] .reduce((total, variant) => total + parseVariantParams(variant).length, 0); @@ -5004,7 +5004,7 @@ scheduleCyclingBarSync(sessionId, num); return true; } - const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]'); + const wrapper = findVariantsWrapper(sessionId); if (!wrapper) return false; updateVariantStateStylesheet(sessionId, num); // Unconditional refresh - covers first-reveal (no-op if state isn't @@ -5820,6 +5820,7 @@ return; } setLiveState('CYCLING'); + hideShaderOverlay(); showOrUpdateCyclingBar(); saveSession(); completeParameterGenerationIfReady(); @@ -6361,7 +6362,7 @@ } rememberSessionFileMeta({ file: filePath }); if (isJsxSourceFile(filePath)) { - const liveWrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]'); + const liveWrapper = findVariantsWrapper(sessionId); if (liveWrapper && liveWrapper.querySelector('[data-impeccable-variant]:not([data-impeccable-variant="original"])')) { completeSourceInjection(liveWrapper, sessionId, { ...opts, filePath }); return; @@ -6433,7 +6434,7 @@ return; } - const existingWrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]'); + const existingWrapper = findVariantsWrapper(sessionId); if (existingWrapper) { const wrapper = srcWrapper.cloneNode(true); existingWrapper.parentElement.replaceChild(wrapper, existingWrapper); @@ -6590,7 +6591,7 @@ if (anchor && !anchor.__impeccableFrozenAnchor) selectedElement = anchor; return; } - const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]'); + const wrapper = findVariantsWrapper(currentSessionId); if (!wrapper) return; const visEl = pickVariantContent(wrapper, visibleVariant); if (visEl) selectedElement = visEl; @@ -6600,7 +6601,7 @@ if (svelteComponentSession?.sessionId === sessionId && svelteComponentSession.mountedVariant > 0) { return svelteComponentSession.mountedVariant; } - const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]'); + const wrapper = findVariantsWrapper(sessionId); if (!wrapper) return 0; const variants = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])'); for (const variant of variants) { @@ -6715,8 +6716,17 @@ document.getElementById(discardStateStyleId(sessionId))?.remove(); } - function releaseDiscardedStaticWrapper(wrapper, sessionId) { - removeDiscardStateStylesheet(sessionId); + /** + * Every wrapper a discard has to unwind. A target inside a `.map()` renders + * one wrapper per item, so the hide, the release, and the existence checks + * all have to speak about the same set. + */ + function discardedWrappers(sessionId) { + if (!sessionId) return []; + return [...document.querySelectorAll('[data-impeccable-variants="' + sessionId + '"]')]; + } + + function releaseDiscardedStaticWrapper(wrapper) { if (!wrapper) return; const orig = wrapper.querySelector('[data-impeccable-variant="original"]'); const content = orig?.firstElementChild; @@ -6727,6 +6737,18 @@ wrapper.remove(); } + /** + * Undo the discard hide on every wrapper it covered. Releasing only the + * first match left the other mapped items sitting at display:none with + * their original content never restored, on exactly the static and + * missed-HMR flows this fallback exists for. + */ + function releaseDiscardedStaticWrappers(sessionId, wrappers) { + removeDiscardStateStylesheet(sessionId); + const set = wrappers && wrappers.length ? wrappers : discardedWrappers(sessionId); + for (const wrapper of set) releaseDiscardedStaticWrapper(wrapper); + } + function watchForDiscardedFrameworkWrapperRemoval(sessionId) { if (!sessionId || !document.body) return; if (discardedFrameworkWrapperWatchers.has(sessionId)) return; @@ -6907,6 +6929,42 @@ // MutationObserver for progressive variant reveal // + // A session id can have more than one wrapper in the DOM: the target may sit + // inside a `.map()` callback (the wrapper renders once per item), or the + // agent may have relocated the wrapper out of the shared primitive live-wrap + // scaffolded into. A plain first match can then pin an empty scaffold while + // the real variants sit in a later wrapper, which strands the session at + // 0/N and leaves the bar, the params panel, and accept all reading the + // wrong element. Prefer a wrapper that actually holds variants. With zero + // or one match this is exactly the querySelector it replaces. + // + // Every lookup of the ACTIVE session's wrapper goes through here. The + // remaining raw `[data-impeccable-variants=...]` uses are deliberate: bare + // existence checks, selector strings for stylesheets and observers (which + // want to cover every match), `querySelectorAll` sweeps, and the parsed + // source document, which is not this document. + function pickPopulatedVariantsWrapper(selector) { + const matches = document.querySelectorAll(selector); + if (matches.length < 2) return matches[0] || null; + for (const candidate of matches) { + if (candidate.querySelector('[data-impeccable-variant]:not([data-impeccable-variant="original"])')) { + return candidate; + } + } + return matches[0]; + } + + /** The wrapper holding `sessionId`'s variants, or null without an id. */ + function findVariantsWrapper(sessionId) { + if (!sessionId) return null; + return pickPopulatedVariantsWrapper('[data-impeccable-variants="' + sessionId + '"]'); + } + + /** Any live variant wrapper, for the resume paths that have no id yet. */ + function findAnyVariantsWrapper() { + return pickPopulatedVariantsWrapper('[data-impeccable-variants]'); + } + function startVariantObserver(sessionId) { let updating = false; // re-entrancy guard @@ -6936,7 +6994,7 @@ } if (!dominated) return; - const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]'); + const wrapper = findVariantsWrapper(sessionId); if (!wrapper) return; const variants = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])'); @@ -7145,6 +7203,7 @@ if (arrivedVariants >= expectedVariants && expectedVariants > 0) { if (state === 'GENERATING') { setLiveState('CYCLING'); + hideShaderOverlay(); showOrUpdateCyclingBar(); disableInlineEdit(); refreshParamsPanel(); @@ -7205,6 +7264,7 @@ pendingAcceptedSession = null; awaitingAcceptResult = null; setLiveState('CYCLING'); + hideShaderOverlay(); updateBarContent('cycling'); showToast('Could not complete accept cleanup. Try Accept again.', 5000); break; @@ -8307,6 +8367,15 @@ void main() { // matches the original off-white risograph paper. const SHADER_PAPER_FALLBACK = [0.975, 0.965, 0.955]; let shaderState = null; // { canvas, gl, program, texture, rafId, startTime } + // showShaderOverlay is async: it appends its canvas, then awaits + // createImageBitmap and the GL setup before it publishes shaderState. A + // teardown that landed inside that window found shaderState still null, + // returned, and then watched the construction publish itself over a session + // that had already left GENERATING, with no teardown left to run. That is + // the generating loader frozen over a page that already cycles (issue #719). + // Every teardown bumps this epoch; a construction abandons its own canvas as + // soon as it sees the epoch move. + let shaderEpoch = 0; // The element's effective background tone, used as the uniform halftone // ground so content dissolves into dots over it. Unlike resolveCanvasBackground @@ -8453,14 +8522,28 @@ void main() { }); } + /** Drop a shader node no shaderState owns (an abandoned construction). */ + function removeStrayShaderNode() { + const stray = uiGetById(PREFIX + '-shader'); + if (stray) stray.remove(); + } + function hideShaderOverlay() { - if (!shaderState) return; + // Bump first, unconditionally: this is what tells an in-flight + // showShaderOverlay to abandon itself rather than publish over a session + // that has already moved on. + shaderEpoch += 1; + if (!shaderState) { + removeStrayShaderNode(); + return; + } if (shaderState.rafId) cancelAnimationFrame(shaderState.rafId); if (shaderState.canvas) shaderState.canvas.remove(); if (shaderState.objectUrl) URL.revokeObjectURL(shaderState.objectUrl); const lose = shaderState.gl?.getExtension?.('WEBGL_lose_context'); try { lose?.loseContext(); } catch {} shaderState = null; + removeStrayShaderNode(); } function showShaderBitmapFallback(canvas, blob) { @@ -8485,6 +8568,16 @@ void main() { async function showShaderOverlay(el, blob, rect, paper) { hideShaderOverlay(); if (!blob || !el) return; + // hideShaderOverlay just bumped the epoch, so this run owns it until the + // next teardown. Every step past an await re-checks before it publishes. + const epoch = shaderEpoch; + const abandoned = (node, gl) => { + if (epoch === shaderEpoch) return false; + node.remove(); + const lose = gl?.getExtension?.('WEBGL_lose_context'); + try { lose?.loseContext(); } catch {} + return true; + }; const canvas = document.createElement('canvas'); canvas.id = PREFIX + '-shader'; const dpr = Math.min(window.devicePixelRatio || 1, 2); @@ -8507,6 +8600,7 @@ void main() { if (!gl) { // WebGL unavailable: use the captured bitmap as a background overlay so // the user still sees something meaningful during generation. + if (abandoned(canvas, null)) return; showShaderBitmapFallback(canvas, blob); return; } @@ -8546,16 +8640,22 @@ void main() { } // Upload the screenshot as a texture + if (abandoned(canvas, gl)) return; let bitmap; try { bitmap = await createImageBitmap(blob); } catch (err) { console.warn('[impeccable] shader bitmap decode failed:', err); + if (abandoned(canvas, gl)) return; const lose = gl.getExtension?.('WEBGL_lose_context'); try { lose?.loseContext(); } catch {} showShaderBitmapFallback(canvas, blob); return; } + if (abandoned(canvas, gl)) { + if (bitmap.close) bitmap.close(); + return; + } texture = gl.createTexture(); gl.bindTexture(gl.TEXTURE_2D, texture); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); @@ -8574,6 +8674,7 @@ void main() { const paperRgb = paper || resolvePaperRgb(el); const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches; + if (abandoned(canvas, gl)) return; shaderState = { canvas, gl, program, texture, rafId: 0, startTime: performance.now(), reduced }; function frame() { if (!shaderState) return; @@ -8610,7 +8711,7 @@ void main() { clientSentAt: Date.now(), }; if (!currentSessionId || arrivedVariants === 0) return; - const acceptWrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]'); + const acceptWrapper = findVariantsWrapper(currentSessionId); if (Object.keys(paramsCurrentValues).length > 0) { acceptPayload.paramValues = { ...paramsCurrentValues }; } @@ -8653,6 +8754,7 @@ void main() { .catch(() => { if (pendingAcceptedSession?.id === acceptedSessionId) pendingAcceptedSession = null; setLiveState('CYCLING'); + hideShaderOverlay(); showOrUpdateCyclingBar(); showToast('Could not confirm accept with the live server. Session kept for recovery; try Accept again.', 5000); }); @@ -8704,7 +8806,7 @@ void main() { } function snapshotAcceptedVariantDom(sessionId, variantId) { - const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]'); + const wrapper = findVariantsWrapper(sessionId); const accepted = wrapper?.querySelector?.('[data-impeccable-variant="' + variantId + '"]'); const root = accepted?.firstElementChild || null; return { @@ -8831,7 +8933,7 @@ void main() { } function commitAcceptedVariantToDom(sessionId, variantId) { - const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]'); + const wrapper = findVariantsWrapper(sessionId); if (!wrapper) return false; const accepted = wrapper.querySelector('[data-impeccable-variant="' + variantId + '"]'); if (!accepted || !accepted.firstElementChild) return false; @@ -9059,7 +9161,7 @@ void main() { } function restoreFromActiveSessions(activeSessions, reason) { - const wrapper = document.querySelector('[data-impeccable-variants]'); + const wrapper = findAnyVariantsWrapper(); if (wrapper && !isFrameworkComponentPreviewMode(wrapper.dataset.impeccablePreview)) return false; if (svelteComponentSession?.sessionId === currentSessionId) return false; return restoreSessionWithoutWrapper(reason || 'sse_connected', activeSessions); @@ -9172,10 +9274,13 @@ void main() { // reconciler later tries to remove a wrapper we already removed. // Schedule a 2s fallback that does the manual swap only if HMR hasn't // replaced the wrapper by then (keeps static-server / no-HMR flows alive). - const wrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]'); - if (wrapper) { + // Every match, not the first: a target inside a `.map()` renders one + // wrapper per item, and hiding only one leaves the rest of the + // discarded variants on screen. + const discardWrappers = discardedWrappers(cleanupSessionId); + if (discardWrappers.length > 0) { if (restoreOriginal) showOriginalDuringDiscard(cleanupSessionId); - else wrapper.style.display = 'none'; + else for (const discardWrapper of discardWrappers) discardWrapper.style.display = 'none'; } setTimeout(function() { const recoverySuperseded = deferredRecoverySuperseded(cleanupSessionId, cleanupRevision); @@ -9183,16 +9288,19 @@ void main() { removeDiscardStateStylesheet(); return; } - const lateWrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]'); - if (!lateWrapper) { + const lateWrappers = discardedWrappers(cleanupSessionId); + if (lateWrappers.length === 0) { removeDiscardStateStylesheet(cleanupSessionId); return; } + // Duplicates all render from one source element, so HMR ownership is + // uniform across them; the first is a fair witness for the set. + const lateWrapper = lateWrappers[0]; if (recoverySuperseded) { if (hasFrameworkHmrOwnership(lateWrapper)) { watchForDiscardedFrameworkWrapperRemoval(cleanupSessionId); } else { - releaseDiscardedStaticWrapper(lateWrapper, cleanupSessionId); + releaseDiscardedStaticWrappers(cleanupSessionId, lateWrappers); } return; } @@ -9201,18 +9309,20 @@ void main() { // the final source rewrite, reload once after a grace window so the // discarded source becomes authoritative without a reconciler race. setTimeout(function() { - const staleWrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]'); + const staleWrappers = discardedWrappers(cleanupSessionId); if (deferredRecoverySuperseded(cleanupSessionId, cleanupRevision)) { - if (!staleWrapper) removeDiscardStateStylesheet(cleanupSessionId); + if (staleWrappers.length === 0) removeDiscardStateStylesheet(cleanupSessionId); else watchForDiscardedFrameworkWrapperRemoval(cleanupSessionId); return; } removeDiscardStateStylesheet(cleanupSessionId); - if (staleWrapper) location.reload(); + // A reload restores every wrapper's original at once, so there is + // nothing per-wrapper to do here. + if (staleWrappers.length > 0) location.reload(); }, 2000); return; } - releaseDiscardedStaticWrapper(lateWrapper, cleanupSessionId); + releaseDiscardedStaticWrappers(cleanupSessionId, lateWrappers); }, 2000); } hideBar(instantChrome); @@ -9400,8 +9510,13 @@ void main() { return restoreSessionWithoutWrapper('browser_resumed_over_handled_wrapper'); } - function resumeSession(recoveryRevision = liveInteractionRevision) { - const wrapper = document.querySelector('[data-impeccable-variants]'); + function resumeSession(recoveryRevision = liveInteractionRevision, opts = {}) { + // Which path resumed matters in the journal: an init resume is a fresh + // page load, the deferred-wrapper scout is a mid-page-load arrival. Both + // used to log the same `browser_resumed`, which made issue #719 take a + // DOM reconstruction to diagnose. + const resumeReason = opts.reason || 'browser_resumed'; + const wrapper = findAnyVariantsWrapper(); const runtimeWrapper = wrapper || document.querySelector('[data-impeccable-carbonize]'); if (restoreSessionSupersedingHandledWrapper(runtimeWrapper)) return true; if (scheduleHandledRuntimeWrapperReload(runtimeWrapper, recoveryRevision)) return false; @@ -9500,16 +9615,38 @@ void main() { showBar(state === 'CYCLING' ? 'cycling' : 'generating'); startScrollTracking(); - // Build the params panel for the restored visible variant. Previously - // this was missed on page-reload resume: showVariantInDOM above fires - // refreshParamsPanel, but state was still IDLE at that moment so it - // hid. Now that state is CYCLING, re-fire. - if (state === 'CYCLING') refreshParamsPanel(); + // A resume can BE the arrival, not just a re-entry after one. The server's + // generation preflight runs live-wrap with --defer-source-write, so the + // wrapper and every variant reach the DOM in one HMR batch, and the + // deferred-wrapper scout (constructed at init) runs before the variant + // MutationObserver (constructed at Go) on that batch. Finish the same + // transition the observer would have finished. Without hideShaderOverlay + // the generating shader stays frozen over the target and the session looks + // stuck at GENERATING while the bar already cycles (issue #719). + if (state === 'CYCLING') { + recoveryWaitingForAnchor = false; + hideShaderOverlay(); + if (isInsert) finalizeInsertSession(); + disableInlineEdit(); + // Build the params panel for the restored visible variant. Previously + // this was missed on page-reload resume: showVariantInDOM above fires + // refreshParamsPanel, but state was still IDLE at that moment so it + // hid. Now that state is CYCLING, re-fire. + refreshParamsPanel(); + } saveSession(); if (arrivedVariants > 0 && arrivedVariants < expectedVariants) { sendCheckpoint('variants_progress'); } else { - queueCheckpoint('browser_resumed'); + queueCheckpoint(resumeReason); + // Only variants_progress and variants_ready count as publication + // progress. When the resume is the arrival, the observer never gets to + // report it (this function disconnects and re-creates it below, which + // drops the records it had already queued for this same batch), so + // without this the server never learns the variants were published. + if (arrivedVariants > 0 && arrivedVariants >= expectedVariants && expectedVariants > 0) { + sendCheckpoint('variants_ready'); + } } // Start observing for more variants AFTER initial setup @@ -12831,7 +12968,7 @@ void main() { const wrapper = document.querySelector('[data-impeccable-variants],[data-impeccable-carbonize]'); if (!wrapper) return; scout.disconnect(); - if (resumeSession(deferredResumeRevision)) { + if (resumeSession(deferredResumeRevision, { reason: 'browser_resumed_deferred_wrapper' })) { console.log('[impeccable] Resumed deferred session ' + currentSessionId + ' (post-hydration).'); } }); diff --git a/.trae/skills/impeccable/scripts/live-browser.js b/.trae/skills/impeccable/scripts/live-browser.js index c32101b12..277c60e2c 100644 --- a/.trae/skills/impeccable/scripts/live-browser.js +++ b/.trae/skills/impeccable/scripts/live-browser.js @@ -2060,7 +2060,7 @@ if (anchor) return anchor; } if (currentSessionId && (state === 'GENERATING' || state === 'CYCLING')) { - const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]'); + const wrapper = findVariantsWrapper(currentSessionId); if (wrapper) { const variantCount = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])').length; if (variantCount > 0 && visibleVariant > 0) { @@ -2131,14 +2131,14 @@ function isInsertGeneratingSession() { if (state !== 'GENERATING' || !currentSessionId) return false; - const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]'); + const wrapper = findVariantsWrapper(currentSessionId); return !!wrapper && wrapper.dataset.impeccableMode === 'insert'; } /** Recreate the dotted placeholder if Astro/Vite HMR removed it mid-generation. */ function ensureInsertPlaceholder() { if (!isInsertGeneratingSession()) return placeholderElement; - const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]'); + const wrapper = findVariantsWrapper(currentSessionId); const variantCount = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])').length; if (variantCount > 0) return placeholderElement; if (placeholderElement && document.body.contains(placeholderElement)) return placeholderElement; @@ -3156,7 +3156,7 @@ || svelteComponentSession.wrapperEl || null; } - const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]'); + const wrapper = findVariantsWrapper(currentSessionId); if (!wrapper) return null; return wrapper.querySelector('[data-impeccable-variant="' + visibleVariant + '"]'); } @@ -4900,7 +4900,7 @@ return Object.values(svelteComponentSession.paramsByVariant || {}) .reduce((total, params) => total + (Array.isArray(params) ? params.length : 0), 0); } - const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]'); + const wrapper = findVariantsWrapper(currentSessionId); if (!wrapper) return 0; return [...wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])')] .reduce((total, variant) => total + parseVariantParams(variant).length, 0); @@ -5004,7 +5004,7 @@ scheduleCyclingBarSync(sessionId, num); return true; } - const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]'); + const wrapper = findVariantsWrapper(sessionId); if (!wrapper) return false; updateVariantStateStylesheet(sessionId, num); // Unconditional refresh - covers first-reveal (no-op if state isn't @@ -5820,6 +5820,7 @@ return; } setLiveState('CYCLING'); + hideShaderOverlay(); showOrUpdateCyclingBar(); saveSession(); completeParameterGenerationIfReady(); @@ -6361,7 +6362,7 @@ } rememberSessionFileMeta({ file: filePath }); if (isJsxSourceFile(filePath)) { - const liveWrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]'); + const liveWrapper = findVariantsWrapper(sessionId); if (liveWrapper && liveWrapper.querySelector('[data-impeccable-variant]:not([data-impeccable-variant="original"])')) { completeSourceInjection(liveWrapper, sessionId, { ...opts, filePath }); return; @@ -6433,7 +6434,7 @@ return; } - const existingWrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]'); + const existingWrapper = findVariantsWrapper(sessionId); if (existingWrapper) { const wrapper = srcWrapper.cloneNode(true); existingWrapper.parentElement.replaceChild(wrapper, existingWrapper); @@ -6590,7 +6591,7 @@ if (anchor && !anchor.__impeccableFrozenAnchor) selectedElement = anchor; return; } - const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]'); + const wrapper = findVariantsWrapper(currentSessionId); if (!wrapper) return; const visEl = pickVariantContent(wrapper, visibleVariant); if (visEl) selectedElement = visEl; @@ -6600,7 +6601,7 @@ if (svelteComponentSession?.sessionId === sessionId && svelteComponentSession.mountedVariant > 0) { return svelteComponentSession.mountedVariant; } - const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]'); + const wrapper = findVariantsWrapper(sessionId); if (!wrapper) return 0; const variants = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])'); for (const variant of variants) { @@ -6715,8 +6716,17 @@ document.getElementById(discardStateStyleId(sessionId))?.remove(); } - function releaseDiscardedStaticWrapper(wrapper, sessionId) { - removeDiscardStateStylesheet(sessionId); + /** + * Every wrapper a discard has to unwind. A target inside a `.map()` renders + * one wrapper per item, so the hide, the release, and the existence checks + * all have to speak about the same set. + */ + function discardedWrappers(sessionId) { + if (!sessionId) return []; + return [...document.querySelectorAll('[data-impeccable-variants="' + sessionId + '"]')]; + } + + function releaseDiscardedStaticWrapper(wrapper) { if (!wrapper) return; const orig = wrapper.querySelector('[data-impeccable-variant="original"]'); const content = orig?.firstElementChild; @@ -6727,6 +6737,18 @@ wrapper.remove(); } + /** + * Undo the discard hide on every wrapper it covered. Releasing only the + * first match left the other mapped items sitting at display:none with + * their original content never restored, on exactly the static and + * missed-HMR flows this fallback exists for. + */ + function releaseDiscardedStaticWrappers(sessionId, wrappers) { + removeDiscardStateStylesheet(sessionId); + const set = wrappers && wrappers.length ? wrappers : discardedWrappers(sessionId); + for (const wrapper of set) releaseDiscardedStaticWrapper(wrapper); + } + function watchForDiscardedFrameworkWrapperRemoval(sessionId) { if (!sessionId || !document.body) return; if (discardedFrameworkWrapperWatchers.has(sessionId)) return; @@ -6907,6 +6929,42 @@ // MutationObserver for progressive variant reveal // + // A session id can have more than one wrapper in the DOM: the target may sit + // inside a `.map()` callback (the wrapper renders once per item), or the + // agent may have relocated the wrapper out of the shared primitive live-wrap + // scaffolded into. A plain first match can then pin an empty scaffold while + // the real variants sit in a later wrapper, which strands the session at + // 0/N and leaves the bar, the params panel, and accept all reading the + // wrong element. Prefer a wrapper that actually holds variants. With zero + // or one match this is exactly the querySelector it replaces. + // + // Every lookup of the ACTIVE session's wrapper goes through here. The + // remaining raw `[data-impeccable-variants=...]` uses are deliberate: bare + // existence checks, selector strings for stylesheets and observers (which + // want to cover every match), `querySelectorAll` sweeps, and the parsed + // source document, which is not this document. + function pickPopulatedVariantsWrapper(selector) { + const matches = document.querySelectorAll(selector); + if (matches.length < 2) return matches[0] || null; + for (const candidate of matches) { + if (candidate.querySelector('[data-impeccable-variant]:not([data-impeccable-variant="original"])')) { + return candidate; + } + } + return matches[0]; + } + + /** The wrapper holding `sessionId`'s variants, or null without an id. */ + function findVariantsWrapper(sessionId) { + if (!sessionId) return null; + return pickPopulatedVariantsWrapper('[data-impeccable-variants="' + sessionId + '"]'); + } + + /** Any live variant wrapper, for the resume paths that have no id yet. */ + function findAnyVariantsWrapper() { + return pickPopulatedVariantsWrapper('[data-impeccable-variants]'); + } + function startVariantObserver(sessionId) { let updating = false; // re-entrancy guard @@ -6936,7 +6994,7 @@ } if (!dominated) return; - const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]'); + const wrapper = findVariantsWrapper(sessionId); if (!wrapper) return; const variants = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])'); @@ -7145,6 +7203,7 @@ if (arrivedVariants >= expectedVariants && expectedVariants > 0) { if (state === 'GENERATING') { setLiveState('CYCLING'); + hideShaderOverlay(); showOrUpdateCyclingBar(); disableInlineEdit(); refreshParamsPanel(); @@ -7205,6 +7264,7 @@ pendingAcceptedSession = null; awaitingAcceptResult = null; setLiveState('CYCLING'); + hideShaderOverlay(); updateBarContent('cycling'); showToast('Could not complete accept cleanup. Try Accept again.', 5000); break; @@ -8307,6 +8367,15 @@ void main() { // matches the original off-white risograph paper. const SHADER_PAPER_FALLBACK = [0.975, 0.965, 0.955]; let shaderState = null; // { canvas, gl, program, texture, rafId, startTime } + // showShaderOverlay is async: it appends its canvas, then awaits + // createImageBitmap and the GL setup before it publishes shaderState. A + // teardown that landed inside that window found shaderState still null, + // returned, and then watched the construction publish itself over a session + // that had already left GENERATING, with no teardown left to run. That is + // the generating loader frozen over a page that already cycles (issue #719). + // Every teardown bumps this epoch; a construction abandons its own canvas as + // soon as it sees the epoch move. + let shaderEpoch = 0; // The element's effective background tone, used as the uniform halftone // ground so content dissolves into dots over it. Unlike resolveCanvasBackground @@ -8453,14 +8522,28 @@ void main() { }); } + /** Drop a shader node no shaderState owns (an abandoned construction). */ + function removeStrayShaderNode() { + const stray = uiGetById(PREFIX + '-shader'); + if (stray) stray.remove(); + } + function hideShaderOverlay() { - if (!shaderState) return; + // Bump first, unconditionally: this is what tells an in-flight + // showShaderOverlay to abandon itself rather than publish over a session + // that has already moved on. + shaderEpoch += 1; + if (!shaderState) { + removeStrayShaderNode(); + return; + } if (shaderState.rafId) cancelAnimationFrame(shaderState.rafId); if (shaderState.canvas) shaderState.canvas.remove(); if (shaderState.objectUrl) URL.revokeObjectURL(shaderState.objectUrl); const lose = shaderState.gl?.getExtension?.('WEBGL_lose_context'); try { lose?.loseContext(); } catch {} shaderState = null; + removeStrayShaderNode(); } function showShaderBitmapFallback(canvas, blob) { @@ -8485,6 +8568,16 @@ void main() { async function showShaderOverlay(el, blob, rect, paper) { hideShaderOverlay(); if (!blob || !el) return; + // hideShaderOverlay just bumped the epoch, so this run owns it until the + // next teardown. Every step past an await re-checks before it publishes. + const epoch = shaderEpoch; + const abandoned = (node, gl) => { + if (epoch === shaderEpoch) return false; + node.remove(); + const lose = gl?.getExtension?.('WEBGL_lose_context'); + try { lose?.loseContext(); } catch {} + return true; + }; const canvas = document.createElement('canvas'); canvas.id = PREFIX + '-shader'; const dpr = Math.min(window.devicePixelRatio || 1, 2); @@ -8507,6 +8600,7 @@ void main() { if (!gl) { // WebGL unavailable: use the captured bitmap as a background overlay so // the user still sees something meaningful during generation. + if (abandoned(canvas, null)) return; showShaderBitmapFallback(canvas, blob); return; } @@ -8546,16 +8640,22 @@ void main() { } // Upload the screenshot as a texture + if (abandoned(canvas, gl)) return; let bitmap; try { bitmap = await createImageBitmap(blob); } catch (err) { console.warn('[impeccable] shader bitmap decode failed:', err); + if (abandoned(canvas, gl)) return; const lose = gl.getExtension?.('WEBGL_lose_context'); try { lose?.loseContext(); } catch {} showShaderBitmapFallback(canvas, blob); return; } + if (abandoned(canvas, gl)) { + if (bitmap.close) bitmap.close(); + return; + } texture = gl.createTexture(); gl.bindTexture(gl.TEXTURE_2D, texture); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); @@ -8574,6 +8674,7 @@ void main() { const paperRgb = paper || resolvePaperRgb(el); const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches; + if (abandoned(canvas, gl)) return; shaderState = { canvas, gl, program, texture, rafId: 0, startTime: performance.now(), reduced }; function frame() { if (!shaderState) return; @@ -8610,7 +8711,7 @@ void main() { clientSentAt: Date.now(), }; if (!currentSessionId || arrivedVariants === 0) return; - const acceptWrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]'); + const acceptWrapper = findVariantsWrapper(currentSessionId); if (Object.keys(paramsCurrentValues).length > 0) { acceptPayload.paramValues = { ...paramsCurrentValues }; } @@ -8653,6 +8754,7 @@ void main() { .catch(() => { if (pendingAcceptedSession?.id === acceptedSessionId) pendingAcceptedSession = null; setLiveState('CYCLING'); + hideShaderOverlay(); showOrUpdateCyclingBar(); showToast('Could not confirm accept with the live server. Session kept for recovery; try Accept again.', 5000); }); @@ -8704,7 +8806,7 @@ void main() { } function snapshotAcceptedVariantDom(sessionId, variantId) { - const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]'); + const wrapper = findVariantsWrapper(sessionId); const accepted = wrapper?.querySelector?.('[data-impeccable-variant="' + variantId + '"]'); const root = accepted?.firstElementChild || null; return { @@ -8831,7 +8933,7 @@ void main() { } function commitAcceptedVariantToDom(sessionId, variantId) { - const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]'); + const wrapper = findVariantsWrapper(sessionId); if (!wrapper) return false; const accepted = wrapper.querySelector('[data-impeccable-variant="' + variantId + '"]'); if (!accepted || !accepted.firstElementChild) return false; @@ -9059,7 +9161,7 @@ void main() { } function restoreFromActiveSessions(activeSessions, reason) { - const wrapper = document.querySelector('[data-impeccable-variants]'); + const wrapper = findAnyVariantsWrapper(); if (wrapper && !isFrameworkComponentPreviewMode(wrapper.dataset.impeccablePreview)) return false; if (svelteComponentSession?.sessionId === currentSessionId) return false; return restoreSessionWithoutWrapper(reason || 'sse_connected', activeSessions); @@ -9172,10 +9274,13 @@ void main() { // reconciler later tries to remove a wrapper we already removed. // Schedule a 2s fallback that does the manual swap only if HMR hasn't // replaced the wrapper by then (keeps static-server / no-HMR flows alive). - const wrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]'); - if (wrapper) { + // Every match, not the first: a target inside a `.map()` renders one + // wrapper per item, and hiding only one leaves the rest of the + // discarded variants on screen. + const discardWrappers = discardedWrappers(cleanupSessionId); + if (discardWrappers.length > 0) { if (restoreOriginal) showOriginalDuringDiscard(cleanupSessionId); - else wrapper.style.display = 'none'; + else for (const discardWrapper of discardWrappers) discardWrapper.style.display = 'none'; } setTimeout(function() { const recoverySuperseded = deferredRecoverySuperseded(cleanupSessionId, cleanupRevision); @@ -9183,16 +9288,19 @@ void main() { removeDiscardStateStylesheet(); return; } - const lateWrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]'); - if (!lateWrapper) { + const lateWrappers = discardedWrappers(cleanupSessionId); + if (lateWrappers.length === 0) { removeDiscardStateStylesheet(cleanupSessionId); return; } + // Duplicates all render from one source element, so HMR ownership is + // uniform across them; the first is a fair witness for the set. + const lateWrapper = lateWrappers[0]; if (recoverySuperseded) { if (hasFrameworkHmrOwnership(lateWrapper)) { watchForDiscardedFrameworkWrapperRemoval(cleanupSessionId); } else { - releaseDiscardedStaticWrapper(lateWrapper, cleanupSessionId); + releaseDiscardedStaticWrappers(cleanupSessionId, lateWrappers); } return; } @@ -9201,18 +9309,20 @@ void main() { // the final source rewrite, reload once after a grace window so the // discarded source becomes authoritative without a reconciler race. setTimeout(function() { - const staleWrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]'); + const staleWrappers = discardedWrappers(cleanupSessionId); if (deferredRecoverySuperseded(cleanupSessionId, cleanupRevision)) { - if (!staleWrapper) removeDiscardStateStylesheet(cleanupSessionId); + if (staleWrappers.length === 0) removeDiscardStateStylesheet(cleanupSessionId); else watchForDiscardedFrameworkWrapperRemoval(cleanupSessionId); return; } removeDiscardStateStylesheet(cleanupSessionId); - if (staleWrapper) location.reload(); + // A reload restores every wrapper's original at once, so there is + // nothing per-wrapper to do here. + if (staleWrappers.length > 0) location.reload(); }, 2000); return; } - releaseDiscardedStaticWrapper(lateWrapper, cleanupSessionId); + releaseDiscardedStaticWrappers(cleanupSessionId, lateWrappers); }, 2000); } hideBar(instantChrome); @@ -9400,8 +9510,13 @@ void main() { return restoreSessionWithoutWrapper('browser_resumed_over_handled_wrapper'); } - function resumeSession(recoveryRevision = liveInteractionRevision) { - const wrapper = document.querySelector('[data-impeccable-variants]'); + function resumeSession(recoveryRevision = liveInteractionRevision, opts = {}) { + // Which path resumed matters in the journal: an init resume is a fresh + // page load, the deferred-wrapper scout is a mid-page-load arrival. Both + // used to log the same `browser_resumed`, which made issue #719 take a + // DOM reconstruction to diagnose. + const resumeReason = opts.reason || 'browser_resumed'; + const wrapper = findAnyVariantsWrapper(); const runtimeWrapper = wrapper || document.querySelector('[data-impeccable-carbonize]'); if (restoreSessionSupersedingHandledWrapper(runtimeWrapper)) return true; if (scheduleHandledRuntimeWrapperReload(runtimeWrapper, recoveryRevision)) return false; @@ -9500,16 +9615,38 @@ void main() { showBar(state === 'CYCLING' ? 'cycling' : 'generating'); startScrollTracking(); - // Build the params panel for the restored visible variant. Previously - // this was missed on page-reload resume: showVariantInDOM above fires - // refreshParamsPanel, but state was still IDLE at that moment so it - // hid. Now that state is CYCLING, re-fire. - if (state === 'CYCLING') refreshParamsPanel(); + // A resume can BE the arrival, not just a re-entry after one. The server's + // generation preflight runs live-wrap with --defer-source-write, so the + // wrapper and every variant reach the DOM in one HMR batch, and the + // deferred-wrapper scout (constructed at init) runs before the variant + // MutationObserver (constructed at Go) on that batch. Finish the same + // transition the observer would have finished. Without hideShaderOverlay + // the generating shader stays frozen over the target and the session looks + // stuck at GENERATING while the bar already cycles (issue #719). + if (state === 'CYCLING') { + recoveryWaitingForAnchor = false; + hideShaderOverlay(); + if (isInsert) finalizeInsertSession(); + disableInlineEdit(); + // Build the params panel for the restored visible variant. Previously + // this was missed on page-reload resume: showVariantInDOM above fires + // refreshParamsPanel, but state was still IDLE at that moment so it + // hid. Now that state is CYCLING, re-fire. + refreshParamsPanel(); + } saveSession(); if (arrivedVariants > 0 && arrivedVariants < expectedVariants) { sendCheckpoint('variants_progress'); } else { - queueCheckpoint('browser_resumed'); + queueCheckpoint(resumeReason); + // Only variants_progress and variants_ready count as publication + // progress. When the resume is the arrival, the observer never gets to + // report it (this function disconnects and re-creates it below, which + // drops the records it had already queued for this same batch), so + // without this the server never learns the variants were published. + if (arrivedVariants > 0 && arrivedVariants >= expectedVariants && expectedVariants > 0) { + sendCheckpoint('variants_ready'); + } } // Start observing for more variants AFTER initial setup @@ -12831,7 +12968,7 @@ void main() { const wrapper = document.querySelector('[data-impeccable-variants],[data-impeccable-carbonize]'); if (!wrapper) return; scout.disconnect(); - if (resumeSession(deferredResumeRevision)) { + if (resumeSession(deferredResumeRevision, { reason: 'browser_resumed_deferred_wrapper' })) { console.log('[impeccable] Resumed deferred session ' + currentSessionId + ' (post-hydration).'); } }); diff --git a/.vibe/skills/impeccable/scripts/live-browser.js b/.vibe/skills/impeccable/scripts/live-browser.js index c32101b12..277c60e2c 100644 --- a/.vibe/skills/impeccable/scripts/live-browser.js +++ b/.vibe/skills/impeccable/scripts/live-browser.js @@ -2060,7 +2060,7 @@ if (anchor) return anchor; } if (currentSessionId && (state === 'GENERATING' || state === 'CYCLING')) { - const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]'); + const wrapper = findVariantsWrapper(currentSessionId); if (wrapper) { const variantCount = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])').length; if (variantCount > 0 && visibleVariant > 0) { @@ -2131,14 +2131,14 @@ function isInsertGeneratingSession() { if (state !== 'GENERATING' || !currentSessionId) return false; - const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]'); + const wrapper = findVariantsWrapper(currentSessionId); return !!wrapper && wrapper.dataset.impeccableMode === 'insert'; } /** Recreate the dotted placeholder if Astro/Vite HMR removed it mid-generation. */ function ensureInsertPlaceholder() { if (!isInsertGeneratingSession()) return placeholderElement; - const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]'); + const wrapper = findVariantsWrapper(currentSessionId); const variantCount = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])').length; if (variantCount > 0) return placeholderElement; if (placeholderElement && document.body.contains(placeholderElement)) return placeholderElement; @@ -3156,7 +3156,7 @@ || svelteComponentSession.wrapperEl || null; } - const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]'); + const wrapper = findVariantsWrapper(currentSessionId); if (!wrapper) return null; return wrapper.querySelector('[data-impeccable-variant="' + visibleVariant + '"]'); } @@ -4900,7 +4900,7 @@ return Object.values(svelteComponentSession.paramsByVariant || {}) .reduce((total, params) => total + (Array.isArray(params) ? params.length : 0), 0); } - const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]'); + const wrapper = findVariantsWrapper(currentSessionId); if (!wrapper) return 0; return [...wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])')] .reduce((total, variant) => total + parseVariantParams(variant).length, 0); @@ -5004,7 +5004,7 @@ scheduleCyclingBarSync(sessionId, num); return true; } - const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]'); + const wrapper = findVariantsWrapper(sessionId); if (!wrapper) return false; updateVariantStateStylesheet(sessionId, num); // Unconditional refresh - covers first-reveal (no-op if state isn't @@ -5820,6 +5820,7 @@ return; } setLiveState('CYCLING'); + hideShaderOverlay(); showOrUpdateCyclingBar(); saveSession(); completeParameterGenerationIfReady(); @@ -6361,7 +6362,7 @@ } rememberSessionFileMeta({ file: filePath }); if (isJsxSourceFile(filePath)) { - const liveWrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]'); + const liveWrapper = findVariantsWrapper(sessionId); if (liveWrapper && liveWrapper.querySelector('[data-impeccable-variant]:not([data-impeccable-variant="original"])')) { completeSourceInjection(liveWrapper, sessionId, { ...opts, filePath }); return; @@ -6433,7 +6434,7 @@ return; } - const existingWrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]'); + const existingWrapper = findVariantsWrapper(sessionId); if (existingWrapper) { const wrapper = srcWrapper.cloneNode(true); existingWrapper.parentElement.replaceChild(wrapper, existingWrapper); @@ -6590,7 +6591,7 @@ if (anchor && !anchor.__impeccableFrozenAnchor) selectedElement = anchor; return; } - const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]'); + const wrapper = findVariantsWrapper(currentSessionId); if (!wrapper) return; const visEl = pickVariantContent(wrapper, visibleVariant); if (visEl) selectedElement = visEl; @@ -6600,7 +6601,7 @@ if (svelteComponentSession?.sessionId === sessionId && svelteComponentSession.mountedVariant > 0) { return svelteComponentSession.mountedVariant; } - const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]'); + const wrapper = findVariantsWrapper(sessionId); if (!wrapper) return 0; const variants = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])'); for (const variant of variants) { @@ -6715,8 +6716,17 @@ document.getElementById(discardStateStyleId(sessionId))?.remove(); } - function releaseDiscardedStaticWrapper(wrapper, sessionId) { - removeDiscardStateStylesheet(sessionId); + /** + * Every wrapper a discard has to unwind. A target inside a `.map()` renders + * one wrapper per item, so the hide, the release, and the existence checks + * all have to speak about the same set. + */ + function discardedWrappers(sessionId) { + if (!sessionId) return []; + return [...document.querySelectorAll('[data-impeccable-variants="' + sessionId + '"]')]; + } + + function releaseDiscardedStaticWrapper(wrapper) { if (!wrapper) return; const orig = wrapper.querySelector('[data-impeccable-variant="original"]'); const content = orig?.firstElementChild; @@ -6727,6 +6737,18 @@ wrapper.remove(); } + /** + * Undo the discard hide on every wrapper it covered. Releasing only the + * first match left the other mapped items sitting at display:none with + * their original content never restored, on exactly the static and + * missed-HMR flows this fallback exists for. + */ + function releaseDiscardedStaticWrappers(sessionId, wrappers) { + removeDiscardStateStylesheet(sessionId); + const set = wrappers && wrappers.length ? wrappers : discardedWrappers(sessionId); + for (const wrapper of set) releaseDiscardedStaticWrapper(wrapper); + } + function watchForDiscardedFrameworkWrapperRemoval(sessionId) { if (!sessionId || !document.body) return; if (discardedFrameworkWrapperWatchers.has(sessionId)) return; @@ -6907,6 +6929,42 @@ // MutationObserver for progressive variant reveal // + // A session id can have more than one wrapper in the DOM: the target may sit + // inside a `.map()` callback (the wrapper renders once per item), or the + // agent may have relocated the wrapper out of the shared primitive live-wrap + // scaffolded into. A plain first match can then pin an empty scaffold while + // the real variants sit in a later wrapper, which strands the session at + // 0/N and leaves the bar, the params panel, and accept all reading the + // wrong element. Prefer a wrapper that actually holds variants. With zero + // or one match this is exactly the querySelector it replaces. + // + // Every lookup of the ACTIVE session's wrapper goes through here. The + // remaining raw `[data-impeccable-variants=...]` uses are deliberate: bare + // existence checks, selector strings for stylesheets and observers (which + // want to cover every match), `querySelectorAll` sweeps, and the parsed + // source document, which is not this document. + function pickPopulatedVariantsWrapper(selector) { + const matches = document.querySelectorAll(selector); + if (matches.length < 2) return matches[0] || null; + for (const candidate of matches) { + if (candidate.querySelector('[data-impeccable-variant]:not([data-impeccable-variant="original"])')) { + return candidate; + } + } + return matches[0]; + } + + /** The wrapper holding `sessionId`'s variants, or null without an id. */ + function findVariantsWrapper(sessionId) { + if (!sessionId) return null; + return pickPopulatedVariantsWrapper('[data-impeccable-variants="' + sessionId + '"]'); + } + + /** Any live variant wrapper, for the resume paths that have no id yet. */ + function findAnyVariantsWrapper() { + return pickPopulatedVariantsWrapper('[data-impeccable-variants]'); + } + function startVariantObserver(sessionId) { let updating = false; // re-entrancy guard @@ -6936,7 +6994,7 @@ } if (!dominated) return; - const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]'); + const wrapper = findVariantsWrapper(sessionId); if (!wrapper) return; const variants = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])'); @@ -7145,6 +7203,7 @@ if (arrivedVariants >= expectedVariants && expectedVariants > 0) { if (state === 'GENERATING') { setLiveState('CYCLING'); + hideShaderOverlay(); showOrUpdateCyclingBar(); disableInlineEdit(); refreshParamsPanel(); @@ -7205,6 +7264,7 @@ pendingAcceptedSession = null; awaitingAcceptResult = null; setLiveState('CYCLING'); + hideShaderOverlay(); updateBarContent('cycling'); showToast('Could not complete accept cleanup. Try Accept again.', 5000); break; @@ -8307,6 +8367,15 @@ void main() { // matches the original off-white risograph paper. const SHADER_PAPER_FALLBACK = [0.975, 0.965, 0.955]; let shaderState = null; // { canvas, gl, program, texture, rafId, startTime } + // showShaderOverlay is async: it appends its canvas, then awaits + // createImageBitmap and the GL setup before it publishes shaderState. A + // teardown that landed inside that window found shaderState still null, + // returned, and then watched the construction publish itself over a session + // that had already left GENERATING, with no teardown left to run. That is + // the generating loader frozen over a page that already cycles (issue #719). + // Every teardown bumps this epoch; a construction abandons its own canvas as + // soon as it sees the epoch move. + let shaderEpoch = 0; // The element's effective background tone, used as the uniform halftone // ground so content dissolves into dots over it. Unlike resolveCanvasBackground @@ -8453,14 +8522,28 @@ void main() { }); } + /** Drop a shader node no shaderState owns (an abandoned construction). */ + function removeStrayShaderNode() { + const stray = uiGetById(PREFIX + '-shader'); + if (stray) stray.remove(); + } + function hideShaderOverlay() { - if (!shaderState) return; + // Bump first, unconditionally: this is what tells an in-flight + // showShaderOverlay to abandon itself rather than publish over a session + // that has already moved on. + shaderEpoch += 1; + if (!shaderState) { + removeStrayShaderNode(); + return; + } if (shaderState.rafId) cancelAnimationFrame(shaderState.rafId); if (shaderState.canvas) shaderState.canvas.remove(); if (shaderState.objectUrl) URL.revokeObjectURL(shaderState.objectUrl); const lose = shaderState.gl?.getExtension?.('WEBGL_lose_context'); try { lose?.loseContext(); } catch {} shaderState = null; + removeStrayShaderNode(); } function showShaderBitmapFallback(canvas, blob) { @@ -8485,6 +8568,16 @@ void main() { async function showShaderOverlay(el, blob, rect, paper) { hideShaderOverlay(); if (!blob || !el) return; + // hideShaderOverlay just bumped the epoch, so this run owns it until the + // next teardown. Every step past an await re-checks before it publishes. + const epoch = shaderEpoch; + const abandoned = (node, gl) => { + if (epoch === shaderEpoch) return false; + node.remove(); + const lose = gl?.getExtension?.('WEBGL_lose_context'); + try { lose?.loseContext(); } catch {} + return true; + }; const canvas = document.createElement('canvas'); canvas.id = PREFIX + '-shader'; const dpr = Math.min(window.devicePixelRatio || 1, 2); @@ -8507,6 +8600,7 @@ void main() { if (!gl) { // WebGL unavailable: use the captured bitmap as a background overlay so // the user still sees something meaningful during generation. + if (abandoned(canvas, null)) return; showShaderBitmapFallback(canvas, blob); return; } @@ -8546,16 +8640,22 @@ void main() { } // Upload the screenshot as a texture + if (abandoned(canvas, gl)) return; let bitmap; try { bitmap = await createImageBitmap(blob); } catch (err) { console.warn('[impeccable] shader bitmap decode failed:', err); + if (abandoned(canvas, gl)) return; const lose = gl.getExtension?.('WEBGL_lose_context'); try { lose?.loseContext(); } catch {} showShaderBitmapFallback(canvas, blob); return; } + if (abandoned(canvas, gl)) { + if (bitmap.close) bitmap.close(); + return; + } texture = gl.createTexture(); gl.bindTexture(gl.TEXTURE_2D, texture); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); @@ -8574,6 +8674,7 @@ void main() { const paperRgb = paper || resolvePaperRgb(el); const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches; + if (abandoned(canvas, gl)) return; shaderState = { canvas, gl, program, texture, rafId: 0, startTime: performance.now(), reduced }; function frame() { if (!shaderState) return; @@ -8610,7 +8711,7 @@ void main() { clientSentAt: Date.now(), }; if (!currentSessionId || arrivedVariants === 0) return; - const acceptWrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]'); + const acceptWrapper = findVariantsWrapper(currentSessionId); if (Object.keys(paramsCurrentValues).length > 0) { acceptPayload.paramValues = { ...paramsCurrentValues }; } @@ -8653,6 +8754,7 @@ void main() { .catch(() => { if (pendingAcceptedSession?.id === acceptedSessionId) pendingAcceptedSession = null; setLiveState('CYCLING'); + hideShaderOverlay(); showOrUpdateCyclingBar(); showToast('Could not confirm accept with the live server. Session kept for recovery; try Accept again.', 5000); }); @@ -8704,7 +8806,7 @@ void main() { } function snapshotAcceptedVariantDom(sessionId, variantId) { - const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]'); + const wrapper = findVariantsWrapper(sessionId); const accepted = wrapper?.querySelector?.('[data-impeccable-variant="' + variantId + '"]'); const root = accepted?.firstElementChild || null; return { @@ -8831,7 +8933,7 @@ void main() { } function commitAcceptedVariantToDom(sessionId, variantId) { - const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]'); + const wrapper = findVariantsWrapper(sessionId); if (!wrapper) return false; const accepted = wrapper.querySelector('[data-impeccable-variant="' + variantId + '"]'); if (!accepted || !accepted.firstElementChild) return false; @@ -9059,7 +9161,7 @@ void main() { } function restoreFromActiveSessions(activeSessions, reason) { - const wrapper = document.querySelector('[data-impeccable-variants]'); + const wrapper = findAnyVariantsWrapper(); if (wrapper && !isFrameworkComponentPreviewMode(wrapper.dataset.impeccablePreview)) return false; if (svelteComponentSession?.sessionId === currentSessionId) return false; return restoreSessionWithoutWrapper(reason || 'sse_connected', activeSessions); @@ -9172,10 +9274,13 @@ void main() { // reconciler later tries to remove a wrapper we already removed. // Schedule a 2s fallback that does the manual swap only if HMR hasn't // replaced the wrapper by then (keeps static-server / no-HMR flows alive). - const wrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]'); - if (wrapper) { + // Every match, not the first: a target inside a `.map()` renders one + // wrapper per item, and hiding only one leaves the rest of the + // discarded variants on screen. + const discardWrappers = discardedWrappers(cleanupSessionId); + if (discardWrappers.length > 0) { if (restoreOriginal) showOriginalDuringDiscard(cleanupSessionId); - else wrapper.style.display = 'none'; + else for (const discardWrapper of discardWrappers) discardWrapper.style.display = 'none'; } setTimeout(function() { const recoverySuperseded = deferredRecoverySuperseded(cleanupSessionId, cleanupRevision); @@ -9183,16 +9288,19 @@ void main() { removeDiscardStateStylesheet(); return; } - const lateWrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]'); - if (!lateWrapper) { + const lateWrappers = discardedWrappers(cleanupSessionId); + if (lateWrappers.length === 0) { removeDiscardStateStylesheet(cleanupSessionId); return; } + // Duplicates all render from one source element, so HMR ownership is + // uniform across them; the first is a fair witness for the set. + const lateWrapper = lateWrappers[0]; if (recoverySuperseded) { if (hasFrameworkHmrOwnership(lateWrapper)) { watchForDiscardedFrameworkWrapperRemoval(cleanupSessionId); } else { - releaseDiscardedStaticWrapper(lateWrapper, cleanupSessionId); + releaseDiscardedStaticWrappers(cleanupSessionId, lateWrappers); } return; } @@ -9201,18 +9309,20 @@ void main() { // the final source rewrite, reload once after a grace window so the // discarded source becomes authoritative without a reconciler race. setTimeout(function() { - const staleWrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]'); + const staleWrappers = discardedWrappers(cleanupSessionId); if (deferredRecoverySuperseded(cleanupSessionId, cleanupRevision)) { - if (!staleWrapper) removeDiscardStateStylesheet(cleanupSessionId); + if (staleWrappers.length === 0) removeDiscardStateStylesheet(cleanupSessionId); else watchForDiscardedFrameworkWrapperRemoval(cleanupSessionId); return; } removeDiscardStateStylesheet(cleanupSessionId); - if (staleWrapper) location.reload(); + // A reload restores every wrapper's original at once, so there is + // nothing per-wrapper to do here. + if (staleWrappers.length > 0) location.reload(); }, 2000); return; } - releaseDiscardedStaticWrapper(lateWrapper, cleanupSessionId); + releaseDiscardedStaticWrappers(cleanupSessionId, lateWrappers); }, 2000); } hideBar(instantChrome); @@ -9400,8 +9510,13 @@ void main() { return restoreSessionWithoutWrapper('browser_resumed_over_handled_wrapper'); } - function resumeSession(recoveryRevision = liveInteractionRevision) { - const wrapper = document.querySelector('[data-impeccable-variants]'); + function resumeSession(recoveryRevision = liveInteractionRevision, opts = {}) { + // Which path resumed matters in the journal: an init resume is a fresh + // page load, the deferred-wrapper scout is a mid-page-load arrival. Both + // used to log the same `browser_resumed`, which made issue #719 take a + // DOM reconstruction to diagnose. + const resumeReason = opts.reason || 'browser_resumed'; + const wrapper = findAnyVariantsWrapper(); const runtimeWrapper = wrapper || document.querySelector('[data-impeccable-carbonize]'); if (restoreSessionSupersedingHandledWrapper(runtimeWrapper)) return true; if (scheduleHandledRuntimeWrapperReload(runtimeWrapper, recoveryRevision)) return false; @@ -9500,16 +9615,38 @@ void main() { showBar(state === 'CYCLING' ? 'cycling' : 'generating'); startScrollTracking(); - // Build the params panel for the restored visible variant. Previously - // this was missed on page-reload resume: showVariantInDOM above fires - // refreshParamsPanel, but state was still IDLE at that moment so it - // hid. Now that state is CYCLING, re-fire. - if (state === 'CYCLING') refreshParamsPanel(); + // A resume can BE the arrival, not just a re-entry after one. The server's + // generation preflight runs live-wrap with --defer-source-write, so the + // wrapper and every variant reach the DOM in one HMR batch, and the + // deferred-wrapper scout (constructed at init) runs before the variant + // MutationObserver (constructed at Go) on that batch. Finish the same + // transition the observer would have finished. Without hideShaderOverlay + // the generating shader stays frozen over the target and the session looks + // stuck at GENERATING while the bar already cycles (issue #719). + if (state === 'CYCLING') { + recoveryWaitingForAnchor = false; + hideShaderOverlay(); + if (isInsert) finalizeInsertSession(); + disableInlineEdit(); + // Build the params panel for the restored visible variant. Previously + // this was missed on page-reload resume: showVariantInDOM above fires + // refreshParamsPanel, but state was still IDLE at that moment so it + // hid. Now that state is CYCLING, re-fire. + refreshParamsPanel(); + } saveSession(); if (arrivedVariants > 0 && arrivedVariants < expectedVariants) { sendCheckpoint('variants_progress'); } else { - queueCheckpoint('browser_resumed'); + queueCheckpoint(resumeReason); + // Only variants_progress and variants_ready count as publication + // progress. When the resume is the arrival, the observer never gets to + // report it (this function disconnects and re-creates it below, which + // drops the records it had already queued for this same batch), so + // without this the server never learns the variants were published. + if (arrivedVariants > 0 && arrivedVariants >= expectedVariants && expectedVariants > 0) { + sendCheckpoint('variants_ready'); + } } // Start observing for more variants AFTER initial setup @@ -12831,7 +12968,7 @@ void main() { const wrapper = document.querySelector('[data-impeccable-variants],[data-impeccable-carbonize]'); if (!wrapper) return; scout.disconnect(); - if (resumeSession(deferredResumeRevision)) { + if (resumeSession(deferredResumeRevision, { reason: 'browser_resumed_deferred_wrapper' })) { console.log('[impeccable] Resumed deferred session ' + currentSessionId + ' (post-hydration).'); } }); diff --git a/plugin/skills/impeccable/scripts/live-browser.js b/plugin/skills/impeccable/scripts/live-browser.js index c32101b12..277c60e2c 100644 --- a/plugin/skills/impeccable/scripts/live-browser.js +++ b/plugin/skills/impeccable/scripts/live-browser.js @@ -2060,7 +2060,7 @@ if (anchor) return anchor; } if (currentSessionId && (state === 'GENERATING' || state === 'CYCLING')) { - const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]'); + const wrapper = findVariantsWrapper(currentSessionId); if (wrapper) { const variantCount = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])').length; if (variantCount > 0 && visibleVariant > 0) { @@ -2131,14 +2131,14 @@ function isInsertGeneratingSession() { if (state !== 'GENERATING' || !currentSessionId) return false; - const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]'); + const wrapper = findVariantsWrapper(currentSessionId); return !!wrapper && wrapper.dataset.impeccableMode === 'insert'; } /** Recreate the dotted placeholder if Astro/Vite HMR removed it mid-generation. */ function ensureInsertPlaceholder() { if (!isInsertGeneratingSession()) return placeholderElement; - const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]'); + const wrapper = findVariantsWrapper(currentSessionId); const variantCount = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])').length; if (variantCount > 0) return placeholderElement; if (placeholderElement && document.body.contains(placeholderElement)) return placeholderElement; @@ -3156,7 +3156,7 @@ || svelteComponentSession.wrapperEl || null; } - const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]'); + const wrapper = findVariantsWrapper(currentSessionId); if (!wrapper) return null; return wrapper.querySelector('[data-impeccable-variant="' + visibleVariant + '"]'); } @@ -4900,7 +4900,7 @@ return Object.values(svelteComponentSession.paramsByVariant || {}) .reduce((total, params) => total + (Array.isArray(params) ? params.length : 0), 0); } - const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]'); + const wrapper = findVariantsWrapper(currentSessionId); if (!wrapper) return 0; return [...wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])')] .reduce((total, variant) => total + parseVariantParams(variant).length, 0); @@ -5004,7 +5004,7 @@ scheduleCyclingBarSync(sessionId, num); return true; } - const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]'); + const wrapper = findVariantsWrapper(sessionId); if (!wrapper) return false; updateVariantStateStylesheet(sessionId, num); // Unconditional refresh - covers first-reveal (no-op if state isn't @@ -5820,6 +5820,7 @@ return; } setLiveState('CYCLING'); + hideShaderOverlay(); showOrUpdateCyclingBar(); saveSession(); completeParameterGenerationIfReady(); @@ -6361,7 +6362,7 @@ } rememberSessionFileMeta({ file: filePath }); if (isJsxSourceFile(filePath)) { - const liveWrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]'); + const liveWrapper = findVariantsWrapper(sessionId); if (liveWrapper && liveWrapper.querySelector('[data-impeccable-variant]:not([data-impeccable-variant="original"])')) { completeSourceInjection(liveWrapper, sessionId, { ...opts, filePath }); return; @@ -6433,7 +6434,7 @@ return; } - const existingWrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]'); + const existingWrapper = findVariantsWrapper(sessionId); if (existingWrapper) { const wrapper = srcWrapper.cloneNode(true); existingWrapper.parentElement.replaceChild(wrapper, existingWrapper); @@ -6590,7 +6591,7 @@ if (anchor && !anchor.__impeccableFrozenAnchor) selectedElement = anchor; return; } - const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]'); + const wrapper = findVariantsWrapper(currentSessionId); if (!wrapper) return; const visEl = pickVariantContent(wrapper, visibleVariant); if (visEl) selectedElement = visEl; @@ -6600,7 +6601,7 @@ if (svelteComponentSession?.sessionId === sessionId && svelteComponentSession.mountedVariant > 0) { return svelteComponentSession.mountedVariant; } - const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]'); + const wrapper = findVariantsWrapper(sessionId); if (!wrapper) return 0; const variants = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])'); for (const variant of variants) { @@ -6715,8 +6716,17 @@ document.getElementById(discardStateStyleId(sessionId))?.remove(); } - function releaseDiscardedStaticWrapper(wrapper, sessionId) { - removeDiscardStateStylesheet(sessionId); + /** + * Every wrapper a discard has to unwind. A target inside a `.map()` renders + * one wrapper per item, so the hide, the release, and the existence checks + * all have to speak about the same set. + */ + function discardedWrappers(sessionId) { + if (!sessionId) return []; + return [...document.querySelectorAll('[data-impeccable-variants="' + sessionId + '"]')]; + } + + function releaseDiscardedStaticWrapper(wrapper) { if (!wrapper) return; const orig = wrapper.querySelector('[data-impeccable-variant="original"]'); const content = orig?.firstElementChild; @@ -6727,6 +6737,18 @@ wrapper.remove(); } + /** + * Undo the discard hide on every wrapper it covered. Releasing only the + * first match left the other mapped items sitting at display:none with + * their original content never restored, on exactly the static and + * missed-HMR flows this fallback exists for. + */ + function releaseDiscardedStaticWrappers(sessionId, wrappers) { + removeDiscardStateStylesheet(sessionId); + const set = wrappers && wrappers.length ? wrappers : discardedWrappers(sessionId); + for (const wrapper of set) releaseDiscardedStaticWrapper(wrapper); + } + function watchForDiscardedFrameworkWrapperRemoval(sessionId) { if (!sessionId || !document.body) return; if (discardedFrameworkWrapperWatchers.has(sessionId)) return; @@ -6907,6 +6929,42 @@ // MutationObserver for progressive variant reveal // + // A session id can have more than one wrapper in the DOM: the target may sit + // inside a `.map()` callback (the wrapper renders once per item), or the + // agent may have relocated the wrapper out of the shared primitive live-wrap + // scaffolded into. A plain first match can then pin an empty scaffold while + // the real variants sit in a later wrapper, which strands the session at + // 0/N and leaves the bar, the params panel, and accept all reading the + // wrong element. Prefer a wrapper that actually holds variants. With zero + // or one match this is exactly the querySelector it replaces. + // + // Every lookup of the ACTIVE session's wrapper goes through here. The + // remaining raw `[data-impeccable-variants=...]` uses are deliberate: bare + // existence checks, selector strings for stylesheets and observers (which + // want to cover every match), `querySelectorAll` sweeps, and the parsed + // source document, which is not this document. + function pickPopulatedVariantsWrapper(selector) { + const matches = document.querySelectorAll(selector); + if (matches.length < 2) return matches[0] || null; + for (const candidate of matches) { + if (candidate.querySelector('[data-impeccable-variant]:not([data-impeccable-variant="original"])')) { + return candidate; + } + } + return matches[0]; + } + + /** The wrapper holding `sessionId`'s variants, or null without an id. */ + function findVariantsWrapper(sessionId) { + if (!sessionId) return null; + return pickPopulatedVariantsWrapper('[data-impeccable-variants="' + sessionId + '"]'); + } + + /** Any live variant wrapper, for the resume paths that have no id yet. */ + function findAnyVariantsWrapper() { + return pickPopulatedVariantsWrapper('[data-impeccable-variants]'); + } + function startVariantObserver(sessionId) { let updating = false; // re-entrancy guard @@ -6936,7 +6994,7 @@ } if (!dominated) return; - const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]'); + const wrapper = findVariantsWrapper(sessionId); if (!wrapper) return; const variants = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])'); @@ -7145,6 +7203,7 @@ if (arrivedVariants >= expectedVariants && expectedVariants > 0) { if (state === 'GENERATING') { setLiveState('CYCLING'); + hideShaderOverlay(); showOrUpdateCyclingBar(); disableInlineEdit(); refreshParamsPanel(); @@ -7205,6 +7264,7 @@ pendingAcceptedSession = null; awaitingAcceptResult = null; setLiveState('CYCLING'); + hideShaderOverlay(); updateBarContent('cycling'); showToast('Could not complete accept cleanup. Try Accept again.', 5000); break; @@ -8307,6 +8367,15 @@ void main() { // matches the original off-white risograph paper. const SHADER_PAPER_FALLBACK = [0.975, 0.965, 0.955]; let shaderState = null; // { canvas, gl, program, texture, rafId, startTime } + // showShaderOverlay is async: it appends its canvas, then awaits + // createImageBitmap and the GL setup before it publishes shaderState. A + // teardown that landed inside that window found shaderState still null, + // returned, and then watched the construction publish itself over a session + // that had already left GENERATING, with no teardown left to run. That is + // the generating loader frozen over a page that already cycles (issue #719). + // Every teardown bumps this epoch; a construction abandons its own canvas as + // soon as it sees the epoch move. + let shaderEpoch = 0; // The element's effective background tone, used as the uniform halftone // ground so content dissolves into dots over it. Unlike resolveCanvasBackground @@ -8453,14 +8522,28 @@ void main() { }); } + /** Drop a shader node no shaderState owns (an abandoned construction). */ + function removeStrayShaderNode() { + const stray = uiGetById(PREFIX + '-shader'); + if (stray) stray.remove(); + } + function hideShaderOverlay() { - if (!shaderState) return; + // Bump first, unconditionally: this is what tells an in-flight + // showShaderOverlay to abandon itself rather than publish over a session + // that has already moved on. + shaderEpoch += 1; + if (!shaderState) { + removeStrayShaderNode(); + return; + } if (shaderState.rafId) cancelAnimationFrame(shaderState.rafId); if (shaderState.canvas) shaderState.canvas.remove(); if (shaderState.objectUrl) URL.revokeObjectURL(shaderState.objectUrl); const lose = shaderState.gl?.getExtension?.('WEBGL_lose_context'); try { lose?.loseContext(); } catch {} shaderState = null; + removeStrayShaderNode(); } function showShaderBitmapFallback(canvas, blob) { @@ -8485,6 +8568,16 @@ void main() { async function showShaderOverlay(el, blob, rect, paper) { hideShaderOverlay(); if (!blob || !el) return; + // hideShaderOverlay just bumped the epoch, so this run owns it until the + // next teardown. Every step past an await re-checks before it publishes. + const epoch = shaderEpoch; + const abandoned = (node, gl) => { + if (epoch === shaderEpoch) return false; + node.remove(); + const lose = gl?.getExtension?.('WEBGL_lose_context'); + try { lose?.loseContext(); } catch {} + return true; + }; const canvas = document.createElement('canvas'); canvas.id = PREFIX + '-shader'; const dpr = Math.min(window.devicePixelRatio || 1, 2); @@ -8507,6 +8600,7 @@ void main() { if (!gl) { // WebGL unavailable: use the captured bitmap as a background overlay so // the user still sees something meaningful during generation. + if (abandoned(canvas, null)) return; showShaderBitmapFallback(canvas, blob); return; } @@ -8546,16 +8640,22 @@ void main() { } // Upload the screenshot as a texture + if (abandoned(canvas, gl)) return; let bitmap; try { bitmap = await createImageBitmap(blob); } catch (err) { console.warn('[impeccable] shader bitmap decode failed:', err); + if (abandoned(canvas, gl)) return; const lose = gl.getExtension?.('WEBGL_lose_context'); try { lose?.loseContext(); } catch {} showShaderBitmapFallback(canvas, blob); return; } + if (abandoned(canvas, gl)) { + if (bitmap.close) bitmap.close(); + return; + } texture = gl.createTexture(); gl.bindTexture(gl.TEXTURE_2D, texture); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); @@ -8574,6 +8674,7 @@ void main() { const paperRgb = paper || resolvePaperRgb(el); const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches; + if (abandoned(canvas, gl)) return; shaderState = { canvas, gl, program, texture, rafId: 0, startTime: performance.now(), reduced }; function frame() { if (!shaderState) return; @@ -8610,7 +8711,7 @@ void main() { clientSentAt: Date.now(), }; if (!currentSessionId || arrivedVariants === 0) return; - const acceptWrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]'); + const acceptWrapper = findVariantsWrapper(currentSessionId); if (Object.keys(paramsCurrentValues).length > 0) { acceptPayload.paramValues = { ...paramsCurrentValues }; } @@ -8653,6 +8754,7 @@ void main() { .catch(() => { if (pendingAcceptedSession?.id === acceptedSessionId) pendingAcceptedSession = null; setLiveState('CYCLING'); + hideShaderOverlay(); showOrUpdateCyclingBar(); showToast('Could not confirm accept with the live server. Session kept for recovery; try Accept again.', 5000); }); @@ -8704,7 +8806,7 @@ void main() { } function snapshotAcceptedVariantDom(sessionId, variantId) { - const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]'); + const wrapper = findVariantsWrapper(sessionId); const accepted = wrapper?.querySelector?.('[data-impeccable-variant="' + variantId + '"]'); const root = accepted?.firstElementChild || null; return { @@ -8831,7 +8933,7 @@ void main() { } function commitAcceptedVariantToDom(sessionId, variantId) { - const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]'); + const wrapper = findVariantsWrapper(sessionId); if (!wrapper) return false; const accepted = wrapper.querySelector('[data-impeccable-variant="' + variantId + '"]'); if (!accepted || !accepted.firstElementChild) return false; @@ -9059,7 +9161,7 @@ void main() { } function restoreFromActiveSessions(activeSessions, reason) { - const wrapper = document.querySelector('[data-impeccable-variants]'); + const wrapper = findAnyVariantsWrapper(); if (wrapper && !isFrameworkComponentPreviewMode(wrapper.dataset.impeccablePreview)) return false; if (svelteComponentSession?.sessionId === currentSessionId) return false; return restoreSessionWithoutWrapper(reason || 'sse_connected', activeSessions); @@ -9172,10 +9274,13 @@ void main() { // reconciler later tries to remove a wrapper we already removed. // Schedule a 2s fallback that does the manual swap only if HMR hasn't // replaced the wrapper by then (keeps static-server / no-HMR flows alive). - const wrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]'); - if (wrapper) { + // Every match, not the first: a target inside a `.map()` renders one + // wrapper per item, and hiding only one leaves the rest of the + // discarded variants on screen. + const discardWrappers = discardedWrappers(cleanupSessionId); + if (discardWrappers.length > 0) { if (restoreOriginal) showOriginalDuringDiscard(cleanupSessionId); - else wrapper.style.display = 'none'; + else for (const discardWrapper of discardWrappers) discardWrapper.style.display = 'none'; } setTimeout(function() { const recoverySuperseded = deferredRecoverySuperseded(cleanupSessionId, cleanupRevision); @@ -9183,16 +9288,19 @@ void main() { removeDiscardStateStylesheet(); return; } - const lateWrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]'); - if (!lateWrapper) { + const lateWrappers = discardedWrappers(cleanupSessionId); + if (lateWrappers.length === 0) { removeDiscardStateStylesheet(cleanupSessionId); return; } + // Duplicates all render from one source element, so HMR ownership is + // uniform across them; the first is a fair witness for the set. + const lateWrapper = lateWrappers[0]; if (recoverySuperseded) { if (hasFrameworkHmrOwnership(lateWrapper)) { watchForDiscardedFrameworkWrapperRemoval(cleanupSessionId); } else { - releaseDiscardedStaticWrapper(lateWrapper, cleanupSessionId); + releaseDiscardedStaticWrappers(cleanupSessionId, lateWrappers); } return; } @@ -9201,18 +9309,20 @@ void main() { // the final source rewrite, reload once after a grace window so the // discarded source becomes authoritative without a reconciler race. setTimeout(function() { - const staleWrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]'); + const staleWrappers = discardedWrappers(cleanupSessionId); if (deferredRecoverySuperseded(cleanupSessionId, cleanupRevision)) { - if (!staleWrapper) removeDiscardStateStylesheet(cleanupSessionId); + if (staleWrappers.length === 0) removeDiscardStateStylesheet(cleanupSessionId); else watchForDiscardedFrameworkWrapperRemoval(cleanupSessionId); return; } removeDiscardStateStylesheet(cleanupSessionId); - if (staleWrapper) location.reload(); + // A reload restores every wrapper's original at once, so there is + // nothing per-wrapper to do here. + if (staleWrappers.length > 0) location.reload(); }, 2000); return; } - releaseDiscardedStaticWrapper(lateWrapper, cleanupSessionId); + releaseDiscardedStaticWrappers(cleanupSessionId, lateWrappers); }, 2000); } hideBar(instantChrome); @@ -9400,8 +9510,13 @@ void main() { return restoreSessionWithoutWrapper('browser_resumed_over_handled_wrapper'); } - function resumeSession(recoveryRevision = liveInteractionRevision) { - const wrapper = document.querySelector('[data-impeccable-variants]'); + function resumeSession(recoveryRevision = liveInteractionRevision, opts = {}) { + // Which path resumed matters in the journal: an init resume is a fresh + // page load, the deferred-wrapper scout is a mid-page-load arrival. Both + // used to log the same `browser_resumed`, which made issue #719 take a + // DOM reconstruction to diagnose. + const resumeReason = opts.reason || 'browser_resumed'; + const wrapper = findAnyVariantsWrapper(); const runtimeWrapper = wrapper || document.querySelector('[data-impeccable-carbonize]'); if (restoreSessionSupersedingHandledWrapper(runtimeWrapper)) return true; if (scheduleHandledRuntimeWrapperReload(runtimeWrapper, recoveryRevision)) return false; @@ -9500,16 +9615,38 @@ void main() { showBar(state === 'CYCLING' ? 'cycling' : 'generating'); startScrollTracking(); - // Build the params panel for the restored visible variant. Previously - // this was missed on page-reload resume: showVariantInDOM above fires - // refreshParamsPanel, but state was still IDLE at that moment so it - // hid. Now that state is CYCLING, re-fire. - if (state === 'CYCLING') refreshParamsPanel(); + // A resume can BE the arrival, not just a re-entry after one. The server's + // generation preflight runs live-wrap with --defer-source-write, so the + // wrapper and every variant reach the DOM in one HMR batch, and the + // deferred-wrapper scout (constructed at init) runs before the variant + // MutationObserver (constructed at Go) on that batch. Finish the same + // transition the observer would have finished. Without hideShaderOverlay + // the generating shader stays frozen over the target and the session looks + // stuck at GENERATING while the bar already cycles (issue #719). + if (state === 'CYCLING') { + recoveryWaitingForAnchor = false; + hideShaderOverlay(); + if (isInsert) finalizeInsertSession(); + disableInlineEdit(); + // Build the params panel for the restored visible variant. Previously + // this was missed on page-reload resume: showVariantInDOM above fires + // refreshParamsPanel, but state was still IDLE at that moment so it + // hid. Now that state is CYCLING, re-fire. + refreshParamsPanel(); + } saveSession(); if (arrivedVariants > 0 && arrivedVariants < expectedVariants) { sendCheckpoint('variants_progress'); } else { - queueCheckpoint('browser_resumed'); + queueCheckpoint(resumeReason); + // Only variants_progress and variants_ready count as publication + // progress. When the resume is the arrival, the observer never gets to + // report it (this function disconnects and re-creates it below, which + // drops the records it had already queued for this same batch), so + // without this the server never learns the variants were published. + if (arrivedVariants > 0 && arrivedVariants >= expectedVariants && expectedVariants > 0) { + sendCheckpoint('variants_ready'); + } } // Start observing for more variants AFTER initial setup @@ -12831,7 +12968,7 @@ void main() { const wrapper = document.querySelector('[data-impeccable-variants],[data-impeccable-carbonize]'); if (!wrapper) return; scout.disconnect(); - if (resumeSession(deferredResumeRevision)) { + if (resumeSession(deferredResumeRevision, { reason: 'browser_resumed_deferred_wrapper' })) { console.log('[impeccable] Resumed deferred session ' + currentSessionId + ' (post-hydration).'); } });