Skip to content

Reimplement hydration with deterministic comment boundaries#58

Open
adebola-io wants to merge 7 commits into
mainfrom
redo-hydration
Open

Reimplement hydration with deterministic comment boundaries#58
adebola-io wants to merge 7 commits into
mainfrom
redo-hydration

Conversation

@adebola-io

Copy link
Copy Markdown
Collaborator

No description provided.

Remove unused hydration functionality and server-side context passing
from the server and client packages to simplify rendering.

After applying these changes, please rebuild the packages and run tests
using `pnpm run test`.
- Remove `data-retend-hydration` attribute after hydration completes.
- Replace CSS selector-based teleport lookups with attribute comparison
  to
  handle special characters.
- Ensure proper cleanup during effect node disposal.

Run `pnpm install` and `pnpm run test` after updating.
- Simplify `hydrate` entry point and remove internal docs.
- Enforce strict hydration structural matching.
- Rebuild packages before running tests with `pnpm run test`.
- Streamline hydration sequence in `hydrate`
- Simplify `renderToString` recursion
- Clean up hydration stack and state management in `DOMRenderer`
- Refactor teleport counter and state initialization
- Standardize node flattening using `.flat()`
- Improve `Await` promise tracking and disposal
- Fix `For` loop initial node rendering
- Add `createSwitch` helper for Switch component
- Optimize hydration data access and `If` component logic
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Preview URL Updated (UTC)
✅ Deployment successful!
View logs
retend 06cddb1 Commit Preview URL

Branch Preview URL
Jul 23 2026, 06:26 PM

@qodo-code-review

Copy link
Copy Markdown
Contributor

PR Summary by Qodo

Deterministic hydration via serialized comment ranges (drop data-dyn markers)

✨ Enhancement 🐞 Bug fix 🧪 Tests 📝 Documentation 🕐 40+ Minutes

Grey Divider

AI Description

• Replace data-dyn hydration markers with deterministic comment range boundaries.
• Enforce strict structural claiming and fail hydration on unclaimed server nodes.
• Stabilize Teleport hydration and async boundary completion; expand regression test coverage.
Diagram

graph TD
  A["SSR render (retend-server)"] --> B["VDOMRenderer"] --> C["renderToString"] --> D["HTML w/ markers"] --> E["hydrate() client"] --> F["DOMRenderer hydration"] --> G["endHydration + effects"]
  D --> H["Teleport anchors"] --> F
  subgraph Legend
    direction LR
    _svc(["Module/Service"]) ~~~ _data[("Serialized HTML")]
  end
Loading
High-Level Assessment

The following are alternative approaches to this PR:

1. Keep element-level data-dyn markers
  • ➕ Simpler claiming logic for elements (direct lookup by id).
  • ➕ Avoids needing comment-range stack parsing.
  • ➖ Brittle across DOM transformations/shadow DOM/teleports and can require dynamic id bookkeeping.
  • ➖ Harder to support text/fragment boundaries deterministically without extra markers.
2. Keyed hydration using explicit component keys
  • ➕ More semantic matching than structural claiming; can tolerate some reordering.
  • ➕ Potentially better developer ergonomics for complex lists.
  • ➖ Requires API surface (keys) to be consistently provided and stable across SSR/CSR.
  • ➖ Still needs a robust boundary representation for fragments/text and async control flow.
3. DOM diff-based hydration (patch existing DOM to match VDOM)
  • ➕ Can recover from some mismatches instead of hard-failing.
  • ➕ Potentially fewer strict constraints on SSR output.
  • ➖ Significantly higher complexity and runtime cost.
  • ➖ Risk of subtle event/ordering bugs; harder to make deterministic across async/teleports.

Recommendation: The PR’s shift to serialized comment range boundaries + strict claiming is the best fit for deterministic hydration across fragments, text nodes, ShadowRoot, and Teleport. Keep the strict failure mode (unclaimed server nodes) to prevent silent corruption; the added tests already validate key edge cases (text markers, raw HTML, divergent branches, teleport retries).

Files changed (28) +1933 / -2137

Enhancement (4) +649 / -808
client.jsSimplify hydrate(): opt-in root marker, robust cleanup on failure +47/-121

Simplify hydrate(): opt-in root marker, robust cleanup on failure

