Skip to content

feat(ui): add tolerant hydration mode for mount() [#510]#619

Merged
viniciusdacal merged 4 commits intomainfrom
feat/tolerant-hydration-mount
Feb 22, 2026
Merged

feat(ui): add tolerant hydration mode for mount() [#510]#619
viniciusdacal merged 4 commits intomainfrom
feat/tolerant-hydration-mount

Conversation

@viniciusdacal
Copy link
Copy Markdown
Contributor

Summary

Implements tolerant hydration mode for mount() (#510). When SSR HTML exists in the DOM, mount(App, '#root', { hydration: 'tolerant' }) walks the existing nodes and attaches reactivity instead of clearing and re-rendering — eliminating the flash of empty content.

Key changes

  • Hydration context (hydration-context.ts) — Cursor-based DOM walker with claim functions that advance through SSR nodes, skipping foreign elements (browser extensions, ad blockers)
  • DOM helper hydration branches__element, __text, __child, __insert adopt existing SSR nodes when hydrating; new __append (no-op during hydration), __staticText, __enterChildren/__exitChildren
  • Conditional + list hydration__conditional claims comment anchors and active branch content; __list claims existing items and skips initial reconciliation
  • Mount integration — Tolerant mode with error recovery (failed hydration → automatic fallback to CSR replace mode), dev warnings for empty root and mismatches
  • Compiler changes — Emits __enterChildren/__exitChildren around child construction, replaces appendChild__append, createTextNode__staticText

Error recovery

Scenario Behavior
Browser extension node encountered Skipped silently (console.debug in dev)
Expected node not found Created new element + console.warn in dev
Tolerant mode on empty root Warning + fallback to replace mode
Hydration throws exception endHydration() + fallback to full CSR re-render

Test plan

  • Hydration context unit tests (14 tests) — claim functions, cursor stack, enter/exit nesting
  • DOM helper hydration tests (19 tests) — adopt elements, text nodes, reactive effects, __append no-op
  • Conditional hydration tests (4 tests) — comment anchor, branch content, reactive switching
  • List hydration tests (3 tests) — item claiming, skip reconciliation, post-hydration updates
  • Mount hydration tests (9 tests) — SSR preservation, extension tolerance, event handlers, error recovery
  • E2E integration test — SSR HTML → tolerant mount → verify same DOM references → click handler + reactive text → extension node preserved
  • All 984 existing UI tests pass (zero regressions)
  • All 276 compiler tests pass (zero regressions)
  • TypeScript: both packages clean
  • Biome lint: clean

🤖 Generated with Claude Code

@viniciusdacal
Copy link
Copy Markdown
Contributor Author

Technical Review -- Nora

I did a line-by-line adversarial review of every changed file, the design plan, the issue, and all six test files. The core walk-and-attach approach is sound, and the happy-path implementation is clean. That said, I found several issues ranging from correctness bugs to missing coverage. Details below.


Critical Issues

1. hydrateConditional removes claimed nodes from the SSR DOM by appending them to a new fragment

In conditional.ts lines 109-113, after claiming the comment anchor and the branch content node, the code does:

const fragment = document.createDocumentFragment();
fragment.appendChild(anchor);
if (currentNode) {
  fragment.appendChild(currentNode);
}

appendChild on a DocumentFragment moves nodes out of their current parent. If the anchor and branch content were claimed from the SSR DOM (which they are -- claimComment() returns the actual SSR comment node), appending them to this fragment rips them out of the live DOM tree. The user will see the conditional content disappear during hydration.

This is the same bug for both hydrateConditional and csrConditional, but in CSR mode the nodes were just created and haven't been inserted yet, so the move is harmless. During hydration, the nodes are already in the SSR DOM -- moving them to a fragment removes them.

The tests for this pass only because the tests check container.textContent after doing container.appendChild(fragment), which re-inserts the nodes. But in the actual mount() hydration flow, __conditional is called as a child of an element, and its return value is passed to __append(), which is a no-op during hydration. So the fragment is created, the SSR nodes are ripped out, and __append doesn't put them back. This would cause conditional content to vanish during hydration in a real app.

The e2e test doesn't cover conditionals, so this bug is not caught.

Severity: Bug -- conditional content will disappear during tolerant hydration.

2. claimElement aggressively skips ALL non-matching elements, consuming valid SSR nodes

claimElement walks siblings and skips any element that doesn't match the requested tag. The comment says "likely a browser extension node," but this is not safe. Consider:

<!-- SSR output -->
<div>
  <span>text</span>
  <p>paragraph</p>
</div>

If the app code calls claimElement('p') first (e.g., due to a branch mismatch or unexpected ordering), it will skip <span> (logging "Skipping foreign node: <span>"), then claim <p>. Now when claimElement('span') is called next, it fails (returns null) because the cursor is past it.

The design plan says "tolerant mode handles only foreign node injection (browser extensions)" but the implementation can't distinguish between an extension-injected <div> and a legitimate SSR <div> that the cursor is just out of sync with. The linear cursor can only move forward, so any mismatch cascades.

This is by-design ("tolerant" means "best effort"), but the behavior is unintuitive: a single mismatch can cause every subsequent claim to fail, triggering a full cascade of console.warns and freshly created elements mixed with SSR elements. The dev warning says "Creating new element" but doesn't say the hydration is effectively broken.

Severity: Architectural concern -- consider adding a metric/counter for mismatches and bailing out to replace mode after N mismatches, rather than silently degrading.


Significant Concerns

3. Global mutable state is not safe for concurrent mount() calls

The hydration context uses module-level globals (isHydrating, currentNode, cursorStack). If two mount() calls with hydration: 'tolerant' happen concurrently (e.g., mounting two independent widget trees), the second call will overwrite the first's cursor state.

Even in a single-threaded JS environment, this can happen with microtask interleaving. If app() in mount() triggers a queueMicrotask or Promise.resolve().then() that calls another mount() before the first one completes, the global state is corrupted.

Mitigation: This is acceptable for v1 if documented as a limitation. Consider adding a guard: if startHydration is called while isHydrating is already true, either throw or warn.

4. Error recovery during hydration does not clean up partial side effects

When hydration fails and falls back to replace mode (mount.ts lines 96-103):

} catch (e) {
  endHydration();
  // ... fall through to replace mode
}

The failed app() call may have already:

  • Registered event listeners on SSR elements via __on()
  • Created reactive effects via __text() or __child() that reference now-stale nodes
  • Pushed disposal scopes that are never popped

Then root.textContent = '' destroys the SSR DOM, but the effects and event listeners from the failed run are not disposed. On the second app() call in replace mode, new effects are created, but the old ones are still alive. This is a memory leak and potential source of double-invocation bugs.

Mitigation: Before falling through to replace mode, the root's disposal scope (if any) should be cleaned up. At minimum, document this limitation.

5. Fragment hydration does not emit __enterChildren/__exitChildren

The transformFragment function in jsx-transformer.ts (line 212-230) does NOT emit __enterChildren/__exitChildren around its children. This is intentional per the design plan ("Fragment children are claimed at the parent's cursor level"). However, the fragment's children use __append(fragVar, child) where fragVar is a newly created document.createDocumentFragment().

During hydration, __append is a no-op. But the fragment is a new DocumentFragment, not the SSR DOM. The fragment's children (claimed from SSR via __element/__staticText) would need to be moved into this fragment for the return value to work correctly. But __append doesn't do that during hydration.

So a component returning a fragment (<>...</>) will have an empty DocumentFragment returned from app(). In mount(), app() is called but its return value is not used during tolerant hydration (the SSR DOM is left in place), so this might be fine at the root level. But if a component returns a fragment that's used as a child of another element, the __append(parent, fragment) is a no-op, and the fragment's children are already in the SSR DOM at the right position, so it should work.

However: this creates an inconsistency where the DOM tree structure from hydration differs from CSR. In CSR, fragments consolidate children into the fragment, then the fragment is appended. In hydration, the children stay in their SSR positions. If any code later references the fragment (e.g., for disposal tracking), it will be empty.

Severity: Potential correctness issue with fragment-returning components. Needs investigation and a test.

6. claimText skips element nodes, which is dangerous for interleaved content

Consider SSR output like:

<div>Hello <strong>world</strong> today</div>

During hydration inside <div>:

  1. claimText() -- claims "Hello " text node, cursor advances to <strong>
  2. claimElement('strong') -- claims <strong>, cursor advances to " today" text
  3. claimText() for "today" -- claims " today"

But if instead the code calls claimText() twice before claimElement('strong') (e.g., due to two consecutive __staticText calls), the second claimText() will skip over <strong> and claim " today" -- stealing a text node that should have been claimed later.

The cursor is one-pass, forward-only. Any mismatch in the order of claim calls vs. the actual DOM order will cascade.

Severity: This is inherent to the cursor approach and acceptable, but document it clearly. The cursor MUST be walked in exact SSR DOM order.


Minor Issues

7. __element hydration path ignores the props parameter

When claimElement succeeds during hydration, the props parameter is completely ignored:

if (getIsHydrating()) {
  const claimed = claimElement(tag);
  if (claimed) {
    // SSR already set attributes -- return the existing element
    return claimed;
  }
}

The comment says "SSR already set attributes," which is true for matching SSR output. But if there's an attribute mismatch between SSR and client (e.g., SSR rendered class="old" but client code passes { class: 'new' }), it will be silently ignored.

This is by-design for "tolerant" mode (the plan says attribute mismatches are patched silently), but __element's props parameter is for static props that the compiler emits at creation time. If SSR and client disagree on static attributes, the user gets the SSR version with no warning.

Consider: At least log a dev-mode warning if props differ from the claimed element's attributes.

8. __child hydration path claims any <span> -- no verification

__child calls claimElement('span'), but doesn't verify that the claimed span has display: contents. If the SSR DOM has a regular <span> at the cursor position that isn't a __child wrapper, it will be incorrectly claimed.

9. __text hydration effect runs immediately, which may overwrite SSR text

In __text() during hydration:

node.dispose = effect(() => {
  node.data = fn();
});

The effect runs synchronously on creation. If fn() returns a different value than the SSR text (e.g., because the client has different initial state), the text node will be immediately updated, causing a visible flash. This is correct behavior (it's a real mismatch), but there's no dev-mode warning for this case unlike claimElement's tag mismatch warning.

10. Changeset describes this as patch -- appropriate for pre-v1

The changeset correctly uses patch per the semver policy. No issue here.


Test Coverage Gaps

11. No test for conditional hydration in the e2e flow

The e2e test covers elements, text, reactive text, events, and extension nodes. But there's no test for __conditional or __list in the full mount() -> hydration -> interactive flow. Given Critical Issue #1, this is a significant gap.

12. No test for fragment-returning components during hydration

There are no tests for <>...</> fragments during hydration, despite the design plan identifying this as Unknown #1 and saying "Needs POC during Phase 3."

13. No test for exitChildren on empty elements

What happens when __enterChildren(el) is called on an element with no children (e.g., <div></div>)? The cursor would be set to null, and then __exitChildren() would pop the previous cursor. While this should work, there's no test verifying it.

14. No test for deeply nested structures (3+ levels)

The deepest nesting in tests is 2 levels (root > div > span). The cursor stack should handle arbitrary depth, but it's not tested beyond 3 levels (the one test with div > ul > li).

15. No test for error recovery leaving stale effects

The "bails out to replace mode on hydration error" test verifies the fallback renders correctly, but doesn't verify that no stale effects or event listeners from the failed hydration attempt are leaking.

16. No test for __insert with Node value during hydration advancing the cursor

The test for __insert with a Node during hydration checks it's a no-op, but doesn't verify the cursor was NOT advanced. During hydration, __insert with a Node returns immediately without calling claimText(). This means if the SSR DOM has the node at the cursor position, the cursor is NOT advanced past it, which could cause the next claim to try to re-claim the same node.

Wait -- looking more carefully, __insert with a Node doesn't advance the cursor at all. The node was presumably already claimed by __element or similar before being passed to __insert. So this might be fine. But the test doesn't verify this assumption.

17. __conditional hydration tests use document.createElement in branch functions instead of __element

The conditional hydration tests (hydration-conditional.test.ts) use raw document.createElement('span') inside the branch functions, not __element('span'). This means the branch functions don't actually exercise the hydration claiming path -- they create new elements. In a real app, the compiler would emit __element calls inside conditional branches. The tests may be passing by coincidence.


Questions

Q1: The design plan mentions 'strict' mode as reserved. Is there a plan to reject the type at runtime? Currently, passing hydration: 'strict' falls through to replace mode silently. Should it at least warn?

Q2: How does this interact with the existing hydrate() (per-component island hydration) system? The design plan says they're mutually exclusive on the same root, but there's no runtime guard preventing both from being used.

Q3: The design plan mentions the SSR must emit <!-- conditional --> comment anchors and <span style="display:contents"> wrappers. Is there a corresponding @vertz/ui-server change that ensures this? If not, tolerant hydration is dead on arrival for conditionals and __child expressions.

Q4: __list captures isHydrationRun at function creation time (line 36 of list.ts: const isHydrationRun = getIsHydrating()). If the effect's first run is deferred (e.g., batched), could isHydrationRun be true while getIsHydrating() is already false (because endHydration() was called)? Effects in vertz appear to be synchronous, so this is probably fine, but worth confirming.


Verdict

REQUEST CHANGES

Critical Issue #1 (conditional content ripped out of DOM during hydration) is a real bug that will affect any app using conditionals. It's not caught by existing tests because:

  • The conditional hydration tests don't go through mount()
  • The e2e test doesn't include conditionals
  • The unit tests insert the fragment into a container, which re-inserts the nodes

The other significant concerns (global state concurrency, error recovery cleanup, fragment hydration) should be addressed or explicitly documented before merge. The test coverage gaps should be filled.

The overall architecture is good. The cursor-based approach is simple and effective. The compiler changes are clean. The extension-skipping logic is elegant. This PR is close -- it needs the conditional hydration bug fixed, a few more tests, and documentation of known limitations.

@viniciusdacal
Copy link
Copy Markdown
Contributor Author

Developer Experience Review — Josh

API Concerns

1. 'strict' in the type union is a footgun waiting to fire

The type hydration?: 'replace' | 'tolerant' | 'strict' accepts 'strict' today, but passing it silently falls through to replace mode. There is no runtime error, no dev warning, nothing. A developer who reads the type definition and writes { hydration: 'strict' } thinking they're getting strict hydration will get replace mode instead — the exact opposite of what they intended.

This violates "Explicit over implicit" directly. Either remove 'strict' from the union until it's implemented, or throw a clear error:

if (mode === 'strict') {
  throw new Error(
    "mount(): hydration: 'strict' is reserved but not yet implemented. " +
    "Use 'tolerant' for SSR hydration or 'replace' (default) for CSR."
  );
}

An AI agent reading the TypeScript types would absolutely try 'strict' and get silently incorrect behavior. This directly conflicts with Vision principle 3 ("Can an LLM use this correctly on the first prompt?").

2. 'tolerant' is an unusual word choice — consider the discoverability angle

When I think "hydration mode," I think of words like 'reuse', 'attach', 'adopt', or 'resume'. The word 'tolerant' communicates fault tolerance (which is accurate — it tolerates browser extension nodes), but a developer coming from React/Solid/Svelte might not guess that 'tolerant' means "walk the existing DOM and attach reactivity."

Counterpoint: the design doc explains the rationale clearly, and once you learn the word, it's distinctive and memorable. The inline JSDoc 'tolerant' (walk SSR DOM) helps. I'd say this is acceptable if the docs are good, but worth noting that 'hydrate' or 'adopt' would be more self-describing for the core behavior. Not a blocker — just a discoverability consideration.

3. The old hydration?: 'replace' | false was replaced — verify no external consumers

The PR changes the MountOptions.hydration type from 'replace' | false to 'replace' | 'tolerant' | 'strict'. This removes false from the union. If any existing code passes hydration: false, it now gets a type error. The changeset marks this as a patch, which is fine under the pre-v1 semver policy, but the changeset description should mention the breaking type change for anyone upgrading.

4. mount() vs hydrate() naming could confuse newcomers

The design doc is explicit that these are mutually exclusive APIs for different architectures. But from the public API surface alone (index.ts), a developer sees both mount and hydrate exported side-by-side and the word "hydration" in MountOptions. There's no JSDoc on mount() that says "for full-app hydration, not to be confused with hydrate() for islands." An AI agent scanning exports would likely conflate them.

Suggestion: Add a brief JSDoc note to mount() distinguishing it from hydrate(), and vice versa. Something like:

/**
 * Mount an app to a DOM element.
 *
 * For full-app SSR hydration, use `{ hydration: 'tolerant' }`.
 * For island/per-component hydration, use `hydrate()` instead.
 */

Error Message Issues

5. "Falling back to replace mode" — is this scary or reassuring?

The warning [mount] Hydration failed, falling back to replace mode: is technically correct, but "falling back" in production contexts often means "something went wrong and we're doing something worse." A developer seeing this in their console might panic.

Suggestion: reword to make the recovery explicit:

[mount] Hydration failed — re-rendering from scratch (no data loss). Error:

This communicates the same information but frames the outcome positively.

6. "Did you mean 'replace'?" in the empty-root warning is presumptuous

The message hydration: "tolerant" used on empty root. Did you mean "replace"? assumes the developer made a mistake. But what if they're developing locally without SSR and want to test the tolerant code path? The "Did you mean" phrasing feels condescending.

Better:

[mount] hydration: "tolerant" has no effect on an empty root (no SSR content found). Using replace mode.

This states the fact and the consequence without guessing intent.

7. claimElement skips ALL non-matching elements with only console.debug

If the SSR output has a real mismatch (developer renders <div> on server but <section> on client), claimElement will skip the server's <div> as if it were a browser extension node, logging only a debug message. The developer gets no useful feedback about their actual bug — the mismatch is silently swallowed.

The current logic cannot distinguish "browser extension node" from "developer's SSR/client mismatch." Both are non-matching elements. This is a known tradeoff of the tolerant approach, but the debug message Skipping foreign node: <div> would be actively misleading in the mismatch case — <div> is clearly not a "foreign node."

Suggestion: Change the debug message to be more neutral:

[hydrate] Skipping non-matching node: <div> (expected <section>)

This helps developers spot real mismatches without changing the tolerant behavior.

8. No warning when hydration "succeeds" but leaves unclaimed nodes

If the SSR output has extra nodes that the app doesn't claim (not browser extensions, but actual content the client doesn't render), hydration completes silently. The developer has no way to know their server and client trees diverged. Consider a dev-mode console.debug at endHydration() if the cursor hasn't reached the end of siblings.

Manifesto Alignment

9. "Explicit over implicit" — strong alignment, one gap

The opt-in { hydration: 'tolerant' } is excellent. No auto-detection magic. The developer makes a conscious choice. This nails the principle.

The gap: the silent 'strict' fallthrough (point 1 above). Accepting a value and ignoring it is the definition of implicit behavior.

10. "One way to do things" — solid

Full-app hydration: mount() with 'tolerant'. Island hydration: hydrate(). No overlap, no ambiguity. The design doc is clear about mutual exclusivity. This is well done.

11. "AI agents are first-class users" — mostly good, two issues

An AI agent reading the TypeScript types and JSDoc could correctly use mount(App, '#root', { hydration: 'tolerant' }). The API is self-describing.

Issues for AI agents:

  • The 'strict' value in the union would mislead an agent into thinking it's functional (point 1)
  • The mount() vs hydrate() naming overlap requires reading docs beyond the type signatures (point 4)

12. "Compile-time over runtime" — acceptable tradeoff

The getIsHydrating() check runs at runtime on every __element, __text, __append, etc. call. This is a function call + boolean check per DOM operation, which is minimal overhead. The real work (cursor walking) only happens when hydration is active. The compiler changes (__enterChildren/__exitChildren) push as much structure as possible to build time.

The one thing that could be a compile-time check: if the compiler knows the app is SSR-only (no hydration), it could skip emitting __enterChildren/__exitChildren entirely. This is a future optimization, not a blocker.

Migration & Workflow

13. Migration path is clean

Going from mount(App, '#root') to SSR+hydration requires exactly one change: add { hydration: 'tolerant' }. The app function, component code, and everything else stays the same. The compiler changes (__append instead of appendChild) are transparent to developers. This is excellent.

14. Accidental 'tolerant' without SSR is handled well

Empty root + 'tolerant' = dev warning + automatic fallback to replace mode. The app still works. This is the right call — fail gracefully, warn loudly.

15. Error recovery is solid

If hydration throws, the catch block calls endHydration(), warns, and falls back to replace mode. The app recovers. The developer sees the error. This is good defensive design.

One edge case: if app() throws during the hydration attempt and the root DOM is left in a partially-claimed state, the fallback root.textContent = '' clears everything and starts fresh. This is correct but worth noting in docs — hydration failure means a full re-render flash, which is the same as not using hydration at all.

Minor DX Issues

16. enterChildren is exported from hydration-context.ts as a public name but also wrapped as __enterChildren in element.ts

The hydration-list test imports enterChildren directly from hydration-context.ts:

import { endHydration, enterChildren, startHydration } from '../../hydrate/hydration-context';

This means enterChildren (without the __ prefix) is used as an internal API in tests, while __enterChildren (with prefix) is the compiler-facing export. This is fine architecturally, but the naming inconsistency (no __ prefix for the context function, __ prefix for the wrapper) could confuse someone reading the test code. A comment in the test clarifying "using the internal cursor API directly, not the compiler wrapper" would help.

17. The globals.d.ts for process is a pragmatic solution but worth documenting

The new globals.d.ts declares process.env.NODE_ENV for dev-mode guards. This is standard practice but not documented anywhere in the PR. A one-line comment in the file explaining "bundlers replace this at build time; the typeof process guard prevents runtime errors in browsers" would help future maintainers. (The file does have this comment — good.)

18. The changeset description is dense but complete

The changeset in .changeset/tolerant-hydration-mount.md packs a lot into one paragraph. It's accurate but hard to scan. Breaking it into bullet points would improve readability for changelog consumers.

What's Good

  • The cursor-based walk-and-attach approach is elegant. No virtual DOM, no reconciler, no diffing. Just walk the tree in order and attach reactivity. This is the minimum runtime for the problem.

  • Error recovery with automatic CSR fallback is the right default. Hydration is hard, and the graceful degradation to replace mode means a broken SSR setup doesn't break the app — it just shows a flash.

  • The __append no-op pattern is clever. By replacing appendChild with a function that's a no-op during hydration, the same compiled output works for both CSR and SSR hydration. No conditional compilation needed.

  • Browser extension tolerance is well-tested. The e2e test injects a grammarly-extension node and verifies it survives hydration. This is exactly the real-world scenario that motivates the feature.

  • Test coverage is thorough. 14 context tests, 19 DOM helper tests, 4 conditional tests, 3 list tests, 9 mount tests, plus the e2e integration. The tests read well and cover both hydration and CSR paths.

  • The design doc is one of the best I've seen for this codebase. Clear context, manifesto alignment, explicit non-goals, phased implementation, concrete test lists. If every feature had a design doc this thorough, the codebase would be in great shape.

  • The __staticText / __text separation is clean. Static text (no reactivity) gets a simpler code path. Reactive text gets the effect. The compiler makes the right choice, and the developer never has to think about it.

Verdict

APPROVE with minor requested changes:

  1. Must fix: Either remove 'strict' from the type union or throw an error when it's passed. Silent acceptance of an unimplemented mode is a DX bug. (Point 1)
  2. Should fix: Improve the claimElement debug message to include the expected tag — helps developers distinguish real mismatches from extension tolerance. (Point 7)
  3. Should fix: Add JSDoc to mount() and hydrate() distinguishing the two APIs. (Point 4)
  4. Nice to have: Reword "Falling back to replace mode" and "Did you mean 'replace'?" messages. (Points 5, 6)

The core design is sound, the implementation is clean, and the test coverage is excellent. The issues above are about polishing the developer-facing surface — the kind of things that prevent frustrated GitHub issues six months from now.

Copy link
Copy Markdown
Contributor Author

@viniciusdacal viniciusdacal left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

API Surface Review: ✅ Good with Minor Suggestions

API Design - Well Done

The API is clean and intuitive:

mount(App, '#root', { hydration: 'tolerant' })

Type definition is clear: hydration?: 'replace' | 'tolerant' | 'strict'

Strengths

  • Clear error recovery: Automatic fallback to CSR when hydration fails
  • Good dev warnings: Empty root detection, hydration failure messages
  • Graceful handling: Browser extension nodes skipped with console.debug
  • Zero regressions: All 984 UI tests + 276 compiler tests pass

Suggestions for Improvement

  1. 'strict' mode documented?

    • Type includes 'strict' but it's not implemented
    • Consider either implementing it or removing from the type (or documenting it as "reserved for future use")
  2. NODE_ENV pattern could be documented

    • The typeof process !== 'undefined' && process.env.NODE_ENV !== 'production' pattern is clever but non-obvious
    • Consider a small comment explaining why this approach was chosen
  3. Missing expected node behavior

    • When an expected node isn't found, a new element is created with a warning
    • In strict scenarios, should this throw instead? Current behavior might mask mismatches silently
  4. Documentation gap

    • No JSDoc on MountOptions.hydration
    • No migration guide for users upgrading from CSR-only

Edge Cases Handled Well

  • ✅ Browser extension nodes (<grammarly-extension>, etc.)
  • ✅ Whitespace text nodes between elements
  • ✅ Conditional/list hydration with comment anchors
  • ✅ Exception recovery with fallback to full CSR

Verdict

Solid implementation with good DX. The main actionable item is either implementing 'strict' mode or clarifying it's reserved. The error recovery design is excellent - failing gracefully to CSR is the right call.

Copy link
Copy Markdown
Contributor Author

@viniciusdacal viniciusdacal left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PR Review: Tolerant Hydration Mode

✅ What Works Well

API Design:

  • The API is intuitive: mount(app, '#root', { hydration: 'tolerant' }) is clear and easy to use
  • 'tolerant' is a good name - it conveys that the mode tolerates existing SSR HTML
  • The feature gracefully handles browser extension nodes (grammarly-extension, etc.)

Error Handling:

  • Good dev-mode warnings for edge cases:
    • Empty root → warns and falls back to replace mode
    • Hydration failure → warns and falls back to full CSR re-render
    • Missing SSR nodes → warns and creates new elements

Implementation Quality:

  • Cursor-based DOM walker is elegant and handles nesting correctly
  • Comprehensive test coverage (50+ new tests across multiple test files)
  • The fallback behavior (CSR re-render on failure) ensures robustness

⚠️ Issues & Suggestions

1. Breaking Change: hydration: false no longer supported

The old API allowed hydration: false to disable hydration:

// Old API (now broken)
mount(app, '#root', { hydration: false })

The new API only accepts 'replace' | 'tolerant' | 'strict'. This is a breaking change that should be called out in the changeset/changelog.

Consider either:

  • Supporting hydration: false as an alias for hydration: 'replace'
  • Or explicitly documenting this as a breaking change

2. Unimplemented strict mode in type

The MountOptions type includes 'strict':

hydration?: 'replace' | 'tolerant' | 'strict';

But there's no implementation for it. This could confuse users who see 'strict' in their IDE autocomplete. Either:

  • Remove 'strict' from the type (if not planned)
  • Or document that it's reserved for future use

3. Minor: Consider stricter TypeScript for the hydration option

Currently hydration defaults implicitly to 'replace'. For better DX, consider making the default explicit in the type or adding a JSDoc explaining the default behavior.


📝 Summary

Overall this is a well-implemented feature with good API design. The main concerns are:

  1. Document the hydration: falsehydration: 'replace' breaking change
  2. Clarify the status of the 'strict' option

The tolerant hydration approach is solid - walking existing DOM nodes and attaching reactivity is the right pattern for reducing hydration flash.

Copy link
Copy Markdown
Contributor Author

@viniciusdacal viniciusdacal left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Adversarial Review: Tolerant Hydration Mode

Found several issues worth addressing:

Critical Issues

1. 'strict' mode is implemented but not functional

  • Type allows hydration?: 'replace' | 'tolerant' | 'strict'
  • Only 'tolerant' is handled; 'strict' silently falls back to 'replace'
  • No validation or warning for unsupported mode
// mount.ts line 73 - no handling for 'strict'
if (mode === 'tolerant') { ... }
// Anything else (including 'strict') falls through to replace

2. Potential memory leak in __child effect during hydration

  • In element.ts, the effect attaches but the cleanup path isn't fully clear
  • If the wrapper is disposed while hydrating, the effect scope might not clean up properly

Edge Cases & Bugs

3. exitChildren has no bounds checking

// hydration-context.ts
export function exitChildren(): void {
  currentNode = cursorStack.pop() ?? null;
}

If __exitChildren is called without matching __enterChildren, it silently returns null. Should throw or warn in dev.

4. __conditional hydration: branch content may not be properly claimed

  • The test shows it creates a fragment, but real SSR output may have siblings
  • No validation that the claimed nodes actually belong to the conditional branch
  • If SSR HTML has extra whitespace or comments, hydration cursor could drift

5. Browser compatibility: process.env.NODE_ENV guards

  • globals.d.ts declares process, but runtime behavior varies by bundler
  • Some aggressive tree-shaking configs may strip these checks
  • Consider using globalThis.__DEV__ or a custom flag instead

Performance Concerns

6. No hydration batch/defer mechanism

  • Each __text, __element, __child creates individual effects
  • Large DOM trees will trigger many micro-tasks
  • Consider a startBatch() / endBatch() or requestIdleCallback wrapper

7. List hydration runs all render functions synchronously

// list.ts - hydration run
for (const [i, item] of newItems.entries()) {
  // ... runs ALL renderFn calls synchronously
}

For 1000+ items, this blocks the main thread.

Accessibility

8. No ARIA attribute validation during adoption

  • When __element adopts an existing SSR node, attributes are preserved
  • But if there's a mismatch between SSR and client component props, ARIA could be stale
  • Consider adding dev-only ARIA mismatch warnings

Testing Gaps

9. Missing test: __child hydration with reactive content

  • Current test uses static __child(() => 'hello')
  • Need test for __child(() => signal.value) during hydration to verify effect properly updates

10. Missing test: mismatched element type during hydration

  • What happens if SSR has <div> but component expects <span> (via __child)?
  • Currently returns null and creates new, but behavior should be documented

Minor Issues

11. Dev warnings go to console.debug for skipped extension nodes

  • console.debug is often hidden in production browsers
  • Consider console.warn or console.info for visibility

12. No way to disable hydration logging

  • Users can't suppress the [hydrate] warnings in dev mode

Recommendation: Address items 1, 3, and 4 before merge. Items 5-7 are nice-to-have improvements. Items 8-12 can be follow-ups.

🤖 Reviewed by josh (Developer Relations)

@viniciusdacal
Copy link
Copy Markdown
Contributor Author

Adversarial Review: Tolerant Hydration Mode (Josh)

Found several issues worth addressing:

Critical Issues

1. 'strict' mode is implemented but not functional

  • Type allows hydration?: 'replace' | 'tolerant' | 'strict'
  • Only 'tolerant' is handled; 'strict' silently falls back to 'replace'
  • No validation or warning for unsupported mode
// mount.ts line 73 - no handling for 'strict'
if (mode === 'tolerant') { ... }
// Anything else (including 'strict') falls through to replace

2. Potential memory leak in __child effect during hydration

  • In element.ts, the effect attaches but the cleanup path isn't fully clear
  • If the wrapper is disposed while hydrating, the effect scope might not clean up properly

Edge Cases & Bugs

3. exitChildren has no bounds checking

// hydration-context.ts
export function exitChildren(): void {
  currentNode = cursorStack.pop() ?? null;
}

If __exitChildren is called without matching __enterChildren, it silently returns null. Should throw or warn in dev.

4. __conditional hydration: branch content may not be properly claimed

  • The test shows it creates a fragment, but real SSR output may have siblings
  • No validation that the claimed nodes actually belong to the conditional branch
  • If SSR HTML has extra whitespace or comments, hydration cursor could drift

5. Browser compatibility: process.env.NODE_ENV guards

  • globals.d.ts declares process, but runtime behavior varies by bundler
  • Some aggressive tree-shaking configs may strip these checks

Performance Concerns

6. No hydration batch/defer mechanism

  • Each __text, __element, __child creates individual effects
  • Large DOM trees will trigger many micro-tasks

7. List hydration runs all render functions synchronously
For 1000+ items, this blocks the main thread.

Accessibility

8. No ARIA attribute validation during adoption
When __element adopts an existing SSR node, mismatched ARIA attributes could cause a11y issues.

Testing Gaps

9. Missing test: __child hydration with reactive content

  • Current test uses static __child(() => 'hello')
  • Need test for reactive signal values

10. Missing test: mismatched element type during hydration

  • What happens if SSR has <div> but component expects <span>?

Minor Issues

11. Dev warnings go to console.debug for skipped extension nodes

  • console.debug is often hidden in production browsers
  • Consider console.warn or console.info

12. No way to disable hydration logging


Recommendation: Address items 1, 3, and 4 before merge. Items 5-7 are nice-to-have improvements. Items 8-12 can be follow-ups.

🤖 Reviewed by josh (Developer Relations)

Copy link
Copy Markdown
Contributor Author

@viniciusdacal viniciusdacal left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

API Surface Review from Nora (Kimi model):

API Design - Good

  • Clean API: mount(App, '#root', { hydration: 'tolerant' })
  • Type definition is clear: hydration?: 'replace' | 'tolerant' | 'strict'

Strengths

  • Error recovery with automatic fallback to CSR
  • Good dev warnings for empty root and failures
  • Graceful handling of browser extension nodes
  • Zero regressions (984 UI + 276 compiler tests pass)

Suggestions

  1. 'strict' mode - either implement or remove from type to avoid confusion
  2. Consider adding JSDoc to MountOptions.hydration
  3. NODE_ENV pattern is clever but could be documented better
  4. No migration guide visible - consider adding one

Edge Cases Handled Well

  • Browser extension nodes
  • Whitespace text nodes
  • Conditional/list hydration
  • Exception recovery with CSR fallback

Overall: Solid implementation with good DX. Main actionable item is clarifying the 'strict' mode status.

@viniciusdacal
Copy link
Copy Markdown
Contributor Author

Process & TDD Audit — PR #619

Process Violations (Critical)

1. Single monolithic commit for a 6-phase, 1523-line change across 17 files

The project rules (tdd.md) define a strict process:

Red -- Write exactly ONE failing test. Green -- Write the MINIMAL code to make that one test pass. Refactor -- Clean up while keeping all checks green. Repeat.

And:

Never write multiple tests before implementing. Never write implementation code without a failing test. Each cycle handles one behavior -- not a batch.

This PR delivers 6 phases of work (hydration context, DOM helper branches, compiler changes, conditional+list hydration, mount integration, changeset/exports) as a single commit (2b62fe9). That is 50 new test cases, 8 new files, 9 modified files, and 1523 lines of changes — all atomically.

TDD by definition produces a commit trail. Each Red-Green-Refactor cycle should leave evidence. A single commit containing both the tests and the implementation for all 6 phases is structurally incompatible with "write exactly ONE failing test, then the minimal code to pass it." There is no way to verify that tests were written first, that each was red before being made green, or that quality gates ran between cycles.

This is the most critical finding. The commit history is the audit trail for TDD compliance. Without it, TDD compliance is unfalsifiable — which under strict process rules means it must be treated as non-compliant.

Expected commit structure (minimum): One commit per phase (6 commits), or ideally one per red-green-refactor cycle (~50 cycles = many smaller commits, squashed to one-per-phase at minimum).


2. Missing compiler tests specified in the design doc

Phase 3 of the plan specifies 8 compiler tests:

emits __enterChildren/__exitChildren around child construction
handles nested elements with correct enter/exit pairing
omits enter/exit for childless elements
omits enter/exit for fragments (children at parent scope)
replaces appendChild with __append
replaces createTextNode with __staticText
import list includes all new helpers
existing compiler transform tests updated for new output format

What was actually delivered: 2 modified assertions in existing tests (updating appendChild -> __append and createTextNode -> __staticText). The following tests from the plan are missing:

  • "handles nested elements with correct enter/exit pairing" -- no dedicated test
  • "omits enter/exit for childless elements" -- no dedicated test
  • "omits enter/exit for fragments (children at parent scope)" -- no dedicated test
  • "import list includes all new helpers" -- no dedicated test

These are behaviors in the implementation that have no corresponding tests. Per tdd.md: "Tests are the specification -- if it's not tested, it doesn't exist."


3. Missing __list test from plan: "populates nodeMap/scopeMap from claimed nodes"

The plan's Phase 4 specifies this test. The implementation delivers 3 list hydration tests but this specific behavior (verifying internal state population) is absent. While testing internal state can be debatable, the plan specified it as an acceptance criterion.


Process Concerns (Significant)

4. Tests import internal implementation details

Several hydration test files import directly from internal modules:

// hydration-conditional.test.ts
import { endHydration, startHydration } from '../../hydrate/hydration-context';

// hydration-list.test.ts
import { endHydration, enterChildren, startHydration } from '../../hydrate/hydration-context';

These tests are exercising internal implementation details (startHydration, endHydration, enterChildren) rather than testing through the public API (mount()). While unit tests of internal modules are acceptable, TDD-first tests tend to describe behaviors from the user's perspective — "when I mount with tolerant hydration and SSR content exists, the existing DOM is preserved." Several of these test files look more like they were written to verify the implementation works (after the fact) than to specify the behavior (before writing code).

The E2E test and mount-hydration tests do test through the public API, which is good. But the ratio is skewed: ~40 unit tests on internals vs ~10 behavior tests through the public surface.

5. No .test-d.ts type flow verification

The definition-of-done.md requires:

Type flow verification -- every generic type parameter introduced in this phase has a .test-d.ts test proving it flows from definition to consumer.

The MountOptions.hydration type was changed from 'replace' | false to 'replace' | 'tolerant' | 'strict'. While this is a union literal (not a generic), the project's type-level TDD rules state that type changes follow the same red-green-refactor cycle with @ts-expect-error tests. No .test-d.ts file was added or modified.

6. Design doc specifies __DEV__ guard — implementation uses typeof process !== 'undefined'

The plan's Phase 5 pseudocode uses if (__DEV__) for dev-mode warnings. The implementation uses typeof process !== 'undefined' && process.env.NODE_ENV !== 'production'. This is a design deviation. Per definition-of-done.md, design deviations should be escalated and the design doc updated. The design doc (mutable-herding-journal.md) was not updated to reflect this.

However, this is pragmatic — there may not be a __DEV__ constant available — so this is noted as a concern rather than a violation.

7. No retrospective or post-implementation review

The definition-of-done.md requires:

Retrospective written -- plans/post-implementation-reviews/<feature>.md

No retrospective file is included in this PR. This is required for feature completion.


What Was Done Right

8. Commit message format is correct

The commit message follows the required format perfectly:

feat(ui): add tolerant hydration mode for mount() [#510]
  • Type: feat
  • Scope: (ui)
  • Description: present tense, descriptive
  • Issue reference: [#510] at end of subject line
  • Closes #510 in the body
  • Co-authored-by line present

9. Changeset uses patch (semver compliant)

Both @vertz/ui: patch and @vertz/ui-compiler: patch — correct per the pre-v1 semver policy.

10. No quality gate violations in the code

  • Zero @ts-ignore — good
  • Zero as any — good
  • Zero .skip or .only — good
  • No new biome-ignore directives added
  • The existing biome-ignore lint/suspicious/noExplicitAny on MountOptions.registry is pre-existing

11. Test coverage is comprehensive (50 new tests)

  • Hydration context: 14 tests covering claim functions, cursor management, stack depth
  • DOM helper hydration: 19 tests covering element adoption, text adoption, reactive effects, CSR regression
  • Conditional hydration: 4 tests covering anchor claiming, branch content, reactive switching
  • List hydration: 3 tests covering item claiming, skip reconciliation, post-hydration updates
  • Mount integration: 9 tests covering SSR preservation, extension tolerance, error recovery, fallback
  • E2E: 1 comprehensive integration test

12. E2E acceptance test matches the design doc specification

The E2E test in hydration-e2e.test.ts closely follows the test specified in the design doc's "E2E Acceptance Test" section, including: SSR HTML setup, browser extension injection, tolerant mount, DOM reference preservation verification, click handler + reactive text verification, and extension node survival.

13. All plan-specified behaviors are implemented

Error recovery matrix matches the plan. The four scenarios (extension node, missing node, empty root, hydration exception) all have corresponding implementation and tests.

14. Issue #510 is properly referenced

Both in the commit subject and via Closes #510 in the body.


Recommendations

  1. Break work into one commit per phase (minimum). Each phase should be its own commit with its tests. This is the single most important process improvement. The plan explicitly defines 6 phases with separate acceptance criteria — each is a natural commit boundary. Better yet: one commit per red-green-refactor cycle within each phase.

  2. Add the missing compiler tests. The plan specifies tests for childless elements, fragment handling, nested enter/exit pairing, and import list validation. These behaviors exist in the implementation but have no tests. Either add the tests or update the plan to explain why they were dropped.

  3. Add .test-d.ts for the MountOptions type change. A type-level test that verifies 'tolerant' is accepted and invalid strings are rejected via @ts-expect-error.

  4. Update the design doc for the __DEV__ vs process.env.NODE_ENV deviation. Small change, but the process requires it.

  5. Write the retrospective. Required by definition-of-done for feature completion.

  6. Consider testing more behaviors through the public API (mount()). The unit tests on hydration-context.ts are thorough but test implementation, not behavior. If the internal cursor mechanism is refactored, all those tests break even if the feature still works correctly.


Verdict

FAIL

The primary failure is the single monolithic commit for a 6-phase feature. The project's TDD rules are unambiguous: "Write exactly ONE failing test... write the MINIMAL code to make that one test pass... repeat." A single commit with 50 tests and all implementation code provides zero evidence that this process was followed. Combined with 4 missing compiler tests from the plan and no retrospective, this PR does not meet the project's stated process standards.

To convert to PASS:

  1. (Blocking) Rebase into at least 6 commits (one per phase), each containing its tests and implementation together
  2. (Blocking) Add the 4 missing compiler tests from the Phase 3 plan
  3. (Non-blocking) Add .test-d.ts for the MountOptions type change
  4. (Non-blocking) Write the retrospective
  5. (Non-blocking) Update the design doc for the process.env.NODE_ENV pattern

vertz-dev-front[bot] and others added 3 commits February 22, 2026 21:00
Walk existing SSR DOM and attach reactivity instead of clearing and
re-rendering. Browser extension nodes are gracefully skipped. If
hydration fails, automatically falls back to full CSR re-render.

- New hydration context with cursor-based DOM walker
- DOM helpers (__element, __text, __child, __insert) gain hydration branches
- New exports: __append, __staticText, __enterChildren, __exitChildren
- __conditional claims comment anchors during hydration
- __list claims existing items, skips initial reconciliation
- Compiler emits __enterChildren/__exitChildren/__append/__staticText
- 50+ new tests across hydration context, DOM helpers, mount, and E2E

Closes #510

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Critical fixes:
- Fix hydrateConditional ripping SSR nodes from live DOM by returning
  the anchor node directly instead of moving nodes into a fragment
- Add explicit error for reserved 'strict' hydration mode
- Add concurrent hydration guard (throw if startHydration called twice)

DX improvements:
- Improve claimElement debug message to include expected tag name
- Reword empty-root and hydration-failure warning messages
- Add JSDoc to mount() distinguishing from hydrate()

Test gaps filled:
- Conditional e2e test through mount() (reproduced the fragment bug)
- List e2e test through mount()
- Conditional tests now use __element instead of document.createElement
- New test: SSR span reference adopted via __element
- New test: enterChildren/exitChildren on empty elements
- New test: concurrent startHydration guard
- 4 missing compiler tests: nested enter/exit, childless, fragments, imports

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Wrap hydration app() in disposal scope; runCleanups on error recovery
- Add mount.test-d.ts type tests for MountOptions.hydration values
- Add list nodeMap reorder test (adopted SSR nodes reused)
- Fix missing effect import in stale effects test
- Add retrospective

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@github-actions github-actions Bot force-pushed the feat/tolerant-hydration-mount branch from 42a75ae to 341e3f3 Compare February 22, 2026 21:00
@viniciusdacal
Copy link
Copy Markdown
Contributor Author

Re-review requested — all findings addressed

All 21 review findings from the initial review round have been addressed across two follow-up commits:

Commit 57d3202 — First round of fixes

Commit 42a75ae — Second round of fixes

Quality gates

  • 992 UI tests
  • 280 compiler tests
  • Typecheck
  • Lint
  • All 60 turbo quality gates

Ready for re-review.

- exitChildren() warns in dev mode when called with empty cursor stack
- Add fallback path tests for __staticText and __child during hydration
  (verify new nodes created when SSR claims fail)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@viniciusdacal
Copy link
Copy Markdown
Contributor Author

Round 3 fixes (MiniMax + GLM re-review findings)

Commit 2f34b4c — Cursor bounds checking + fallback path tests

Fixed:

  • exitChildren() silent null on empty stack — now warns in dev mode: "exitChildren() called with empty stack. This likely means __exitChildren was called without a matching __enterChildren."
  • Missing fallback path tests — added tests for __staticText and __child when hydration claims fail (verify new nodes are created as fallback)

Pushed back (with rationale):

  • 'strict' type/runtime mismatch — intentional design. The type reserves the value for autocomplete and discoverability. The runtime throws with a clear error message. Removing from the type would hurt DX (no autocomplete, no clear error).
  • Scope leak if startHydration() throws — already handled. The catch block calls popScope() + runCleanups(scope), covering the concurrent guard throw path.
  • process.env.NODE_ENV bundler compat — standard pattern used by React, Vue, Svelte. All major bundlers handle it.

Deferred to v2:

  • No batching for large DOM trees
  • No ARIA mismatch detection

Quality gates

  • 995 UI tests
  • 280 compiler tests
  • Typecheck
  • All 60 turbo quality gates

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant