From: edison Date: Tue, 21 Oct 2025 01:10:26 +0000 (+0800) Subject: feat(hydration): hydrate VaporTeleport (#14002) X-Git-Tag: v3.6.0-alpha.3~26 X-Git-Url: http://git.ipfire.org/gitweb.cgi?a=commitdiff_plain;h=a886dfc4d2889d196705e0c98d2663dc9cacdcdb;p=thirdparty%2Fvuejs%2Fcore.git feat(hydration): hydrate VaporTeleport (#14002) --- diff --git a/packages/runtime-core/src/components/Teleport.ts b/packages/runtime-core/src/components/Teleport.ts index 346d2f813e..0dc70bcd83 100644 --- a/packages/runtime-core/src/components/Teleport.ts +++ b/packages/runtime-core/src/components/Teleport.ts @@ -394,7 +394,7 @@ function moveTeleport( } } -interface TeleportTargetElement extends Element { +export interface TeleportTargetElement extends Element { // last teleport target _lpa?: Node | null } diff --git a/packages/runtime-core/src/index.ts b/packages/runtime-core/src/index.ts index e4803b7cc3..0565f4fbd3 100644 --- a/packages/runtime-core/src/index.ts +++ b/packages/runtime-core/src/index.ts @@ -576,6 +576,10 @@ export { isTeleportDisabled, isTeleportDeferred, } from './components/Teleport' +/** + * @internal + */ +export type { TeleportTargetElement } from './components/Teleport' /** * @internal */ diff --git a/packages/runtime-vapor/__tests__/hydration.spec.ts b/packages/runtime-vapor/__tests__/hydration.spec.ts index 50e84e4c08..2e48ae5cee 100644 --- a/packages/runtime-vapor/__tests__/hydration.spec.ts +++ b/packages/runtime-vapor/__tests__/hydration.spec.ts @@ -6,6 +6,7 @@ import * as runtimeDom from '@vue/runtime-dom' import * as VueServerRenderer from '@vue/server-renderer' import { isString } from '@vue/shared' import type { VaporComponentInstance } from '../src/component' +import type { TeleportFragment } from '../src/components/Teleport' const formatHtml = (raw: string) => { return raw @@ -3008,6 +3009,590 @@ describe('Vapor Mode hydration', () => { }) }) + describe('teleport', () => { + test('basic', async () => { + const data = ref({ + msg: ref('foo'), + disabled: ref(false), + fn: vi.fn(), + }) + + const teleportContainer = document.createElement('div') + teleportContainer.id = 'teleport' + teleportContainer.innerHTML = + `` + + `foo` + + `` + + `` + document.body.appendChild(teleportContainer) + + const { block, container } = await mountWithHydration( + '', + ` + {{data.msg}} + + `, + data, + ) + + const teleport = block as TeleportFragment + expect(teleport.anchor).toBe(container.lastChild) + expect(teleport.target).toBe(teleportContainer) + expect(teleport.targetStart).toBe(teleportContainer.childNodes[0]) + expect((teleport.nodes as Node[])[0]).toBe( + teleportContainer.childNodes[1], + ) + expect((teleport.nodes as Node[])[1]).toBe( + teleportContainer.childNodes[2], + ) + expect(teleport.targetAnchor).toBe(teleportContainer.childNodes[3]) + + expect(container.innerHTML).toMatchInlineSnapshot( + `""`, + ) + + // event handler + triggerEvent('click', teleportContainer.querySelector('.foo')!) + expect(data.value.fn).toHaveBeenCalled() + + data.value.msg = 'bar' + await nextTick() + expect(formatHtml(teleportContainer.innerHTML)).toBe( + `` + + `bar` + + `` + + ``, + ) + + data.value.disabled = true + await nextTick() + expect(container.innerHTML).toBe( + `` + + `bar` + + `` + + ``, + ) + expect(formatHtml(teleportContainer.innerHTML)).toMatchInlineSnapshot( + `""`, + ) + + data.value.msg = 'baz' + await nextTick() + expect(container.innerHTML).toBe( + `` + + `baz` + + `` + + ``, + ) + + data.value.disabled = false + await nextTick() + expect(container.innerHTML).toMatchInlineSnapshot( + `""`, + ) + expect(formatHtml(teleportContainer.innerHTML)).toBe( + `` + + `baz` + + `` + + ``, + ) + }) + + test('multiple + integration', async () => { + const data = ref({ + msg: ref('foo'), + fn1: vi.fn(), + fn2: vi.fn(), + }) + + const code = ` + + {{data.msg}} + + + + {{data.msg}}2 + + ` + + const SSRComp = compileVaporComponent(code, data, undefined, true) + const teleportContainer = document.createElement('div') + teleportContainer.id = 'teleport2' + const ctx = {} as any + const mainHtml = await VueServerRenderer.renderToString( + runtimeDom.createSSRApp(SSRComp), + ctx, + ) + expect(mainHtml).toBe( + `` + + `` + + `` + + ``, + ) + + const teleportHtml = ctx.teleports!['#teleport2'] + expect(teleportHtml).toBe( + `` + + `foo` + + `` + + `` + + `foo2` + + ``, + ) + + teleportContainer.innerHTML = teleportHtml + document.body.appendChild(teleportContainer) + + const { block, container } = await mountWithHydration( + mainHtml, + code, + data, + ) + + const teleports = block as any as TeleportFragment[] + const teleport1 = teleports[0] + const teleport2 = teleports[1] + expect(teleport1.anchor).toBe(container.childNodes[2]) + expect(teleport2.anchor).toBe(container.childNodes[4]) + + expect(teleport1.target).toBe(teleportContainer) + expect(teleport1.targetStart).toBe(teleportContainer.childNodes[0]) + expect((teleport1.nodes as Node[])[0]).toBe( + teleportContainer.childNodes[1], + ) + expect(teleport1.targetAnchor).toBe(teleportContainer.childNodes[3]) + + expect(teleport2.target).toBe(teleportContainer) + expect(teleport2.targetStart).toBe(teleportContainer.childNodes[4]) + expect((teleport2.nodes as Node[])[0]).toBe( + teleportContainer.childNodes[5], + ) + expect(teleport2.targetAnchor).toBe(teleportContainer.childNodes[7]) + + expect(container.innerHTML).toBe( + `` + + `` + + `` + + ``, + ) + + // event handler + triggerEvent('click', teleportContainer.querySelector('.foo')!) + expect(data.value.fn1).toHaveBeenCalled() + + triggerEvent('click', teleportContainer.querySelector('.foo2')!) + expect(data.value.fn2).toHaveBeenCalled() + + data.value.msg = 'bar' + await nextTick() + expect(teleportContainer.innerHTML).toBe( + `` + + `bar` + + `` + + `` + + `` + + `bar2` + + `` + + ``, + ) + }) + + test('disabled', async () => { + const data = ref({ + msg: ref('foo'), + fn1: vi.fn(), + fn2: vi.fn(), + }) + + const code = ` +
foo
+ + {{data.msg}} + + +
bar
+ ` + + const SSRComp = compileVaporComponent(code, data, undefined, true) + const teleportContainer = document.createElement('div') + teleportContainer.id = 'teleport3' + const ctx = {} as any + const mainHtml = await VueServerRenderer.renderToString( + runtimeDom.createSSRApp(SSRComp), + ctx, + ) + expect(mainHtml).toBe( + `` + + `
foo
` + + `` + + `foo` + + `` + + `` + + `
bar
` + + ``, + ) + + const teleportHtml = ctx.teleports!['#teleport3'] + expect(teleportHtml).toMatchInlineSnapshot( + `""`, + ) + + teleportContainer.innerHTML = teleportHtml + document.body.appendChild(teleportContainer) + + const { block, container } = await mountWithHydration( + mainHtml, + code, + data, + ) + + const blocks = block as any[] + expect(blocks[0]).toBe(container.childNodes[1]) + + const teleport = blocks[1] as TeleportFragment + expect((teleport.nodes as Node[])[0]).toBe(container.childNodes[3]) + expect((teleport.nodes as Node[])[1]).toBe(container.childNodes[4]) + expect(teleport.anchor).toBe(container.childNodes[5]) + expect(teleport.target).toBe(teleportContainer) + expect(teleport.targetStart).toBe(teleportContainer.childNodes[0]) + expect(teleport.targetAnchor).toBe(teleportContainer.childNodes[1]) + expect(blocks[2]).toBe(container.childNodes[6]) + + expect(container.innerHTML).toBe( + `` + + `
foo
` + + `` + + `foo` + + `` + + `` + + `
bar
` + + ``, + ) + + // event handler + triggerEvent('click', container.querySelector('.foo')!) + expect(data.value.fn1).toHaveBeenCalled() + + triggerEvent('click', container.querySelector('.foo2')!) + expect(data.value.fn2).toHaveBeenCalled() + + data.value.msg = 'bar' + await nextTick() + expect(container.innerHTML).toBe( + `` + + `
foo
` + + `` + + `bar` + + `` + + `` + + `
bar
` + + ``, + ) + }) + + test('disabled + as component root', async () => { + const { container } = await mountWithHydration( + `` + + `
Parent fragment
` + + `
Teleport content
` + + ``, + ` +
Parent fragment
+ +
Teleport content
+
+ `, + ) + expect(container.innerHTML).toBe( + `` + + `
Parent fragment
` + + `` + + `
Teleport content
` + + `` + + ``, + ) + expect(`mismatch`).not.toHaveBeenWarned() + }) + + test('as component root', async () => { + const teleportContainer = document.createElement('div') + teleportContainer.id = 'teleport4' + teleportContainer.innerHTML = `hello` + document.body.appendChild(teleportContainer) + + const { block, container } = await mountWithHydration( + '', + ``, + undefined, + { + Wrapper: compileVaporComponent( + `hello`, + ), + }, + ) + + const teleport = (block as VaporComponentInstance) + .block as TeleportFragment + expect(teleport.anchor).toBe(container.childNodes[1]) + expect(teleport.target).toBe(teleportContainer) + expect(teleport.targetStart).toBe(teleportContainer.childNodes[0]) + expect(teleport.nodes).toBe(teleportContainer.childNodes[1]) + expect(teleport.targetAnchor).toBe(teleportContainer.childNodes[2]) + }) + + test('nested', async () => { + const teleportContainer = document.createElement('div') + teleportContainer.id = 'teleport5' + teleportContainer.innerHTML = + `` + + `` + + `` + + `` + + `
child
` + + `` + document.body.appendChild(teleportContainer) + + const { block, container } = await mountWithHydration( + '', + ` +
child
+
`, + ) + + const teleport = block as TeleportFragment + expect(teleport.anchor).toBe(container.childNodes[1]) + expect(teleport.targetStart).toBe(teleportContainer.childNodes[0]) + expect(teleport.targetAnchor).toBe(teleportContainer.childNodes[3]) + + const childTeleport = teleport.nodes as TeleportFragment + expect(childTeleport.anchor).toBe(teleportContainer.childNodes[2]) + expect(childTeleport.targetStart).toBe(teleportContainer.childNodes[4]) + expect(childTeleport.targetAnchor).toBe(teleportContainer.childNodes[6]) + expect(childTeleport.nodes).toBe(teleportContainer.childNodes[5]) + }) + + test('unmount (full integration)', async () => { + const targetId = 'teleport6' + const data = ref({ + toggle: ref(true), + }) + + const template1 = `Teleported Comp1` + const Comp1 = compileVaporComponent(template1) + const SSRComp1 = compileVaporComponent( + template1, + undefined, + undefined, + true, + ) + + const template2 = `
Comp2
` + const Comp2 = compileVaporComponent(template2) + const SSRComp2 = compileVaporComponent( + template2, + undefined, + undefined, + true, + ) + + const appCode = ` +
+ + +
+ ` + + const SSRApp = compileVaporComponent( + appCode, + data, + { + Comp1: SSRComp1, + Comp2: SSRComp2, + }, + true, + ) + + const teleportContainer = document.createElement('div') + teleportContainer.id = targetId + document.body.appendChild(teleportContainer) + + const ctx = {} as any + const mainHtml = await VueServerRenderer.renderToString( + runtimeDom.createSSRApp(SSRApp), + ctx, + ) + expect(mainHtml).toBe( + '
', + ) + teleportContainer.innerHTML = ctx.teleports![`#${targetId}`] + + const { container } = await mountWithHydration(mainHtml, appCode, data, { + Comp1, + Comp2, + }) + + expect(container.innerHTML).toBe( + '
', + ) + expect(teleportContainer.innerHTML).toBe( + `` + + `Teleported Comp1` + + ``, + ) + expect(`mismatch`).not.toHaveBeenWarned() + + data.value.toggle = false + await nextTick() + expect(container.innerHTML).toBe('
Comp2
') + expect(teleportContainer.innerHTML).toBe('') + }) + + test('unmount (mismatch + full integration)', async () => { + const targetId = 'teleport7' + const data = ref({ + toggle: ref(true), + }) + + const template1 = `Teleported Comp1` + const Comp1 = compileVaporComponent(template1) + const SSRComp1 = compileVaporComponent( + template1, + undefined, + undefined, + true, + ) + + const template2 = `
Comp2
` + const Comp2 = compileVaporComponent(template2) + const SSRComp2 = compileVaporComponent( + template2, + undefined, + undefined, + true, + ) + + const appCode = ` +
+ + +
+ ` + + const SSRApp = compileVaporComponent( + appCode, + data, + { + Comp1: SSRComp1, + Comp2: SSRComp2, + }, + true, + ) + + const teleportContainer = document.createElement('div') + teleportContainer.id = targetId + document.body.appendChild(teleportContainer) + + const mainHtml = await VueServerRenderer.renderToString( + runtimeDom.createSSRApp(SSRApp), + ) + expect(mainHtml).toBe( + '
', + ) + expect(teleportContainer.innerHTML).toBe('') + + const { container } = await mountWithHydration(mainHtml, appCode, data, { + Comp1, + Comp2, + }) + + expect(container.innerHTML).toBe( + '
', + ) + expect(teleportContainer.innerHTML).toBe(`Teleported Comp1`) + expect(`Hydration children mismatch`).toHaveBeenWarned() + + data.value.toggle = false + await nextTick() + expect(container.innerHTML).toBe('
Comp2
') + expect(teleportContainer.innerHTML).toBe('') + }) + + test('target change (mismatch + full integration)', async () => { + const targetId1 = 'teleport8-1' + const targetId2 = 'teleport8-2' + const data = ref({ + target: ref(targetId1), + msg: ref('foo'), + }) + + const template = `{{data.msg}}` + const Comp = compileVaporComponent(template, data) + const SSRComp = compileVaporComponent(template, data, undefined, true) + + const teleportContainer1 = document.createElement('div') + teleportContainer1.id = targetId1 + const teleportContainer2 = document.createElement('div') + teleportContainer2.id = targetId2 + document.body.appendChild(teleportContainer1) + document.body.appendChild(teleportContainer2) + + // server render + const mainHtml = await VueServerRenderer.renderToString( + runtimeDom.createSSRApp(SSRComp), + ) + expect(mainHtml).toBe(``) + expect(teleportContainer1.innerHTML).toBe('') + expect(teleportContainer2.innerHTML).toBe('') + + // hydrate + const { container } = await mountWithHydration(mainHtml, template, data, { + Comp, + }) + + expect(container.innerHTML).toBe( + ``, + ) + expect(teleportContainer1.innerHTML).toBe(`foo`) + expect(teleportContainer2.innerHTML).toBe('') + expect(`Hydration children mismatch`).toHaveBeenWarned() + + data.value.target = targetId2 + data.value.msg = 'bar' + await nextTick() + expect(container.innerHTML).toBe( + ``, + ) + expect(teleportContainer1.innerHTML).toBe('') + expect(teleportContainer2.innerHTML).toBe(`bar`) + }) + + test('with disabled teleport + undefined target', async () => { + const data = ref({ + msg: ref('foo'), + }) + + const { container } = await mountWithHydration( + 'foo', + ` + {{data.msg}} + `, + data, + ) + + expect(container.innerHTML).toBe( + `foo`, + ) + + data.value.msg = 'bar' + await nextTick() + expect(container.innerHTML).toBe( + `bar`, + ) + }) + }) + + describe.todo('Suspense') + describe('force hydrate prop', async () => { test('force hydrate prop with `.prop` modifier', async () => { const { container } = await mountWithHydration( @@ -3053,8 +3638,6 @@ describe('Vapor Mode hydration', () => { // vapor custom element not implemented yet test.todo('force hydrate custom element with dynamic props', () => {}) }) - - describe.todo('Suspense') }) describe('mismatch handling', () => { diff --git a/packages/runtime-vapor/src/component.ts b/packages/runtime-vapor/src/component.ts index e1e4cffe16..6ea662583d 100644 --- a/packages/runtime-vapor/src/component.ts +++ b/packages/runtime-vapor/src/component.ts @@ -224,10 +224,13 @@ export function createComponent( // teleport if (isVaporTeleport(component)) { const frag = component.process(rawProps!, rawSlots!) - if (!isHydrating && _insertionParent) { - insert(frag, _insertionParent, _insertionAnchor) + if (!isHydrating) { + if (_insertionParent) insert(frag, _insertionParent, _insertionAnchor) } else { frag.hydrate() + if (_isLastInsertion) { + advanceHydrationNode(_insertionParent!) + } } return frag as any diff --git a/packages/runtime-vapor/src/components/Teleport.ts b/packages/runtime-vapor/src/components/Teleport.ts index 8609303df2..ef3d4598c9 100644 --- a/packages/runtime-vapor/src/components/Teleport.ts +++ b/packages/runtime-vapor/src/components/Teleport.ts @@ -1,5 +1,8 @@ import { + MismatchTypes, type TeleportProps, + type TeleportTargetElement, + isMismatchAllowed, isTeleportDeferred, isTeleportDisabled, queuePostFlushCb, @@ -17,6 +20,15 @@ import { rawPropsProxyHandlers } from '../componentProps' import { renderEffect } from '../renderEffect' import { extend, isArray } from '@vue/shared' import { VaporFragment } from '../fragment' +import { + advanceHydrationNode, + currentHydrationNode, + isComment, + isHydrating, + logMismatchError, + runWithoutHydration, + setCurrentHydrationNode, +} from '../dom/hydration' export const VaporTeleportImpl = { name: 'VaporTeleport', @@ -46,7 +58,11 @@ export class TeleportFragment extends VaporFragment { super([]) this.rawProps = props this.rawSlots = slots - this.anchor = __DEV__ ? createComment('teleport end') : createTextNode() + this.anchor = isHydrating + ? undefined + : __DEV__ + ? createComment('teleport end') + : createTextNode() renderEffect(() => { // access the props to trigger tracking @@ -60,7 +76,9 @@ export class TeleportFragment extends VaporFragment { this.handlePropsUpdate() }) - this.initChildren() + if (!isHydrating) { + this.initChildren() + } } get parent(): ParentNode | null { @@ -74,7 +92,6 @@ export class TeleportFragment extends VaporFragment { ) }) - // for hmr if (__DEV__) { const nodes = this.nodes if (isVaporComponent(nodes)) { @@ -89,7 +106,7 @@ export class TeleportFragment extends VaporFragment { private handleChildrenUpdate(children: Block): void { // not mounted yet - if (!this.parent) { + if (!this.parent || isHydrating) { this.nodes = children return } @@ -102,7 +119,7 @@ export class TeleportFragment extends VaporFragment { private handlePropsUpdate(): void { // not mounted yet - if (!this.parent) return + if (!this.parent || isHydrating) return const mount = (parent: ParentNode, anchor: Node | null) => { insert( @@ -153,6 +170,8 @@ export class TeleportFragment extends VaporFragment { } insert = (container: ParentNode, anchor: Node | null): void => { + if (isHydrating) return + // insert anchors in the main view this.placeholder = __DEV__ ? createComment('teleport start') @@ -191,8 +210,85 @@ export class TeleportFragment extends VaporFragment { this.mountAnchor = undefined } + private hydrateDisabledTeleport(targetNode: Node | null): void { + let nextNode = this.placeholder!.nextSibling! + setCurrentHydrationNode(nextNode) + this.mountAnchor = this.anchor = locateTeleportEndAnchor(nextNode)! + this.mountContainer = this.anchor.parentNode + this.targetStart = targetNode + this.targetAnchor = targetNode && targetNode.nextSibling + this.initChildren() + } + + private mount(target: Node): void { + target.appendChild((this.targetStart = createTextNode(''))) + target.appendChild( + (this.mountAnchor = this.targetAnchor = createTextNode('')), + ) + + if (!isMismatchAllowed(target as Element, MismatchTypes.CHILDREN)) { + if (__DEV__ || __FEATURE_PROD_HYDRATION_MISMATCH_DETAILS__) { + warn( + `Hydration children mismatch on`, + target, + `\nServer rendered element contains fewer child nodes than client nodes.`, + ) + } + logMismatchError() + } + + runWithoutHydration(this.initChildren.bind(this)) + } + hydrate = (): void => { - //TODO + const target = (this.target = resolveTeleportTarget( + this.resolvedProps!, + querySelector, + )) + const disabled = isTeleportDisabled(this.resolvedProps!) + this.placeholder = currentHydrationNode! + if (target) { + const targetNode = + (target as TeleportTargetElement)._lpa || target.firstChild + if (disabled) { + this.hydrateDisabledTeleport(targetNode) + } else { + this.anchor = locateTeleportEndAnchor()! + this.mountContainer = target + let targetAnchor = targetNode + while (targetAnchor) { + if (targetAnchor && targetAnchor.nodeType === 8) { + if ((targetAnchor as Comment).data === 'teleport start anchor') { + this.targetStart = targetAnchor + } else if ((targetAnchor as Comment).data === 'teleport anchor') { + this.mountAnchor = this.targetAnchor = targetAnchor + ;(target as TeleportTargetElement)._lpa = + this.targetAnchor && this.targetAnchor.nextSibling + break + } + } + targetAnchor = targetAnchor.nextSibling + } + + if (targetNode) { + setCurrentHydrationNode(targetNode.nextSibling) + } + + // if the HTML corresponding to Teleport is not embedded in the + // correct position on the final page during SSR. the targetAnchor will + // always be null, we need to manually add targetAnchor to ensure + // Teleport it can properly unmount or move + if (!this.targetAnchor) { + this.mount(target) + } else { + this.initChildren() + } + } + } else if (disabled) { + this.hydrateDisabledTeleport(currentHydrationNode!) + } + + advanceHydrationNode(this.anchor!) } } @@ -201,3 +297,15 @@ export function isVaporTeleport( ): value is typeof VaporTeleportImpl { return value === VaporTeleportImpl } + +function locateTeleportEndAnchor( + node: Node = currentHydrationNode!, +): Node | null { + while (node) { + if (isComment(node, 'teleport end')) { + return node + } + node = node.nextSibling as Node + } + return null +}