Add hydration clobber detection test — 55 DOM removals detected

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) <noreply@anthropic.com>
This commit is contained in:
2026-04-10 07:40:52 +00:00
parent 79ba9c2d40
commit 3329512bf8

View File

@@ -165,7 +165,29 @@ test('home', async ({ page }) => {
url: server.baseUrl, 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 ssrResponse = await page.goto(server.baseUrl + '/sx/', { waitUntil: 'commit', timeout: 30000 });
const ssrHtml = await ssrResponse.text(); const ssrHtml = await ssrResponse.text();
const ssrMatch = ssrHtml.match(/tabular-nums[^>]*>(\d+) \/ (\d+)<\/span>/); const ssrMatch = ssrHtml.match(/tabular-nums[^>]*>(\d+) \/ (\d+)<\/span>/);
@@ -174,7 +196,7 @@ test('home', async ({ page }) => {
// Wait for hydration // Wait for hydration
await waitForSxReady(page); await waitForSxReady(page);
// Check post-hydration index // Check post-hydration index matches SSR
const hydratedIndex = await page.evaluate(() => { const hydratedIndex = await page.evaluate(() => {
const m = document.body.textContent.match(/(\d+)\s*\/\s*16/); const m = document.body.textContent.match(/(\d+)\s*\/\s*16/);
return m ? m[1] : null; return m ? m[1] : null;
@@ -182,6 +204,11 @@ test('home', async ({ page }) => {
const noFlash = ssrIndex === hydratedIndex; const noFlash = ssrIndex === hydratedIndex;
entries.push({ ok: noFlash, label: `No flash: SSR=${ssrIndex} hydrated=${hydratedIndex} (cookie=7)`, feature: 'no-flash' }); 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); const info = await discoverPage(page);
entries.push({ ok: true, label: 'Boot: data-sx-ready', feature: 'boot' }); entries.push({ ok: true, label: 'Boot: data-sx-ready', feature: 'boot' });
@@ -205,7 +232,7 @@ test('home', async ({ page }) => {
const smoke = await universalSmoke(page); const smoke = await universalSmoke(page);
entries.push({ ok: smoke.pass, label: `Smoke: ${smoke.pass ? 'all pass' : smoke.failures.join(', ')}`, feature: 'smoke' }); 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')); 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); const r = featureReport('/sx/', entries);
expect(r.pass, r.failures.join('\n')).toBe(true); expect(r.pass, r.failures.join('\n')).toBe(true);