fix(hydration): skip dynamic children in __child
ci / test (push) Waiting to run Details
ci / continuous-release (push) Waiting to run Details

This commit is contained in:
daiwei 2025-07-04 10:52:45 +08:00
parent e903dec682
commit 3f3480c05b
4 changed files with 53 additions and 12 deletions

View File

@ -157,7 +157,7 @@ export function render(_ctx, $props, $emit, $attrs, $slots) {
const _component_Comp = _resolveComponent("Comp")
const n0 = t0()
const n3 = t1()
const n2 = _child(n3)
const n2 = _child(n3, 1)
_setInsertionState(n3, 0)
const n1 = _createComponentWithFallback(_component_Comp)
_renderEffect(() => {

View File

@ -82,11 +82,15 @@ export function genChildren(
pushBlock(...genCall(helper('nthChild'), from, String(elementIndex)))
}
} else {
// offset is used to determine the child during hydration.
// if offset is not 0, we need to specify the offset to skip the dynamic
// children and get the correct child.
let childOffset = offset === 0 ? undefined : `${Math.abs(offset)}`
if (elementIndex === 0) {
pushBlock(...genCall(helper('child'), from))
pushBlock(...genCall(helper('child'), from, childOffset))
} else {
// check if there's a node that we can reuse from
let init = genCall(helper('child'), from)
let init = genCall(helper('child'), from, childOffset)
if (elementIndex === 1) {
init = genCall(helper('next'), init)
} else if (elementIndex > 1) {

View File

@ -2176,6 +2176,43 @@ describe('Vapor Mode hydration', () => {
)
})
test('mixed consecutive slot and element', async () => {
const data = reactive({
text: 'foo',
msg: 'hi',
})
const { container } = await testHydration(
`<template>
<components.Child>
<template #foo><span>{{data.text}}</span></template>
<template #bar><span>bar</span></template>
</components.Child>
</template>`,
{
Child: `<template><div><slot name="foo"/><slot name="bar"/><div>{{data.msg}}</div></div></template>`,
},
data,
)
expect(container.innerHTML).toBe(
`<div>` +
`<!--[--><span>foo</span><!--]--><!--${slotAnchorLabel}-->` +
`<!--[--><span>bar</span><!--]--><!--${slotAnchorLabel}-->` +
`<div>hi</div>` +
`</div>`,
)
data.msg = 'bar'
await nextTick()
expect(container.innerHTML).toBe(
`<div>` +
`<!--[--><span>foo</span><!--]--><!--${slotAnchorLabel}-->` +
`<!--[--><span>bar</span><!--]--><!--${slotAnchorLabel}-->` +
`<div>bar</div>` +
`</div>`,
)
})
test('mixed slot and element', async () => {
const data = reactive({
text: 'foo',

View File

@ -41,7 +41,7 @@ export function _child(node: ParentNode): Node {
*
* Client Compiled Code (Simplified):
* const n2 = t0() // n2 = `<div> </div>`
* const n1 = _child(n2) // n1 = text node
* const n1 = _child(n2, 1) // n1 = text node
* // ... slot creation ...
* _renderEffect(() => _setText(n1, _ctx.msg))
*
@ -49,18 +49,18 @@ export function _child(node: ParentNode): Node {
*
* Hydration Mismatch:
* - During hydration, `n2` refers to the SSR `<div>`.
* - `_child(n2)` would return `<!--[-->`.
* - `_child(n2, 1)` 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`.
* - `__child(n2, offset)` is used during hydration. It skips the dynamic children
* to find the "Actual Text Node", correctly matching the client's expectation
* for `n1`.
*/
/*! #__NO_SIDE_EFFECTS__ */
export function __child(node: ParentNode): Node {
let n = node.firstChild!
export function __child(node: ParentNode, offset?: number): Node {
let n = offset ? __nthChild(node, offset) : node.firstChild!
if (isComment(n, '[')) {
n = locateEndAnchor(n)!.nextSibling!
@ -162,8 +162,8 @@ type DelegatedFunction<T extends (...args: any[]) => any> = T & {
}
/*! #__NO_SIDE_EFFECTS__ */
export const child: DelegatedFunction<typeof _child> = node => {
return child.impl(node)
export const child: DelegatedFunction<typeof __child> = (node, offset) => {
return child.impl(node, offset)
}
child.impl = _child