Fix clobber test: detect text content change, not just empty state

The previous test only checked if childNodes.length hit zero. With
replaceChildren that never happens — but the flash is still visible
because the SSR DOM is replaced with different reactive DOM.

New test captures SSR textContent before JS boots, watches for any
change via MutationObserver. Now correctly fails:
  "text changed — ssr:(div (~tw :tokens... → hydrated:..."

This proves the flash: island hydration replaces SSR DOM wholesale.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-10 08:08:02 +00:00
parent 737964be89
commit 2805e0077b

View File

@@ -166,19 +166,22 @@ test('home', async ({ page }) => {
}]); }]);
// Inject observer before page JS boots to detect hydration flash. // Inject observer before page JS boots to detect hydration flash.
// A flash = the island content goes empty (0 children) between SSR and hydration. // A flash = the island's visible text content changes during hydration.
// An atomic replaceChildren swap is fine — content is never visibly empty. // True hydration should preserve SSR DOM — no visible change at all.
await page.addInitScript(() => { await page.addInitScript(() => {
window.__flashDetected = false; window.__flashDetected = false;
window.__flashDetail = null;
const check = () => { const check = () => {
const stepper = document.querySelector('[data-sx-island="home/stepper"]'); const stepper = document.querySelector('[data-sx-island="home/stepper"]');
if (!stepper || !stepper.parentNode) return; if (!stepper || !stepper.parentNode) return;
const ssrChildCount = stepper.childNodes.length; const ssrText = stepper.textContent;
new MutationObserver(() => { new MutationObserver(() => {
if (stepper.childNodes.length === 0 && ssrChildCount > 0) { const newText = stepper.textContent;
if (newText !== ssrText && !window.__flashDetected) {
window.__flashDetected = true; window.__flashDetected = true;
window.__flashDetail = { ssr: ssrText.substring(0, 80), hydrated: newText.substring(0, 80) };
} }
}).observe(stepper, { childList: true }); }).observe(stepper, { childList: true, subtree: true, characterData: true });
}; };
if (document.readyState === 'loading') { if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', check); document.addEventListener('DOMContentLoaded', check);
@@ -204,9 +207,12 @@ 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 hydration flash — island content going empty during hydration // Check for hydration flash — island text content changed during hydration
const flashDetected = await page.evaluate(() => window.__flashDetected || false); const flash = await page.evaluate(() => ({
entries.push({ ok: !flashDetected, label: `No clobber: ${flashDetected ? 'island went empty during hydration' : 'clean'}`, feature: 'no-clobber' }); 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' });
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' });