Coherent.js v1.0 — Wave 4d: Hydration & Event-Survival E2E Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Spec: docs/superpowers/specs/2026-05-17-coherent-v1-hardening-design.md — Section 5, Playwright E2E sub-block, the two flows deferred from Wave 4b: (a) SSR/hydration mismatch detection and (b) event survival across DOM patches.
Goal: Close out the spec's six-item E2E checklist by adding the two remaining browser-level tests, exercising the real hydrate() from @coherent.js/client end-to-end (not just unit-tested with mocked DOM). Result: the v1 audit's "human verification" items from spec Section 5 are 100% automated; nothing carries over to RC.
Architecture: A second tiny fixture under e2e/fixtures/hmr-hydrate/ whose HTML is intentionally written as if it were SSR-rendered output (Coherent-shaped DOM with hydration-ready structure). Its src/app.js imports hydrate from the published client bundle, defines a small counter component, and exposes the mismatch list and click-counter state on window.__coherent_e2e so Playwright can assert them. Two new tests in e2e/tests/hydrate.spec.js:
- Mismatch detection: serve SSR HTML whose text disagrees with the component output; assert the
onMismatchcallback fires with a divergence entry the test can match. - Event survival: hydrate a button + click handler, click once, assert state update + DOM patch happened, click again, assert the handler still fires (proving registration survived
patchDOM).
Tech Stack: No new deps. Reuses Wave 4b's Playwright config, bootFixture helper, and awaitWsFrame pattern from e2e/tests/hmr.spec.js. The new fixture follows the same workspace-symlink trick as hmr-basic so the served /node_modules/@coherent.js/client/dist/index.js resolves.
Wave 4d explicitly NOT in scope
- Server-side rendering integration. The "SSR" HTML in the mismatch fixture is hand-written to look like SSR output. We do NOT invoke the framework's actual SSR pipeline (which lives in
@coherent.js/core's render functions) — that would introduce a build step and double the fixture complexity. The mismatch test cares about "does the client detect a divergence between served DOM and component output," which doesn't require real SSR. - Scroll preservation / form input preservation E2E tests. Wave 4a's HMR cycle handles these via the
state-capturermodule; Wave 4b's "component update" test indirectly exercises the broadcast → re-import path. Dedicated scroll/form preservation tests are valuable but separate concerns — they test HMR-with-state, not hydration. Defer to a future polish wave or skip if the existing unit tests inpackages/client/test/state-capturer.test.jsare deemed sufficient. - Hydration of nested/list components. The fixture uses a single-element component for clarity. Nested/list reconciliation has its own unit tests (
vdom-diffing.test.js,dom-state-management.test.js); covering them at the E2E level would mostly re-prove what unit tests already prove. strict: truemode of mismatch detection. That throws instead of warning; testing it would just verify error propagation, which is uninteresting. Default (warn) mode is the one users hit.hydrateAll/hydrateBySelectoretc. Those were the legacy APIs removed in Wave 1. Onlyhydrate()exists in 1.0.
What we ARE building
- New fixture
e2e/fixtures/hmr-hydrate/— HTML with SSR-shaped output, JS that hydrates, plus awindow.__coherent_e2etest-only surface for Playwright assertions. Registered inpnpm-workspace.yamlso the workspace dep resolves. - Two new Playwright tests in
e2e/tests/hydrate.spec.js:- Mismatch detection — assert
onMismatchfires with a non-empty list and at least one entry mentioning the divergent path. - Event survival — assert click → DOM update → click → DOM update again, proving handler registration survives the patch.
- Mismatch detection — assert
- CHANGELOG entry.
That's it. Three commits including the plan.
File Structure
| Path | Change | Responsibility |
|---|---|---|
pnpm-workspace.yaml |
(Already covers e2e/fixtures/* from Wave 4b — no edit needed) |
— |
e2e/fixtures/hmr-hydrate/package.json |
Create | Mirror of hmr-basic's — workspace dep on @coherent.js/client, "type":"module". |
e2e/fixtures/hmr-hydrate/.gitignore |
Create | Ignore node_modules/. |
e2e/fixtures/hmr-hydrate/index.html |
Create | SSR-shaped HTML with a counter button + a version paragraph. Visually trivial; the test-bearing structure is in the JS. |
e2e/fixtures/hmr-hydrate/src/app.js |
Create | Imports hydrate from the served /node_modules/@coherent.js/client/dist/index.js. Two mount cases gated on location.search: ?mode=mismatch (component disagrees with SSR text) and ?mode=event (counter with click handler). Both expose state on window.__coherent_e2e. |
e2e/tests/hydrate.spec.js |
Create | Two Playwright tests using the existing bootFixture() helper. |
CHANGELOG.md |
Modify | Wave 4d entry — closes the two deferred audit flows from Wave 4b's Notes. |
Why one fixture with ?mode= query, not two fixtures
Each fixture costs setup (package.json, gitignore, helper symlink, workspace registration). Two test cases that share infrastructure benefit from sharing a fixture, with the JS branching on a query parameter. Reads naturally: bootFixture('hmr-hydrate') then page.goto(${baseURL}/?mode=mismatch). No code duplication, no extra workspace package.
Pre-flight
- Step 1: Confirm clean working tree
Run: git status
Expected: pre-existing dirty noise only.
- Step 2: Confirm prior wave gates are still green
Run:
pnpm test && pnpm run e2e && node scripts/check-api-surface.mjs --check && node scripts/check-bundle-size.mjs --checkExpected: green. 4 Playwright tests pass.
- Step 3: Confirm
hydrateis exported from@coherent.js/client/dist/index.js
Run:
node -e "import('./packages/client/dist/index.js').then(m => console.log('hydrate:', typeof m.hydrate, '/ hmrClient:', typeof m.hmrClient))"Expected: hydrate: function / hmrClient: object. Both must export — hydrate for this wave's tests, hmrClient from the HMR bootstrap injected by the dev server.
Task 1: Add the hmr-hydrate fixture
Files:
- Create:
e2e/fixtures/hmr-hydrate/package.json - Create:
e2e/fixtures/hmr-hydrate/.gitignore - Create:
e2e/fixtures/hmr-hydrate/index.html - Create:
e2e/fixtures/hmr-hydrate/src/app.js
Step 1: Create package.json
Create e2e/fixtures/hmr-hydrate/package.json:
{
"name": "@coherent.js/e2e-fixture-hmr-hydrate",
"version": "0.0.0",
"private": true,
"type": "module",
"dependencies": {
"@coherent.js/client": "workspace:*"
}
}Step 2: Create .gitignore
Create e2e/fixtures/hmr-hydrate/.gitignore:
node_modules/Step 3: Create index.html
The HTML is intentionally written as if a Coherent.js SSR pipeline had emitted it — a <div id="app"> wrapping the to-be-hydrated content. For the mismatch test, the static text on the page deliberately says v1; the component will say v2.
Create e2e/fixtures/hmr-hydrate/index.html:
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>HMR Hydrate Fixture</title>
</head>
<body>
<h1>HMR Hydrate Fixture</h1>
<div id="app">
<div>
<button id="inc" type="button">count is 0</button>
<p id="version">v1</p>
</div>
</div>
<script type="module" src="/src/app.js"></script>
</body>
</html>The structure is shared by both ?mode= paths; the component decides what to render against it.
Step 4: Create src/app.js
The JS dispatches on location.search. For each mode it creates a component, hydrates against #app, and exposes test-observable state on window.__coherent_e2e. Both modes use the published client's hydrate function.
Create e2e/fixtures/hmr-hydrate/src/app.js:
// Wave 4d hydrate fixture. Two modes gated on ?mode=:
// - mismatch: component output deliberately disagrees with the
// SSR-shaped HTML so the framework's mismatch detector fires.
// - event: component has a click handler that bumps state and
// re-renders, exercising the patchDOM + handler-survival path.
// Both modes expose results on window.__coherent_e2e for Playwright.
import { hydrate } from '/node_modules/@coherent.js/client/dist/index.js';
const container = document.getElementById('app');
const params = new URLSearchParams(location.search);
const mode = params.get('mode');
window.__coherent_e2e = {
mode,
mismatches: [],
state: null,
clickCount: 0,
};
if (mode === 'mismatch') {
// Component output says version "v2"; SSR HTML says "v1". Same
// structural shape, divergent text → mismatch detector should fire.
const Component = () => ({
div: {
children: [
{ button: { id: 'inc', type: 'button', text: 'count is 0' } },
{ p: { id: 'version', text: 'v2' } },
],
},
});
hydrate(Component, container, {
onMismatch: (mismatches) => {
window.__coherent_e2e.mismatches.push(...mismatches);
},
});
} else if (mode === 'event') {
// Counter with a click handler. Each click bumps state, which
// triggers patchDOM (changing the button's text), which then
// re-registers handlers. The test clicks twice and asserts both
// clicks reach the handler.
const Component = ({ count = 0 }) => ({
div: {
children: [
{
button: {
id: 'inc',
type: 'button',
text: `count is ${count}`,
onClick: () => {
window.__coherent_e2e.clickCount += 1;
const { setState, getState } = window.__coherent_e2e.controls;
setState({ count: getState().count + 1 });
window.__coherent_e2e.state = getState();
},
},
},
{ p: { id: 'version', text: 'v1' } },
],
},
});
const controls = hydrate(Component, container, {
initialState: { count: 0 },
});
window.__coherent_e2e.controls = controls;
window.__coherent_e2e.state = controls.getState();
} else {
// No mode set → no-op (helps debug a navigation accident).
window.__coherent_e2e.error = `unknown mode: ${mode}`;
}Why expose controls on window.__coherent_e2e? The click handler defined inside the component closure needs setState/getState from the hydrate return value, but the component is defined before hydrate runs. Stashing the controls on window after hydrate returns lets the handler reach back into them. This is a fixture-grade convenience, not a recommended user pattern.
Step 5: Install workspace deps
Run: pnpm install
Expected: pnpm sees the new fixture in e2e/fixtures/* (already covered by Wave 4b's pnpm-workspace.yaml update), wires the @coherent.js/client workspace symlink. Lockfile may update.
Verify:
ls -la e2e/fixtures/hmr-hydrate/node_modules/@coherent.js/ 2>/dev/nullExpected: a symlink. If pnpm hoisted instead, the bootFixture helper's manual symlink will handle it at test time.
Step 6: Sanity-run the existing e2e suite (no new tests yet)
Run: pnpm run e2e
Expected: still 4 tests pass. New fixture doesn't break anything because no test references it yet.
Step 7: Commit
git add e2e/fixtures/hmr-hydrate/ pnpm-lock.yaml
PNPM_CONFIG_VERIFY_DEPS_BEFORE_RUN=false CI=true git commit -m "$(cat <<'EOF'
chore(e2e): add hmr-hydrate fixture for Wave 4d tests
Adds e2e/fixtures/hmr-hydrate/ — a second fixture for the Wave-4d
Playwright tests covering SSR/hydration mismatch detection and
event survival across DOM patches.
The fixture is one HTML page + one JS file that branches on
`?mode=`:
- ?mode=mismatch — component output deliberately disagrees with
the SSR-shaped HTML so the framework's mismatch detector fires
via the `onMismatch` callback.
- ?mode=event — counter with a click handler that bumps state
and re-renders, exercising the patchDOM + handler-survival
path.
Both modes expose results on `window.__coherent_e2e` so Playwright
can assert from page.evaluate(). The fixture imports `hydrate`
from the workspace-symlinked /node_modules/@coherent.js/client/
dist/index.js — same wiring as hmr-basic.
Single fixture / two modes (not two fixtures) because the test
infrastructure (HTML scaffold, pnpm workspace, symlink) is
identical and there's no benefit to duplication.
No tests yet — those land in the next commit (Task 2).
First commit of Wave 4d (hydrate E2E) for v1.0 stable hardening.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"Task 2: Two new Playwright tests
Files:
- Create:
e2e/tests/hydrate.spec.js
Step 1: Write the tests
Create e2e/tests/hydrate.spec.js:
/**
* Wave 4d Playwright E2E tests for hydration behavior.
*
* Both tests use the hmr-hydrate fixture and gate behavior on a
* ?mode= query param. The fixture exposes results via
* window.__coherent_e2e so each test reads them with page.evaluate().
*/
import { test, expect } from '@playwright/test';
import { bootFixture } from '../helpers/server.js';
test.describe('Hydration (Wave 4d)', () => {
let server;
test.afterEach(async () => {
if (server) await server.close();
server = null;
});
test('mismatch detection — onMismatch fires when component output disagrees with SSR DOM', async ({ page }) => {
// The fixture's HTML statically says "v1"; the component says "v2".
// The framework's mismatch detector should fire on hydrate.
server = await bootFixture('hmr-hydrate', { hmr: false });
await page.goto(`${server.baseURL}/?mode=mismatch`);
// Wait for hydrate to run AND for window.__coherent_e2e to populate.
await page.waitForFunction(() => window.__coherent_e2e && window.__coherent_e2e.mode === 'mismatch');
const result = await page.evaluate(() => ({
mismatches: window.__coherent_e2e.mismatches,
count: window.__coherent_e2e.mismatches.length,
}));
expect(result.count).toBeGreaterThan(0);
// At least one mismatch entry should reference the divergent text.
// The exact shape of the entry is internal to the framework — we
// serialize the whole thing and look for both text snippets.
const serialized = JSON.stringify(result.mismatches);
expect(serialized).toMatch(/v1|v2/);
});
test('event survival — click handler still fires after a state-driven DOM patch', async ({ page }) => {
// Hydrate a counter. Click once → state updates → patchDOM runs.
// Click again → handler must still be wired up despite the patch.
server = await bootFixture('hmr-hydrate', { hmr: false });
await page.goto(`${server.baseURL}/?mode=event`);
// Wait for hydrate to wire up controls.
await page.waitForFunction(() => window.__coherent_e2e && window.__coherent_e2e.controls);
const initial = await page.evaluate(() => ({
clickCount: window.__coherent_e2e.clickCount,
state: window.__coherent_e2e.state,
buttonText: document.getElementById('inc').textContent,
}));
expect(initial.clickCount).toBe(0);
expect(initial.state).toEqual({ count: 0 });
expect(initial.buttonText).toBe('count is 0');
// First click — proves event delegation works at all.
await page.locator('#inc').click();
await page.waitForFunction(() => window.__coherent_e2e.clickCount === 1);
const afterOne = await page.evaluate(() => ({
clickCount: window.__coherent_e2e.clickCount,
state: window.__coherent_e2e.state,
buttonText: document.getElementById('inc').textContent,
}));
expect(afterOne.clickCount).toBe(1);
expect(afterOne.state).toEqual({ count: 1 });
expect(afterOne.buttonText).toBe('count is 1'); // patchDOM updated the text
// Second click — proves the handler survived patchDOM (the
// re-registration in registerEventHandlers after each rerender).
await page.locator('#inc').click();
await page.waitForFunction(() => window.__coherent_e2e.clickCount === 2);
const afterTwo = await page.evaluate(() => ({
clickCount: window.__coherent_e2e.clickCount,
state: window.__coherent_e2e.state,
buttonText: document.getElementById('inc').textContent,
}));
expect(afterTwo.clickCount).toBe(2);
expect(afterTwo.state).toEqual({ count: 2 });
expect(afterTwo.buttonText).toBe('count is 2');
});
});Step 2: Run the new test file
Run: pnpm exec playwright test e2e/tests/hydrate.spec.js
Expected: 2 tests pass in ~1-2 seconds.
If the mismatch test fails with result.count === 0:
- The framework may not be detecting the text divergence in this exact configuration. Open the page manually:
pnpm --filter coherent-language-support exec node -e "/* boot fixture and serve at fixed port for manual inspection */" # or just inspect packages/client/src/hydration/mismatch-detector.js - The mismatch detector might require specific DOM shape markers. If the
<div id="app">wrapper is the problem, hydrate against its child instead (changeconst container = document.getElementById('app')to... .firstElementChildin the fixture).
If the event-survival test fails with clickCount === 0 after the first click:
- The event delegation may not have initialized. Check that the fixture's HTML doesn't have
<script>tags that would block hydrate from running. - Check that
data-coherent-clickattributes appear on the button after hydrate. Use Playwright'spage.evaluate(() => document.getElementById('inc').outerHTML)to inspect.
Step 3: Run the full suite to confirm no regressions
Run: pnpm run e2e
Expected: 6 tests pass total (4 from Wave 4b + 2 new).
Step 4: Commit
git add e2e/tests/hydrate.spec.js
PNPM_CONFIG_VERIFY_DEPS_BEFORE_RUN=false CI=true git commit -m "$(cat <<'EOF'
test(e2e): add Wave 4d hydration + event-survival Playwright tests
Adds e2e/tests/hydrate.spec.js with two tests against the
hmr-hydrate fixture, closing the two deferred audit-item flows
from Wave 4b:
1. **Mismatch detection** — fixture serves SSR-shaped HTML whose
text says "v1" while the component output says "v2". The
onMismatch callback should fire with a non-empty list and at
least one entry referencing the divergent text.
2. **Event survival across DOM patch** — fixture hydrates a
counter with an onClick handler. Click once → state updates
→ patchDOM runs (button text becomes "count is 1"). Click
again → state increments to 2, button text becomes "count is
2". Proves the handler registration survives the
re-registration that happens after every rerender.
Both tests use the existing bootFixture() helper from Wave 4b
with hmr:false (these tests don't exercise the HMR loop — they
exercise hydration, which is orthogonal). The dev server is just
the static-file delivery mechanism.
Second commit of Wave 4d. The spec's six-item E2E checklist is
now 100% automated:
- (Wave 4b) bootstrap injection
- (Wave 4b) WS connection ack
- (Wave 4b) component HMR update
- (Wave 4b) style HMR update
- (Wave 4d) mismatch detection
- (Wave 4d) event survival across DOM patch
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"Task 3: CHANGELOG entry
File: CHANGELOG.md
Step 1: Add Wave 4d subsections
Open CHANGELOG.md. Find the existing ### Notes (Wave 4c) block. Add after it (before ## [1.0.0-beta.8]):
### Added (Wave 4d)
- **NEW: 2 Playwright tests for hydration behavior** (`e2e/tests/hydrate.spec.js`):
- **Mismatch detection** — fixture serves SSR-shaped HTML whose text disagrees with the component output; asserts the `onMismatch` callback fires with a non-empty list. Closes the deferred audit flow from Wave 4b.
- **Event survival across DOM patch** — hydrates a counter, clicks once (state updates, `patchDOM` re-renders the button text), clicks again, asserts the handler still fires. Closes the deferred audit flow from Wave 4b.
- **NEW: `e2e/fixtures/hmr-hydrate/`** — single fixture / two modes (`?mode=mismatch` and `?mode=event`) so the two tests share HTML scaffolding without duplicating workspace plumbing. Both modes expose results via `window.__coherent_e2e` for Playwright assertions.
### Notes (Wave 4d)
- The spec's six-item E2E checklist (Section 5) is now 100% automated. Nothing carries over to RC.
- The mismatch fixture uses hand-written SSR-shaped HTML, not the framework's actual SSR pipeline. Reasoning: the mismatch detector cares about "does what the server sent agree with what the client renders," which doesn't require invoking real SSR. Avoiding the build step keeps the fixture in static-file territory where the Wave-4a dev server lives.
- Scroll/form preservation E2E tests were not added. They test HMR-with-state-preservation (a different concern from hydration), already have unit coverage in `packages/client/test/state-capturer.test.js`, and Wave 4b's component-update test indirectly exercises the broadcast → re-import path. If a real bug ever surfaces, that's the time to add a dedicated browser test.
- The fixture's click handler reaches the `setState` controls via `window.__coherent_e2e.controls` (stashed after `hydrate()` returns). This is a fixture-grade test convenience, NOT a recommended user pattern — in real apps the closure captures `setState` directly.Step 2: Commit
git add CHANGELOG.md
PNPM_CONFIG_VERIFY_DEPS_BEFORE_RUN=false CI=true git commit -m "$(cat <<'EOF'
docs(changelog): record Wave 4d hydration E2E tests
Documents the two new Playwright tests (mismatch detection + event
survival across DOM patches) and the hmr-hydrate fixture they use.
Notes that the spec's six-item E2E checklist is now 100% automated,
that hand-written SSR-shaped HTML is the deliberate alternative to
invoking the real SSR pipeline (keeps the fixture in static-file
territory), and that scroll/form preservation tests are still
intentionally out of scope (orthogonal to hydration, already unit-
covered).
Closes Wave 4d of v1.0 stable hardening. Next: Wave 5 (release).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"Post-Wave-4d handoff
Wave 4 is complete:
- 4a — built-in HMR dev server (~400 lines + 20 unit tests)
- 4b — Playwright infrastructure + 4 protocol tests + CI job +
--no-hmr - 4c — VS Code extension publish-readiness (script + CI + PUBLISHING.md)
- 4d — 2 hydration E2E tests closing the audit checklist
Next:
- Wave 5 — Release.
MIGRATION-1.0.mdfinalization, coordinated1.0.0version bump across all packages includingvscode-extension,1.0.0-rc.1tag, 1-2 week soak,1.0.0tag. After tag: runPUBLISHING.mdfor the marketplace.
No outstanding follow-ups from Wave 4.