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

@@ -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);
});