Thanks to visit codestin.com
Credit goes to github.com

Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions packages/runtime-vapor/__tests__/hydration.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5156,6 +5156,31 @@ describe('Vapor Mode hydration', () => {
)
})

test('disabled teleport range should count as one logical child during hydration', async () => {
const data = ref({ msg: 'after' })

const { container } = await mountWithHydration(
'<div><!--teleport start--><span>teleported</span><!--teleport end--><p>after</p></div>',
`<div>
<teleport :to="undefined" :disabled="true">
<span>teleported</span>
</teleport>
<p>{{data.msg}}</p>
</div>`,
data,
)

expect(container.innerHTML).toBe(
'<div><!--teleport start--><span>teleported</span><!--teleport end--><p>after</p></div>',
)

data.value.msg = 'updated'
await nextTick()
expect(container.innerHTML).toBe(
'<div><!--teleport start--><span>teleported</span><!--teleport end--><p>updated</p></div>',
)
})

test('enabled teleport with null target', async () => {
const { container } = await mountWithHydration(
'<!--teleport start--><!--teleport end-->',
Expand Down
4 changes: 2 additions & 2 deletions packages/runtime-vapor/src/apiCreateFor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,8 @@ import {
isComment,
isHydrating,
locateHydrationBoundaryClose,
locateNextNode,
markHydrationAnchor,
nextLogicalSibling,
setCurrentHydrationNode,
} from './dom/hydration'
import {
Expand Down Expand Up @@ -452,7 +452,7 @@ export const createFor = (
nextNode = markHydrationAnchor(currentHydrationNode!)
setCurrentHydrationNode(nextNode)
} else {
nextNode = locateNextNode(currentHydrationNode!)
nextNode = nextLogicalSibling(currentHydrationNode!)
}
mount(source, i)
if (nextNode) setCurrentHydrationNode(nextNode)
Expand Down
6 changes: 3 additions & 3 deletions packages/runtime-vapor/src/component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,8 +96,8 @@ import {
isComment,
isHydrating,
locateEndAnchor,
locateNextNode,
markHydrationAnchor,
nextLogicalSibling,
setCurrentHydrationNode,
withDeferredHydrationBoundary,
} from './dom/hydration'
Expand Down Expand Up @@ -893,7 +893,7 @@ export function createComponentWithFallback(
return node as any as HTMLElement
}

const nextAnchor = locateNextNode(currentHydrationNode)
const nextAnchor = nextLogicalSibling(currentHydrationNode)
if (nextAnchor && isReusableNullComponentAnchor(nextAnchor)) {
// Keep the cursor on the stale SSR node before `nextAnchor` so the
// owning DynamicFragment can trim that range on hydrate exit and then
Expand Down Expand Up @@ -967,7 +967,7 @@ export function createPlainElement(
if (rawSlots) {
let nextNode: Node | null = null
if (isHydrating) {
nextNode = locateNextNode(el)
nextNode = nextLogicalSibling(el)
setCurrentHydrationNode(el.firstChild)
}
if (rawSlots.$) {
Expand Down
4 changes: 2 additions & 2 deletions packages/runtime-vapor/src/components/TransitionGroup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,8 +50,8 @@ import {
cleanupHydrationTail,
currentHydrationNode,
isHydrating,
locateNextNode,
markHydrationAnchor,
nextLogicalSibling,
setCurrentHydrationNode,
} from '../dom/hydration'

Expand Down Expand Up @@ -208,7 +208,7 @@ const VaporTransitionGroupImpl = defineVaporComponent({
ensureForHydrationAnchorResolver()
prevForHydrationContainer = currentForHydrationContainer
currentForHydrationContainer = container
nextNode = locateNextNode(container)
nextNode = nextLogicalSibling(container)
setCurrentHydrationNode(container.firstChild || container)
}
let block: Block = slottedBlock
Expand Down
18 changes: 9 additions & 9 deletions packages/runtime-vapor/src/dom/hydration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -232,12 +232,12 @@ function adoptTemplateImpl(node: Node, template: string): Node | null {
return node
}

export function locateNextNode(node: Node): Node | null {
export function nextLogicalSibling(node: Node): Node | null {
return isComment(node, '[')
? _next(locateEndAnchor(node)!)
? locateEndAnchor(node)!.nextSibling
: isComment(node, 'teleport start')
? _next(locateEndAnchor(node, 'teleport start', 'teleport end')!)
: _next(node)
? locateEndAnchor(node, 'teleport start', 'teleport end')!.nextSibling
: node.nextSibling
}
Comment on lines +235 to 241

@coderabbitai coderabbitai Bot May 18, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Potential null dereference if locateEndAnchor returns null.

locateEndAnchor can return null (line 295) when no matching end anchor exists. The non-null assertions on lines 237 and 239 followed by .nextSibling access will throw if the SSR output is malformed or truncated.

Other callers handle this defensively (e.g., line 409: end || undefined).

🛡️ Proposed fix with null guards
 export function nextLogicalSibling(node: Node): Node | null {
-  return isComment(node, '[')
-    ? locateEndAnchor(node)!.nextSibling
-    : isComment(node, 'teleport start')
-      ? locateEndAnchor(node, 'teleport start', 'teleport end')!.nextSibling
-      : node.nextSibling
+  if (isComment(node, '[')) {
+    const end = locateEndAnchor(node)
+    return end ? end.nextSibling : null
+  }
+  if (isComment(node, 'teleport start')) {
+    const end = locateEndAnchor(node, 'teleport start', 'teleport end')
+    return end ? end.nextSibling : null
+  }
+  return node.nextSibling
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
export function nextLogicalSibling(node: Node): Node | null {
return isComment(node, '[')
? _next(locateEndAnchor(node)!)
? locateEndAnchor(node)!.nextSibling
: isComment(node, 'teleport start')
? _next(locateEndAnchor(node, 'teleport start', 'teleport end')!)
: _next(node)
? locateEndAnchor(node, 'teleport start', 'teleport end')!.nextSibling
: node.nextSibling
}
export function nextLogicalSibling(node: Node): Node | null {
if (isComment(node, '[')) {
const end = locateEndAnchor(node)
return end ? end.nextSibling : null
}
if (isComment(node, 'teleport start')) {
const end = locateEndAnchor(node, 'teleport start', 'teleport end')
return end ? end.nextSibling : null
}
return node.nextSibling
}
🤖 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/dom/hydration.ts` around lines 235 - 241, The
function nextLogicalSibling uses non-null assertions on locateEndAnchor results
which can be null; update nextLogicalSibling to defensively handle a null return
from locateEndAnchor (both the '[' branch and the 'teleport start' branch) by
checking the result of locateEndAnchor(node) (and locateEndAnchor(node,
'teleport start','teleport end')) before accessing .nextSibling and return null
(or fallback to node.nextSibling only when safe) instead of using the bang
operator; refer to the nextLogicalSibling, locateEndAnchor, and isComment
symbols to locate the code and mirror the defensive pattern used by other
callers (e.g., using optional chaining or explicit null checks).

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

I don't think this should be guarded here. nextLogicalSibling() preserves the same invariant as the previous locateNextNode() implementation, which already used locateEndAnchor(...)! for Vue-owned structural marker pairs.

A missing fragment / Teleport end marker means the SSR structural range is corrupted or truncated. Returning null from the normal logical stepping helper would silently change boundary cleanup behavior and could hide the broken range. The defensive end || undefined path is specific to cleanup after a node has already been selected for removal, not the general sibling-stepping contract.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

@edison1105, that's a clear and well-reasoned distinction — thanks for the explanation. You're right that:

  1. Invariant parity: locateNextNode already used locateEndAnchor(...)!, so nextLogicalSibling preserves the same contract rather than weakening it.
  2. Fail-loud is correct here: A missing ] or teleport end marker means the SSR output is structurally corrupted. Returning null from the stepping helper would silently short-circuit cleanupHydrationTail / finalizeHydrationBoundary loops, potentially leaving orphaned SSR nodes without surfacing the real problem.
  3. Context of the defensive pattern: The end || undefined guard (e.g., in removeFragmentNodes) belongs to the cleanup/removal contract where the node being cleaned up has already been selected, and graceful no-op behavior is appropriate. That's a fundamentally different concern from the general sibling-stepping path.

I'm withdrawing the suggestion — the non-null assertions are intentional and correct.


✏️ Learnings added
Learnt from: edison1105
Repo: vuejs/core PR: 14832
File: packages/runtime-vapor/src/dom/hydration.ts:235-241
Timestamp: 2026-05-18T01:05:23.718Z
Learning: In `packages/runtime-vapor/src/dom/hydration.ts`, the `nextLogicalSibling` function (and its predecessor `locateNextNode`) intentionally uses non-null assertions (`!`) on `locateEndAnchor` results. Vue's SSR renderer always emits matched structural marker pairs (`[`/`]` for fragments, `teleport start`/`teleport end` for teleports). A missing end marker indicates fatal SSR output corruption; returning `null` from the stepping helper would silently short-circuit boundary cleanup loops (`cleanupHydrationTail`, `finalizeHydrationBoundary`) and hide the corruption. The defensive `end || undefined` pattern used elsewhere (e.g., `removeFragmentNodes`) is specific to cleanup/removal paths where a graceful no-op is appropriate, not the general sibling-stepping contract.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.


🧠 Learnings used
Learnt from: edison1105
Repo: vuejs/core PR: 14786
File: packages/runtime-vapor/src/apiCreateFor.ts:93-95
Timestamp: 2026-05-07T03:25:36.641Z
Learning: In `packages/runtime-vapor` hydration-cursor code paths that use `enterHydrationCursor` (e.g., cursor callers in `apiCreateFor.ts` and related hydration helpers like `createIf`, slots, and dynamic fragments), do not add local try/finally exception-protection solely around cursor entry. If hydration throws, the outer hydration/component cleanup already restores the global hydration state, and there is no supported case where later hydration continues after an inner throw. If exception-safe cursor scoping becomes necessary in the future, implement it as a shared, reusable cursor-scope API (so the behavior is consistent across all cursor users) rather than one-off local try/catch/try/finally patterns in individual files.


function locateHydrationNodeImpl(consumeFragmentStart = false) {
Expand Down Expand Up @@ -305,9 +305,9 @@ export function locateHydrationBoundaryClose(
if (isComment(node, ']')) {
close = node
} else {
let candidate = locateNextNode(node)
let candidate = nextLogicalSibling(node)
while (candidate && !isComment(candidate, ']')) {
candidate = locateNextNode(candidate)
candidate = nextLogicalSibling(candidate)
}
close = candidate
}
Expand Down Expand Up @@ -436,7 +436,7 @@ export function cleanupHydrationTail(node: Node, container?: ParentNode): void {

let current: Node | null = node
while (current && current.parentNode === container) {
const next = locateNextNode(current)
const next = nextLogicalSibling(current)
removeHydrationNode(current)
current = next
}
Expand Down Expand Up @@ -493,7 +493,7 @@ function finalizeHydrationBoundary(close: Node | null): void {
if (!isHydrationAnchor(cur)) {
hasRemovableNode = true
}
cur = locateNextNode(cur)
cur = nextLogicalSibling(cur)
}
if (!cur) return
if (!hasRemovableNode) {
Expand All @@ -504,7 +504,7 @@ function finalizeHydrationBoundary(close: Node | null): void {
warnHydrationChildrenMismatch((close as Node).parentElement)

while (node && node !== close) {
const next = locateNextNode(node)
const next = nextLogicalSibling(node)
if (!isHydrationAnchor(node)) {
removeHydrationNode(node, close)
}
Expand Down
9 changes: 2 additions & 7 deletions packages/runtime-vapor/src/dom/node.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { ChildItem, InsertionParent } from '../insertionState'
import { isComment, isHydrating, locateEndAnchor } from './hydration'
import { isHydrating, nextLogicalSibling } from './hydration'

/*@__NO_SIDE_EFFECTS__*/
export function createElement(tagName: string): HTMLElement {
Expand Down Expand Up @@ -102,12 +102,7 @@ export function locateChildByLogicalIndex(
return (parent.$llc = child)
}

child = (
isComment(child, '[')
? // fragment start: jump to the node after the matching end anchor
locateEndAnchor(child)!.nextSibling
: child.nextSibling
) as ChildItem
child = nextLogicalSibling(child) as ChildItem

fromIndex++
}
Expand Down
6 changes: 3 additions & 3 deletions packages/runtime-vapor/src/fragment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,8 +40,8 @@ import {
locateEndAnchor,
locateHydrationBoundaryClose,
locateHydrationNode,
locateNextNode,
markHydrationAnchor,
nextLogicalSibling,
setCurrentHydrationNode,
} from './dom/hydration'
import { isArray } from '@vue/shared'
Expand Down Expand Up @@ -559,7 +559,7 @@ export class DynamicFragment extends VaporFragment {
!isComment(currentHydrationNode, ']')
) {
const parentNode = getParentNode(currentHydrationNode)
const anchor = locateNextNode(currentHydrationNode)
const anchor = nextLogicalSibling(currentHydrationNode)
// Empty branch against non-empty SSR output has no block node to
// derive an insertion point from, so use the current hydration range.
const reusableAnchor =
Expand Down Expand Up @@ -619,7 +619,7 @@ export class DynamicFragment extends VaporFragment {
currentHydrationNode
) {
const parentNode = getParentNode(currentHydrationNode)
const nextNode = locateNextNode(currentHydrationNode)
const nextNode = nextLogicalSibling(currentHydrationNode)
if (parentNode) {
this.nodes = []
cleanupAndInsertRuntimeAnchor(
Expand Down
Loading