Skip to content

fix(runtime-vapor): preserve hydration cursor for nested insertions#14786

Merged
edison1105 merged 6 commits intominorfrom
edison/fix/hydrationCursor
May 7, 2026
Merged

fix(runtime-vapor): preserve hydration cursor for nested insertions#14786
edison1105 merged 6 commits intominorfrom
edison/fix/hydrationCursor

Conversation

@edison1105
Copy link
Copy Markdown
Member

@edison1105 edison1105 commented May 7, 2026

Summary by CodeRabbit

Release Notes

  • Refactor

    • Simplified insertion state tracking in the compiler and runtime for more efficient component placement management.
    • Improved hydration cursor handling to ensure correct behavior in complex scenarios, including conditional rendering within teleports.
  • Tests

    • Updated test expectations to reflect simplified insertion state API.
    • Added enhanced hydration test coverage for edge cases.

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 7, 2026

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: b9914939-e4b9-4361-8699-d756ed5c91e1

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch edison/fix/hydrationCursor

Comment @coderabbitai help to get the list of available commands and usage tips.

@edison1105 edison1105 added the scope: vapor related to vapor mode label May 7, 2026
@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new Bot commented May 7, 2026

Open in StackBlitz

@vue/compiler-core

pnpm add https://pkg.pr.new/@vue/compiler-core@14786
npm i https://pkg.pr.new/@vue/compiler-core@14786
yarn add https://pkg.pr.new/@vue/compiler-core@14786.tgz

@vue/compiler-dom

pnpm add https://pkg.pr.new/@vue/compiler-dom@14786
npm i https://pkg.pr.new/@vue/compiler-dom@14786
yarn add https://pkg.pr.new/@vue/compiler-dom@14786.tgz

@vue/compiler-sfc

pnpm add https://pkg.pr.new/@vue/compiler-sfc@14786
npm i https://pkg.pr.new/@vue/compiler-sfc@14786
yarn add https://pkg.pr.new/@vue/compiler-sfc@14786.tgz

@vue/compiler-ssr

pnpm add https://pkg.pr.new/@vue/compiler-ssr@14786
npm i https://pkg.pr.new/@vue/compiler-ssr@14786
yarn add https://pkg.pr.new/@vue/compiler-ssr@14786.tgz

@vue/compiler-vapor

pnpm add https://pkg.pr.new/@vue/compiler-vapor@14786
npm i https://pkg.pr.new/@vue/compiler-vapor@14786
yarn add https://pkg.pr.new/@vue/compiler-vapor@14786.tgz

@vue/reactivity

pnpm add https://pkg.pr.new/@vue/reactivity@14786
npm i https://pkg.pr.new/@vue/reactivity@14786
yarn add https://pkg.pr.new/@vue/reactivity@14786.tgz

@vue/runtime-core

pnpm add https://pkg.pr.new/@vue/runtime-core@14786
npm i https://pkg.pr.new/@vue/runtime-core@14786
yarn add https://pkg.pr.new/@vue/runtime-core@14786.tgz

@vue/runtime-dom

pnpm add https://pkg.pr.new/@vue/runtime-dom@14786
npm i https://pkg.pr.new/@vue/runtime-dom@14786
yarn add https://pkg.pr.new/@vue/runtime-dom@14786.tgz

@vue/runtime-vapor

pnpm add https://pkg.pr.new/@vue/runtime-vapor@14786
npm i https://pkg.pr.new/@vue/runtime-vapor@14786
yarn add https://pkg.pr.new/@vue/runtime-vapor@14786.tgz

@vue/server-renderer

pnpm add https://pkg.pr.new/@vue/server-renderer@14786
npm i https://pkg.pr.new/@vue/server-renderer@14786
yarn add https://pkg.pr.new/@vue/server-renderer@14786.tgz

@vue/shared

pnpm add https://pkg.pr.new/@vue/shared@14786
npm i https://pkg.pr.new/@vue/shared@14786
yarn add https://pkg.pr.new/@vue/shared@14786.tgz

vue

pnpm add https://pkg.pr.new/vue@14786
npm i https://pkg.pr.new/vue@14786
yarn add https://pkg.pr.new/vue@14786.tgz

@vue/compat