• Removes server-context script dependency and dev-mode SPA fallback logic. Hydration now uses current browser location as initial router state, hydrates only when root has data-retend-hydration="1", removes the marker after success, and performs full cleanup (detach router listeners, dispose state node, restore global context) on errors.

packages/retend-server/source/client.js

render-to-string.jsSerialize deterministic text and empty-text comment markers +31/-37

Serialize deterministic text and empty-text comment markers

• Renames the text separator marker to <!--retend:text-separator--> and introduces <!--retend:empty-text--> for empty text nodes. Refactors child rendering into a helper and consistently inserts separators between adjacent text nodes to prevent parser merging.

packages/retend-server/source/render-to-string.js

server.jsSSR marks hydration root and hardens Teleport mounting loop +82/-112

SSR marks hydration root and hardens Teleport mounting loop

• Stops emitting a server-context JSON script and instead marks the root with data-retend-hydration="1". Updates SSR Teleport mounting to retry until resolved (or fail if stalled) and ensures async boundaries settle between retries.

packages/retend-server/source/server.js

dom-renderer.jsReimplement hydration with deterministic comment ranges and strict claiming +489/-538

Reimplement hydration with deterministic comment ranges and strict claiming

• Replaces data-dyn/table-based hydration with a frame/range cursor that deterministically claims existing DOM nodes and dynamic ranges marked by retend:range-start/end comments. Adds strict mismatch handling (HydrationMismatchError), tracks fragment children during hydration, supports empty text markers, and asserts that all server nodes are claimed before completing hydration.

packages/retend-web/source/dom-renderer.js

Bug fix (6) +248 / -213
index.jsTeleport mounting returns progress and removes unused flags +6/-13

Teleport mounting returns progress and removes unused flags

• Simplifies VNode/VText by removing unused hydration-related flags. Reworks document.mountAllTeleports() to process a snapshot of pending mounts, requeue unresolved ones, and return a resolved-count used for stall detection.

packages/retend-server/source/v-dom/index.js

teleport.jsHydration-safe Teleport claiming and disposal +65/-34

Hydration-safe Teleport claiming and disposal

• Uses serialized teleport anchor ids (retend:teleport:...) and claims existing teleport containers via renderer.claimHydrationTeleportContainer() rather than relying on CSS selector parsing for ids. Adds disposal safety (onSetup-driven cleanup) and ensures deferred mounts can be retried without leaking containers.

packages/retend-web/source/teleport.js

await.jsAwait: cancellable tracking and handle-based rendering +67/-46

Await: cancellable tracking and handle-based rendering

• Reworks Await to render into a group handle, swapping fallback/content via renderer.write(). Tracks async work so waitForAsyncBoundaries() stops waiting once the owning effect node is disposed, preventing hangs from abandoned async branches.

packages/retend/source/library/await.js

for.jsFor: stable initial rendering and hydration handle propagation +45/-87

For: stable initial rendering and hydration handle propagation

• Standardizes node flattening and renders initial list content by writing into a created group handle (instead of linkNodes). Stores the handle on snapshot.data for hydration-aware renderer behavior and simplifies promise handling for list updates.

packages/retend/source/library/for.js

scope.jsEffectNode: explicit disposal hooks and consistent localContext reset +9/-13

EffectNode: explicit disposal hooks and consistent localContext reset

• Adds EffectNode.addDispose() used by Await to cancel waiting when disposed. Refines dispose behavior to return early when inactive/disabled and standardizes resetting localContext after disposal.

packages/retend/source/library/scope.js

unique.jsUnique: coordinate rendering with pending Await and handle rewrites +56/-20

Unique: coordinate rendering with pending Await and handle rewrites

• Moves Unique rendering to a stable group/handle created on first instance and introduces an internal render() that can defer commits until pending Await completes. Ensures Unique can recover and render after an earlier pending Await subtree is removed, without leaving stale instance state.

packages/retend/source/library/unique.js

Refactor (6) +132 / -645
renderer.jsRemove data-dyn generation; stable teleport anchors with snapshot state +24/-92

Remove data-dyn generation; stable teleport anchors with snapshot state

• Drops markDynamicNodes/data-dyn behavior and related reactive-child detection. scheduleTeleport() now serializes a comment anchor with a stable id (retend:teleport:...) and captures render state via withState() when mounting teleports.

packages/retend-server/source/v-dom/renderer.js

