diff --git a/sx/sx/home-stepper.sx b/sx/sx/home-stepper.sx index d24000e8..d8105d0e 100644 --- a/sx/sx/home-stepper.sx +++ b/sx/sx/home-stepper.sx @@ -345,7 +345,7 @@ (build-code-tokens (first parsed) tokens step-ref 0) (reset! code-tokens tokens))))) (let - ((_eff (effect (fn () (schedule-idle (fn () (build-code-dom) (rebuild-preview (deref step-idx)) (update-code-highlight) (run-post-render-hooks))))))) + ((_eff (let ((initial-idx (deref step-idx)) (first-run (signal true))) (effect (fn () (let ((cur (deref step-idx))) (if (and (deref first-run) (= cur initial-idx)) (reset! first-run false) (do (reset! first-run false) (schedule-idle (fn () (build-code-dom) (rebuild-preview cur) (update-code-highlight) (run-post-render-hooks))))))))))) (div (~tw :tokens "space-y-4 text-center") (div diff --git a/tests/playwright/site-full.spec.js b/tests/playwright/site-full.spec.js index fa642caa..1dc00ec7 100644 --- a/tests/playwright/site-full.spec.js +++ b/tests/playwright/site-full.spec.js @@ -167,19 +167,24 @@ test('home', async ({ page }) => { // Inject observer before page JS boots to detect hydration flash. // A flash = the island's visible text content changes during hydration. - // True hydration should preserve SSR DOM — no visible change at all. + // replaceChildren swaps DOM nodes but if text matches, no visible flash. await page.addInitScript(() => { window.__flashDetected = false; window.__flashDetail = null; + window.__allTransitions = []; const check = () => { const stepper = document.querySelector('[data-sx-island="home/stepper"]'); if (!stepper || !stepper.parentNode) return; - const ssrText = stepper.textContent; + let lastText = stepper.textContent; new MutationObserver(() => { const newText = stepper.textContent; - if (newText !== ssrText && !window.__flashDetected) { - window.__flashDetected = true; - window.__flashDetail = { ssr: ssrText.substring(0, 80), hydrated: newText.substring(0, 80) }; + window.__allTransitions.push({ from: lastText.substring(0, 120), to: newText.substring(0, 120) }); + if (newText !== lastText) { + if (!window.__flashDetected) { + window.__flashDetected = true; + window.__flashDetail = { ssr: lastText.substring(0, 80), hydrated: newText.substring(0, 80) }; + } + lastText = newText; } }).observe(stepper, { childList: true, subtree: true, characterData: true }); }; @@ -212,7 +217,22 @@ test('home', async ({ page }) => { detected: window.__flashDetected || false, detail: window.__flashDetail, })); - entries.push({ ok: !flash.detected, label: `No clobber: ${flash.detected ? 'text changed — ssr:"' + (flash.detail?.ssr || '').substring(0, 40) + '" → hydrated:"' + (flash.detail?.hydrated || '').substring(0, 40) + '"' : 'clean'}`, feature: 'no-clobber' }); + // Log all text transitions + const allTransitions = await page.evaluate(() => window.__allTransitions || []); + const realChanges = allTransitions.filter(t => t.from !== t.to); + entries.push({ ok: realChanges.length === 0, label: `No clobber: ${realChanges.length === 0 ? 'clean' : realChanges.length + ' text changes'}`, feature: 'no-clobber' }); + if (realChanges.length > 0) { + for (const t of realChanges.slice(0, 3)) { + // Find first diff + let d = -1; + for (let i = 0; i < Math.max(t.from.length, t.to.length); i++) { + if (t.from[i] !== t.to[i]) { d = i; break; } + } + console.log(` Change: len ${t.from.length}→${t.to.length}, diff@${d}`); + if (d >= 0) console.log(` FROM: "${t.from.substring(Math.max(0,d-10), d+40)}"`); + if (d >= 0) console.log(` TO: "${t.to.substring(Math.max(0,d-10), d+40)}"`); + } + } const info = await discoverPage(page); entries.push({ ok: true, label: 'Boot: data-sx-ready', feature: 'boot' });