wip: v-for hydration

This commit is contained in:
daiwei 2025-04-25 17:08:07 +08:00
parent aad75fd7c4
commit e6e016016f
6 changed files with 276 additions and 14 deletions

View File

@ -15,7 +15,7 @@ describe('transition-group', () => {
_ssrRenderList(_ctx.list, (i) => { _ssrRenderList(_ctx.list, (i) => {
_push(\`<div></div>\`) _push(\`<div></div>\`)
}) })
_push(\`<!--]-->\`) _push(\`<!--for--><!--]-->\`)
}" }"
`) `)
}) })
@ -33,7 +33,7 @@ describe('transition-group', () => {
_ssrRenderList(_ctx.list, (i) => { _ssrRenderList(_ctx.list, (i) => {
_push(\`<div></div>\`) _push(\`<div></div>\`)
}) })
_push(\`</ul>\`) _push(\`<!--for--></ul>\`)
}" }"
`) `)
}) })
@ -52,6 +52,7 @@ describe('transition-group', () => {
_ssrRenderList(_ctx.list, (i) => { _ssrRenderList(_ctx.list, (i) => {
_push(\`<div></div>\`) _push(\`<div></div>\`)
}) })
_push(\`<!--for-->\`)
if (false) { if (false) {
_push(\`<div></div>\`) _push(\`<div></div>\`)
_push(\`<!--if-->\`) _push(\`<!--if-->\`)
@ -75,7 +76,7 @@ describe('transition-group', () => {
_ssrRenderList(_ctx.list, (i) => { _ssrRenderList(_ctx.list, (i) => {
_push(\`<div></div>\`) _push(\`<div></div>\`)
}) })
_push(\`</ul>\`) _push(\`<!--for--></ul>\`)
}" }"
`) `)
}) })
@ -97,7 +98,7 @@ describe('transition-group', () => {
_ssrRenderList(_ctx.list, (i) => { _ssrRenderList(_ctx.list, (i) => {
_push(\`<div></div>\`) _push(\`<div></div>\`)
}) })
_push(\`</\${_ctx.someTag}>\`) _push(\`<!--for--></\${_ctx.someTag}>\`)
}" }"
`) `)
}) })
@ -119,9 +120,11 @@ describe('transition-group', () => {
_ssrRenderList(10, (i) => { _ssrRenderList(10, (i) => {
_push(\`<div></div>\`) _push(\`<div></div>\`)
}) })
_push(\`<!--for-->\`)
_ssrRenderList(10, (i) => { _ssrRenderList(10, (i) => {
_push(\`<div></div>\`) _push(\`<div></div>\`)
}) })
_push(\`<!--for-->\`)
if (_ctx.ok) { if (_ctx.ok) {
_push(\`<div>ok</div>\`) _push(\`<div>ok</div>\`)
_push(\`<!--if-->\`) _push(\`<!--if-->\`)

View File

@ -13,6 +13,7 @@ import {
processChildrenAsStatement, processChildrenAsStatement,
} from '../ssrCodegenTransform' } from '../ssrCodegenTransform'
import { SSR_RENDER_LIST } from '../runtimeHelpers' import { SSR_RENDER_LIST } from '../runtimeHelpers'
import { FOR_ANCHOR_LABEL } from '@vue/shared'
// Plugin for the first transform pass, which simply constructs the AST node // Plugin for the first transform pass, which simply constructs the AST node
export const ssrTransformFor: NodeTransform = export const ssrTransformFor: NodeTransform =
@ -48,5 +49,8 @@ export function ssrProcessFor(
) )
if (!disableNestedFragments) { if (!disableNestedFragments) {
context.pushStringPart(`<!--]-->`) context.pushStringPart(`<!--]-->`)
} else {
// add anchor for non-fragment v-for
context.pushStringPart(`<!--${FOR_ANCHOR_LABEL}-->`)
} }
} }

View File

@ -1123,6 +1123,73 @@ describe('Vapor Mode hydration', () => {
}) })
}) })
test('on fragment component', async () => {
runWithEnv(isProd, async () => {
const data = ref(true)
const { container } = await testHydration(
`<template>
<div>
<components.Child v-if="data"/>
</div>
</template>`,
{
Child: `<template><div>{{ data }}</div>-{{ data }}-</template>`,
},
data,
)
expect(container.innerHTML).toBe(
`<div>` +
`<!--[--><div>true</div>-true-<!--]-->` +
`<!--if-->` +
`</div>`,
)
data.value = false
await nextTick()
expect(container.innerHTML).toBe(
`<div>` + `<!--[--><!--]-->` + `<!--${anchorLabel}-->` + `</div>`,
)
})
})
test('on fragment component with anchor insertion', async () => {
runWithEnv(isProd, async () => {
const data = ref(true)
const { container } = await testHydration(
`<template>
<div>
<span/>
<components.Child v-if="data"/>
<span/>
</div>
</template>`,
{
Child: `<template><div>{{ data }}</div>-{{ data }}-</template>`,
},
data,
)
expect(container.innerHTML).toBe(
`<div>` +
`<span></span>` +
`<!--[--><div>true</div>-true-<!--]-->` +
`<!--if-->` +
`<span></span>` +
`</div>`,
)
data.value = false
await nextTick()
expect(container.innerHTML).toBe(
`<div>` +
`<span></span>` +
`<!--[--><!--]-->` +
`<!--${anchorLabel}-->` +
`<span></span>` +
`</div>`,
)
})
})
test('consecutive v-if on fragment component with anchor insertion', async () => { test('consecutive v-if on fragment component with anchor insertion', async () => {
runWithEnv(isProd, async () => { runWithEnv(isProd, async () => {
const data = ref(true) const data = ref(true)
@ -1311,7 +1378,168 @@ describe('Vapor Mode hydration', () => {
} }
}) })
test.todo('for') describe('for', () => {
test('basic v-for', async () => {
const { container, data } = await testHydration(
`<template>
<div>
<span v-for="item in data" :key="item">{{ item }}</span>
</div>
</template>`,
undefined,
ref(['a', 'b', 'c']),
)
expect(container.innerHTML).toBe(
`<div>` +
`<!--[-->` +
`<span>a</span>` +
`<span>b</span>` +
`<span>c</span>` +
`<!--]-->` +
`</div>`,
)
data.value.push('d')
await nextTick()
expect(container.innerHTML).toBe(
`<div>` +
`<!--[-->` +
`<span>a</span>` +
`<span>b</span>` +
`<span>c</span>` +
`<span>d</span>` +
`<!--]-->` +
`</div>`,
)
})
test('v-for with text node', async () => {
const { container, data } = await testHydration(
`<template>
<div>
<span v-for="item in data" :key="item">{{ item }}</span>
</div>
</template>`,
undefined,
ref(['a', 'b', 'c']),
)
expect(container.innerHTML).toBe(
`<div><!--[--><span>a</span><span>b</span><span>c</span><!--]--></div>`,
)
data.value.push('d')
await nextTick()
expect(container.innerHTML).toBe(
`<div><!--[--><span>a</span><span>b</span><span>c</span><span>d</span><!--]--></div>`,
)
})
test('v-for with anchor insertion', async () => {
const { container, data } = await testHydration(
`<template>
<div>
<span/>
<span v-for="item in data" :key="item">{{ item }}</span>
<span/>
</div>
</template>`,
undefined,
ref(['a', 'b', 'c']),
)
expect(container.innerHTML).toBe(
`<div>` +
`<span></span>` +
`<!--[-->` +
`<span>a</span>` +
`<span>b</span>` +
`<span>c</span>` +
`<!--]-->` +
`<span></span>` +
`</div>`,
)
data.value.push('d')
await nextTick()
expect(container.innerHTML).toBe(
`<div>` +
`<span></span>` +
`<!--[-->` +
`<span>a</span>` +
`<span>b</span>` +
`<span>c</span>` +
`<span>d</span>` +
`<!--]-->` +
`<span></span>` +
`</div>`,
)
})
test('consecutive v-for with anchor insertion', async () => {
const { container, data } = await testHydration(
`<template>
<div>
<span/>
<span v-for="item in data" :key="item">{{ item }}</span>
<span v-for="item in data" :key="item">{{ item }}</span>
<span/>
</div>
</template>`,
undefined,
ref(['a', 'b', 'c']),
)
expect(container.innerHTML).toBe(
`<div>` +
`<span></span>` +
`<!--[-->` +
`<span>a</span>` +
`<span>b</span>` +
`<span>c</span>` +
`<!--]-->` +
`<!--[[-->` +
`<!--[-->` +
`<span>a</span>` +
`<span>b</span>` +
`<span>c</span>` +
`<!--]-->` +
`<!--]]-->` +
`<span></span>` +
`</div>`,
)
data.value.push('d')
await nextTick()
expect(container.innerHTML).toBe(
`<div>` +
`<span></span>` +
`<!--[-->` +
`<span>a</span>` +
`<span>b</span>` +
`<span>c</span>` +
`<span>d</span>` +
`<!--]-->` +
`<!--[[-->` +
`<!--[-->` +
`<span>a</span>` +
`<span>b</span>` +
`<span>c</span>` +
`<span>d</span>` +
`<!--]-->` +
`<!--]]-->` +
`<span></span>` +
`</div>`,
)
})
// TODO wait for slots hydration support
test.todo('v-for on component', async () => {})
// TODO wait for slots hydration support
test.todo('on fragment component', async () => {})
// TODO wait for vapor TransitionGroup support
// v-for inside TransitionGroup does not render as a fragment
test.todo('v-for in TransitionGroup', async () => {})
})
test.todo('slots') test.todo('slots')

View File

@ -9,8 +9,14 @@ import {
shallowRef, shallowRef,
toReactive, toReactive,
} from '@vue/reactivity' } from '@vue/reactivity'
import { getSequence, isArray, isObject, isString } from '@vue/shared' import {
import { createComment, createTextNode } from './dom/node' FOR_ANCHOR_LABEL,
getSequence,
isArray,
isObject,
isString,
} from '@vue/shared'
import { createComment, createTextNode, nextSiblingAnchor } from './dom/node'
import { import {
type Block, type Block,
VaporFragment, VaporFragment,
@ -22,8 +28,17 @@ import { currentInstance, isVaporComponent } from './component'
import type { DynamicSlot } from './componentSlots' import type { DynamicSlot } from './componentSlots'
import { renderEffect } from './renderEffect' import { renderEffect } from './renderEffect'
import { VaporVForFlags } from '../../shared/src/vaporFlags' import { VaporVForFlags } from '../../shared/src/vaporFlags'
import { isHydrating, locateHydrationNode } from './dom/hydration' import {
import { insertionAnchor, insertionParent } from './insertionState' currentHydrationNode,
isComment,
isHydrating,
locateHydrationNode,
} from './dom/hydration'
import {
insertionAnchor,
insertionParent,
resetInsertionState,
} from './insertionState'
class ForBlock extends VaporFragment { class ForBlock extends VaporFragment {
scope: EffectScope | undefined scope: EffectScope | undefined
@ -71,15 +86,24 @@ export const createFor = (
const _insertionParent = insertionParent const _insertionParent = insertionParent
const _insertionAnchor = insertionAnchor const _insertionAnchor = insertionAnchor
if (isHydrating) { if (isHydrating) {
locateHydrationNode() locateHydrationNode(true)
} else {
resetInsertionState()
} }
let isMounted = false let isMounted = false
let oldBlocks: ForBlock[] = [] let oldBlocks: ForBlock[] = []
let newBlocks: ForBlock[] let newBlocks: ForBlock[]
let parent: ParentNode | undefined | null let parent: ParentNode | undefined | null
// TODO handle this in hydration const parentAnchor = isHydrating
const parentAnchor = __DEV__ ? createComment('for') : createTextNode() ? // Use fragment end anchor if available, otherwise use the specific for anchor.
nextSiblingAnchor(
currentHydrationNode!,
isComment(currentHydrationNode!, '[') ? ']' : FOR_ANCHOR_LABEL,
)!
: __DEV__
? createComment('for')
: createTextNode()
const frag = new VaporFragment(oldBlocks) const frag = new VaporFragment(oldBlocks)
const instance = currentInstance! const instance = currentInstance!
const canUseFastRemove = flags & VaporVForFlags.FAST_REMOVE const canUseFastRemove = flags & VaporVForFlags.FAST_REMOVE

View File

@ -10,7 +10,6 @@ import {
disableHydrationNodeLookup, disableHydrationNodeLookup,
enableHydrationNodeLookup, enableHydrationNodeLookup,
next, next,
prev,
} from './node' } from './node'
import { isDynamicFragmentEndAnchor } from '@vue/shared' import { isDynamicFragmentEndAnchor } from '@vue/shared'
@ -98,7 +97,7 @@ function locateHydrationNodeImpl(isFragment?: boolean) {
// if the last child is a comment, it is the anchor for the fragment // if the last child is a comment, it is the anchor for the fragment
// so it need to find the previous node // so it need to find the previous node
if (isFragment && node && isDynamicFragmentEndAnchor(node)) { if (isFragment && node && isDynamicFragmentEndAnchor(node)) {
let previous = prev(node) let previous = node.previousSibling //prev(node)
if (previous) node = previous if (previous) node = previous
} }

View File

@ -105,6 +105,7 @@ export function disableHydrationNodeLookup(): void {
} }
/*! #__NO_SIDE_EFFECTS__ */ /*! #__NO_SIDE_EFFECTS__ */
// TODO check if this is still needed
export function prev(node: Node): Node | null { export function prev(node: Node): Node | null {
// process dynamic node (<!--[[-->...<!--]]-->) as a single one // process dynamic node (<!--[[-->...<!--]]-->) as a single one
if (isComment(node, DYNAMIC_END_ANCHOR_LABEL)) { if (isComment(node, DYNAMIC_END_ANCHOR_LABEL)) {
@ -145,6 +146,9 @@ export function nextSiblingAnchor(
anchorLabel: string, anchorLabel: string,
): Comment | null { ): Comment | null {
node = handleWrappedNode(node) node = handleWrappedNode(node)
if (isComment(node, anchorLabel)) {
return node as Comment
}
let n = node.nextSibling let n = node.nextSibling
while (n) { while (n) {