dom-ops.jsSimplify range writes; remove hydration handle finalization logic +8/-180

Simplify range writes; remove hydration handle finalization logic

• Optimizes write() to no-op when content is already in place. Removes finalizeHydrationHandleSegment() and the previous heuristic comment-pair resolution/synthesis used by the old hydration implementation.

packages/retend-web/source/dom-ops.js

shadowroot.jsDefer ShadowRoot children materialization +0/-2

Defer ShadowRoot children materialization

• Stops pre-converting ShadowRoot children templates into nodes at component creation time. Relies on renderer append/hydration logic to materialize/claim children appropriately.

packages/retend-web/source/shadowroot.js

utils.jsRename range comment markers and remove old hydration helpers +10/-131

Rename range comment markers and remove old hydration helpers

• Updates createCommentPair() to emit retend:range-start/end instead of [ / ]. Improves style serialization for async style cells and avoids redundant innerHTML writes. Removes Skip/DeferredHandleSymbol and multiple dynamic/reactive detection utilities used by the previous hydration implementation.

packages/retend-web/source/utils.js

if.jsIf: handle-based output and simplified branch selection +24/-60

If: handle-based output and simplified branch selection

• Simplifies callback selection logic and always returns a flat node array. Uses a group handle stored on snapshot.data and updates content via renderer.write() for both initial and reactive changes, aligning with the new hydration ownership model.

packages/retend/source/library/if.js

switch.jsRefactor Switch to shared createSwitch implementation +66/-180

Refactor Switch to shared createSwitch implementation

• Introduces a shared createSwitch() helper used by both Switch and Switch.OnProperty. Moves switch rendering to group-handle writes and ensures listener registration precedes initial get(), improving consistency with hydration and async updates.

packages/retend/source/library/switch.js

Tests (11) +901 / -468
await.spec.tsxAdd regression test for disposed Await not blocking async completion +35/-1

Add regression test for disposed Await not blocking async completion

• Adds coverage ensuring waitForAsyncBoundaries() resolves even when an initially pending Await subtree is disposed. Imports waitForAsyncBoundaries for direct verification.

tests/async/await.spec.tsx

client-boundaries.spec.tsxUpdate vDomSetup usage after removing markDynamicNodes option +2/-2

Update vDomSetup usage after removing markDynamicNodes option

• Adjusts SSR setup helper calls to match the simplified VDOMRenderer constructor without markDynamicNodes.

tests/hydration/client-boundaries.spec.tsx

hydration-async.spec.tsxUpdate async hydration tests for new range markers and disposal behavior +79/-65

Update async hydration tests for new range markers and disposal behavior

• Adds a test ensuring hydration completion isn’t blocked by late async work from disposed branches. Updates assertions from [/] to retend:range-start/end markers and adapts flows to explicitly wait for async boundaries then endHydration().

tests/hydration/hydration-async.spec.tsx

hydration-await.spec.tsxStrengthen Await+Teleport hydration tests and ordering semantics +148/-7

Strengthen Await+Teleport hydration tests and ordering semantics

• Reworks helpers to start hydration with Await wrapper and captures pre-end DOM for better debugging. Adds cases covering deferred Teleport async work, Teleport retry after target appears, and sibling Teleports resolving in different client order.

tests/hydration/hydration-await.spec.tsx

hydration-helpers.tsxHydration helpers: data-retend-hydration root, Await wrapper, teleport loop +30/-15

Hydration helpers: data-retend-hydration root, Await wrapper, teleport loop

• Server helper now uses the simplified VDOMRenderer and retries Teleport mounts until progress stops (failing if stalled). Client helper marks the root with data-retend-hydration="1" and enables hydration mode against that root. Async hydration start now renders via Await(fallback:null) to match production hydrate behavior.

tests/hydration/hydration-helpers.tsx

hydration-router-parent-dialog.spec.tsxUpdate router hydration test to new root marker and Await-based SSR render +14/-6

Update router hydration test to new root marker and Await-based SSR render

• Updates SSR rendering to use Await wrapper before serializing and removes server-context script usage in the client HTML shell. Uses real browser location for initial route setup and waits for async boundaries explicitly.

tests/hydration/hydration-router-parent-dialog.spec.tsx

hydration-router-unique.spec.tsxAdd hydrate() contract tests and update SSR renderer construction +95/-12

