From 732d733eac897770ff67ec23d4596960dcd6a3be Mon Sep 17 00:00:00 2001 From: giles Date: Tue, 24 Mar 2026 17:36:51 +0000 Subject: [PATCH] Fix island reactivity lost on client-side navigation; add Playwright tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When morphing DOM after server fetch, the morph engine reuses elements with the same tag. If old element was island A and new is island B, syncAttrs updates data-sx-island but the JS property _sxBoundisland-hydrated persists on the reused element. sx-hydrate-islands then skips it. Fix: in morphNode, when data-sx-island attribute changes between old and new elements, dispose the old island's signals and clear the hydration flag so the new island gets properly hydrated. New Playwright tests: - counter → temperature navigation: temperature signals work - temperature → counter navigation: counter signals work - Direct load verification for both islands - No JS errors during navigation Co-Authored-By: Claude Opus 4.6 (1M context) --- shared/static/scripts/sx-browser.js | 4 +- tests/playwright/reactive-nav.spec.js | 142 ++++++++++++++++++++++++++ web/engine.sx | 9 ++ 3 files changed, 153 insertions(+), 2 deletions(-) create mode 100644 tests/playwright/reactive-nav.spec.js diff --git a/shared/static/scripts/sx-browser.js b/shared/static/scripts/sx-browser.js index 2c23d11..044deb9 100644 --- a/shared/static/scripts/sx-browser.js +++ b/shared/static/scripts/sx-browser.js @@ -14,7 +14,7 @@ // ========================================================================= var NIL = Object.freeze({ _nil: true, toString: function() { return "nil"; } }); - var SX_VERSION = "2026-03-24T15:37:52Z"; + var SX_VERSION = "2026-03-24T17:35:05Z"; function isNil(x) { return x === NIL || x === null || x === undefined; } function isSxTruthy(x) { return x !== false && !isNil(x); } @@ -3925,7 +3925,7 @@ PRIMITIVES["revert-optimistic"] = revertOptimistic; PRIMITIVES["find-oob-swaps"] = findOobSwaps; // morph-node - var morphNode = function(oldNode, newNode) { return (isSxTruthy(sxOr(domHasAttr(oldNode, "sx-preserve"), domHasAttr(oldNode, "sx-ignore"))) ? NIL : (isSxTruthy((isSxTruthy(domHasAttr(oldNode, "data-sx-island")) && isSxTruthy(isProcessed(oldNode, "island-hydrated")) && isSxTruthy(domHasAttr(newNode, "data-sx-island")) && (domGetAttr(oldNode, "data-sx-island") == domGetAttr(newNode, "data-sx-island")))) ? morphIslandChildren(oldNode, newNode) : (isSxTruthy(sxOr(!isSxTruthy((domNodeType(oldNode) == domNodeType(newNode))), !isSxTruthy((domNodeName(oldNode) == domNodeName(newNode))))) ? domReplaceChild(domParent(oldNode), domClone(newNode), oldNode) : (isSxTruthy(sxOr((domNodeType(oldNode) == 3), (domNodeType(oldNode) == 8))) ? (isSxTruthy(!isSxTruthy((domTextContent(oldNode) == domTextContent(newNode)))) ? domSetTextContent(oldNode, domTextContent(newNode)) : NIL) : (isSxTruthy((domNodeType(oldNode) == 1)) ? (syncAttrs(oldNode, newNode), (isSxTruthy(!isSxTruthy((isSxTruthy(domIsActiveElement(oldNode)) && domIsInputElement(oldNode)))) ? morphChildren(oldNode, newNode) : NIL)) : NIL))))); }; + var morphNode = function(oldNode, newNode) { return (isSxTruthy(sxOr(domHasAttr(oldNode, "sx-preserve"), domHasAttr(oldNode, "sx-ignore"))) ? NIL : (isSxTruthy((isSxTruthy(domHasAttr(oldNode, "data-sx-island")) && isSxTruthy(isProcessed(oldNode, "island-hydrated")) && isSxTruthy(domHasAttr(newNode, "data-sx-island")) && (domGetAttr(oldNode, "data-sx-island") == domGetAttr(newNode, "data-sx-island")))) ? morphIslandChildren(oldNode, newNode) : (isSxTruthy(sxOr(!isSxTruthy((domNodeType(oldNode) == domNodeType(newNode))), !isSxTruthy((domNodeName(oldNode) == domNodeName(newNode))))) ? domReplaceChild(domParent(oldNode), domClone(newNode), oldNode) : (isSxTruthy(sxOr((domNodeType(oldNode) == 3), (domNodeType(oldNode) == 8))) ? (isSxTruthy(!isSxTruthy((domTextContent(oldNode) == domTextContent(newNode)))) ? domSetTextContent(oldNode, domTextContent(newNode)) : NIL) : (isSxTruthy((domNodeType(oldNode) == 1)) ? ((isSxTruthy((isSxTruthy(domHasAttr(oldNode, "data-sx-island")) && isSxTruthy(domHasAttr(newNode, "data-sx-island")) && !isSxTruthy((domGetAttr(oldNode, "data-sx-island") == domGetAttr(newNode, "data-sx-island"))))) ? (disposeIslandsIn(oldNode), clearProcessed(oldNode, "island-hydrated")) : NIL), syncAttrs(oldNode, newNode), (isSxTruthy(!isSxTruthy((isSxTruthy(domIsActiveElement(oldNode)) && domIsInputElement(oldNode)))) ? morphChildren(oldNode, newNode) : NIL)) : NIL))))); }; PRIMITIVES["morph-node"] = morphNode; // sync-attrs diff --git a/tests/playwright/reactive-nav.spec.js b/tests/playwright/reactive-nav.spec.js new file mode 100644 index 0000000..5597134 --- /dev/null +++ b/tests/playwright/reactive-nav.spec.js @@ -0,0 +1,142 @@ +// @ts-check +const { test, expect } = require('playwright/test'); +const BASE_URL = process.env.SX_TEST_URL || 'http://localhost:8013'; + +test.describe('Reactive Island Navigation', () => { + + test('counter island works on direct load', async ({ page }) => { + await page.goto(BASE_URL + '/sx/(geography.(reactive.(examples.counter)))', { waitUntil: 'networkidle' }); + await page.waitForTimeout(2000); + + const island = page.locator('[data-sx-island*="counter"]'); + await expect(island).toBeVisible({ timeout: 10000 }); + + const buttons = island.locator('button'); + await expect(buttons).toHaveCount(2, { timeout: 5000 }); + + const textBefore = await island.textContent(); + await buttons.last().click(); + await page.waitForTimeout(500); + const textAfter = await island.textContent(); + expect(textAfter).not.toBe(textBefore); + }); + + test('temperature island works on direct load', async ({ page }) => { + await page.goto(BASE_URL + '/sx/(geography.(reactive.(examples.temperature)))', { waitUntil: 'networkidle' }); + await page.waitForTimeout(2000); + + const island = page.locator('[data-sx-island*="temperature"]'); + await expect(island).toBeVisible({ timeout: 10000 }); + + // Temperature demo should have an input or interactive element + const inputs = island.locator('input'); + const buttons = island.locator('button'); + const interactive = (await inputs.count()) + (await buttons.count()); + expect(interactive).toBeGreaterThan(0); + }); + + test('counter → temperature navigation: temperature island is reactive', async ({ page }) => { + // Step 1: Load counter page directly (full page load) + await page.goto(BASE_URL + '/sx/(geography.(reactive.(examples.counter)))', { waitUntil: 'networkidle' }); + await page.waitForTimeout(2000); + + // Verify counter works + const counter = page.locator('[data-sx-island*="counter"]'); + await expect(counter).toBeVisible({ timeout: 10000 }); + + // Step 2: Navigate to temperature via client-side link + const tempLink = page.locator('a[href*="temperature"]').first(); + if (await tempLink.count() === 0) { + // No link found — try sidebar or nav + const anyTempLink = page.locator('a').filter({ hasText: /temperature/i }).first(); + if (await anyTempLink.count() > 0) { + await anyTempLink.click(); + } else { + // Fall back to evaluating navigation + await page.evaluate((url) => { + const a = document.querySelector('a[href*="temperature"]'); + if (a) a.click(); + else window.location.href = url; + }, '/sx/(geography.(reactive.(examples.temperature)))'); + } + } else { + await tempLink.click(); + } + + await page.waitForTimeout(3000); + + // Step 3: Temperature island should exist and be reactive + const tempIsland = page.locator('[data-sx-island*="temperature"]'); + await expect(tempIsland).toBeVisible({ timeout: 10000 }); + + // Step 4: Interact and verify reactivity + const inputs = tempIsland.locator('input'); + if (await inputs.count() > 0) { + const input = inputs.first(); + const textBefore = await tempIsland.textContent(); + await input.fill('100'); + await input.press('Enter'); + await page.waitForTimeout(500); + const textAfter = await tempIsland.textContent(); + expect(textAfter).not.toBe(textBefore); + } else { + const buttons = tempIsland.locator('button'); + if (await buttons.count() > 0) { + const textBefore = await tempIsland.textContent(); + await buttons.first().click(); + await page.waitForTimeout(500); + const textAfter = await tempIsland.textContent(); + expect(textAfter).not.toBe(textBefore); + } + } + }); + + test('temperature → counter navigation: counter island is reactive', async ({ page }) => { + // Start on temperature + await page.goto(BASE_URL + '/sx/(geography.(reactive.(examples.temperature)))', { waitUntil: 'networkidle' }); + await page.waitForTimeout(2000); + + // Navigate to counter + const counterLink = page.locator('a[href*="counter"]').first(); + if (await counterLink.count() > 0) { + await counterLink.click(); + } else { + await page.evaluate(() => { + const a = document.querySelector('a[href*="counter"]'); + if (a) a.click(); + }); + } + await page.waitForTimeout(3000); + + // Counter island should be reactive + const counter = page.locator('[data-sx-island*="counter"]'); + await expect(counter).toBeVisible({ timeout: 10000 }); + + const buttons = counter.locator('button'); + await expect(buttons).toHaveCount(2, { timeout: 5000 }); + + const textBefore = await counter.textContent(); + await buttons.last().click(); + await page.waitForTimeout(500); + const textAfter = await counter.textContent(); + expect(textAfter).not.toBe(textBefore); + }); + + test('no JS errors during reactive navigation', async ({ page }) => { + const errors = []; + page.on('pageerror', err => errors.push(err.message)); + + await page.goto(BASE_URL + '/sx/(geography.(reactive.(examples.counter)))', { waitUntil: 'networkidle' }); + await page.waitForTimeout(2000); + + // Navigate to temperature + const link = page.locator('a[href*="temperature"]').first(); + if (await link.count() > 0) await link.click(); + await page.waitForTimeout(3000); + + const real_errors = errors.filter(e => + !e.includes('Failed to fetch') && !e.includes('net::ERR') + ); + expect(real_errors).toEqual([]); + }); +}); diff --git a/web/engine.sx b/web/engine.sx index feab773..9fe05cb 100644 --- a/web/engine.sx +++ b/web/engine.sx @@ -368,6 +368,15 @@ ;; Element nodes → sync attributes, then recurse children (= (dom-node-type old-node) 1) (do + ;; If the island name changed, clear hydration flag so the new + ;; island gets re-hydrated after morph. The DOM element is reused + ;; (same tag) but the island content is completely different. + (when (and (dom-has-attr? old-node "data-sx-island") + (dom-has-attr? new-node "data-sx-island") + (not (= (dom-get-attr old-node "data-sx-island") + (dom-get-attr new-node "data-sx-island")))) + (dispose-islands-in old-node) + (clear-processed! old-node "island-hydrated")) (sync-attrs old-node new-node) ;; Skip morphing focused input to preserve user's in-progress edits (when (not (and (dom-is-active-element? old-node)