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:
@@ -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
@@ -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
Binary file not shown.
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user