Fix stepper hydration flash and stepping structure

Two bugs fixed:

1. Hydration flash: The effect's schedule-idle called rebuild-preview
   on initial hydration, which cleared the SSR HTML and re-rendered
   (flashing raw SX source or blank). Fix: skip rebuild-preview on
   initial render — the SSR content is already correct. Only rebuild
   when stepping.

2. Stepping structure: After do-back calls rebuild-preview, the DOM
   stack was reset to just [container]. Subsequent do-step calls
   appended elements to the wrong parent (e.g. "sx" span outside h1).
   Fix: compute the correct stack depth by replaying open/close step
   counts, then walk the rendered DOM tree that many levels deep via
   lastElementChild to find the actual DOM nodes.

Proven by harness test: compute-depth returns 3 at step 10 (inside
div > h1 > span), 2 at step 8 (inside div > h1), 0 at step 16 (all
closed).

Playwright test: lake never shows raw SX (~tw, :tokens) during
hydration — now passes.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-02 14:42:40 +00:00
parent 9a64f13dc6
commit b13962e8dd
2 changed files with 91 additions and 46 deletions

View File

@@ -1,58 +1,62 @@
const { test, expect } = require('playwright/test');
const { loadPage, trackErrors } = require('./helpers');
test('stepper: no raw SX component calls visible after hydration', async ({ page }) => {
const t = trackErrors(page);
await loadPage(page, '');
test('stepper: lake never shows raw SX source during hydration', async ({ page }) => {
// Monitor the lake content during page load to catch flashes
const lakeStates = [];
await page.context().clearCookies();
const stepper = page.locator('[data-sx-island="home/stepper"]');
await expect(stepper).toBeVisible({ timeout: 10000 });
// 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));
}
}
});
// Start observing once DOM is ready
if (document.body) {
observer.observe(document.body, { childList: true, subtree: true, characterData: true });
} else {
document.addEventListener('DOMContentLoaded', () => {
observer.observe(document.body, { childList: true, subtree: true, characterData: true });
});
}
});
const lake = stepper.locator('[data-sx-lake]');
await expect(lake).toBeVisible({ timeout: 5000 });
const lakeText = await lake.textContent();
expect(lakeText).not.toContain('~cssx/tw');
expect(lakeText).not.toContain('~tw');
expect(lakeText).not.toContain(':tokens');
await page.goto('http://localhost:8013/', { waitUntil: 'networkidle' });
await page.waitForTimeout(3000);
expect(t.errors()).toEqual([]);
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', async ({ page }) => {
// Clear stepper cookie
test('stepper: default view shows all four words after hydration', async ({ page }) => {
await page.context().clearCookies();
await loadPage(page, '');
const lake = page.locator('[data-sx-lake="home-preview"]');
await expect(lake).toBeVisible({ timeout: 10000 });
// Wait for hydration
await page.waitForTimeout(2000);
await page.waitForTimeout(3000);
const text = await lake.textContent();
// All four words should be present after hydration
expect(text).toContain('the');
expect(text).toContain('joy');
expect(text).toContain('of');
expect(text).toContain('sx');
});
test('stepper: all spans inside h1 with correct structure', async ({ page }) => {
await page.context().clearCookies();
await loadPage(page, '');
await page.waitForTimeout(3000);
const lake = page.locator('[data-sx-lake="home-preview"]');
const h1 = lake.locator('h1');
await expect(h1).toBeVisible({ timeout: 5000 });
// All colored spans should be inside the h1
const spans = h1.locator('span');
const count = await spans.count();
expect(count).toBeGreaterThanOrEqual(4);
});
test('stepper: stepping forward renders styled text', async ({ page }) => {
// Start from step 0
test('stepper: stepped spans have colored text', async ({ page }) => {
await page.context().addCookies([{
name: 'sx-home-stepper',
value: '0',
@@ -64,18 +68,29 @@ test('stepper: stepping forward renders styled text', async ({ page }) => {
const fwdBtn = page.locator('button:has-text("▶")');
await expect(fwdBtn).toBeVisible({ timeout: 5000 });
// Step forward 10 times to get through "of"
for (let i = 0; i < 10; i++) {
// Step forward 16 times to complete the expression
for (let i = 0; i < 16; i++) {
await fwdBtn.click();
await page.waitForTimeout(300);
await page.waitForTimeout(200);
}
const lake = page.locator('[data-sx-lake="home-preview"]');
const text = await lake.textContent();
expect(text).toContain('of');
// The "of" text should be in a styled span (with sx- or data-tw class)
const styledSpan = lake.locator('span[data-tw]').filter({ hasText: 'of' });
const count = await styledSpan.count();
expect(count).toBeGreaterThan(0);
// 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)');
}
});