Add hydrate() contract tests and update SSR renderer construction

• Adds tests verifying hydrate() falls back to client render for empty/whitespace roots, resets context after a prior render, detaches router listeners on hydration failure, and preserves location hash. Updates SSR setup to use the simplified VDOMRenderer API.

tests/hydration/hydration-router-unique.spec.tsx

hydration.spec.tsxExpand hydration mismatch coverage and Teleport id/selector safety tests +406/-57

Expand hydration mismatch coverage and Teleport id/selector safety tests

• Adds extensive tests for strict structural mismatches (no hydrationcompleted, no setup effects), unclaimed server nodes, text mismatch update behavior, dangerouslySetInnerHTML preservation/update, and Teleport id claiming (serialized id, selector-safe id, external container preservation). Ensures hydrationcompleted fires after setup effects and Teleport setup runs once.

tests/hydration/hydration.spec.tsx

serialization.spec.tsxUpdate serialization expectations for new markers and remove data-dyn tests +50/-301

Update serialization expectations for new markers and remove data-dyn tests

• Updates text-separator expectations and adds coverage for preserving whitespace-only text nodes and explicitly serializing empty reactive text slots. Removes dynamic marker (data-dyn) assertions and validates control-flow serialization via retend:range-start/end nesting.

tests/serialization.spec.tsx

setup.tsxRemove markDynamicNodes option from vDomSetup +2/-2

Remove markDynamicNodes option from vDomSetup

• Simplifies vDomSetup() to always construct VDOMRenderer(window) without options, reflecting removal of SSR dynamic marker mode.

tests/setup.tsx

unique.spec.tsxAdd regression test for Unique rendering after pending Await subtree removal +40/-0

Add regression test for Unique rendering after pending Await subtree removal

• Adds a test ensuring a Unique component can later render in a live subtree after the initial pending Await subtree that contained it is removed, validating new Unique/Await coordination.

tests/unique.spec.tsx

Documentation (1) +3 / -3
22-hydration.mdxDocument hydration contract and opt-in marker +3/-3

Document hydration contract and opt-in marker

• Replaces dev-mode oriented guidance with an explicit hydration contract. Documents that hydration occurs only when the root is marked with data-retend-hydration="1", otherwise the root is client-rendered, and clarifies strict structural matching behavior.

docs/content/22-hydration.mdx

@qodo-code-review

Copy link
Copy Markdown
Contributor

Code Review by Qodo

🐞 Bugs (1) 📘 Rule violations (0) 📜 Skill insights (0)

Grey Divider


Remediation recommended

1. Claim flag type mismatch 🐞 Bug ≡ Correctness
Description
DOMRenderer.#markClaimed stores hydrationData(node).claim as boolean true, but #markClientNode
later assumes claim is an object and writes claim.opaque, which can throw a TypeError if the
same element is ever marked as both claimed and client-owned during hydration updates.
Code

packages/retend-web/source/dom-renderer.js[R791-799]

+  #markClaimed(node) {
+    if (node) hydrationData(node).claim ??= true;
  }

-  /**
-   * @param {{ cursor: number, renderDepth: number, pendingHydrations: number, hydrating: boolean }} state
-   */
-  #maybeCompleteHydrationBranch(state) {
-    if (!state.hydrating) return;
-    const branchIsIdle =
-      state.renderDepth === 0 && state.pendingHydrations === 0;
-    if (!branchIsIdle) return;
-    state.hydrating = false;
-    this.#hydratingBranchCount = Math.max(0, this.#hydratingBranchCount - 1);
+  /** @param {Node} node */
+  #markClientNode(node) {
+    const claim = (hydrationData(node).claim ??= {});
+    if (node.nodeType === ELEMENT_NODE) claim.opaque = true;
  }
Evidence
The code explicitly stores claim as a boolean in #markClaimed, but later mutates claim as an
object in #markClientNode. #markClaimed is used on an actual Element (Teleport container), and
#markClientNode is executed during hydration writes/reconciles for client-owned ranges, creating a
clear type inconsistency that can become a runtime exception when the same node hits both paths.

packages/retend-web/source/dom-renderer.js[785-799]
packages/retend-web/source/dom-renderer.js[580-593]
packages/retend-web/source/dom-renderer.js[177-195]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`packages/retend-web/source/dom-renderer.js` uses `hydrationData(node).claim` with two incompatible shapes:
- `#markClaimed()` initializes it to boolean `true`.
- `#markClientNode()` treats it as an object and assigns `claim.opaque = true`.

If an element with `claim === true` later flows into `#markClientNode()`, the property assignment will throw (strict mode), potentially aborting hydration.

## Issue Context
This PR introduced the new hydration metadata model (`HYDRATION` symbol) and these helpers. `#markClaimed()` is used at least for Teleport containers and hydration separators.

## Fix Focus Areas
- packages/retend-web/source/dom-renderer.js[790-799]
- packages/retend-web/source/dom-renderer.js[580-592]
- packages/retend-web/source/dom-renderer.js[177-195]

## Suggested fix
1. Make `claim` a single consistent type (object) everywhere:
  - Change `#markClaimed(node)` to initialize `claim` as `{}` (or a minimal object shape), not `true`.
2. Defensive normalization in `#markClientNode(node)`:
  - If `hydrationData(node).claim === true`, replace it with `{}` before setting `opaque`.
3. Update any logic that relies on truthiness of `claim` (`#isClaimed`) to continue working with the object representation.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

Qodo Logo

Comment on lines +791 to 799
#markClaimed(node) {
if (node) hydrationData(node).claim ??= true;
}

/**
* @param {{ cursor: number, renderDepth: number, pendingHydrations: number, hydrating: boolean }} state
*/
#maybeCompleteHydrationBranch(state) {
if (!state.hydrating) return;
const branchIsIdle =
state.renderDepth === 0 && state.pendingHydrations === 0;
if (!branchIsIdle) return;
state.hydrating = false;
this.#hydratingBranchCount = Math.max(0, this.#hydratingBranchCount - 1);
/** @param {Node} node */
#markClientNode(node) {
const claim = (hydrationData(node).claim ??= {});
if (node.nodeType === ELEMENT_NODE) claim.opaque = true;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Remediation recommended

1. Claim flag type mismatch 🐞 Bug ≡ Correctness

DOMRenderer.#markClaimed stores hydrationData(node).claim as boolean true, but #markClientNode
later assumes claim is an object and writes claim.opaque, which can throw a TypeError if the
same element is ever marked as both claimed and client-owned during hydration updates.
Agent Prompt
## Issue description
`packages/retend-web/source/dom-renderer.js` uses `hydrationData(node).claim` with two incompatible shapes:
- `#markClaimed()` initializes it to boolean `true`.
- `#markClientNode()` treats it as an object and assigns `claim.opaque = true`.

If an element with `claim === true` later flows into `#markClientNode()`, the property assignment will throw (strict mode), potentially aborting hydration.

## Issue Context
This PR introduced the new hydration metadata model (`HYDRATION` symbol) and these helpers. `#markClaimed()` is used at least for Teleport containers and hydration separators.

## Fix Focus Areas
- packages/retend-web/source/dom-renderer.js[790-799]
- packages/retend-web/source/dom-renderer.js[580-592]
- packages/retend-web/source/dom-renderer.js[177-195]

## Suggested fix
1. Make `claim` a single consistent type (object) everywhere:
   - Change `#markClaimed(node)` to initialize `claim` as `{}` (or a minimal object shape), not `true`.
2. Defensive normalization in `#markClientNode(node)`:
   - If `hydrationData(node).claim === true`, replace it with `{}` before setting `opaque`.
3. Update any logic that relies on truthiness of `claim` (`#isClaimed`) to continue working with the object representation.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

@pkg-pr-new

pkg-pr-new Bot commented Jul 23, 2026

Copy link
Copy Markdown

Open in StackBlitz

retend

npm i https://pkg.pr.new/resuite/retend@58

retend-oxlint-plugin

npm i https://pkg.pr.new/resuite/retend/retend-oxlint-plugin@58

retend-server

npm i https://pkg.pr.new/resuite/retend/retend-server@58

retend-start

npm i https://pkg.pr.new/resuite/retend/retend-start@58

retend-utils

npm i https://pkg.pr.new/resuite/retend/retend-utils@58

retend-web

npm i https://pkg.pr.new/resuite/retend/retend-web@58

retend-web-devtools

npm i https://pkg.pr.new/resuite/retend/retend-web-devtools@58

commit: 06cddb1

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