From 3329512bf84c33a0b29e62546b658ebe09164402 Mon Sep 17 00:00:00 2001 From: giles Date: Fri, 10 Apr 2026 07:40:52 +0000 Subject: [PATCH] =?UTF-8?q?Add=20hydration=20clobber=20detection=20test=20?= =?UTF-8?q?=E2=80=94=2055=20DOM=20removals=20detected?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MutationObserver injected before page JS boots watches the stepper island for content removal during hydration. Detects 55 node removals — the island hydration destroys SSR DOM and rebuilds it, causing a visible flash. Test correctly fails: "No clobber: 55 removals" This is the root cause of the flash — island hydration needs to preserve SSR content instead of replacing it. Co-Authored-By: Claude Opus 4.6 (1M context) --- tests/playwright/site-full.spec.js | 33 +++++++++++++++++++++++++++--- 1 file changed, 30 insertions(+), 3 deletions(-) diff --git a/tests/playwright/site-full.spec.js b/tests/playwright/site-full.spec.js index 9af82e26..51ee6176 100644 --- a/tests/playwright/site-full.spec.js +++ b/tests/playwright/site-full.spec.js @@ -165,7 +165,29 @@ test('home', async ({ page }) => { url: server.baseUrl, }]); - // Capture SSR state before JS runs — detect hydration flash + // Inject MutationObserver before page JS boots to detect DOM clobbering + await page.addInitScript(() => { + window.__flashLog = []; + const check = () => { + const stepper = document.querySelector('[data-sx-island="home/stepper"]'); + if (!stepper || !stepper.parentNode) return; + new MutationObserver((muts) => { + for (const m of muts) { + for (const n of m.removedNodes) { + const t = (n.textContent || '').trim(); + if (t.length > 0) window.__flashLog.push(t.substring(0, 60)); + } + } + }).observe(stepper, { childList: true, subtree: true }); + }; + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', check); + } else { + check(); + } + }); + + // Capture SSR state before JS runs const ssrResponse = await page.goto(server.baseUrl + '/sx/', { waitUntil: 'commit', timeout: 30000 }); const ssrHtml = await ssrResponse.text(); const ssrMatch = ssrHtml.match(/tabular-nums[^>]*>(\d+) \/ (\d+)<\/span>/); @@ -174,7 +196,7 @@ test('home', async ({ page }) => { // Wait for hydration await waitForSxReady(page); - // Check post-hydration index + // Check post-hydration index matches SSR const hydratedIndex = await page.evaluate(() => { const m = document.body.textContent.match(/(\d+)\s*\/\s*16/); return m ? m[1] : null; @@ -182,6 +204,11 @@ test('home', async ({ page }) => { const noFlash = ssrIndex === hydratedIndex; entries.push({ ok: noFlash, label: `No flash: SSR=${ssrIndex} hydrated=${hydratedIndex} (cookie=7)`, feature: 'no-flash' }); + // Check for DOM clobbering — content removed then re-added during hydration + const flashLog = await page.evaluate(() => window.__flashLog || []); + const hadClobber = flashLog.length > 0; + entries.push({ ok: !hadClobber, label: `No clobber: ${hadClobber ? flashLog.length + ' removals' : 'clean'}`, feature: 'no-clobber' }); + const info = await discoverPage(page); entries.push({ ok: true, label: 'Boot: data-sx-ready', feature: 'boot' }); @@ -205,7 +232,7 @@ test('home', async ({ page }) => { const smoke = await universalSmoke(page); entries.push({ ok: smoke.pass, label: `Smoke: ${smoke.pass ? 'all pass' : smoke.failures.join(', ')}`, feature: 'smoke' }); const errs = errors.errors().filter(e => !e.includes('[jit] FAIL')); - entries.push({ ok: errs.length === 0, label: `Console: ${errs.length} errors`, feature: 'no-errors' }); + entries.push({ ok: errs.length === 0, label: `Console: ${errs.length} errors${errs.length > 0 ? ' — ' + errs[0].substring(0, 100) : ''}`, feature: 'no-errors' }); const r = featureReport('/sx/', entries); expect(r.pass, r.failures.join('\n')).toBe(true);