From ea1c60bb163e1dd4d038fcfe412c9f3b6c97edd7 Mon Sep 17 00:00:00 2001 From: daiwei Date: Sat, 28 Jun 2025 09:18:16 +0800 Subject: [PATCH] wip: handle more case --- .../compiler-vapor/src/generators/template.ts | 4 +- packages/compiler-vapor/src/ir/index.ts | 1 + .../src/transforms/transformChildren.ts | 7 +- packages/runtime-core/src/apiCreateApp.ts | 1 + packages/runtime-core/src/hydration.ts | 7 + .../runtime-vapor/__tests__/hydration.spec.ts | 176 ++++++++++++++++++ packages/runtime-vapor/src/apiCreateFor.ts | 2 +- packages/runtime-vapor/src/block.ts | 2 +- packages/runtime-vapor/src/component.ts | 13 +- packages/runtime-vapor/src/dom/hydration.ts | 97 ++++------ packages/runtime-vapor/src/dom/template.ts | 7 +- packages/runtime-vapor/src/insertionState.ts | 14 +- packages/runtime-vapor/src/vdomInterop.ts | 56 +++--- 13 files changed, 287 insertions(+), 100 deletions(-) diff --git a/packages/compiler-vapor/src/generators/template.ts b/packages/compiler-vapor/src/generators/template.ts index 5a066b09e9..9e2b810610 100644 --- a/packages/compiler-vapor/src/generators/template.ts +++ b/packages/compiler-vapor/src/generators/template.ts @@ -24,10 +24,10 @@ export function genSelf( context: CodegenContext, ): CodeFragment[] { const [frag, push] = buildCodeFragment() - const { id, template, operation } = dynamic + const { id, template, operation, dynamicChildOffset } = dynamic if (id !== undefined && template !== undefined) { - push(NEWLINE, `const n${id} = t${template}()`) + push(NEWLINE, `const n${id} = t${template}(${dynamicChildOffset || ''})`) push(...genDirectivesForElement(id, context)) } diff --git a/packages/compiler-vapor/src/ir/index.ts b/packages/compiler-vapor/src/ir/index.ts index 18f0139ab5..2574497d4b 100644 --- a/packages/compiler-vapor/src/ir/index.ts +++ b/packages/compiler-vapor/src/ir/index.ts @@ -259,6 +259,7 @@ export interface IRDynamicInfo { children: IRDynamicInfo[] template?: number hasDynamicChild?: boolean + dynamicChildOffset?: number operation?: OperationNode } diff --git a/packages/compiler-vapor/src/transforms/transformChildren.ts b/packages/compiler-vapor/src/transforms/transformChildren.ts index da47438c2a..429bfa04f2 100644 --- a/packages/compiler-vapor/src/transforms/transformChildren.ts +++ b/packages/compiler-vapor/src/transforms/transformChildren.ts @@ -59,7 +59,7 @@ export const transformChildren: NodeTransform = (node, context) => { function processDynamicChildren(context: TransformContext) { let prevDynamics: IRDynamicInfo[] = [] - let hasStaticTemplate = false + let staticCount = 0 const children = context.dynamic.children for (const [index, child] of children.entries()) { @@ -69,7 +69,7 @@ function processDynamicChildren(context: TransformContext) { if (!(child.flags & DynamicFlag.NON_TEMPLATE)) { if (prevDynamics.length) { - if (hasStaticTemplate) { + if (staticCount) { // each dynamic child gets its own placeholder node. // this makes it easier to locate the corresponding node during hydration. for (let i = 0; i < prevDynamics.length; i++) { @@ -92,12 +92,13 @@ function processDynamicChildren(context: TransformContext) { } prevDynamics = [] } - hasStaticTemplate = true + staticCount++ } } if (prevDynamics.length) { registerInsertion(prevDynamics, context) + context.dynamic.dynamicChildOffset = staticCount } } diff --git a/packages/runtime-core/src/apiCreateApp.ts b/packages/runtime-core/src/apiCreateApp.ts index 097c515b1f..99edd2c266 100644 --- a/packages/runtime-core/src/apiCreateApp.ts +++ b/packages/runtime-core/src/apiCreateApp.ts @@ -188,6 +188,7 @@ export interface VaporInteropInterface { move(vnode: VNode, container: any, anchor: any): void slot(n1: VNode | null, n2: VNode, container: any, anchor: any): void hydrate(node: Node, fn: () => void): void + hydrateSlot(vnode: VNode, container: any): void vdomMount: (component: ConcreteComponent, props?: any, slots?: any) => any vdomUnmount: UnmountComponentFn diff --git a/packages/runtime-core/src/hydration.ts b/packages/runtime-core/src/hydration.ts index 8ed7b6af18..62bcb76867 100644 --- a/packages/runtime-core/src/hydration.ts +++ b/packages/runtime-core/src/hydration.ts @@ -5,6 +5,7 @@ import { Comment as VComment, type VNode, type VNodeHook, + VaporSlot, createTextVNode, createVNode, invokeVNodeHook, @@ -276,6 +277,12 @@ export function createHydrationFunctions( ) } break + case VaporSlot: + getVaporInterface(parentComponent, vnode).hydrateSlot( + vnode, + parentNode(node)!, + ) + break default: if (shapeFlag & ShapeFlags.ELEMENT) { if ( diff --git a/packages/runtime-vapor/__tests__/hydration.spec.ts b/packages/runtime-vapor/__tests__/hydration.spec.ts index fbc27f1d41..0c0919ae40 100644 --- a/packages/runtime-vapor/__tests__/hydration.spec.ts +++ b/packages/runtime-vapor/__tests__/hydration.spec.ts @@ -476,6 +476,34 @@ describe('Vapor Mode hydration', () => { ) }) + test('consecutive components with insertion parent', async () => { + const data = reactive({ foo: 'foo', bar: 'bar' }) + const { container } = await testHydration( + ` + `, + { + Child1: ``, + Child2: ``, + }, + data, + ) + expect(container.innerHTML).toBe( + `
foobar
`, + ) + + data.foo = 'foo1' + data.bar = 'bar1' + await nextTick() + expect(container.innerHTML).toBe( + `
foo1bar1
`, + ) + }) + test('nested consecutive components with anchor insertion', async () => { const { container, data } = await testHydration( ` @@ -1046,6 +1074,27 @@ describe('Vapor Mode hydration', () => { ``, ) }) + + test('dynamic component fallback', async () => { + const { container, data } = await testHydration( + ``, + {}, + ref('foo'), + ) + + expect(container.innerHTML).toBe( + ``, + ) + data.value = 'bar' + await nextTick() + expect(container.innerHTML).toBe( + ``, + ) + }) }) describe('if', () => { @@ -1314,6 +1363,38 @@ describe('Vapor Mode hydration', () => { ) }) + test('consecutive component with insertion parent', async () => { + const data = reactive({ + show: true, + foo: 'foo', + bar: 'bar', + }) + const { container } = await testHydration( + ``, + { + Child: ``, + Child2: ``, + }, + data, + ) + expect(container.innerHTML).toBe( + `
` + + `foo` + + `bar` + + `
` + + ``, + ) + + data.show = false + await nextTick() + expect(container.innerHTML).toBe(``) + }) + test('consecutive v-if on component with anchor insertion', async () => { const data = ref(true) const { container } = await testHydration( @@ -2354,6 +2435,31 @@ describe('Vapor Mode hydration', () => { ``, ) }) + + test('slot fallback', async () => { + const data = reactive({ + foo: 'foo', + }) + const { container } = await testHydration( + ``, + { + Child: ``, + }, + data, + ) + expect(container.innerHTML).toBe( + `foo`, + ) + + data.foo = 'bar' + await nextTick() + expect(container.innerHTML).toBe( + `bar`, + ) + }) }) describe.todo('transition', async () => { @@ -3912,6 +4018,76 @@ describe('VDOM hydration interop', () => { expect(container.innerHTML).toMatchInlineSnapshot(`"false"`) }) + test('nested components (VDOM -> Vapor -> VDOM (with slot fallback))', async () => { + const data = ref(true) + const { container } = await testHydrationInterop( + ` + `, + { + VaporChild: { + code: ``, + vapor: true, + }, + VdomChild: { + code: ` + `, + vapor: false, + }, + }, + data, + ) + + expect(container.innerHTML).toMatchInlineSnapshot( + `"true"`, + ) + + data.value = false + await nextTick() + expect(container.innerHTML).toMatchInlineSnapshot( + `"false"`, + ) + }) + + test('nested components (VDOM -> Vapor(with slot fallback) -> VDOM)', async () => { + const data = ref(true) + const { container } = await testHydrationInterop( + ` + `, + { + VaporChild: { + code: ``, + vapor: true, + }, + VdomChild: { + code: ` + `, + vapor: false, + }, + }, + data, + ) + + expect(container.innerHTML).toMatchInlineSnapshot( + `"true vapor fallback"`, + ) + + data.value = false + await nextTick() + expect(container.innerHTML).toMatchInlineSnapshot( + `"false vapor fallback"`, + ) + }) + test('vapor slot render vdom component', async () => { const data = ref(true) const { container } = await testHydrationInterop( diff --git a/packages/runtime-vapor/src/apiCreateFor.ts b/packages/runtime-vapor/src/apiCreateFor.ts index 488fb5b9e8..19346f6f5d 100644 --- a/packages/runtime-vapor/src/apiCreateFor.ts +++ b/packages/runtime-vapor/src/apiCreateFor.ts @@ -89,7 +89,7 @@ export const createFor = ( const _insertionParent = insertionParent const _insertionAnchor = insertionAnchor if (isHydrating) { - locateHydrationNode(true) + locateHydrationNode() } else { resetInsertionState() } diff --git a/packages/runtime-vapor/src/block.ts b/packages/runtime-vapor/src/block.ts index d6be89efc3..f501e6f5d2 100644 --- a/packages/runtime-vapor/src/block.ts +++ b/packages/runtime-vapor/src/block.ts @@ -44,7 +44,7 @@ export class DynamicFragment extends VaporFragment { constructor(anchorLabel?: string) { super([]) if (isHydrating) { - locateHydrationNode(true) + locateHydrationNode() this.hydrate(anchorLabel!) } else { this.anchor = diff --git a/packages/runtime-vapor/src/component.ts b/packages/runtime-vapor/src/component.ts index e703a9de7e..48e8c643ce 100644 --- a/packages/runtime-vapor/src/component.ts +++ b/packages/runtime-vapor/src/component.ts @@ -58,7 +58,13 @@ import { getSlot, } from './componentSlots' import { hmrReload, hmrRerender } from './hmr' -import { isHydrating, locateHydrationNode } from './dom/hydration' +import { + adoptTemplate, + currentHydrationNode, + isHydrating, + locateHydrationNode, + setCurrentHydrationNode, +} from './dom/hydration' import { insertionAnchor, insertionParent, @@ -488,7 +494,9 @@ export function createComponentWithFallback( resetInsertionState() } - const el = document.createElement(comp) + const el = isHydrating + ? (adoptTemplate(currentHydrationNode!, `<${comp}/>`) as HTMLElement) + : document.createElement(comp) // mark single root ;(el as any).$root = isSingleRoot @@ -499,6 +507,7 @@ export function createComponentWithFallback( } if (rawSlots) { + isHydrating && setCurrentHydrationNode(el.firstChild) if (rawSlots.$) { // TODO dynamic slot fragment } else { diff --git a/packages/runtime-vapor/src/dom/hydration.ts b/packages/runtime-vapor/src/dom/hydration.ts index e3f666b5b2..2ba6124855 100644 --- a/packages/runtime-vapor/src/dom/hydration.ts +++ b/packages/runtime-vapor/src/dom/hydration.ts @@ -6,11 +6,11 @@ import { setInsertionState, } from '../insertionState' import { + _nthChild, disableHydrationNodeLookup, enableHydrationNodeLookup, - next, } from './node' -import { isVaporAnchors, isVaporFragmentAnchor } from '@vue/shared' +import { isVaporAnchors } from '@vue/shared' export let isHydrating = false export let currentHydrationNode: Node | null = null @@ -31,7 +31,8 @@ function performHydration( locateHydrationNode = locateHydrationNodeImpl // optimize anchor cache lookup - ;(Comment.prototype as any).$fs = undefined + ;(Comment.prototype as any).$fe = undefined + ;(Node.prototype as any).$dp = undefined isOptimized = true } enableHydrationNodeLookup() @@ -58,12 +59,12 @@ export function hydrateNode(node: Node, fn: () => void): void { } export let adoptTemplate: (node: Node, template: string) => Node | null -export let locateHydrationNode: (hasFragmentAnchor?: boolean) => void +export let locateHydrationNode: () => void type Anchor = Comment & { - // cached matching fragment start to avoid repeated traversal + // cached matching fragment end to avoid repeated traversal // on nested fragments - $fs?: Anchor + $fe?: Anchor } export const isComment = (node: Node, data: string): node is Anchor => @@ -95,13 +96,11 @@ function adoptTemplateImpl(node: Node, template: string): Node | null { } } - currentHydrationNode = next(node) + currentHydrationNode = node.nextSibling return node } -const hydrationPositionMap = new WeakMap() - -function locateHydrationNodeImpl(hasFragmentAnchor?: boolean) { +function locateHydrationNodeImpl() { let node: Node | null // prepend / firstChild if (insertionAnchor === 0) { @@ -112,52 +111,16 @@ function locateHydrationNodeImpl(hasFragmentAnchor?: boolean) { // SSR Output: `...Content...`// `insertionAnchor` is the actual node node = insertionAnchor } else { - node = insertionParent - ? hydrationPositionMap.get(insertionParent) || insertionParent.lastChild - : currentHydrationNode - - // if node is a vapor fragment anchor, find the previous one - if (hasFragmentAnchor && node && isVaporFragmentAnchor(node)) { - node = node.previousSibling - if (__DEV__ && !node) { - // this should not happen - throw new Error(`vapor fragment anchor previous node was not found.`) - } - } + node = currentHydrationNode - if (node && isComment(node, ']')) { - // fragment backward search - if (node.$fs) { - // already cached matching fragment start - node = node.$fs - } else { - let cur: Node | null = node - let curFragEnd = node - let fragDepth = 0 - node = null - while (cur) { - cur = cur.previousSibling - if (cur) { - if (isComment(cur, '[')) { - curFragEnd.$fs = cur - if (!fragDepth) { - node = cur - break - } else { - fragDepth-- - } - } else if (isComment(cur, ']')) { - curFragEnd = cur - fragDepth++ - } - } - } - } + // if current hydration node is not under the current parent, or no + // current node, find node by dynamic position or use the first child + if (insertionParent && (!node || node.parentNode !== insertionParent)) { + node = _nthChild(insertionParent, insertionParent.$dp || 0) } - if (insertionParent && node) { - const prev = node.previousSibling - if (prev) hydrationPositionMap.set(insertionParent, prev) + while (node && isNonHydrationNode(node)) { + node = node.nextSibling! } } @@ -171,24 +134,28 @@ function locateHydrationNodeImpl(hasFragmentAnchor?: boolean) { } export function locateEndAnchor( - node: Node | null, + node: Anchor, open = '[', close = ']', ): Node | null { - let match = 0 - while (node) { - node = node.nextSibling - if (node && node.nodeType === 8) { - if ((node as Comment).data === open) match++ - if ((node as Comment).data === close) { - if (match === 0) { - return node - } else { - match-- - } + // already cached matching end + if (node.$fe) { + return node.$fe + } + + const stack: Anchor[] = [node] + while ((node = node.nextSibling as Anchor) && stack.length > 0) { + if (node.nodeType === 8) { + if (node.data === open) { + stack.push(node) + } else if (node.data === close) { + const matchingOpen = stack.pop()! + matchingOpen.$fe = node + if (stack.length === 0) return node } } } + return null } diff --git a/packages/runtime-vapor/src/dom/template.ts b/packages/runtime-vapor/src/dom/template.ts index b78ca4e52c..a987f21494 100644 --- a/packages/runtime-vapor/src/dom/template.ts +++ b/packages/runtime-vapor/src/dom/template.ts @@ -6,13 +6,16 @@ let t: HTMLTemplateElement /*! #__NO_SIDE_EFFECTS__ */ export function template(html: string, root?: boolean) { let node: Node - return (): Node & { $root?: true } => { + return (n?: number): Node & { $root?: true } => { if (isHydrating) { if (__DEV__ && !currentHydrationNode) { // TODO this should not happen throw new Error('No current hydration node') } - return adoptTemplate(currentHydrationNode!, html)! + node = adoptTemplate(currentHydrationNode!, html)! + // dynamic node position, default is 0 + ;(node as any).$dp = n || 0 + return node } // fast path for text nodes if (html[0] !== '<') { diff --git a/packages/runtime-vapor/src/insertionState.ts b/packages/runtime-vapor/src/insertionState.ts index c8c7ffbcd1..5c4c41fe23 100644 --- a/packages/runtime-vapor/src/insertionState.ts +++ b/packages/runtime-vapor/src/insertionState.ts @@ -1,4 +1,16 @@ -export let insertionParent: ParentNode | undefined +export let insertionParent: + | (ParentNode & { + // dynamic node position - hydration only + // indicates the position where dynamic nodes begin within the parent + // during hydration, static nodes before this index are skipped + // + // Example: + // const t0 = _template("
", true) + // const n4 = t0(2) // n4.$dp = 2 + // The first 2 nodes are static, dynamic nodes start from index 2 + $dp?: number + }) + | undefined export let insertionAnchor: Node | 0 | undefined /** diff --git a/packages/runtime-vapor/src/vdomInterop.ts b/packages/runtime-vapor/src/vdomInterop.ts index 0e3f70dc79..3365910ce2 100644 --- a/packages/runtime-vapor/src/vdomInterop.ts +++ b/packages/runtime-vapor/src/vdomInterop.ts @@ -41,6 +41,8 @@ import { currentHydrationNode, isHydrating, locateHydrationNode, + locateVaporFragmentAnchor, + setCurrentHydrationNode, hydrateNode as vaporHydrateNode, } from './dom/hydration' @@ -125,6 +127,16 @@ const vaporInteropImpl: Omit< }, hydrate: vaporHydrateNode, + hydrateSlot(vnode, container) { + const { slot } = vnode.vs! + const propsRef = (vnode.vs!.ref = shallowRef(vnode.props)) + const slotBlock = slot(new Proxy(propsRef, vaporSlotPropsProxyHandler)) + vaporHydrateNode(slotBlock, () => { + const anchor = locateVaporFragmentAnchor(currentHydrationNode!, 'slot')! + vnode.el = vnode.anchor = anchor + insert((vnode.vb = slotBlock), container, anchor) + }) + }, } const vaporSlotPropsProxyHandler: ProxyHandler< @@ -199,17 +211,7 @@ function createVDOMComponent( frag.insert = (parentNode, anchor) => { if (!isMounted || isHydrating) { if (isHydrating) { - ;( - vdomHydrateNode || - (vdomHydrateNode = ensureHydrationRenderer().hydrateNode!) - )( - currentHydrationNode!, - vnode, - parentInstance as any, - null, - null, - false, - ) + hydrateVNode(vnode, parentInstance as any) } else { internals.mt( vnode, @@ -266,18 +268,7 @@ function renderVDOMSlot( props, ) if (isHydrating) { - locateHydrationNode(true) - ;( - vdomHydrateNode || - (vdomHydrateNode = ensureHydrationRenderer().hydrateNode!) - )( - currentHydrationNode!, - vnode, - parentComponent as any, - null, - null, - false, - ) + hydrateVNode(vnode!, parentComponent as any) } else { if ((vnode.children as any[]).length) { if (fallbackNodes) { @@ -328,6 +319,25 @@ function renderVDOMSlot( return frag } +function hydrateVNode( + vnode: VNode, + parentComponent: ComponentInternalInstance | null, +) { + // keep fragment start anchor, hydrateNode uses it to + // determine if node is a fragmentStart + locateHydrationNode() + if (!vdomHydrateNode) vdomHydrateNode = ensureHydrationRenderer().hydrateNode! + const nextNode = vdomHydrateNode( + currentHydrationNode!, + vnode, + parentComponent, + null, + null, + false, + ) + setCurrentHydrationNode(nextNode) +} + export const vaporInteropPlugin: Plugin = app => { const internals = ensureRenderer().internals app._context.vapor = extend(vaporInteropImpl, { -- 2.47.2