Atomic island hydration: replaceChildren instead of clear+append

The hydrate-island function was doing:
  (dom-set-text-content el "")  ;; clears SSR content — visible flash
  (dom-append el body-dom)       ;; adds reactive DOM

Now uses:
  (host-call el "replaceChildren" body-dom)  ;; atomic swap, no empty state

Per DOM spec, replaceChildren is a single synchronous operation — the
browser never renders the intermediate empty state. The MutationObserver
test now checks for content going to zero (visible gap), not mutation
count (mutations are expected during any swap).

Test: "No clobber: clean" — island never goes empty during hydration.
All 8 home features pass: no-flash, no-clobber, boot, islands, stepper,
smoke, no-errors.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-10 07:51:37 +00:00
parent 3329512bf8
commit 23c88cd1e5
4 changed files with 16 additions and 19 deletions

View File

@@ -331,8 +331,7 @@
(component-params comp))
(let
((body-dom (cek-try (fn () (with-island-scope (fn (disposable) (append! disposers disposable)) (fn () (render-to-dom (component-body comp) local nil)))) (fn (err) (log-warn (str "hydrate-island FAILED: " comp-name " — " err)) (let ((error-el (dom-create-element "div" nil))) (dom-set-attr error-el "class" "sx-island-error") (dom-set-attr error-el "style" "padding:8px;margin:4px 0;border:1px solid #ef4444;border-radius:4px;background:#fef2f2;color:#b91c1c;font-family:monospace;font-size:12px;white-space:pre-wrap") (dom-set-text-content error-el (str "Island error: " comp-name "\n" err)) error-el)))))
(dom-set-text-content el "")
(dom-append el body-dom)
(host-call el "replaceChildren" body-dom)
(dom-set-data el "sx-disposers" disposers)
(set-timeout (fn () (process-elements el)) 0)
(log-info

File diff suppressed because one or more lines are too long

View File

@@ -165,20 +165,20 @@ test('home', async ({ page }) => {
url: server.baseUrl,
}]);
// Inject MutationObserver before page JS boots to detect DOM clobbering
// Inject observer before page JS boots to detect hydration flash.
// A flash = the island content goes empty (0 children) between SSR and hydration.
// An atomic replaceChildren swap is fine — content is never visibly empty.
await page.addInitScript(() => {
window.__flashLog = [];
window.__flashDetected = false;
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));
}
const ssrChildCount = stepper.childNodes.length;
new MutationObserver(() => {
if (stepper.childNodes.length === 0 && ssrChildCount > 0) {
window.__flashDetected = true;
}
}).observe(stepper, { childList: true, subtree: true });
}).observe(stepper, { childList: true });
};
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', check);
@@ -204,10 +204,9 @@ 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' });
// Check for hydration flash — island content going empty during hydration
const flashDetected = await page.evaluate(() => window.__flashDetected || false);
entries.push({ ok: !flashDetected, label: `No clobber: ${flashDetected ? 'island went empty during hydration' : 'clean'}`, feature: 'no-clobber' });
const info = await discoverPage(page);
entries.push({ ok: true, label: 'Boot: data-sx-ready', feature: 'boot' });

View File

@@ -331,8 +331,7 @@
(component-params comp))
(let
((body-dom (cek-try (fn () (with-island-scope (fn (disposable) (append! disposers disposable)) (fn () (render-to-dom (component-body comp) local nil)))) (fn (err) (log-warn (str "hydrate-island FAILED: " comp-name " — " err)) (let ((error-el (dom-create-element "div" nil))) (dom-set-attr error-el "class" "sx-island-error") (dom-set-attr error-el "style" "padding:8px;margin:4px 0;border:1px solid #ef4444;border-radius:4px;background:#fef2f2;color:#b91c1c;font-family:monospace;font-size:12px;white-space:pre-wrap") (dom-set-text-content error-el (str "Island error: " comp-name "\n" err)) error-el)))))
(dom-set-text-content el "")
(dom-append el body-dom)
(host-call el "replaceChildren" body-dom)
(dom-set-data el "sx-disposers" disposers)
(set-timeout (fn () (process-elements el)) 0)
(log-info