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:
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user