feat(ui): add tolerant hydration mode for mount() [#510]#619
feat(ui): add tolerant hydration mode for mount() [#510]#619viniciusdacal merged 4 commits intomainfrom
Conversation
Technical Review -- NoraI 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 Issues1. In const fragment = document.createDocumentFragment();
fragment.appendChild(anchor);
if (currentNode) {
fragment.appendChild(currentNode);
}
This is the same bug for both The tests for this pass only because the tests check The e2e test doesn't cover conditionals, so this bug is not caught. Severity: Bug -- conditional content will disappear during tolerant hydration. 2.
<!-- SSR output -->
<div>
<span>text</span>
<p>paragraph</p>
</div>If the app code calls The design plan says "tolerant mode handles only foreign node injection (browser extensions)" but the implementation can't distinguish between an extension-injected 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 Severity: Architectural concern -- consider adding a metric/counter for mismatches and bailing out to replace mode after N mismatches, rather than silently degrading. Significant Concerns3. Global mutable state is not safe for concurrent The hydration context uses module-level globals ( Even in a single-threaded JS environment, this can happen with microtask interleaving. If Mitigation: This is acceptable for v1 if documented as a limitation. Consider adding a guard: if 4. Error recovery during hydration does not clean up partial side effects When hydration fails and falls back to replace mode ( } catch (e) {
endHydration();
// ... fall through to replace mode
}The failed
Then 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 The During hydration, So a component returning a fragment ( 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. Consider SSR output like: <div>Hello <strong>world</strong> today</div>During hydration inside
But if instead the code calls 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 Issues7. When 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 This is by-design for "tolerant" mode (the plan says attribute mismatches are patched silently), but Consider: At least log a dev-mode warning if props differ from the claimed element's attributes. 8.
9. In node.dispose = effect(() => {
node.data = fn();
});The effect runs synchronously on creation. If 10. Changeset describes this as The changeset correctly uses Test Coverage Gaps11. 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 12. No test for fragment-returning components during hydration There are no tests for 13. No test for What happens when 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 The test for Wait -- looking more carefully, 17. The conditional hydration tests (hydration-conditional.test.ts) use raw QuestionsQ1: The design plan mentions Q2: How does this interact with the existing Q3: The design plan mentions the SSR must emit Q4: VerdictREQUEST 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 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. |
Developer Experience Review — JoshAPI Concerns1. The type This violates "Explicit over implicit" directly. Either remove 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 2. When I think "hydration mode," I think of words like Counterpoint: the design doc explains the rationale clearly, and once you learn the word, it's distinctive and memorable. The inline JSDoc 3. The old The PR changes the 4. The design doc is explicit that these are mutually exclusive APIs for different architectures. But from the public API surface alone ( Suggestion: Add a brief JSDoc note to /**
* 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 Issues5. "Falling back to replace mode" — is this scary or reassuring? The warning Suggestion: reword to make the recovery explicit: This communicates the same information but frames the outcome positively. 6. "Did you mean 'replace'?" in the empty-root warning is presumptuous The message Better: This states the fact and the consequence without guessing intent. 7. If the SSR output has a real mismatch (developer renders 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 Suggestion: Change the debug message to be more neutral: 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 Manifesto Alignment9. "Explicit over implicit" — strong alignment, one gap The opt-in The gap: the silent 10. "One way to do things" — solid Full-app hydration: 11. "AI agents are first-class users" — mostly good, two issues An AI agent reading the TypeScript types and JSDoc could correctly use Issues for AI agents:
12. "Compile-time over runtime" — acceptable tradeoff The 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 Migration & Workflow13. Migration path is clean Going from 14. Accidental Empty root + 15. Error recovery is solid If hydration throws, the catch block calls One edge case: if Minor DX Issues16. The hydration-list test imports import { endHydration, enterChildren, startHydration } from '../../hydrate/hydration-context';This means 17. The The new 18. The changeset description is dense but complete The changeset in What's Good
VerdictAPPROVE with minor requested changes:
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. |
viniciusdacal
left a comment
There was a problem hiding this comment.
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
-
'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")
- Type includes
-
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
- The
-
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
-
Documentation gap
- No JSDoc on
MountOptions.hydration - No migration guide for users upgrading from CSR-only
- No JSDoc on
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.
viniciusdacal
left a comment
There was a problem hiding this comment.
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: falseas an alias forhydration: '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:
- Document the
hydration: false→hydration: 'replace'breaking change - 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.
viniciusdacal
left a comment
There was a problem hiding this comment.
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 replace2. 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.tsdeclaresprocess, 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,__childcreates individual effects - Large DOM trees will trigger many micro-tasks
- Consider a
startBatch()/endBatch()orrequestIdleCallbackwrapper
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
__elementadopts 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.debugis often hidden in production browsers- Consider
console.warnorconsole.infofor 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)
Adversarial Review: Tolerant Hydration Mode (Josh)Found several issues worth addressing: Critical Issues1.
// mount.ts line 73 - no handling for 'strict'
if (mode === 'tolerant') { ... }
// Anything else (including 'strict') falls through to replace2. Potential memory leak in
Edge Cases & Bugs3. // hydration-context.ts
export function exitChildren(): void {
currentNode = cursorStack.pop() ?? null;
}If 4.
5. Browser compatibility:
Performance Concerns6. No hydration batch/defer mechanism
7. List hydration runs all render functions synchronously Accessibility8. No ARIA attribute validation during adoption Testing Gaps9. Missing test:
10. Missing test: mismatched element type during hydration
Minor Issues11. Dev warnings go to
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) |
viniciusdacal
left a comment
There was a problem hiding this comment.
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
- 'strict' mode - either implement or remove from type to avoid confusion
- Consider adding JSDoc to MountOptions.hydration
- NODE_ENV pattern is clever but could be documented better
- 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.
Process & TDD Audit — PR #619Process Violations (Critical)1. Single monolithic commit for a 6-phase, 1523-line change across 17 files The project rules (
And:
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 ( 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: What was actually delivered: 2 modified assertions in existing tests (updating
These are behaviors in the implementation that have no corresponding tests. Per 3. Missing 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 ( 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 The
The 6. Design doc specifies The plan's Phase 5 pseudocode uses However, this is pragmatic — there may not be a 7. No retrospective or post-implementation review The
No retrospective file is included in this PR. This is required for feature completion. What Was Done Right8. Commit message format is correct The commit message follows the required format perfectly:
9. Changeset uses Both 10. No quality gate violations in the code
11. Test coverage is comprehensive (50 new tests)
12. E2E acceptance test matches the design doc specification The E2E test in 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 Recommendations
VerdictFAIL 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:
|
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>
42a75ae to
341e3f3
Compare
Re-review requested — all findings addressedAll 21 review findings from the initial review round have been addressed across two follow-up commits: Commit
|
- 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>
Round 3 fixes (MiniMax + GLM re-review findings)Commit
|
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.ts) — Cursor-based DOM walker with claim functions that advance through SSR nodes, skipping foreign elements (browser extensions, ad blockers)__element,__text,__child,__insertadopt existing SSR nodes when hydrating; new__append(no-op during hydration),__staticText,__enterChildren/__exitChildren__conditionalclaims comment anchors and active branch content;__listclaims existing items and skips initial reconciliation__enterChildren/__exitChildrenaround child construction, replacesappendChild→__append,createTextNode→__staticTextError recovery
console.debugin dev)console.warnin devendHydration()+ fallback to full CSR re-renderTest plan
🤖 Generated with Claude Code