diff --git a/packages/compiler-ssr/src/ssrCodegenTransform.ts b/packages/compiler-ssr/src/ssrCodegenTransform.ts index 29f198370..852e02820 100644 --- a/packages/compiler-ssr/src/ssrCodegenTransform.ts +++ b/packages/compiler-ssr/src/ssrCodegenTransform.ts @@ -410,7 +410,7 @@ function shouldProcessChildAsDynamic( if (dynamicNodeCount === 2) { return prevDynamicCount > 0 } - // For three or more dynamic nodes, mark the intermediate node as dynamic + // For three or more dynamic nodes, mark the middle nodes as dynamic else if (dynamicNodeCount >= 3) { return prevDynamicCount > 0 && nextDynamicCount > 0 } diff --git a/packages/runtime-core/src/hydration.ts b/packages/runtime-core/src/hydration.ts index e1420d335..8ed7b6af1 100644 --- a/packages/runtime-core/src/hydration.ts +++ b/packages/runtime-core/src/hydration.ts @@ -25,14 +25,13 @@ import { getEscapedCssVarName, includeBooleanAttr, isBooleanAttr, - isDynamicAnchor, isKnownHtmlAttr, isKnownSvgAttr, isOn, isRenderableAttrValue, isReservedProp, isString, - isVaporFragmentEndAnchor, + isVaporAnchors, normalizeClass, normalizeStyle, stringifyStyle, @@ -127,10 +126,8 @@ export function createHydrationFunctions( function nextSibling(node: Node) { let n = next(node) - // skip if: - // - dynamic anchors (``, ``) - // - vapor fragment end anchors (e.g. ``, ``) - if (n && (isDynamicAnchor(n) || isVaporFragmentEndAnchor(n))) { + // skip vapor mode specific anchors + if (n && isVaporAnchors(n)) { n = next(n) } return n @@ -162,7 +159,8 @@ export function createHydrationFunctions( slotScopeIds: string[] | null, optimized = false, ): Node | null => { - if (isDynamicAnchor(node) || isVaporFragmentEndAnchor(node)) { + // skip vapor mode specific anchors + if (isVaporAnchors(node)) { node = nextSibling(node)! } optimized = optimized || !!vnode.dynamicChildren diff --git a/packages/runtime-core/src/renderer.ts b/packages/runtime-core/src/renderer.ts index fade2a4b3..bb895a480 100644 --- a/packages/runtime-core/src/renderer.ts +++ b/packages/runtime-core/src/renderer.ts @@ -107,7 +107,7 @@ export interface Renderer { export interface HydrationRenderer extends Renderer { hydrate: RootHydrateFunction - hydrateNode: ReturnType[1] | undefined + hydrateNode: ReturnType[1] } export type ElementNamespace = 'svg' | 'mathml' | undefined diff --git a/packages/runtime-vapor/src/apiCreateFor.ts b/packages/runtime-vapor/src/apiCreateFor.ts index 06a2bf752..7138d01a6 100644 --- a/packages/runtime-vapor/src/apiCreateFor.ts +++ b/packages/runtime-vapor/src/apiCreateFor.ts @@ -30,9 +30,9 @@ import { renderEffect } from './renderEffect' import { VaporVForFlags } from '../../shared/src/vaporFlags' import { currentHydrationNode, - findVaporFragmentAnchor, isHydrating, locateHydrationNode, + locateVaporFragmentAnchor, } from './dom/hydration' import { insertionAnchor, @@ -97,13 +97,13 @@ export const createFor = ( let parent: ParentNode | undefined | null let parentAnchor: Node if (isHydrating) { - parentAnchor = findVaporFragmentAnchor( + parentAnchor = locateVaporFragmentAnchor( currentHydrationNode!, FOR_ANCHOR_LABEL, )! if (__DEV__ && !parentAnchor) { - // TODO warn, should not happen - warn(`createFor anchor not found...`) + // this should not happen + throw new Error(`v-for fragment anchor node was not found.`) } } else { parentAnchor = __DEV__ ? createComment('for') : createTextNode() diff --git a/packages/runtime-vapor/src/block.ts b/packages/runtime-vapor/src/block.ts index 50e2e4b26..d6be89efc 100644 --- a/packages/runtime-vapor/src/block.ts +++ b/packages/runtime-vapor/src/block.ts @@ -9,12 +9,11 @@ import { createComment, createTextNode } from './dom/node' import { EffectScope, pauseTracking, resetTracking } from '@vue/reactivity' import { currentHydrationNode, - findVaporFragmentAnchor, isComment, isHydrating, locateHydrationNode, + locateVaporFragmentAnchor, } from './dom/hydration' -import { warn } from '@vue/runtime-dom' export type Block = | Node @@ -89,17 +88,17 @@ export class DynamicFragment extends VaporFragment { } hydrate(label: string): void { - // for `v-if="false"` the node will be an empty comment node use it as the anchor. + // for `v-if="false"` the node will be an empty comment, use it as the anchor. // otherwise, find next sibling vapor fragment anchor if (isComment(currentHydrationNode!, '')) { this.anchor = currentHydrationNode } else { - const anchor = findVaporFragmentAnchor(currentHydrationNode!, label)! + const anchor = locateVaporFragmentAnchor(currentHydrationNode!, label)! if (anchor) { this.anchor = anchor } else if (__DEV__) { - // TODO warning, should not happen - warn(`DynamicFragment anchor not found...`) + // this should not happen + throw new Error(`${label} fragment anchor node was not found.`) } } } diff --git a/packages/runtime-vapor/src/dom/hydration.ts b/packages/runtime-vapor/src/dom/hydration.ts index d82aa33f9..e3f666b5b 100644 --- a/packages/runtime-vapor/src/dom/hydration.ts +++ b/packages/runtime-vapor/src/dom/hydration.ts @@ -6,12 +6,11 @@ import { setInsertionState, } from '../insertionState' import { - _child, disableHydrationNodeLookup, enableHydrationNodeLookup, next, } from './node' -import { isDynamicAnchor, isVaporFragmentEndAnchor } from '@vue/shared' +import { isVaporAnchors, isVaporFragmentAnchor } from '@vue/shared' export let isHydrating = false export let currentHydrationNode: Node | null = null @@ -33,7 +32,6 @@ function performHydration( // optimize anchor cache lookup ;(Comment.prototype as any).$fs = undefined - ;(Node.prototype as any).$nc = undefined isOptimized = true } enableHydrationNodeLookup() @@ -101,24 +99,29 @@ function adoptTemplateImpl(node: Node, template: string): Node | null { return node } +const hydrationPositionMap = new WeakMap() + function locateHydrationNodeImpl(hasFragmentAnchor?: boolean) { let node: Node | null // prepend / firstChild if (insertionAnchor === 0) { - node = _child(insertionParent!) + node = insertionParent!.firstChild } else if (insertionAnchor) { - // for dynamic children, use insertionAnchor as the node + // `insertionAnchor` is a Node, it is the DOM node to hydrate + // Template: `......`// `insertionAnchor` is the placeholder + // SSR Output: `...Content...`// `insertionAnchor` is the actual node node = insertionAnchor } else { node = insertionParent - ? insertionParent.$nc || insertionParent.lastChild + ? hydrationPositionMap.get(insertionParent) || insertionParent.lastChild : currentHydrationNode - // if the last child is a vapor fragment end anchor, find the previous one - if (hasFragmentAnchor && node && isVaporFragmentEndAnchor(node)) { + // if node is a vapor fragment anchor, find the previous one + if (hasFragmentAnchor && node && isVaporFragmentAnchor(node)) { node = node.previousSibling if (__DEV__ && !node) { - // TODO warning, should not happen + // this should not happen + throw new Error(`vapor fragment anchor previous node was not found.`) } } @@ -153,7 +156,8 @@ function locateHydrationNodeImpl(hasFragmentAnchor?: boolean) { } if (insertionParent && node) { - insertionParent.$nc = node!.previousSibling + const prev = node.previousSibling + if (prev) hydrationPositionMap.set(insertionParent, prev) } } @@ -166,10 +170,6 @@ function locateHydrationNodeImpl(hasFragmentAnchor?: boolean) { currentHydrationNode = node } -export function isEmptyText(node: Node): node is Text { - return node.nodeType === 3 && !(node as Text).data.trim() -} - export function locateEndAnchor( node: Node | null, open = '[', @@ -194,26 +194,26 @@ export function locateEndAnchor( export function isNonHydrationNode(node: Node): boolean { return ( - // empty text nodes - isEmptyText(node) || - // dynamic node anchors (, ) - isDynamicAnchor(node) || - // fragment end anchor (``) + // empty text node + isEmptyTextNode(node) || + // vdom fragment end anchor (``) isComment(node, ']') || - // vapor fragment end anchors - isVaporFragmentEndAnchor(node) + // vapor mode specific anchors + isVaporAnchors(node) ) } -export function findVaporFragmentAnchor( +export function locateVaporFragmentAnchor( node: Node, anchorLabel: string, -): Comment | null { +): Comment | undefined { let n = node.nextSibling while (n) { if (isComment(n, anchorLabel)) return n n = n.nextSibling } - - return null +} + +export function isEmptyTextNode(node: Node): node is Text { + return node.nodeType === 3 && !(node as Text).data.trim() } diff --git a/packages/runtime-vapor/src/dom/node.ts b/packages/runtime-vapor/src/dom/node.ts index 2384697ed..3f38c477a 100644 --- a/packages/runtime-vapor/src/dom/node.ts +++ b/packages/runtime-vapor/src/dom/node.ts @@ -2,7 +2,7 @@ import { isComment, isNonHydrationNode, locateEndAnchor } from './hydration' import { DYNAMIC_END_ANCHOR_LABEL, DYNAMIC_START_ANCHOR_LABEL, - isVaporFragmentEndAnchor, + isVaporAnchors, } from '@vue/shared' /*! #__NO_SIDE_EFFECTS__ */ @@ -25,33 +25,43 @@ export function _child(node: ParentNode): Node { return node.firstChild! } +/** + * Hydration-specific version of `child`. + * + * This function skips leading fragment anchors to find the first node relevant + * for hydration matching against the client-side template structure. + * + * Problem: + * Template: `
{{ msg }}
` + * + * Client Compiled Code (Simplified): + * const n2 = t0() // n2 = `
` + * const n1 = _child(n2) // n1 = text node + * // ... slot creation ... + * _renderEffect(() => _setText(n1, _ctx.msg)) + * + * SSR Output: `
slot contentActual Text Node
` + * + * Hydration Mismatch: + * - During hydration, `n2` refers to the SSR `
`. + * - `_child(n2)` would return ``. + * - The client code expects `n1` to be the text node, but gets the comment. + * The subsequent `_setText(n1, ...)` would fail or target the wrong node. + * + * Solution (`__child`): + * - `__child(n2)` is used during hydration. It skips the SSR fragment anchors + * (`...`) and any other non-content nodes to find the + * "Actual Text Node", correctly matching the client's expectation for `n1`. + */ /*! #__NO_SIDE_EFFECTS__ */ export function __child(node: ParentNode): Node { - /** - * During hydration, the first child of a node not be the expected - * if the first child is slot - * - * for template code: `div>{{ data }}
` - * - slot: 'slot', - * - data: 'hi', - * - * client side: - * const n2 = _template("
")() - * const n1 = _child(n2) -> the text node - * _setInsertionState(n2, 0) -> slot fragment - * - * during hydration: - * const n2 =
slotHi
// server output - * const n1 = _child(n2) -> should be `Hi` instead of the slot fragment - * _setInsertionState(n2, 0) -> slot fragment - */ let n = node.firstChild! if (isComment(n, '[')) { n = locateEndAnchor(n)!.nextSibling! } - while (n && isVaporFragmentEndAnchor(n)) { + while (n && isVaporAnchors(n)) { n = n.nextSibling! } return n @@ -62,11 +72,14 @@ export function _nthChild(node: Node, i: number): Node { return node.childNodes[i] } +/** + * Hydration-specific version of `nthChild`. + */ /*! #__NO_SIDE_EFFECTS__ */ export function __nthChild(node: Node, i: number): Node { let n = node.firstChild! for (let start = 0; start < i; start++) { - n = next(n) as ChildNode + n = __next(n) as ChildNode } return n } @@ -76,6 +89,46 @@ function _next(node: Node): Node { return node.nextSibling! } +/** + * Hydration-specific version of `next`. + * + * SSR comment anchors (fragments `...`, dynamic `...`) + * disrupt standard `node.nextSibling` traversal during hydration. `_next` might + * return a comment node or an internal node of a fragment instead of skipping + * the entire fragment block. + * + * Example: + * Template: `
Node1Node2
` (where is a dynamic component placeholder) + * + * Client Compiled Code (Simplified): + * const n2 = t0() // n2 = `
Node1Node2
` + * const n1 = _next(_child(n2)) // n1 = _next(Node1) returns `` + * _setInsertionState(n2, n1) // insertion anchor is `` + * const n0 = _createComponent(_ctx.Comp) // inserted before `` + * + * SSR Output: `
Node1Node3 Node4Node2
` + * + * Hydration Mismatch: + * - During hydration, `n2` refers to the SSR `
`. + * - `_child(n2)` returns `Node1`. + * - `_next(Node1)` would return ``. + * - The client logic expects `n1` to be the node *after* `Node1` in its structure + * (the placeholder), but gets the fragment start anchor `` from SSR. + * - Using `` as the insertion anchor for hydrating the component is incorrect. + * + * Solution (`__next`): + * - During hydration, `next.impl` is `__next`. + * - `n1 = __next(Node1)` is called. + * - `__next` recognizes that the immediate sibling `` is a fragment start anchor. + * - It skips the entire fragment block (`Node3 Node4`). + * - It returns the node immediately *after* the fragment's end anchor, which is `Node2`. + * - This correctly identifies the logical "next sibling" anchor (`Node2`) in the SSR structure, + * allowing the component to be hydrated correctly relative to `Node1` and `Node2`. + * + * This function ensures traversal correctly skips over non-hydration nodes and + * treats entire fragment/dynamic blocks (when starting *from* their beginning anchor) + * as single logical units to find the next actual sibling node for hydration matching. + */ /*! #__NO_SIDE_EFFECTS__ */ export function __next(node: Node): Node { // process dynamic node (...) as a single node @@ -99,49 +152,36 @@ export function __next(node: Node): Node { return n } -type ChildFn = (node: ParentNode) => Node -type NextFn = (node: Node) => Node -type NthChildFn = (node: Node, i: number) => Node - -interface DelegatedChildFunction extends ChildFn { - impl: ChildFn -} -interface DelegatedNextFunction extends NextFn { - impl: NextFn -} -interface DelegatedNthChildFunction extends NthChildFn { - impl: NthChildFn +type DelegatedFunction any> = T & { + impl: T } /*! #__NO_SIDE_EFFECTS__ */ -export const child: DelegatedChildFunction = node => { +export const child: DelegatedFunction = node => { return child.impl(node) } child.impl = _child /*! #__NO_SIDE_EFFECTS__ */ -export const next: DelegatedNextFunction = node => { +export const next: DelegatedFunction = node => { return next.impl(node) } next.impl = _next /*! #__NO_SIDE_EFFECTS__ */ -export const nthChild: DelegatedNthChildFunction = (node, i) => { +export const nthChild: DelegatedFunction = (node, i) => { return nthChild.impl(node, i) } nthChild.impl = _nthChild -// During hydration, there might be differences between the server-rendered (SSR) -// HTML and the client-side template. -// For example, a dynamic node `` in the template might be rendered as a -// `Fragment` (`...`) in the SSR output. -// The content of the `Fragment` affects the lookup results of the `next` and -// `nthChild` functions. -// To ensure the hydration process correctly finds nodes, we need to treat the -// `Fragment` as a single node. -// Therefore, during hydration, we need to temporarily switch the implementations -// of `next` and `nthChild`. After hydration is complete, their implementations -// are restored to the original versions. +/** + * Enables hydration-specific node lookup behavior. + * + * Temporarily switches the implementations of the exported + * `child`, `next`, and `nthChild` functions to their hydration-specific + * versions (`__child`, `__next`, `__nthChild`). This allows traversal + * logic to correctly handle SSR comment anchors during hydration. + */ export function enableHydrationNodeLookup(): void { child.impl = __child next.impl = __next diff --git a/packages/runtime-vapor/src/insertionState.ts b/packages/runtime-vapor/src/insertionState.ts index b33c820e0..c8c7ffbcd 100644 --- a/packages/runtime-vapor/src/insertionState.ts +++ b/packages/runtime-vapor/src/insertionState.ts @@ -1,9 +1,4 @@ -export let insertionParent: - | (ParentNode & { - // the next child node to be hydrated - $nc?: Node | null - }) - | undefined +export let insertionParent: ParentNode | undefined export let insertionAnchor: Node | 0 | undefined /** diff --git a/packages/shared/src/domAnchors.ts b/packages/shared/src/domAnchors.ts index e93bc0188..f807ee169 100644 --- a/packages/shared/src/domAnchors.ts +++ b/packages/shared/src/domAnchors.ts @@ -15,7 +15,7 @@ export function isDynamicAnchor(node: Node): node is Comment { ) } -export function isVaporFragmentEndAnchor(node: Node): node is Comment { +export function isVaporFragmentAnchor(node: Node): node is Comment { if (node.nodeType !== 8) return false const data = (node as Comment).data @@ -26,3 +26,7 @@ export function isVaporFragmentEndAnchor(node: Node): node is Comment { data === DYNAMIC_COMPONENT_ANCHOR_LABEL ) } + +export function isVaporAnchors(node: Node): node is Comment { + return isDynamicAnchor(node) || isVaporFragmentAnchor(node) +}