pnpm add https://pkg.pr.new/@vue/compat@14786
npm i https://pkg.pr.new/@vue/compat@14786
yarn add https://pkg.pr.new/@vue/compat@14786.tgz

commit: 1b85039

@github-actions
Copy link
Copy Markdown

github-actions Bot commented May 7, 2026

Size Report

Bundles

File Size Gzip Brotli
compiler-dom.global.prod.js 86.5 kB 30.3 kB 26.6 kB
runtime-dom.global.prod.js 113 kB 42.6 kB 38.1 kB
vue.global.prod.js 172 kB 62.4 kB 55.6 kB

Usages

Name Size Gzip Brotli
createApp (CAPI only) 51.5 kB 20.1 kB 18.3 kB
createApp 60.5 kB 23.4 kB 21.3 kB
createApp + vaporInteropPlugin 101 kB (+420 B) 36.4 kB (+122 B) 32.9 kB (+83 B)
createVaporApp 27.4 kB 10.7 kB 9.78 kB
createSSRApp 65 kB 25.2 kB 22.9 kB
createVaporSSRApp 33.7 kB (+252 B) 12.8 kB (+69 B) 11.8 kB (+74 B)
defineCustomElement 67.1 kB 25.4 kB 23.1 kB
defineVaporCustomElement 40.2 kB 14.3 kB 13.1 kB
overall 75.8 kB 28.9 kB 26.2 kB

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
packages/runtime-vapor/src/apiCreateFragment.ts (1)

30-43: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Guard keyed-fragment cursor teardown with finally.

renderEffect(() => frag.update(...)) can synchronously hit user render code on the first pass. If that throws, Line 43 is skipped and the captured hydration cursor remains active for the rest of the hydration walk.

Suggested fix
   const hydrationCursor: HydrationCursor | null = isHydrating
     ? captureHydrationCursor()
     : null
 
-  const frag = __DEV__
-    ? new DynamicFragment('keyed', true)
-    : new DynamicFragment(undefined, true)
-
-  renderEffect(() => frag.update(render, key()))
-
-  if (!isHydrating) {
-    if (_insertionParent) insert(frag, _insertionParent, _insertionAnchor)
-  } else {
-    exitHydrationCursor(hydrationCursor)
-  }
-  return frag
+  try {
+    const frag = __DEV__
+      ? new DynamicFragment('keyed', true)
+      : new DynamicFragment(undefined, true)
+
+    renderEffect(() => frag.update(render, key()))
+
+    if (!isHydrating) {
+      if (_insertionParent) insert(frag, _insertionParent, _insertionAnchor)
+    }
+    return frag
+  } finally {
+    if (isHydrating) {
+      exitHydrationCursor(hydrationCursor)
+    }
+  }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/runtime-vapor/src/apiCreateFragment.ts` around lines 30 - 43, Wrap
the code path that captures and later exits the hydration cursor in a
try/finally so exitHydrationCursor(hydrationCursor) is always called even if
renderEffect(() => frag.update(render, key())) throws; specifically, when
isHydrating is true call captureHydrationCursor() into hydrationCursor, then
call renderEffect as before inside the try block, and call
exitHydrationCursor(hydrationCursor) in the finally block (while keeping the
existing insertion logic for the non-hydrating branch); reference
functions/variables: captureHydrationCursor, hydrationCursor, renderEffect,
frag.update, exitHydrationCursor, and isHydrating.
packages/runtime-vapor/src/componentSlots.ts (1)

193-213: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Release the slot hydration cursor via finally.

The cursor captured/entered here is only released on the happy path. If slot resolution or fragment.hydrate() throws before Line 312, nested hydration continues with a stale cursor and subsequent insertions can target the wrong range.

Suggested fix
   if (!isHydrating) resetInsertionState()
   let hydrationCursor: HydrationCursor | null = null
+  try {
 
   const instance = getScopeOwner()!
   const rawSlots = instance.rawSlots
@@
-  if (!isHydrating) {
-    if (!noSlotted) {
-      const scopeId = instance.type.__scopeId
-      if (scopeId) {
-        setScopeId(fragment, [`${scopeId}-s`])
-      }
-    }
-
-    if (_insertionParent) insert(fragment, _insertionParent, _insertionAnchor)
-  } else {
-    if (fragment.insert) {
-      ;(fragment as VaporFragment).hydrate!()
-    }
-    exitHydrationCursor(hydrationCursor)
-  }
-
-  return fragment
+    if (!isHydrating) {
+      if (!noSlotted) {
+        const scopeId = instance.type.__scopeId
+        if (scopeId) {
+          setScopeId(fragment, [`${scopeId}-s`])
+        }
+      }
+
+      if (_insertionParent) insert(fragment, _insertionParent, _insertionAnchor)
+    } else if (fragment.insert) {
+      ;(fragment as VaporFragment).hydrate!()
+    }
+
+    return fragment
+  } finally {
+    if (isHydrating) {
+      exitHydrationCursor(hydrationCursor)
+    }
+  }

Also applies to: 308-312

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/runtime-vapor/src/componentSlots.ts` around lines 193 - 213, Slot
hydration cursor (hydrationCursor) is only released on the happy path; wrap the
slot-resolution and subsequent fragment.hydrate() logic (the block that may call
enterHydrationCursor() or captureHydrationCursor() and creates
fragment/slotFragment) in a try/finally and in the finally release/reset the
cursor if set (i.e., call the appropriate cursor cleanup function used elsewhere
in this module after enterHydrationCursor()/captureHydrationCursor()), so that
hydrationCursor is always cleared even if slot resolution or fragment.hydrate()
throws; reference hydrationCursor, enterHydrationCursor(),
captureHydrationCursor(), and fragment.hydrate() to locate the code to wrap.
🧹 Nitpick comments (3)
packages/runtime-vapor/__tests__/hydration.spec.ts (2)

