diff --git a/skill/scripts/live-browser.js b/skill/scripts/live-browser.js index 1f373a894..da026e255 100644 --- a/skill/scripts/live-browser.js +++ b/skill/scripts/live-browser.js @@ -6212,6 +6212,72 @@ showToast('The previous live session no longer matches the source file, so it was discarded. Pick an element to start fresh.', 6000); } + function isJsxSourceFile(filePath) { + return /\.[cm]?[jt]sx$/i.test(String(filePath || '')); + } + + function completeSourceInjection(wrapper, sessionId, opts) { + recoveryWaitingForAnchor = false; + if (pendingVariantAnchorRetryObserver) { + pendingVariantAnchorRetryObserver.disconnect(); + pendingVariantAnchorRetryObserver = null; + } + + const previousVisibleVariant = currentSessionId === sessionId ? visibleVariant : 0; + const variants = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])'); + arrivedVariants = variants.length; + expectedVariants = parseInt(wrapper.dataset.impeccableVariantCount || arrivedVariants); + if (arrivedVariants <= 0) { + if (state === 'GENERATING') { + // Mid-generation the source legitimately holds a scaffold wrapper + // with no variants yet (the server-side preflight wraps before the + // agent writes). Tearing the session down here would destroy an + // in-flight generation; stay in GENERATING — the variant observer + // is armed and the server re-delivers a missed `done`. + if (!opts.generationCompleted) { + console.log('[impeccable] Source has scaffold but no variants yet; still generating.'); + return; + } + // Generation finished, yet the read shows only the scaffold: the + // source view is stale and no further event will fire. Re-read a + // few times before surfacing recovery — a single silent return + // here would strand the tab in GENERATING forever. + const attempt = opts.attempt || 0; + if (attempt < COMPLETED_SOURCE_FALLBACK_RETRIES) { + console.log('[impeccable] Generation is done but source shows no variants yet; retrying read (' + + (attempt + 1) + '/' + COMPLETED_SOURCE_FALLBACK_RETRIES + ').'); + setTimeout(() => { + if (state !== 'GENERATING' || currentSessionId !== sessionId) return; + if (arrivedVariants > 0) return; + injectVariantsFromSource(opts.filePath, sessionId, { ...opts, attempt: attempt + 1 }); + }, COMPLETED_SOURCE_FALLBACK_RETRY_MS); + return; + } + } + recoverEmptyCycling('source-fallback-empty'); + return; + } + const saved = loadSession(); + const savedVisibleVariant = saved && saved.id === sessionId ? saved.visible : 0; + visibleVariant = previousVisibleVariant > 0 && previousVisibleVariant <= arrivedVariants + ? previousVisibleVariant + : (savedVisibleVariant > 0 && savedVisibleVariant <= arrivedVariants ? savedVisibleVariant : 1); + showVariantInDOM(sessionId, visibleVariant); + + selectedElement = pickVariantContent(wrapper, visibleVariant) || wrapper.parentElement; + + setLiveState('CYCLING'); + recoveryWaitingForAnchor = false; + hideShaderOverlay(); + showOrUpdateCyclingBar(); + disableInlineEdit(); + refreshParamsPanel(); + positionBar(); + saveSession(); + completeParameterGenerationIfReady(); + console.log('[impeccable] Injected ' + arrivedVariants + ' variants from source file.'); + } + /** * No-HMR fallback: fetch the raw source file from the live server, * parse it, extract the variant wrapper, and inject it into the live DOM. @@ -6229,14 +6295,53 @@ return; } rememberSessionFileMeta({ file: filePath }); + if (isJsxSourceFile(filePath)) { + const liveWrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]'); + if (liveWrapper && liveWrapper.querySelector('[data-impeccable-variant]:not([data-impeccable-variant="original"])')) { + completeSourceInjection(liveWrapper, sessionId, { ...opts, filePath }); + return; + } + // #454: never fetch/parse JSX. Missing wrap waits for mount (closed + // modal / other route). Insert scaffolds stay for late HMR. A replace + // scaffold with no variants after retries is a failed generation. + if (opts.generationCompleted && sessionId === currentSessionId) { + const attempt = opts.attempt || 0; + if (attempt < COMPLETED_SOURCE_FALLBACK_RETRIES) { + setTimeout(() => { + if (state !== 'GENERATING' || currentSessionId !== sessionId) return; + injectVariantsFromSource(filePath, sessionId, { ...opts, attempt: attempt + 1 }); + }, COMPLETED_SOURCE_FALLBACK_RETRY_MS); + return; + } + if (!liveWrapper) { + showToast( + "Variants ready. If the picked element isn't visible, retrace the path that revealed it - they'll appear automatically.", + 15000, + ); + return; + } + if (liveWrapper.dataset.impeccableMode !== 'insert') { + recoverEmptyCycling('source-fallback-empty'); + } + return; + } + if (opts.orphanDiscard && !liveWrapper && sessionId === currentSessionId) { + const attempt = opts._orphanAttempt || 0; + if (attempt < COMPLETED_SOURCE_FALLBACK_RETRIES) { + setTimeout(() => { + if (sessionId !== currentSessionId) return; + if (state !== 'GENERATING' && state !== 'CYCLING') return; + injectVariantsFromSource(filePath, sessionId, { ...opts, _orphanAttempt: attempt + 1 }); + }, COMPLETED_SOURCE_FALLBACK_RETRY_MS); + } + } + return; + } const url = 'http://localhost:' + PORT + '/source?token=' + TOKEN + '&path=' + encodeURIComponent(filePath); fetch(url) .then(r => { if (!r.ok) throw new Error(r.status); return r.text(); }) .then(html => { const parser = new DOMParser(); - let srcWrapper = null; - - // Full-file parse works for HTML/JSX; Astro/Vue sources need marker extraction. const startMark = ''; const endMark = ''; const startIdx = html.indexOf(startMark); @@ -6244,8 +6349,8 @@ const block = startIdx !== -1 && endIdx !== -1 && endIdx > startIdx ? html.slice(startIdx + startMark.length, endIdx).trim() : html; - const doc = parser.parseFromString(normalizeSourceFallbackBlock(block, filePath), 'text/html'); - srcWrapper = doc.querySelector('[data-impeccable-variants="' + sessionId + '"]'); + const doc = parser.parseFromString(block, 'text/html'); + const srcWrapper = doc.querySelector('[data-impeccable-variants="' + sessionId + '"]'); if (!srcWrapper) { console.warn('[impeccable] Variant wrapper not found in source file.'); // A resumed cycling session whose wrapper is gone from source is an @@ -6270,93 +6375,33 @@ return; } - const previousVisibleVariant = currentSessionId === sessionId ? visibleVariant : 0; - const wrapper = srcWrapper.cloneNode(true); - - // Wrapper already in DOM (wrap HMR landed, variant insert did not). const existingWrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]'); if (existingWrapper) { + const wrapper = srcWrapper.cloneNode(true); existingWrapper.parentElement.replaceChild(wrapper, existingWrapper); - } else { - const origContent = srcWrapper.querySelector('[data-impeccable-variant="original"] > :first-child'); - if (!origContent) return; - - const liveEl = resolveLiveInjectionAnchor(origContent.outerHTML); - if (!liveEl) { - console.warn('[impeccable] Could not find original element in live DOM.'); - enterRecoveryWaitingForAnchor({ - filePath, - sessionId, - srcWrapper, - checkpointReason: 'variant_anchor_missing', - trackScroll: false, - }); - return; - } - - liveEl.parentElement.replaceChild(wrapper, liveEl); - } - recoveryWaitingForAnchor = false; - if (pendingVariantAnchorRetryObserver) { - pendingVariantAnchorRetryObserver.disconnect(); - pendingVariantAnchorRetryObserver = null; - } - - // Update state: count variants, preserving the user's current variant - // when a late HMR/source reinjection lands after they have cycled. - const variants = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])'); - arrivedVariants = variants.length; - expectedVariants = parseInt(wrapper.dataset.impeccableVariantCount || arrivedVariants); - if (arrivedVariants <= 0) { - if (state === 'GENERATING') { - // Mid-generation the source legitimately holds a scaffold wrapper - // with no variants yet (the server-side preflight wraps before the - // agent writes). Tearing the session down here would destroy an - // in-flight generation; stay in GENERATING — the variant observer - // is armed and the server re-delivers a missed `done`. - if (!opts.generationCompleted) { - console.log('[impeccable] Source has scaffold but no variants yet; still generating.'); - return; - } - // Generation finished, yet the read shows only the scaffold: the - // source view is stale and no further event will fire. Re-read a - // few times before surfacing recovery — a single silent return - // here would strand the tab in GENERATING forever. - const attempt = opts.attempt || 0; - if (attempt < COMPLETED_SOURCE_FALLBACK_RETRIES) { - console.log('[impeccable] Generation is done but source shows no variants yet; retrying read (' - + (attempt + 1) + '/' + COMPLETED_SOURCE_FALLBACK_RETRIES + ').'); - setTimeout(() => { - if (state !== 'GENERATING' || currentSessionId !== sessionId) return; - if (arrivedVariants > 0) return; - injectVariantsFromSource(filePath, sessionId, { ...opts, attempt: attempt + 1 }); - }, COMPLETED_SOURCE_FALLBACK_RETRY_MS); - return; - } - } - recoverEmptyCycling('source-fallback-empty'); + completeSourceInjection(wrapper, sessionId, { ...opts, filePath }); return; } - const saved = loadSession(); - const savedVisibleVariant = saved && saved.id === sessionId ? saved.visible : 0; - visibleVariant = previousVisibleVariant > 0 && previousVisibleVariant <= arrivedVariants - ? previousVisibleVariant - : (savedVisibleVariant > 0 && savedVisibleVariant <= arrivedVariants ? savedVisibleVariant : 1); - showVariantInDOM(sessionId, visibleVariant); - // Update selectedElement to the visible variant's content - selectedElement = pickVariantContent(wrapper, visibleVariant) || wrapper.parentElement; + const wrapper = srcWrapper.cloneNode(true); + const origContent = srcWrapper.querySelector('[data-impeccable-variant="original"] > :first-child'); + if (!origContent) return; - setLiveState('CYCLING'); - recoveryWaitingForAnchor = false; - hideShaderOverlay(); - showOrUpdateCyclingBar(); - disableInlineEdit(); - refreshParamsPanel(); - positionBar(); - saveSession(); - completeParameterGenerationIfReady(); - console.log('[impeccable] Injected ' + arrivedVariants + ' variants from source file.'); + const liveEl = resolveLiveInjectionAnchor(origContent.outerHTML); + if (!liveEl) { + console.warn('[impeccable] Could not find original element in live DOM.'); + enterRecoveryWaitingForAnchor({ + filePath, + sessionId, + srcWrapper, + checkpointReason: 'variant_anchor_missing', + trackScroll: false, + }); + return; + } + + liveEl.parentElement.replaceChild(wrapper, liveEl); + completeSourceInjection(wrapper, sessionId, { ...opts, filePath }); }) .catch(err => { console.error('[impeccable] Failed to fetch source:', err); @@ -6364,44 +6409,6 @@ }); } - function normalizeSourceFallbackBlock(block, filePath) { - if (!/\.[cm]?[jt]sx$/i.test(String(filePath || ''))) return block; - return String(block) - .replace( - /]*)>\s*\{\s*`([\s\S]*?)`\s*\}\s*<\/style>/g, - (_match, attrs, css) => '' + css + '', - ) - .replace(/\bclassName\s*=\s*\{\s*`([^`]*?)`\s*\}/g, (_match, value) => { - const literalClasses = value.replace(/\$\{[^}]*\}/g, ' ').replace(/\s+/g, ' ').trim(); - return literalClasses ? 'class="' + escapeHtml(literalClasses) + '"' : ''; - }) - .replace(/\bclassName\s*=/g, 'class=') - .replace(/\sstyle=\{\{([\s\S]*?)\}\}/g, (_match, body) => { - const css = jsxStyleObjectToCss(body); - return css ? ' style="' + escapeHtml(css) + '"' : ''; - }); - } - - function jsxStyleObjectToCss(body) { - const declarations = []; - const re = /(["'][^"']+["']|[A-Za-z_$][\w$-]*)\s*:\s*(?:"([^"]*)"|'([^']*)'|(-?\d+(?:\.\d+)?))/g; - let match; - while ((match = re.exec(String(body || '')))) { - const prop = jsxStylePropToCss(match[1]); - const value = match[2] ?? match[3] ?? match[4] ?? ''; - if (!prop || value === '') continue; - declarations.push(prop + ': ' + value); - } - return declarations.join('; '); - } - - function jsxStylePropToCss(prop) { - let out = String(prop || '').trim().replace(/^["']|["']$/g, ''); - if (!out) return ''; - if (out.startsWith('--')) return out; - return out.replace(/[A-Z]/g, (ch) => '-' + ch.toLowerCase()).replace(/^-ms-/, '-ms-'); - } - function buildSvelteExpressionTextMap(sourceOriginal, liveOriginal) { const map = new Map(); if (!sourceOriginal || !liveOriginal) return map; diff --git a/tests/live-browser-source.test.mjs b/tests/live-browser-source.test.mjs index 235bd866d..e93dbff86 100644 --- a/tests/live-browser-source.test.mjs +++ b/tests/live-browser-source.test.mjs @@ -553,37 +553,57 @@ describe('live-browser source contracts', () => { ); }); - it('normalizes generated JSX source before source-fallback DOM parsing', () => { - assert.match( - SOURCE, - /parser\.parseFromString\(normalizeSourceFallbackBlock\(block, filePath\), 'text\/html'\)/, - 'source fallback should normalize JSX wrapper syntax before DOMParser sees it', + it('never DOMParser-injects JSX source (#454)', () => { + const isJsxStart = SOURCE.indexOf('function isJsxSourceFile('); + const isJsxEnd = SOURCE.indexOf('function completeSourceInjection', isJsxStart); + const isJsxSourceFile = new Function( + SOURCE.slice(isJsxStart, isJsxEnd) + '\nreturn isJsxSourceFile;', + )(); + + assert.equal(isJsxSourceFile('src/App.jsx'), true); + assert.equal(isJsxSourceFile('panel/src/Widget.tsx'), true); + assert.equal(isJsxSourceFile('index.html'), false); + assert.equal(isJsxSourceFile('Card.vue'), false); + + const injectStart = SOURCE.indexOf('function injectVariantsFromSource('); + const injectEnd = SOURCE.indexOf('function buildSvelteExpressionTextMap', injectStart); + const injectFn = SOURCE.slice(injectStart, injectEnd); + const jsxGateIdx = injectFn.indexOf('if (isJsxSourceFile(filePath))'); + const htmlFetchIdx = injectFn.indexOf("const url = 'http://localhost:'"); + assert.ok(jsxGateIdx !== -1 && htmlFetchIdx > jsxGateIdx, 'JSX must return before /source fetch'); + const jsxGate = injectFn.slice(jsxGateIdx, htmlFetchIdx); + assert.doesNotMatch( + jsxGate, + /replaceChild/, + 'the JSX gate must not replaceChild a React tree', + ); + assert.doesNotMatch( + jsxGate, + /discardOrphanedSession/, + 'a missing JSX wrap must wait for mount, not discard as an orphan', ); assert.match( - SOURCE, - /function normalizeSourceFallbackBlock\(block, filePath\)[\s\S]*?\]\*\)>\\s\*\\\{\\s\*`\(\[\\s\\S\]\*\?\)`\\s\*\\\}\\s\*<\\\/style>/, - 'source fallback should unwrap JSX style template literals', + jsxGate, + /if \(!liveWrapper\) \{[\s\S]*?showToast\([\s\S]*?return;[\s\S]*?if \(liveWrapper\.dataset\.impeccableMode !== 'insert'\) \{[\s\S]*?recoverEmptyCycling\('source-fallback-empty'\)/, + 'missing wrap waits; empty replace wrap recovers after retries; insert scaffolds stay', + ); + assert.doesNotMatch(SOURCE, /function normalizeSourceFallbackBlock/); + assert.doesNotMatch(SOURCE, /function jsxStyleObjectToCss/); + assert.match( + injectFn, + /parser\.parseFromString\(block, 'text\/html'\)/, + 'HTML fallback should parse the extracted marker block as HTML', ); assert.match( - SOURCE, - /replace\(\/\\bclassName\\s\*=\/g, 'class='\)/, - 'source fallback should translate className back to HTML class attributes', - ); - assert.match( - SOURCE, - /value\.replace\(\/\\\$\\\{\[\^}\]\*\\\}\/g, ' '\)/, - 'source fallback should reduce JSX template className values to literal class tokens', + injectFn, + /const startMark = ''/, + 'HTML fallback should still scan HTML comment markers', ); assert.doesNotMatch( SOURCE, /querySelectorAll\(tag \+ '\\.' \+ cls\.split/, 'source fallback should not construct unsafe selectors from JSX-ish class strings', ); - assert.match( - SOURCE, - /function jsxStyleObjectToCss\(body\)/, - 'source fallback should translate simple JSX style objects such as display:none', - ); }); it('does not source-inject per variant_progress checkpoint (HMR owns mid-generation reconciliation)', () => {