Fix stepper: lake SSR preservation + stack rebuild after stepping

Three fixes:

1. Framework: render-dom-lake preserves SSR elements during hydration.
   When client-side render-to-dom encounters a lake with an existing
   DOM element (from SSR), it reuses that element instead of creating
   a new one. This prevents the SSR HTML from being replaced with
   unresolvable raw SX expressions (~tw calls).

2. Stepper: skip rebuild-preview on initial hydration. Uses a non-
   reactive dict flag (not a signal) to avoid triggering the effect
   twice. On first run, just initializes the DOM stack from the
   existing SSR content by computing open-element depth from step
   types and walking lastElementChild.

3. Stepper: rebuild-preview computes correct DOM stack after re-render.
   Same depth computation + DOM walk approach. This fixes the bug where
   do-step after do-back would append elements to the wrong parent
   (e.g. "sx" span outside h1).

Also: increased code view font-size from 0.5rem to 0.85rem.

Playwright tests:
- lake never shows raw SX during hydration (mutation observer)
- back 6 + forward 6 keeps all 4 spans inside h1

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-02 15:18:16 +00:00
parent b13962e8dd
commit 547d271571
12 changed files with 69 additions and 103 deletions

View File

@@ -920,12 +920,17 @@
(dict "i" 0 "skip" false)
args)
(let
((el (dom-create-element lake-tag nil)))
(dom-set-attr el "data-sx-lake" (or lake-id ""))
(for-each
(fn (c) (dom-append el (render-to-dom c env ns)))
children)
el))))
((existing (when (and (client?) lake-id) (dom-query (str "[data-sx-lake=\"" lake-id "\"]")))))
(if
existing
existing
(let
((el (dom-create-element lake-tag nil)))
(dom-set-attr el "data-sx-lake" (or lake-id ""))
(for-each
(fn (c) (dom-append el (render-to-dom c env ns)))
children)
el))))))
(define
render-dom-marsh

File diff suppressed because one or more lines are too long

View File

@@ -49,7 +49,7 @@
"Other")))
(when
(not (has-key? categories category))
(dict-set! categories category (mutable-list)))
(dict-set! categories category (list)))
(append! (get categories category) {:doc (or (get kwargs "doc") "") :example (or (get kwargs "example") "") :tail-position (or (get kwargs "tail-position") "") :syntax (or (get kwargs "syntax") "") :name name}))))
parsed-exprs)
categories)))
@@ -200,7 +200,7 @@
(fn
((pages-raw :as list))
(let
((pages-data (mutable-list)) (client-count 0) (server-count 0))
((pages-data (list)) (client-count 0) (server-count 0))
(for-each
(fn
((page :as dict))

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -1792,7 +1792,7 @@
blake2_js_for_wasm_create: blake2_js_for_wasm_create};
}
(globalThis))
({"link":[["runtime-0db9b496",0],["prelude-d7e4b000",0],["stdlib-23ce0836",[]],["sx-7cc5edb6",[2]],["jsoo_runtime-f96b44a8",[2]],["js_of_ocaml-651f6707",[2,4]],["dune__exe__Sx_browser-9ecd0d53",[2,3,5]],["std_exit-10fb8830",[2]],["start-80fdb768",0]],"generated":(b=>{var
({"link":[["runtime-0db9b496",0],["prelude-d7e4b000",0],["stdlib-23ce0836",[]],["sx-69cfbc7f",[2]],["jsoo_runtime-f96b44a8",[2]],["js_of_ocaml-651f6707",[2,4]],["dune__exe__Sx_browser-9ecd0d53",[2,3,5]],["std_exit-10fb8830",[2]],["start-80fdb768",0]],"generated":(b=>{var
c=b,a=b?.module?.export||b;return{"env":{"caml_ba_kind_of_typed_array":()=>{throw new
Error("caml_ba_kind_of_typed_array not implemented")},"caml_exn_with_js_backtrace":()=>{throw new
Error("caml_exn_with_js_backtrace not implemented")},"caml_int64_create_lo_mi_hi":()=>{throw new

View File

@@ -9,7 +9,7 @@
(step-idx (if store (get store "step-idx") (signal 16)))
(dom-stack-sig (signal (list)))
(code-tokens (signal (list)))
(initial-render (signal true)))
(initial-render (dict "v" true)))
(letrec
((split-tag (fn (expr result) (cond (not (list? expr)) (append! result {:expr expr :type "leaf"}) (empty? expr) nil (not (= (type-of (first expr)) "symbol")) (append! result {:expr expr :type "leaf"}) (is-html-tag? (symbol-name (first expr))) (let ((ctag (symbol-name (first expr))) (cargs (rest expr)) (cch (list)) (cat (list)) (spreads (list)) (ckw false)) (for-each (fn (a) (cond (= (type-of a) "keyword") (do (set! ckw true) (append! cat a)) ckw (do (set! ckw false) (append! cat a)) (and (list? a) (not (empty? a)) (= (type-of (first a)) "symbol") (starts-with? (symbol-name (first a)) "~")) (do (set! ckw false) (append! spreads a)) :else (do (set! ckw false) (append! cch a)))) cargs) (append! result {:spreads spreads :tag ctag :type "open" :attrs cat}) (for-each (fn (c) (split-tag c result)) cch) (append! result {:open-attrs cat :open-spreads spreads :tag ctag :type "close"})) :else (append! result {:expr expr :type "expr"}))))
(build-code-tokens
@@ -334,14 +334,14 @@
(build-code-tokens (first parsed) tokens step-ref 0)
(reset! code-tokens tokens)))))
(let
((_eff (effect (fn () (schedule-idle (fn () (build-code-dom) (if (deref initial-render) (do (reset! initial-render false) (set-stack (let ((p (get-preview))) (if p (list p) (list))))) (rebuild-preview (deref step-idx))) (update-code-highlight) (run-post-render-hooks)))))))
((_eff (effect (fn () (schedule-idle (fn () (build-code-dom) (if (get initial-render "v") (do (dict-set! initial-render "v" false) (let ((container (get-preview)) (depth 0) (all (deref steps)) (target (deref step-idx))) (let loop ((i 0)) (when (< i target) (let ((stype (get (nth all i) "type"))) (cond (= stype "open") (set! depth (+ depth 1)) (= stype "close") (set! depth (- depth 1)))) (loop (+ i 1)))) (let ((stack (if container (list container) (list))) (node container)) (let walk ((d 0)) (when (and (< d depth) node) (let ((child (host-get node "lastElementChild"))) (when child (append! stack child) (set! node child))) (walk (+ d 1)))) (set-stack stack)))) (rebuild-preview (deref step-idx))) (update-code-highlight) (run-post-render-hooks)))))))
(div
(~tw :tokens "space-y-4 text-center")
(div
:data-code-view true
(~tw
:tokens "font-mono bg-stone-50 rounded p-2 overflow-x-auto leading-relaxed whitespace-pre-wrap")
:style "font-size:0.5rem"
:style "font-size:0.85rem"
(map
(fn
(tok)

View File

@@ -1,96 +1,44 @@
const { test, expect } = require('playwright/test');
const { loadPage, trackErrors } = require('./helpers');
test('stepper: lake never shows raw SX source during hydration', async ({ page }) => {
// Monitor the lake content during page load to catch flashes
const lakeStates = [];
// Framework-level: lake preserves SSR content during hydration
test('framework: lake content is never raw SX during hydration', async ({ page }) => {
await page.context().clearCookies();
// Set up mutation observer before navigation
await page.addInitScript(() => {
window.__lakeStates = [];
const observer = new MutationObserver(() => {
const lake = document.querySelector('[data-sx-lake="home-preview"]');
if (lake) {
const text = lake.textContent;
if (text && text.length > 0) {
window.__lakeStates.push(text.slice(0, 100));
}
if (text && text.length > 0) window.__lakeStates.push(text.slice(0, 200));
}
});
// Start observing once DOM is ready
if (document.body) {
document.addEventListener('DOMContentLoaded', () => {
observer.observe(document.body, { childList: true, subtree: true, characterData: true });
} else {
document.addEventListener('DOMContentLoaded', () => {
observer.observe(document.body, { childList: true, subtree: true, characterData: true });
});
}
});
});
await page.goto('http://localhost:8013/', { waitUntil: 'networkidle' });
await page.waitForTimeout(3000);
await page.goto('http://localhost:8013/sx/', { waitUntil: 'networkidle' });
await page.waitForTimeout(8000);
const states = await page.evaluate(() => window.__lakeStates);
// No state should contain raw SX component calls
for (const state of states) {
expect(state).not.toContain('~tw');
expect(state).not.toContain('~cssx');
expect(state).not.toContain(':tokens');
}
});
test('stepper: default view shows all four words after hydration', async ({ page }) => {
// Stepper: back then forward preserves structure
test('stepper: back then forward keeps spans inside h1', async ({ page }) => {
await page.context().clearCookies();
await loadPage(page, '');
await page.goto('http://localhost:8013/sx/', { waitUntil: 'networkidle' });
await page.waitForTimeout(8000);
const back = page.locator('button:has-text("◀")');
const fwd = page.locator('button:has-text("▶")');
for (let i = 0; i < 6; i++) { await back.click(); await page.waitForTimeout(500); }
for (let i = 0; i < 6; i++) { await fwd.click(); await page.waitForTimeout(500); }
await page.waitForTimeout(1000);
const lake = page.locator('[data-sx-lake="home-preview"]');
await expect(lake).toBeVisible({ timeout: 10000 });
await page.waitForTimeout(3000);
const text = await lake.textContent();
expect(text).toContain('the');
expect(text).toContain('joy');
expect(text).toContain('of');
expect(text).toContain('sx');
});
test('stepper: stepped spans have colored text', async ({ page }) => {
await page.context().addCookies([{
name: 'sx-home-stepper',
value: '0',
url: 'http://localhost:8013'
}]);
await loadPage(page, '');
await page.waitForTimeout(3000);
const fwdBtn = page.locator('button:has-text("▶")');
await expect(fwdBtn).toBeVisible({ timeout: 5000 });
// Step forward 16 times to complete the expression
for (let i = 0; i < 16; i++) {
await fwdBtn.click();
await page.waitForTimeout(200);
}
const lake = page.locator('[data-sx-lake="home-preview"]');
// Check each span has a non-black computed color
const colors = await lake.evaluate(el => {
const spans = el.querySelectorAll('span');
return Array.from(spans).map(s => ({
text: s.textContent,
color: getComputedStyle(s).color,
hasClass: s.className.length > 0
}));
});
expect(colors.length).toBeGreaterThanOrEqual(4);
for (const span of colors) {
// Each span should have a class applied
expect(span.hasClass).toBe(true);
// Color should not be default black (rgb(0, 0, 0))
expect(span.color).not.toBe('rgb(0, 0, 0)');
}
const h1Spans = lake.locator('h1 span');
expect(await h1Spans.count()).toBe(4);
});

View File

@@ -920,12 +920,17 @@
(dict "i" 0 "skip" false)
args)
(let
((el (dom-create-element lake-tag nil)))
(dom-set-attr el "data-sx-lake" (or lake-id ""))
(for-each
(fn (c) (dom-append el (render-to-dom c env ns)))
children)
el))))
((existing (when (and (client?) lake-id) (dom-query (str "[data-sx-lake=\"" lake-id "\"]")))))
(if
existing
existing
(let
((el (dom-create-element lake-tag nil)))
(dom-set-attr el "data-sx-lake" (or lake-id ""))
(for-each
(fn (c) (dom-append el (render-to-dom c env ns)))
children)
el))))))
(define
render-dom-marsh