5079-5079: ⚡ Quick win

Assert hydration output before flushing a tick

At Line 5079, the pre-assertion await nextTick() can mask hydration-only regressions by allowing post-mount effects to settle first. Assert the initial DOM immediately after mountWithHydration, then tick for reactive updates.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/runtime-vapor/__tests__/hydration.spec.ts` at line 5079, The test
currently awaits nextTick() before asserting hydration output, which hides
hydration-only regressions; change the test to assert the initial DOM state
immediately after calling mountWithHydration (use the same
selectors/expectations used later) and only then call await nextTick() to allow
post-mount effects to run and assert subsequent reactive updates—update the test
around mountWithHydration and nextTick to place the initial assertion before the
await.

5066-5092: ⚡ Quick win

Verify anchor identity, not only serialized HTML

The current checks only validate innerHTML. A regression that recreates anchor comments could still pass. Capture the original start/end anchor nodes and assert they are preserved across hydration and the ok toggle.

Suggested test hardening
       const teleportContainer = document.createElement('div')
       teleportContainer.id = 'teleport-empty-if'
       teleportContainer.innerHTML =
         `<!--teleport start anchor-->` + `<!--teleport anchor-->`
+      const startAnchor = teleportContainer.childNodes[0]
+      const endAnchor = teleportContainer.childNodes[1]
       document.body.appendChild(teleportContainer)

       const { container } = await mountWithHydration(
         '<!--teleport start--><!--teleport end-->',
         `<teleport to="#teleport-empty-if">
           <span v-if="data.ok">{{data.msg}}</span>
         </teleport>`,
         data,
       )
-      await nextTick()

       expect(container.innerHTML).toBe(
         `<!--teleport start--><!--teleport end-->`,
       )
       expect(teleportContainer.innerHTML).toBe(
         `<!--teleport start anchor--><!--if--><!--teleport anchor-->`,
       )
+      expect(teleportContainer.childNodes[0]).toBe(startAnchor)
+      expect(teleportContainer.childNodes[2]).toBe(endAnchor)

       data.value.ok = true
       await nextTick()
       expect(teleportContainer.innerHTML).toBe(
         `<!--teleport start anchor--><span>foo</span><!--if--><!--teleport anchor-->`,
       )
+      expect(teleportContainer.childNodes[0]).toBe(startAnchor)
+      expect(teleportContainer.childNodes[3]).toBe(endAnchor)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/runtime-vapor/__tests__/hydration.spec.ts` around lines 5066 - 5092,
Store references to the original anchor comment nodes and assert their identity
instead of only checking innerHTML: after creating teleportContainer and before
calling mountWithHydration, capture the start/end anchor nodes (e.g., via
teleportContainer.firstChild / lastChild or by finding the comment nodes), then
after mountWithHydration and after toggling data.value.ok (and awaiting
nextTick), assert the captured nodes are the very same nodes (using reference
equality or Node.isSameNode) to ensure anchors were not recreated; keep the
existing innerHTML assertions but add these identity checks around the same
spots where teleportContainer.innerHTML is currently asserted.
packages/runtime-vapor/src/apiCreateIf.ts (1)

57-62: 💤 Low value

Hydration cursor entry inside renderEffect relies on synchronous first execution.

The cursor is entered inside the renderEffect callback but exited outside it (line 86). This works because Vue's renderEffect executes synchronously on its first run during hydration. While this is correct behavior, the implicit dependency on synchronous execution is subtle.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/runtime-vapor/src/apiCreateIf.ts` around lines 57 - 62, The
hydration cursor is entered inside the renderEffect callback via
enterHydrationCursor (when isHydrating is true) but exited outside it, relying
implicitly on renderEffect's first-run being synchronous; fix this by pairing
entry and exit within the same synchronous execution context: either move
enterHydrationCursor out of the renderEffect so you call it immediately before
invoking renderEffect (and keep the matching exit where it currently is), or
ensure the matching exit occurs inside the same renderEffect callback (so both
enterHydrationCursor and its corresponding exitHydrationCursor call are
colocated); update the code around renderEffect, enterHydrationCursor and the
existing exit call to guarantee the enter/exit happen in the same sync
execution.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@packages/runtime-vapor/src/apiCreateFor.ts`:
- Around line 93-95: The hydration cursor entered with
enterHydrationCursor(true) (assigned to hydrationCursor) must always be released
even if an exception occurs: wrap the code after the cursor is entered in a
try/finally and in the finally check if hydrationCursor is non-null and call its
release/unwind method (or call exitHydrationCursor/hydrationCursor.exit as
appropriate) to ensure global cursor state is restored; update both the
initial-entry site (where hydrationCursor is set) and the similar block around
lines 560–565 to use the same try/finally pattern so hydrateList()/renderItem()
exceptions cannot leak the cursor.

---

Outside diff comments:
In `@packages/runtime-vapor/src/apiCreateFragment.ts`:
- Around line 30-43: Wrap the code path that captures and later exits the
hydration cursor in a try/finally so exitHydrationCursor(hydrationCursor) is
always called even if renderEffect(() => frag.update(render, key())) throws;
specifically, when isHydrating is true call captureHydrationCursor() into
hydrationCursor, then call renderEffect as before inside the try block, and call
exitHydrationCursor(hydrationCursor) in the finally block (while keeping the
existing insertion logic for the non-hydrating branch); reference
functions/variables: captureHydrationCursor, hydrationCursor, renderEffect,
frag.update, exitHydrationCursor, and isHydrating.

In `@packages/runtime-vapor/src/componentSlots.ts`:
- Around line 193-213: Slot hydration cursor (hydrationCursor) is only released
on the happy path; wrap the slot-resolution and subsequent fragment.hydrate()
logic (the block that may call enterHydrationCursor() or
captureHydrationCursor() and creates fragment/slotFragment) in a try/finally and
in the finally release/reset the cursor if set (i.e., call the appropriate
cursor cleanup function used elsewhere in this module after
enterHydrationCursor()/captureHydrationCursor()), so that hydrationCursor is
always cleared even if slot resolution or fragment.hydrate() throws; reference
hydrationCursor, enterHydrationCursor(), captureHydrationCursor(), and
fragment.hydrate() to locate the code to wrap.

---

Nitpick comments:
In `@packages/runtime-vapor/__tests__/hydration.spec.ts`:
- Line 5079: The test currently awaits nextTick() before asserting hydration
output, which hides hydration-only regressions; change the test to assert the
initial DOM state immediately after calling mountWithHydration (use the same
selectors/expectations used later) and only then call await nextTick() to allow
post-mount effects to run and assert subsequent reactive updates—update the test
around mountWithHydration and nextTick to place the initial assertion before the
await.
- Around line 5066-5092: Store references to the original anchor comment nodes
and assert their identity instead of only checking innerHTML: after creating
teleportContainer and before calling mountWithHydration, capture the start/end
anchor nodes (e.g., via teleportContainer.firstChild / lastChild or by finding
the comment nodes), then after mountWithHydration and after toggling
data.value.ok (and awaiting nextTick), assert the captured nodes are the very
same nodes (using reference equality or Node.isSameNode) to ensure anchors were
not recreated; keep the existing innerHTML assertions but add these identity
checks around the same spots where teleportContainer.innerHTML is currently
asserted.

In `@packages/runtime-vapor/src/apiCreateIf.ts`:
- Around line 57-62: The hydration cursor is entered inside the renderEffect
callback via enterHydrationCursor (when isHydrating is true) but exited outside
it, relying implicitly on renderEffect's first-run being synchronous; fix this
by pairing entry and exit within the same synchronous execution context: either
move enterHydrationCursor out of the renderEffect so you call it immediately
before invoking renderEffect (and keep the matching exit where it currently is),
or ensure the matching exit occurs inside the same renderEffect callback (so
both enterHydrationCursor and its corresponding exitHydrationCursor call are
colocated); update the code around renderEffect, enterHydrationCursor and the
existing exit call to guarantee the enter/exit happen in the same sync
execution.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: b7564653-1416-4d54-81b1-7dd4740017c9

📥 Commits

Reviewing files that changed from the base of the PR and between fb97d04 and 4efae81.

⛔ Files ignored due to path filters (9)
  • packages/compiler-vapor/__tests__/__snapshots__/compile.spec.ts.snap is excluded by !**/*.snap
  • packages/compiler-vapor/__tests__/transforms/__snapshots__/TransformTransition.spec.ts.snap is excluded by !**/*.snap
  • packages/compiler-vapor/__tests__/transforms/__snapshots__/logicalIndex.spec.ts.snap is excluded by !**/*.snap
  • packages/compiler-vapor/__tests__/transforms/__snapshots__/transformChildren.spec.ts.snap is excluded by !**/*.snap
  • packages/compiler-vapor/__tests__/transforms/__snapshots__/transformKey.spec.ts.snap is excluded by !**/*.snap
  • packages/compiler-vapor/__tests__/transforms/__snapshots__/vFor.spec.ts.snap is excluded by !**/*.snap
  • packages/compiler-vapor/__tests__/transforms/__snapshots__/vIf.spec.ts.snap is excluded by !**/*.snap
  • packages/compiler-vapor/__tests__/transforms/__snapshots__/vOnce.spec.ts.snap is excluded by !**/*.snap
  • packages/compiler-vapor/__tests__/transforms/__snapshots__/vSlot.spec.ts.snap is excluded by !**/*.snap
📒 Files selected for processing (20)
  • packages/compiler-vapor/__tests__/compile.spec.ts
  • packages/compiler-vapor/__tests__/transforms/logicalIndex.spec.ts
  • packages/compiler-vapor/__tests__/transforms/transformChildren.spec.ts
  • packages/compiler-vapor/src/generators/operation.ts
  • packages/compiler-vapor/src/ir/index.ts
  • packages/compiler-vapor/src/transforms/transformChildren.ts
  • packages/runtime-vapor/__tests__/componentSlots.spec.ts
  • packages/runtime-vapor/__tests__/components/Teleport.spec.ts
  • packages/runtime-vapor/__tests__/customElement.spec.ts
  • packages/runtime-vapor/__tests__/hydration.spec.ts
  • packages/runtime-vapor/__tests__/scopeId.spec.ts
  • packages/runtime-vapor/src/apiCreateDynamicComponent.ts
  • packages/runtime-vapor/src/apiCreateFor.ts
  • packages/runtime-vapor/src/apiCreateFragment.ts
  • packages/runtime-vapor/src/apiCreateIf.ts
  • packages/runtime-vapor/src/component.ts
  • packages/runtime-vapor/src/componentSlots.ts
  • packages/runtime-vapor/src/dom/hydration.ts
  • packages/runtime-vapor/src/fragment.ts
  • packages/runtime-vapor/src/insertionState.ts
💤 Files with no reviewable changes (1)
  • packages/compiler-vapor/src/ir/index.ts

Comment thread packages/runtime-vapor/src/apiCreateFor.ts
@edison1105 edison1105 merged commit c75d471 into minor May 7, 2026
17 checks passed
@edison1105 edison1105 deleted the edison/fix/hydrationCursor branch May 7, 2026 08:31
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

scope: vapor related to vapor mode

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant