From 9be697b38cbe85807c39c41dd5e0d5563df02589 Mon Sep 17 00:00:00 2001 From: daiwei Date: Thu, 20 Mar 2025 22:17:57 +0800 Subject: [PATCH 01/30] wip: save --- .../runtime-core/src/components/Teleport.ts | 6 +- packages/runtime-core/src/index.ts | 9 + packages/runtime-vapor/src/block.ts | 4 +- packages/runtime-vapor/src/component.ts | 18 ++ .../runtime-vapor/src/components/Teleport.ts | 162 ++++++++++++++++++ packages/runtime-vapor/src/index.ts | 1 + 6 files changed, 196 insertions(+), 4 deletions(-) create mode 100644 packages/runtime-vapor/src/components/Teleport.ts diff --git a/packages/runtime-core/src/components/Teleport.ts b/packages/runtime-core/src/components/Teleport.ts index a6445df7b..c365ad0a2 100644 --- a/packages/runtime-core/src/components/Teleport.ts +++ b/packages/runtime-core/src/components/Teleport.ts @@ -27,10 +27,10 @@ export const TeleportEndKey: unique symbol = Symbol('_vte') export const isTeleport = (type: any): boolean => type.__isTeleport -const isTeleportDisabled = (props: VNode['props']): boolean => +export const isTeleportDisabled = (props: VNode['props']): boolean => props && (props.disabled || props.disabled === '') -const isTeleportDeferred = (props: VNode['props']): boolean => +export const isTeleportDeferred = (props: VNode['props']): boolean => props && (props.defer || props.defer === '') const isTargetSVG = (target: RendererElement): boolean => @@ -39,7 +39,7 @@ const isTargetSVG = (target: RendererElement): boolean => const isTargetMathML = (target: RendererElement): boolean => typeof MathMLElement === 'function' && target instanceof MathMLElement -const resolveTarget = ( +export const resolveTarget = ( props: TeleportProps | null, select: RendererOptions['querySelector'], ): T | null => { diff --git a/packages/runtime-core/src/index.ts b/packages/runtime-core/src/index.ts index c7150e38e..2d721b058 100644 --- a/packages/runtime-core/src/index.ts +++ b/packages/runtime-core/src/index.ts @@ -557,3 +557,12 @@ export { startMeasure, endMeasure } from './profiling' * @internal */ export { initFeatureFlags } from './featureFlags' +/** + * @internal + */ +export { + resolveTarget, + isTeleportDisabled, + isTeleportDeferred, + TeleportEndKey, +} from './components/Teleport' diff --git a/packages/runtime-vapor/src/block.ts b/packages/runtime-vapor/src/block.ts index b782afd38..6ec62da20 100644 --- a/packages/runtime-vapor/src/block.ts +++ b/packages/runtime-vapor/src/block.ts @@ -20,6 +20,8 @@ export type BlockFn = (...args: any[]) => Block export class VaporFragment { nodes: Block + target?: ParentNode | null + targetAnchor?: Node | null anchor?: Node insert?: (parent: ParentNode, anchor: Node | null) => void remove?: (parent?: ParentNode) => void @@ -129,7 +131,7 @@ export function insert( // TODO handle hydration for vdom interop block.insert(parent, anchor) } else { - insert(block.nodes, parent, anchor) + insert(block.nodes, block.target || parent, block.targetAnchor || anchor) } if (block.anchor) insert(block.anchor, parent, anchor) } diff --git a/packages/runtime-vapor/src/component.ts b/packages/runtime-vapor/src/component.ts index 548babebf..7989b67a8 100644 --- a/packages/runtime-vapor/src/component.ts +++ b/packages/runtime-vapor/src/component.ts @@ -60,6 +60,7 @@ import { import { hmrReload, hmrRerender } from './hmr' import { isHydrating, locateHydrationNode } from './dom/hydration' import { insertionAnchor, insertionParent } from './insertionState' +import type { VaporTeleportImpl } from './components/Teleport' export { currentInstance } from '@vue/runtime-dom' @@ -92,6 +93,8 @@ export interface ObjectVaporComponent name?: string vapor?: boolean + + __isTeleport?: boolean } interface SharedInternalOptions { @@ -157,6 +160,21 @@ export function createComponent( return frag } + // teleport + if (component.__isTeleport) { + const frag = (component as typeof VaporTeleportImpl).process( + rawProps!, + rawSlots!, + ) + if (!isHydrating && _insertionParent) { + insert(frag, _insertionParent, _insertionAnchor) + } else { + frag.hydrate() + } + + return frag as any + } + if ( isSingleRoot && component.inheritAttrs !== false && diff --git a/packages/runtime-vapor/src/components/Teleport.ts b/packages/runtime-vapor/src/components/Teleport.ts new file mode 100644 index 000000000..762d69ce8 --- /dev/null +++ b/packages/runtime-vapor/src/components/Teleport.ts @@ -0,0 +1,162 @@ +import { + TeleportEndKey, + type TeleportProps, + isTeleportDeferred, + isTeleportDisabled, + queuePostFlushCb, + resolveTarget, + warn, +} from '@vue/runtime-dom' +import { + type Block, + type BlockFn, + VaporFragment, + insert, + remove, +} from '../block' +import { createComment, createTextNode, querySelector } from '../dom/node' +import type { LooseRawProps, LooseRawSlots } from '../component' +import { rawPropsProxyHandlers } from '../componentProps' +import { renderEffect } from '../renderEffect' + +export const VaporTeleportImpl = { + name: 'VaporTeleport', + __isTeleport: true, + __vapor: true, + + process(props: LooseRawProps, slots: LooseRawSlots): TeleportFragment { + const children = slots.default && (slots.default as BlockFn)() + const frag = __DEV__ + ? new TeleportFragment('teleport') + : new TeleportFragment() + + const resolvedProps = new Proxy( + props, + rawPropsProxyHandlers, + ) as any as TeleportProps + + renderEffect(() => frag.update(resolvedProps, children)) + + frag.remove = parent => { + const { + nodes, + target, + cachedTargetAnchor, + targetStart, + placeholder, + mainAnchor, + } = frag + + remove(nodes, target || parent) + + // remove anchors + if (targetStart) { + let parentNode = targetStart.parentNode! + remove(targetStart!, parentNode) + remove(cachedTargetAnchor!, parentNode) + } + if (placeholder && placeholder.isConnected) { + remove(placeholder!, parent) + remove(mainAnchor!, parent) + } + } + + return frag + }, +} + +export class TeleportFragment extends VaporFragment { + anchor: Node + target?: ParentNode | null + targetStart?: Node | null + targetAnchor?: Node | null + cachedTargetAnchor?: Node + mainAnchor?: Node + placeholder?: Node + + constructor(anchorLabel?: string) { + super([]) + this.anchor = + __DEV__ && anchorLabel ? createComment(anchorLabel) : createTextNode() + } + + update(props: TeleportProps, children: Block): void { + this.nodes = children + const parent = this.anchor.parentNode + + if (!this.mainAnchor) { + this.mainAnchor = __DEV__ + ? createComment('teleport end') + : createTextNode() + } + if (!this.placeholder) { + this.placeholder = __DEV__ + ? createComment('teleport start') + : createTextNode() + } + if (parent) { + insert(this.placeholder, parent, this.anchor) + insert(this.mainAnchor, parent, this.anchor) + } + + const disabled = isTeleportDisabled(props) + if (disabled) { + this.target = this.anchor.parentNode + this.targetAnchor = parent ? this.mainAnchor : null + } else { + const target = (this.target = resolveTarget( + props, + querySelector, + ) as ParentNode) + if (target) { + if ( + // initial mount + !this.targetStart || + // target changed + this.targetStart.parentNode !== target + ) { + ;[this.targetAnchor, this.targetStart] = prepareAnchor(target) + this.cachedTargetAnchor = this.targetAnchor + } else { + // re-mount or target not changed, use cached target anchor + this.targetAnchor = this.cachedTargetAnchor + } + } else if (__DEV__) { + warn('Invalid Teleport target on mount:', target, `(${typeof target})`) + } + } + + const mountToTarget = () => { + insert(this.nodes, this.target!, this.targetAnchor) + } + + if (parent) { + if (isTeleportDeferred(props)) { + queuePostFlushCb(mountToTarget) + } else { + mountToTarget() + } + } + } + + hydrate(): void { + // TODO + } +} + +function prepareAnchor(target: ParentNode | null) { + const targetStart = createTextNode('targetStart') + const targetAnchor = createTextNode('targetAnchor') + + // attach a special property, so we can skip teleported content in + // renderer's nextSibling search + // @ts-expect-error + targetStart[TeleportEndKey] = targetAnchor + + if (target) { + insert(targetStart, target) + insert(targetAnchor, target) + } + + return [targetAnchor, targetStart] +} diff --git a/packages/runtime-vapor/src/index.ts b/packages/runtime-vapor/src/index.ts index 682532fa4..4b55949a6 100644 --- a/packages/runtime-vapor/src/index.ts +++ b/packages/runtime-vapor/src/index.ts @@ -3,6 +3,7 @@ export { createVaporApp, createVaporSSRApp } from './apiCreateApp' export { defineVaporComponent } from './apiDefineComponent' export { vaporInteropPlugin } from './vdomInterop' export type { VaporDirective } from './directives/custom' +export { VaporTeleportImpl as VaporTeleport } from './components/Teleport' // compiler-use only export { insert, prepend, remove, isFragment, VaporFragment } from './block' From 257138810f51802fdb4bfb0bb7068b84f309e9eb Mon Sep 17 00:00:00 2001 From: daiwei Date: Fri, 21 Mar 2025 14:43:27 +0800 Subject: [PATCH 02/30] wip: save --- .../__tests__/components/Teleport.spec.ts | 252 ++++++++++++++++++ .../runtime-vapor/src/components/Teleport.ts | 121 +++++---- 2 files changed, 312 insertions(+), 61 deletions(-) create mode 100644 packages/runtime-vapor/__tests__/components/Teleport.spec.ts diff --git a/packages/runtime-vapor/__tests__/components/Teleport.spec.ts b/packages/runtime-vapor/__tests__/components/Teleport.spec.ts new file mode 100644 index 000000000..373d170bd --- /dev/null +++ b/packages/runtime-vapor/__tests__/components/Teleport.spec.ts @@ -0,0 +1,252 @@ +import { + type LooseRawProps, + type VaporComponent, + createComponent as originalCreateComponent, +} from '../../src/component' +import { VaporTeleport, template } from '@vue/runtime-vapor' + +import { makeRender } from '../_utils' +import { nextTick, onBeforeUnmount, onUnmounted, ref, shallowRef } from 'vue' + +const define = makeRender() + +describe('renderer: VaporTeleport', () => { + describe('eager mode', () => { + runSharedTests(false) + }) + + describe('defer mode', () => { + runSharedTests(true) + }) +}) + +function runSharedTests(deferMode: boolean): void { + const createComponent = deferMode + ? ( + component: VaporComponent, + rawProps?: LooseRawProps | null, + ...args: any[] + ) => { + if (component === VaporTeleport) { + rawProps!.defer = () => true + } + return originalCreateComponent(component, rawProps, ...args) + } + : originalCreateComponent + + test('should work', () => { + const target = document.createElement('div') + const root = document.createElement('div') + + const { mount } = define({ + setup() { + const n0 = createComponent( + VaporTeleport, + { + to: () => target, + }, + { + default: () => template('
teleported
')(), + }, + ) + const n1 = template('
root
')() + return [n0, n1] + }, + }).create() + mount(root) + + expect(root.innerHTML).toBe('
root
') + expect(target.innerHTML).toBe('
teleported
') + }) + + test.todo('should work with SVG', async () => {}) + + test('should update target', async () => { + const targetA = document.createElement('div') + const targetB = document.createElement('div') + const target = ref(targetA) + const root = document.createElement('div') + + const { mount } = define({ + setup() { + const n0 = createComponent( + VaporTeleport, + { + to: () => target.value, + }, + { + default: () => template('
teleported
')(), + }, + ) + const n1 = template('
root
')() + return [n0, n1] + }, + }).create() + mount(root) + + expect(root.innerHTML).toBe('
root
') + expect(targetA.innerHTML).toBe('
teleported
') + expect(targetB.innerHTML).toBe('') + + target.value = targetB + await nextTick() + + expect(root.innerHTML).toBe('
root
') + expect(targetA.innerHTML).toBe('') + expect(targetB.innerHTML).toBe('
teleported
') + }) + + test('should update children', async () => { + const target = document.createElement('div') + const root = document.createElement('div') + const children = shallowRef([template('
teleported
')()]) + + const { mount } = define({ + setup() { + const n0 = createComponent( + VaporTeleport, + { + to: () => target, + }, + { + default: () => children.value, + }, + ) + const n1 = template('
root
')() + return [n0, n1] + }, + }).create() + mount(root) + + expect(target.innerHTML).toBe('
teleported
') + + children.value = [template('')()] + await nextTick() + expect(target.innerHTML).toBe('') + + children.value = [template('teleported')()] + await nextTick() + expect(target.innerHTML).toBe('teleported') + }) + + test('should remove children when unmounted', async () => { + const target = document.createElement('div') + const root = document.createElement('div') + + function testUnmount(props: any) { + const { app } = define({ + setup() { + const n0 = createComponent(VaporTeleport, props, { + default: () => template('
teleported
')(), + }) + const n1 = template('
root
')() + return [n0, n1] + }, + }).create() + app.mount(root) + + expect(target.innerHTML).toBe( + props.disabled() ? '' : '
teleported
', + ) + + app.unmount() + expect(target.innerHTML).toBe('') + expect(target.children.length).toBe(0) + } + + testUnmount({ to: () => target, disabled: () => false }) + testUnmount({ to: () => target, disabled: () => true }) + testUnmount({ to: () => null, disabled: () => true }) + }) + + test('component with multi roots should be removed when unmounted', async () => { + const target = document.createElement('div') + const root = document.createElement('div') + + const { component: Comp } = define({ + setup() { + return [template('

')(), template('

')()] + }, + }) + + const { app } = define({ + setup() { + const n0 = createComponent( + VaporTeleport, + { + to: () => target, + }, + { + default: () => createComponent(Comp), + }, + ) + const n1 = template('

root
')() + return [n0, n1] + }, + }).create() + + app.mount(root) + expect(target.innerHTML).toBe('

') + + app.unmount() + expect(target.innerHTML).toBe('') + }) + + test.todo( + 'descendent component should be unmounted when teleport is disabled and unmounted', + async () => { + const root = document.createElement('div') + const beforeUnmount = vi.fn() + const unmounted = vi.fn() + const { component: Comp } = define({ + setup() { + onBeforeUnmount(beforeUnmount) + onUnmounted(unmounted) + return [template('

')(), template('

')()] + }, + }) + + const { app } = define({ + setup() { + const n0 = createComponent( + VaporTeleport, + { + to: () => null, + disabled: () => true, + }, + { + default: () => createComponent(Comp), + }, + ) + return [n0] + }, + }).create() + app.mount(root) + + expect(beforeUnmount).toHaveBeenCalledTimes(0) + expect(unmounted).toHaveBeenCalledTimes(0) + + app.unmount() + expect(beforeUnmount).toHaveBeenCalledTimes(1) + expect(unmounted).toHaveBeenCalledTimes(1) + }, + ) + + test.todo('multiple teleport with same target', async () => {}) + test.todo('should work when using template ref as target', async () => {}) + test.todo('disabled', async () => {}) + test.todo('moving teleport while enabled', async () => {}) + test.todo('moving teleport while disabled', async () => {}) + test.todo('should work with block tree', async () => {}) + test.todo( + `the dir hooks of the Teleport's children should be called correctly`, + async () => {}, + ) + test.todo( + `ensure that target changes when disabled are updated correctly when enabled`, + async () => {}, + ) + test.todo('toggle sibling node inside target node', async () => {}) + test.todo('unmount previous sibling node inside target node', async () => {}) + test.todo('accessing template refs inside teleport', async () => {}) +} diff --git a/packages/runtime-vapor/src/components/Teleport.ts b/packages/runtime-vapor/src/components/Teleport.ts index 762d69ce8..e12ad4d87 100644 --- a/packages/runtime-vapor/src/components/Teleport.ts +++ b/packages/runtime-vapor/src/components/Teleport.ts @@ -18,6 +18,7 @@ import { createComment, createTextNode, querySelector } from '../dom/node' import type { LooseRawProps, LooseRawSlots } from '../component' import { rawPropsProxyHandlers } from '../componentProps' import { renderEffect } from '../renderEffect' +import { extend } from '@vue/shared' export const VaporTeleportImpl = { name: 'VaporTeleport', @@ -25,7 +26,6 @@ export const VaporTeleportImpl = { __vapor: true, process(props: LooseRawProps, slots: LooseRawSlots): TeleportFragment { - const children = slots.default && (slots.default as BlockFn)() const frag = __DEV__ ? new TeleportFragment('teleport') : new TeleportFragment() @@ -35,31 +35,11 @@ export const VaporTeleportImpl = { rawPropsProxyHandlers, ) as any as TeleportProps - renderEffect(() => frag.update(resolvedProps, children)) - - frag.remove = parent => { - const { - nodes, - target, - cachedTargetAnchor, - targetStart, - placeholder, - mainAnchor, - } = frag - - remove(nodes, target || parent) - - // remove anchors - if (targetStart) { - let parentNode = targetStart.parentNode! - remove(targetStart!, parentNode) - remove(cachedTargetAnchor!, parentNode) - } - if (placeholder && placeholder.isConnected) { - remove(placeholder!, parent) - remove(mainAnchor!, parent) - } - } + renderEffect(() => { + const children = slots.default && (slots.default as BlockFn)() + // access the props to trigger tracking + frag.update(extend({}, resolvedProps), children) + }) return frag }, @@ -67,12 +47,10 @@ export const VaporTeleportImpl = { export class TeleportFragment extends VaporFragment { anchor: Node - target?: ParentNode | null targetStart?: Node | null - targetAnchor?: Node | null - cachedTargetAnchor?: Node mainAnchor?: Node placeholder?: Node + currentParent?: ParentNode | null constructor(anchorLabel?: string) { super([]) @@ -81,33 +59,21 @@ export class TeleportFragment extends VaporFragment { } update(props: TeleportProps, children: Block): void { + // teardown previous + if (this.currentParent && this.nodes) { + remove(this.nodes, this.currentParent) + } + this.nodes = children + const disabled = isTeleportDisabled(props) const parent = this.anchor.parentNode - if (!this.mainAnchor) { - this.mainAnchor = __DEV__ - ? createComment('teleport end') - : createTextNode() - } - if (!this.placeholder) { - this.placeholder = __DEV__ - ? createComment('teleport start') - : createTextNode() - } - if (parent) { - insert(this.placeholder, parent, this.anchor) - insert(this.mainAnchor, parent, this.anchor) + const mount = (parent: ParentNode, anchor: Node | null) => { + insert(this.nodes, (this.currentParent = parent), anchor) } - const disabled = isTeleportDisabled(props) - if (disabled) { - this.target = this.anchor.parentNode - this.targetAnchor = parent ? this.mainAnchor : null - } else { - const target = (this.target = resolveTarget( - props, - querySelector, - ) as ParentNode) + const mountToTarget = () => { + const target = (this.target = resolveTarget(props, querySelector)) if (target) { if ( // initial mount @@ -116,21 +82,38 @@ export class TeleportFragment extends VaporFragment { this.targetStart.parentNode !== target ) { ;[this.targetAnchor, this.targetStart] = prepareAnchor(target) - this.cachedTargetAnchor = this.targetAnchor - } else { - // re-mount or target not changed, use cached target anchor - this.targetAnchor = this.cachedTargetAnchor } + + mount(target, this.targetAnchor!) } else if (__DEV__) { - warn('Invalid Teleport target on mount:', target, `(${typeof target})`) + warn( + `Invalid Teleport target on ${this.targetStart ? 'update' : 'mount'}:`, + target, + `(${typeof target})`, + ) } } - const mountToTarget = () => { - insert(this.nodes, this.target!, this.targetAnchor) + if (parent && disabled) { + if (!this.mainAnchor) { + this.mainAnchor = __DEV__ + ? createComment('teleport end') + : createTextNode() + } + if (!this.placeholder) { + this.placeholder = __DEV__ + ? createComment('teleport start') + : createTextNode() + } + if (!this.mainAnchor.isConnected) { + insert(this.placeholder, parent, this.anchor) + insert(this.mainAnchor, parent, this.anchor) + } + + mount(parent, this.mainAnchor) } - if (parent) { + if (!disabled) { if (isTeleportDeferred(props)) { queuePostFlushCb(mountToTarget) } else { @@ -139,14 +122,30 @@ export class TeleportFragment extends VaporFragment { } } + remove = (parent: ParentNode | undefined): void => { + // remove nodes + remove(this.nodes, this.currentParent || parent) + + // remove anchors + if (this.targetStart) { + let parentNode = this.targetStart.parentNode! + remove(this.targetStart!, parentNode) + remove(this.targetAnchor!, parentNode) + } + if (this.placeholder) { + remove(this.placeholder!, parent) + remove(this.mainAnchor!, parent) + } + } + hydrate(): void { // TODO } } function prepareAnchor(target: ParentNode | null) { - const targetStart = createTextNode('targetStart') - const targetAnchor = createTextNode('targetAnchor') + const targetStart = createTextNode('') + const targetAnchor = createTextNode('') // attach a special property, so we can skip teleported content in // renderer's nextSibling search From c0cd7fc810ec20a1241842563c5a5084ebf26722 Mon Sep 17 00:00:00 2001 From: daiwei Date: Fri, 21 Mar 2025 15:42:17 +0800 Subject: [PATCH 03/30] wip: add tests --- .../__tests__/components/Teleport.spec.ts | 256 +++++++++++++++--- .../runtime-vapor/src/components/Teleport.ts | 6 +- 2 files changed, 218 insertions(+), 44 deletions(-) diff --git a/packages/runtime-vapor/__tests__/components/Teleport.spec.ts b/packages/runtime-vapor/__tests__/components/Teleport.spec.ts index 373d170bd..bc77be2dc 100644 --- a/packages/runtime-vapor/__tests__/components/Teleport.spec.ts +++ b/packages/runtime-vapor/__tests__/components/Teleport.spec.ts @@ -3,7 +3,12 @@ import { type VaporComponent, createComponent as originalCreateComponent, } from '../../src/component' -import { VaporTeleport, template } from '@vue/runtime-vapor' +import { + VaporTeleport, + createTemplateRefSetter, + setInsertionState, + template, +} from '@vue/runtime-vapor' import { makeRender } from '../_utils' import { nextTick, onBeforeUnmount, onUnmounted, ref, shallowRef } from 'vue' @@ -192,50 +197,219 @@ function runSharedTests(deferMode: boolean): void { expect(target.innerHTML).toBe('') }) - test.todo( - 'descendent component should be unmounted when teleport is disabled and unmounted', - async () => { - const root = document.createElement('div') - const beforeUnmount = vi.fn() - const unmounted = vi.fn() - const { component: Comp } = define({ - setup() { - onBeforeUnmount(beforeUnmount) - onUnmounted(unmounted) - return [template('

')(), template('

')()] - }, - }) + test('descendent component should be unmounted when teleport is disabled and unmounted', async () => { + const root = document.createElement('div') + const beforeUnmount = vi.fn() + const unmounted = vi.fn() + const { component: Comp } = define({ + setup() { + onBeforeUnmount(beforeUnmount) + onUnmounted(unmounted) + return [template('

')(), template('

')()] + }, + }) - const { app } = define({ - setup() { - const n0 = createComponent( - VaporTeleport, - { - to: () => null, - disabled: () => true, - }, - { - default: () => createComponent(Comp), - }, - ) - return [n0] - }, - }).create() - app.mount(root) + const { app } = define({ + setup() { + const n0 = createComponent( + VaporTeleport, + { + to: () => null, + disabled: () => true, + }, + { + default: () => createComponent(Comp), + }, + ) + return [n0] + }, + }).create() + app.mount(root) - expect(beforeUnmount).toHaveBeenCalledTimes(0) - expect(unmounted).toHaveBeenCalledTimes(0) + expect(beforeUnmount).toHaveBeenCalledTimes(0) + expect(unmounted).toHaveBeenCalledTimes(0) - app.unmount() - expect(beforeUnmount).toHaveBeenCalledTimes(1) - expect(unmounted).toHaveBeenCalledTimes(1) - }, - ) + app.unmount() + await nextTick() + expect(beforeUnmount).toHaveBeenCalledTimes(1) + expect(unmounted).toHaveBeenCalledTimes(1) + }) + + test('multiple teleport with same target', async () => { + const target = document.createElement('div') + const root = document.createElement('div') + + const child1 = shallowRef(template('

one
')()) + const child2 = shallowRef(template('two')()) + + const { mount } = define({ + setup() { + const n0 = template('
')() + setInsertionState(n0 as any) + createComponent( + VaporTeleport, + { + to: () => target, + }, + { + default: () => child1.value, + }, + ) + createComponent( + VaporTeleport, + { + to: () => target, + }, + { + default: () => child2.value, + }, + ) + return [n0] + }, + }).create() + mount(root) + expect(root.innerHTML).toBe('
') + expect(target.innerHTML).toBe('
one
two') + + // update existing content + child1.value = [ + template('
one
')(), + template('
two
')(), + ] as any + child2.value = [template('three')()] as any + await nextTick() + expect(target.innerHTML).toBe('
one
two
three') + + // toggling + child1.value = [] as any + await nextTick() + expect(root.innerHTML).toBe('
') + expect(target.innerHTML).toBe('three') + + // toggle back + child1.value = [ + template('
one
')(), + template('
two
')(), + ] as any + child2.value = [template('three')()] as any + await nextTick() + expect(root.innerHTML).toBe('
') + // should append + expect(target.innerHTML).toBe('
one
two
three') + + // toggle the other teleport + child2.value = [] as any + await nextTick() + expect(root.innerHTML).toBe('
') + expect(target.innerHTML).toBe('
one
two
') + }) + + test('should work when using template ref as target', async () => { + const root = document.createElement('div') + const target = ref(null) + const disabled = ref(true) + + const { mount } = define({ + setup() { + const setTemplateRef = createTemplateRefSetter() + const n0 = template('
')() as any + setTemplateRef(n0, target) + + const n1 = createComponent( + VaporTeleport, + { + to: () => target.value, + disabled: () => disabled.value, + }, + { + default: () => template('
teleported
')(), + }, + ) + return [n0, n1] + }, + }).create() + mount(root) + + expect(root.innerHTML).toBe( + '
teleported
', + ) + disabled.value = false + await nextTick() + expect(root.innerHTML).toBe( + '
teleported
', + ) + }) + + test('disabled', async () => { + const target = document.createElement('div') + const root = document.createElement('div') + + const disabled = ref(false) + const { mount } = define({ + setup() { + const n0 = createComponent( + VaporTeleport, + { + to: () => target, + disabled: () => disabled.value, + }, + { + default: () => template('
teleported
')(), + }, + ) + const n1 = template('
root
')() + return [n0, n1] + }, + }).create() + mount(root) + + expect(root.innerHTML).toBe('
root
') + expect(target.innerHTML).toBe('
teleported
') + + disabled.value = true + await nextTick() + expect(root.innerHTML).toBe( + '
teleported
root
', + ) + expect(target.innerHTML).toBe('') + + // toggle back + disabled.value = false + await nextTick() + expect(root.innerHTML).toBe( + '
root
', + ) + expect(target.innerHTML).toBe('
teleported
') + }) + + test.todo('moving teleport while enabled', async () => { + const target = document.createElement('div') + const root = document.createElement('div') + + const child1 = createComponent( + VaporTeleport, + { to: () => target }, + { default: () => template('
teleported
')() }, + ) + const child2 = template('
root
')() + + const children = shallowRef([child1, child2]) + const { mount } = define({ + setup() { + return children.value + }, + }).create() + mount(root) + + expect(root.innerHTML).toBe('
root
') + expect(target.innerHTML).toBe('
teleported
') + + children.value = [child2, child1] + await nextTick() + expect(root.innerHTML).toBe('
root
') + expect(target.innerHTML).toBe('
teleported
') + }) - test.todo('multiple teleport with same target', async () => {}) - test.todo('should work when using template ref as target', async () => {}) - test.todo('disabled', async () => {}) - test.todo('moving teleport while enabled', async () => {}) test.todo('moving teleport while disabled', async () => {}) test.todo('should work with block tree', async () => {}) test.todo( diff --git a/packages/runtime-vapor/src/components/Teleport.ts b/packages/runtime-vapor/src/components/Teleport.ts index e12ad4d87..b0da34540 100644 --- a/packages/runtime-vapor/src/components/Teleport.ts +++ b/packages/runtime-vapor/src/components/Teleport.ts @@ -59,14 +59,14 @@ export class TeleportFragment extends VaporFragment { } update(props: TeleportProps, children: Block): void { + const parent = this.anchor.parentNode // teardown previous - if (this.currentParent && this.nodes) { - remove(this.nodes, this.currentParent) + if (this.nodes && (parent || this.currentParent)) { + remove(this.nodes, this.currentParent! || parent) } this.nodes = children const disabled = isTeleportDisabled(props) - const parent = this.anchor.parentNode const mount = (parent: ParentNode, anchor: Node | null) => { insert(this.nodes, (this.currentParent = parent), anchor) From b945079643d15ccd34f167a0aebaa11cd78793fa Mon Sep 17 00:00:00 2001 From: daiwei Date: Fri, 21 Mar 2025 16:23:27 +0800 Subject: [PATCH 04/30] wip: update tests --- packages/runtime-vapor/src/components/Teleport.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/runtime-vapor/src/components/Teleport.ts b/packages/runtime-vapor/src/components/Teleport.ts index b0da34540..1402349e1 100644 --- a/packages/runtime-vapor/src/components/Teleport.ts +++ b/packages/runtime-vapor/src/components/Teleport.ts @@ -61,8 +61,8 @@ export class TeleportFragment extends VaporFragment { update(props: TeleportProps, children: Block): void { const parent = this.anchor.parentNode // teardown previous - if (this.nodes && (parent || this.currentParent)) { - remove(this.nodes, this.currentParent! || parent) + if (this.nodes && (this.currentParent || parent)) { + remove(this.nodes, (this.currentParent || parent)!) } this.nodes = children From dd18528023fb224cb80781584b4fa4d0cb3a359f Mon Sep 17 00:00:00 2001 From: daiwei Date: Fri, 21 Mar 2025 17:47:43 +0800 Subject: [PATCH 05/30] wip: save --- .../runtime-vapor/src/components/Teleport.ts | 43 ++++++++++++++----- 1 file changed, 33 insertions(+), 10 deletions(-) diff --git a/packages/runtime-vapor/src/components/Teleport.ts b/packages/runtime-vapor/src/components/Teleport.ts index 1402349e1..4b029b641 100644 --- a/packages/runtime-vapor/src/components/Teleport.ts +++ b/packages/runtime-vapor/src/components/Teleport.ts @@ -35,10 +35,17 @@ export const VaporTeleportImpl = { rawPropsProxyHandlers, ) as any as TeleportProps + let children: Block + + renderEffect(() => { + frag.updateChildren( + (children = slots.default && (slots.default as BlockFn)()), + ) + }) + renderEffect(() => { - const children = slots.default && (slots.default as BlockFn)() // access the props to trigger tracking - frag.update(extend({}, resolvedProps), children) + frag.update(extend({}, resolvedProps), children!) }) return frag @@ -50,7 +57,8 @@ export class TeleportFragment extends VaporFragment { targetStart?: Node | null mainAnchor?: Node placeholder?: Node - currentParent?: ParentNode | null + container?: ParentNode | null + currentAnchor?: Node | null constructor(anchorLabel?: string) { super([]) @@ -58,18 +66,33 @@ export class TeleportFragment extends VaporFragment { __DEV__ && anchorLabel ? createComment(anchorLabel) : createTextNode() } + updateChildren(children: Block): void { + const parent = this.anchor.parentNode + if (!parent) return + + const container = this.container || parent + + // teardown previous + remove(this.nodes, container) + + insert( + (this.nodes = children), + container, + this.currentAnchor || this.anchor, + ) + } + update(props: TeleportProps, children: Block): void { const parent = this.anchor.parentNode - // teardown previous - if (this.nodes && (this.currentParent || parent)) { - remove(this.nodes, (this.currentParent || parent)!) - } - this.nodes = children const disabled = isTeleportDisabled(props) const mount = (parent: ParentNode, anchor: Node | null) => { - insert(this.nodes, (this.currentParent = parent), anchor) + insert( + this.nodes, + (this.container = parent), + (this.currentAnchor = anchor), + ) } const mountToTarget = () => { @@ -124,7 +147,7 @@ export class TeleportFragment extends VaporFragment { remove = (parent: ParentNode | undefined): void => { // remove nodes - remove(this.nodes, this.currentParent || parent) + remove(this.nodes, this.container || parent) // remove anchors if (this.targetStart) { From 5c8f7ed2add8e6022e744912225d7a5ba381d963 Mon Sep 17 00:00:00 2001 From: daiwei Date: Fri, 21 Mar 2025 21:53:35 +0800 Subject: [PATCH 06/30] wip: save --- .../runtime-vapor/src/components/Teleport.ts | 121 +++++++++--------- 1 file changed, 64 insertions(+), 57 deletions(-) diff --git a/packages/runtime-vapor/src/components/Teleport.ts b/packages/runtime-vapor/src/components/Teleport.ts index 4b029b641..823f6c5cc 100644 --- a/packages/runtime-vapor/src/components/Teleport.ts +++ b/packages/runtime-vapor/src/components/Teleport.ts @@ -30,13 +30,7 @@ export const VaporTeleportImpl = { ? new TeleportFragment('teleport') : new TeleportFragment() - const resolvedProps = new Proxy( - props, - rawPropsProxyHandlers, - ) as any as TeleportProps - let children: Block - renderEffect(() => { frag.updateChildren( (children = slots.default && (slots.default as BlockFn)()), @@ -44,21 +38,28 @@ export const VaporTeleportImpl = { }) renderEffect(() => { - // access the props to trigger tracking - frag.update(extend({}, resolvedProps), children!) + frag.update( + // access the props to trigger tracking + extend( + {}, + new Proxy(props, rawPropsProxyHandlers) as any as TeleportProps, + ), + children!, + ) }) return frag }, } -export class TeleportFragment extends VaporFragment { +class TeleportFragment extends VaporFragment { anchor: Node - targetStart?: Node | null - mainAnchor?: Node - placeholder?: Node - container?: ParentNode | null - currentAnchor?: Node | null + + private targetStart?: Node + private mainAnchor?: Node + private placeholder?: Node + private mountContainer?: ParentNode | null + private mountAnchor?: Node | null constructor(anchorLabel?: string) { super([]) @@ -66,32 +67,37 @@ export class TeleportFragment extends VaporFragment { __DEV__ && anchorLabel ? createComment(anchorLabel) : createTextNode() } + get currentParent(): ParentNode { + return (this.mountContainer || this.parent)! + } + + get currentAnchor(): Node | null { + return this.mountAnchor || this.anchor + } + + get parent(): ParentNode | null { + return this.anchor.parentNode + } + updateChildren(children: Block): void { - const parent = this.anchor.parentNode - if (!parent) return + // not mounted yet, early return + if (!this.parent) return - const container = this.container || parent + // teardown previous children + remove(this.nodes, this.currentParent) - // teardown previous - remove(this.nodes, container) - - insert( - (this.nodes = children), - container, - this.currentAnchor || this.anchor, - ) + // mount new + insert((this.nodes = children), this.currentParent, this.currentAnchor) } update(props: TeleportProps, children: Block): void { - const parent = this.anchor.parentNode this.nodes = children - const disabled = isTeleportDisabled(props) const mount = (parent: ParentNode, anchor: Node | null) => { insert( this.nodes, - (this.container = parent), - (this.currentAnchor = anchor), + (this.mountContainer = parent), + (this.mountAnchor = anchor), ) } @@ -99,10 +105,10 @@ export class TeleportFragment extends VaporFragment { const target = (this.target = resolveTarget(props, querySelector)) if (target) { if ( - // initial mount - !this.targetStart || + // initial mount into target + !this.targetAnchor || // target changed - this.targetStart.parentNode !== target + this.targetAnchor.parentNode !== target ) { ;[this.targetAnchor, this.targetStart] = prepareAnchor(target) } @@ -110,33 +116,36 @@ export class TeleportFragment extends VaporFragment { mount(target, this.targetAnchor!) } else if (__DEV__) { warn( - `Invalid Teleport target on ${this.targetStart ? 'update' : 'mount'}:`, + `Invalid Teleport target on ${this.targetAnchor ? 'update' : 'mount'}:`, target, `(${typeof target})`, ) } } - if (parent && disabled) { - if (!this.mainAnchor) { - this.mainAnchor = __DEV__ - ? createComment('teleport end') - : createTextNode() - } - if (!this.placeholder) { - this.placeholder = __DEV__ - ? createComment('teleport start') - : createTextNode() - } - if (!this.mainAnchor.isConnected) { - insert(this.placeholder, parent, this.anchor) - insert(this.mainAnchor, parent, this.anchor) - } + // mount into main container + if (isTeleportDisabled(props)) { + if (this.parent) { + if (!this.mainAnchor) { + this.mainAnchor = __DEV__ + ? createComment('teleport end') + : createTextNode() + } + if (!this.placeholder) { + this.placeholder = __DEV__ + ? createComment('teleport start') + : createTextNode() + } + if (!this.mainAnchor.isConnected) { + insert(this.placeholder, this.parent, this.anchor) + insert(this.mainAnchor, this.parent, this.anchor) + } - mount(parent, this.mainAnchor) + mount(this.parent, this.mainAnchor) + } } - - if (!disabled) { + // mount into target container + else { if (isTeleportDeferred(props)) { queuePostFlushCb(mountToTarget) } else { @@ -147,13 +156,12 @@ export class TeleportFragment extends VaporFragment { remove = (parent: ParentNode | undefined): void => { // remove nodes - remove(this.nodes, this.container || parent) + remove(this.nodes, this.currentParent) // remove anchors if (this.targetStart) { - let parentNode = this.targetStart.parentNode! - remove(this.targetStart!, parentNode) - remove(this.targetAnchor!, parentNode) + remove(this.targetStart!, this.target!) + remove(this.targetAnchor!, this.target!) } if (this.placeholder) { remove(this.placeholder!, parent) @@ -167,12 +175,11 @@ export class TeleportFragment extends VaporFragment { } function prepareAnchor(target: ParentNode | null) { - const targetStart = createTextNode('') + const targetStart = createTextNode('') as Text & { [TeleportEndKey]: Node } const targetAnchor = createTextNode('') // attach a special property, so we can skip teleported content in // renderer's nextSibling search - // @ts-expect-error targetStart[TeleportEndKey] = targetAnchor if (target) { From 17317c5bd588c2e116ad504f7edf063da5407b1c Mon Sep 17 00:00:00 2001 From: daiwei Date: Mon, 24 Mar 2025 13:55:17 +0800 Subject: [PATCH 07/30] wip: port tests --- .../__tests__/components/Teleport.spec.ts | 275 +++++++++++++++++- 1 file changed, 261 insertions(+), 14 deletions(-) diff --git a/packages/runtime-vapor/__tests__/components/Teleport.spec.ts b/packages/runtime-vapor/__tests__/components/Teleport.spec.ts index bc77be2dc..86b1ec62c 100644 --- a/packages/runtime-vapor/__tests__/components/Teleport.spec.ts +++ b/packages/runtime-vapor/__tests__/components/Teleport.spec.ts @@ -4,14 +4,24 @@ import { createComponent as originalCreateComponent, } from '../../src/component' import { + type VaporDirective, VaporTeleport, + createIf, createTemplateRefSetter, setInsertionState, template, + withVaporDirectives, } from '@vue/runtime-vapor' import { makeRender } from '../_utils' -import { nextTick, onBeforeUnmount, onUnmounted, ref, shallowRef } from 'vue' +import { + nextTick, + onBeforeUnmount, + onMounted, + onUnmounted, + ref, + shallowRef, +} from 'vue' const define = makeRender() @@ -410,17 +420,254 @@ function runSharedTests(deferMode: boolean): void { expect(target.innerHTML).toBe('
teleported
') }) - test.todo('moving teleport while disabled', async () => {}) - test.todo('should work with block tree', async () => {}) - test.todo( - `the dir hooks of the Teleport's children should be called correctly`, - async () => {}, - ) - test.todo( - `ensure that target changes when disabled are updated correctly when enabled`, - async () => {}, - ) - test.todo('toggle sibling node inside target node', async () => {}) - test.todo('unmount previous sibling node inside target node', async () => {}) - test.todo('accessing template refs inside teleport', async () => {}) + test.todo('moving teleport while disabled', async () => { + const target = document.createElement('div') + const root = document.createElement('div') + + const child1 = createComponent( + VaporTeleport, + { to: () => target, disabled: () => true }, + { default: () => template('
teleported
')() }, + ) + const child2 = template('
root
')() + + const children = shallowRef([child1, child2]) + const { mount } = define({ + setup() { + return children.value + }, + }).create() + mount(root) + + expect(root.innerHTML).toBe( + '
teleported
root
', + ) + expect(target.innerHTML).toBe('') + + children.value = [child2, child1] + await nextTick() + expect(root.innerHTML).toBe( + '
root
teleported
', + ) + expect(target.innerHTML).toBe('') + }) + + test(`the dir hooks of the Teleport's children should be called correctly`, async () => { + const target = document.createElement('div') + const root = document.createElement('div') + const toggle = ref(true) + + const spy = vi.fn() + const teardown = vi.fn() + const dir: VaporDirective = vi.fn((el, source) => { + spy() + return teardown + }) + + const { mount } = define({ + setup() { + return createComponent( + VaporTeleport, + { + to: () => target, + }, + { + default: () => { + return createIf( + () => toggle.value, + () => { + const n1 = template('
foo
')() as any + withVaporDirectives(n1, [[dir]]) + return n1 + }, + ) + }, + }, + ) + }, + }).create() + + mount(root) + expect(root.innerHTML).toBe('') + expect(target.innerHTML).toBe('
foo
') + expect(spy).toHaveBeenCalledTimes(1) + expect(teardown).not.toHaveBeenCalled() + + toggle.value = false + await nextTick() + expect(root.innerHTML).toBe('') + expect(target.innerHTML).toBe('') + expect(spy).toHaveBeenCalledTimes(1) + expect(teardown).toHaveBeenCalledTimes(1) + }) + + test(`ensure that target changes when disabled are updated correctly when enabled`, async () => { + const root = document.createElement('div') + const target1 = document.createElement('div') + const target2 = document.createElement('div') + const target3 = document.createElement('div') + const target = ref(target1) + const disabled = ref(true) + + const { mount } = define({ + setup() { + return createComponent( + VaporTeleport, + { + to: () => target.value, + disabled: () => disabled.value, + }, + { + default: () => template('
teleported
')(), + }, + ) + }, + }).create() + mount(root) + + disabled.value = false + await nextTick() + expect(target1.innerHTML).toBe('
teleported
') + expect(target2.innerHTML).toBe('') + expect(target3.innerHTML).toBe('') + + disabled.value = true + await nextTick() + target.value = target2 + await nextTick() + expect(target1.innerHTML).toBe('') + expect(target2.innerHTML).toBe('') + expect(target3.innerHTML).toBe('') + + target.value = target3 + await nextTick() + expect(target1.innerHTML).toBe('') + expect(target2.innerHTML).toBe('') + expect(target3.innerHTML).toBe('') + + disabled.value = false + await nextTick() + expect(target1.innerHTML).toBe('') + expect(target2.innerHTML).toBe('') + expect(target3.innerHTML).toBe('
teleported
') + }) + + test('toggle sibling node inside target node', async () => { + const root = document.createElement('div') + const show = ref(false) + const { mount } = define({ + setup() { + return createIf( + () => show.value, + () => { + return createComponent( + VaporTeleport, + { + to: () => root, + }, + { + default: () => template('
teleported
')(), + }, + ) + }, + () => { + return template('
foo
')() + }, + ) + }, + }).create() + + mount(root) + expect(root.innerHTML).toBe('
foo
') + + show.value = true + await nextTick() + expect(root.innerHTML).toBe('
teleported
') + + show.value = false + await nextTick() + expect(root.innerHTML).toBe('
foo
') + }) + + test('unmount previous sibling node inside target node', async () => { + const root = document.createElement('div') + const parentShow = ref(false) + const childShow = ref(true) + + const { component: Comp } = define({ + setup() { + return createComponent( + VaporTeleport, + { to: () => root }, + { + default: () => { + return template('
foo
')() + }, + }, + ) + }, + }) + + const { mount } = define({ + setup() { + return createIf( + () => parentShow.value, + () => + createIf( + () => childShow.value, + () => createComponent(Comp), + () => template('bar')(), + ), + () => template('foo')(), + ) + }, + }).create() + + mount(root) + expect(root.innerHTML).toBe('foo') + + parentShow.value = true + await nextTick() + expect(root.innerHTML).toBe( + '
foo
', + ) + + parentShow.value = false + await nextTick() + expect(root.innerHTML).toBe('foo') + }) + + test('accessing template refs inside teleport', async () => { + const target = document.createElement('div') + const tRef = ref() + let tRefInMounted + + const { mount } = define({ + setup() { + onMounted(() => { + tRefInMounted = tRef.value + }) + const n1 = createComponent( + VaporTeleport, + { + to: () => target, + }, + { + default: () => { + const setTemplateRef = createTemplateRefSetter() + const n0 = template('
teleported
')() as any + setTemplateRef(n0, tRef) + return n0 + }, + }, + ) + return n1 + }, + }).create() + mount(target) + + const child = target.children[0] + expect(child.outerHTML).toBe(`
teleported
`) + expect(tRefInMounted).toBe(child) + }) } From b232e456f444551df4a5df2d4bbce125cece2303 Mon Sep 17 00:00:00 2001 From: daiwei Date: Mon, 24 Mar 2025 14:17:52 +0800 Subject: [PATCH 08/30] wip: remove unnecessary tests --- .../__tests__/components/Teleport.spec.ts | 60 ------------------- 1 file changed, 60 deletions(-) diff --git a/packages/runtime-vapor/__tests__/components/Teleport.spec.ts b/packages/runtime-vapor/__tests__/components/Teleport.spec.ts index 86b1ec62c..27c1682fa 100644 --- a/packages/runtime-vapor/__tests__/components/Teleport.spec.ts +++ b/packages/runtime-vapor/__tests__/components/Teleport.spec.ts @@ -392,66 +392,6 @@ function runSharedTests(deferMode: boolean): void { expect(target.innerHTML).toBe('
teleported
') }) - test.todo('moving teleport while enabled', async () => { - const target = document.createElement('div') - const root = document.createElement('div') - - const child1 = createComponent( - VaporTeleport, - { to: () => target }, - { default: () => template('
teleported
')() }, - ) - const child2 = template('
root
')() - - const children = shallowRef([child1, child2]) - const { mount } = define({ - setup() { - return children.value - }, - }).create() - mount(root) - - expect(root.innerHTML).toBe('
root
') - expect(target.innerHTML).toBe('
teleported
') - - children.value = [child2, child1] - await nextTick() - expect(root.innerHTML).toBe('
root
') - expect(target.innerHTML).toBe('
teleported
') - }) - - test.todo('moving teleport while disabled', async () => { - const target = document.createElement('div') - const root = document.createElement('div') - - const child1 = createComponent( - VaporTeleport, - { to: () => target, disabled: () => true }, - { default: () => template('
teleported
')() }, - ) - const child2 = template('
root
')() - - const children = shallowRef([child1, child2]) - const { mount } = define({ - setup() { - return children.value - }, - }).create() - mount(root) - - expect(root.innerHTML).toBe( - '
teleported
root
', - ) - expect(target.innerHTML).toBe('') - - children.value = [child2, child1] - await nextTick() - expect(root.innerHTML).toBe( - '
root
teleported
', - ) - expect(target.innerHTML).toBe('') - }) - test(`the dir hooks of the Teleport's children should be called correctly`, async () => { const target = document.createElement('div') const root = document.createElement('div') From 97e6174c41097f59c1a249ce2516a263c5c4321e Mon Sep 17 00:00:00 2001 From: daiwei Date: Mon, 24 Mar 2025 14:27:46 +0800 Subject: [PATCH 09/30] wip: handle vapor teleport --- packages/compiler-vapor/src/generators/component.ts | 10 +++++++++- .../compiler-vapor/src/transforms/transformElement.ts | 8 +++++++- packages/compiler-vapor/src/utils.ts | 11 +++++++++++ 3 files changed, 27 insertions(+), 2 deletions(-) diff --git a/packages/compiler-vapor/src/generators/component.ts b/packages/compiler-vapor/src/generators/component.ts index 7c232db75..7dbf8c9a2 100644 --- a/packages/compiler-vapor/src/generators/component.ts +++ b/packages/compiler-vapor/src/generators/component.ts @@ -39,6 +39,7 @@ import { genEventHandler } from './event' import { genDirectiveModifiers, genDirectivesForElement } from './directive' import { genBlock } from './block' import { genModelHandler } from './vModel' +import { isBuiltInComponent } from '../utils' export function genCreateComponent( operation: CreateComponentIRNode, @@ -92,8 +93,15 @@ export function genCreateComponent( } else if (operation.asset) { return toValidAssetId(operation.tag, 'component') } else { + const { tag } = operation + const builtInTag = isBuiltInComponent(tag) + if (builtInTag) { + // @ts-expect-error + helper(builtInTag) + return `_${builtInTag}` + } return genExpression( - extend(createSimpleExpression(operation.tag, false), { ast: null }), + extend(createSimpleExpression(tag, false), { ast: null }), context, ) } diff --git a/packages/compiler-vapor/src/transforms/transformElement.ts b/packages/compiler-vapor/src/transforms/transformElement.ts index dceb3fd61..07f88ae0c 100644 --- a/packages/compiler-vapor/src/transforms/transformElement.ts +++ b/packages/compiler-vapor/src/transforms/transformElement.ts @@ -36,7 +36,7 @@ import { type VaporDirectiveNode, } from '../ir' import { EMPTY_EXPRESSION } from './utils' -import { findProp } from '../utils' +import { findProp, isBuiltInComponent } from '../utils' export const isReservedProp: (key: string) => boolean = /*#__PURE__*/ makeMap( // the leading comma is intentional so empty string "" is also included @@ -109,6 +109,12 @@ function transformComponentElement( asset = false } + const builtInTag = isBuiltInComponent(tag) + if (builtInTag) { + tag = builtInTag + asset = false + } + const dotIndex = tag.indexOf('.') if (dotIndex > 0) { const ns = resolveSetupReference(tag.slice(0, dotIndex), context) diff --git a/packages/compiler-vapor/src/utils.ts b/packages/compiler-vapor/src/utils.ts index 728281914..9b99ef869 100644 --- a/packages/compiler-vapor/src/utils.ts +++ b/packages/compiler-vapor/src/utils.ts @@ -88,3 +88,14 @@ export function getLiteralExpressionValue( } return exp.isStatic ? exp.content : null } + +export function isTeleportTag(tag: string): boolean { + tag = tag.toLowerCase() + return tag === 'teleport' || tag === 'vaporteleport' +} + +export function isBuiltInComponent(tag: string): string | undefined { + if (isTeleportTag(tag)) { + return 'VaporTeleport' + } +} From 33830a07450c50f89b1b90faa0fa6b6a46810e82 Mon Sep 17 00:00:00 2001 From: daiwei Date: Mon, 24 Mar 2025 15:11:00 +0800 Subject: [PATCH 10/30] wip: save --- packages/runtime-vapor/src/components/Teleport.ts | 11 +++++++++++ packages/runtime-vapor/src/index.ts | 2 +- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/packages/runtime-vapor/src/components/Teleport.ts b/packages/runtime-vapor/src/components/Teleport.ts index 823f6c5cc..ba4744250 100644 --- a/packages/runtime-vapor/src/components/Teleport.ts +++ b/packages/runtime-vapor/src/components/Teleport.ts @@ -189,3 +189,14 @@ function prepareAnchor(target: ParentNode | null) { return [targetAnchor, targetStart] } + +export const VaporTeleport = VaporTeleportImpl as unknown as { + __vapor: true + __isTeleport: true + new (): { + $props: TeleportProps + $slots: { + default(): Block + } + } +} diff --git a/packages/runtime-vapor/src/index.ts b/packages/runtime-vapor/src/index.ts index 4b55949a6..2edceb549 100644 --- a/packages/runtime-vapor/src/index.ts +++ b/packages/runtime-vapor/src/index.ts @@ -3,7 +3,7 @@ export { createVaporApp, createVaporSSRApp } from './apiCreateApp' export { defineVaporComponent } from './apiDefineComponent' export { vaporInteropPlugin } from './vdomInterop' export type { VaporDirective } from './directives/custom' -export { VaporTeleportImpl as VaporTeleport } from './components/Teleport' +export { VaporTeleport } from './components/Teleport' // compiler-use only export { insert, prepend, remove, isFragment, VaporFragment } from './block' From 098f50d5a173f2ba24b1053804b675c1eb1cc8a8 Mon Sep 17 00:00:00 2001 From: daiwei Date: Mon, 24 Mar 2025 17:28:11 +0800 Subject: [PATCH 11/30] wip: handing teleport hmr updating --- packages/runtime-vapor/src/component.ts | 8 ++++++++ packages/runtime-vapor/src/hmr.ts | 8 ++++++++ 2 files changed, 16 insertions(+) diff --git a/packages/runtime-vapor/src/component.ts b/packages/runtime-vapor/src/component.ts index 7989b67a8..2855fa9ce 100644 --- a/packages/runtime-vapor/src/component.ts +++ b/packages/runtime-vapor/src/component.ts @@ -172,6 +172,13 @@ export function createComponent( frag.hydrate() } + if (__DEV__) { + const instance = currentInstance as VaporComponentInstance + ;(instance!.hmrEffects || (instance!.hmrEffects = [])).push(() => + frag.remove(frag.anchor.parentNode!), + ) + } + return frag as any } @@ -389,6 +396,7 @@ export class VaporComponentInstance implements GenericComponentInstance { devtoolsRawSetupState?: any hmrRerender?: () => void hmrReload?: (newComp: VaporComponent) => void + hmrEffects?: (() => void)[] propsOptions?: NormalizedPropsOptions emitsOptions?: ObjectEmitsOptions | null isSingleRoot?: boolean diff --git a/packages/runtime-vapor/src/hmr.ts b/packages/runtime-vapor/src/hmr.ts index 741f38586..c960a2610 100644 --- a/packages/runtime-vapor/src/hmr.ts +++ b/packages/runtime-vapor/src/hmr.ts @@ -19,6 +19,10 @@ export function hmrRerender(instance: VaporComponentInstance): void { const parent = normalized[0].parentNode! const anchor = normalized[normalized.length - 1].nextSibling remove(instance.block, parent) + if (instance.hmrEffects) { + instance.hmrEffects.forEach(e => e()) + instance.hmrEffects.length = 0 + } const prev = currentInstance simpleSetCurrentInstance(instance) pushWarningContext(instance) @@ -36,6 +40,10 @@ export function hmrReload( const parent = normalized[0].parentNode! const anchor = normalized[normalized.length - 1].nextSibling unmountComponent(instance, parent) + if (instance.hmrEffects) { + instance.hmrEffects.forEach(e => e()) + instance.hmrEffects.length = 0 + } const prev = currentInstance simpleSetCurrentInstance(instance.parent) const newInstance = createComponent( From 51ca617632adf28d91df4fc6a0fa3ddbd4002faa Mon Sep 17 00:00:00 2001 From: daiwei Date: Mon, 24 Mar 2025 21:14:46 +0800 Subject: [PATCH 12/30] test: add e2e tests for vdom interop --- .../vapor-e2e-test/__tests__/teleport.spec.ts | 61 +++++++++++++++++++ .../vapor-e2e-test/__tests__/todomvc.spec.ts | 3 +- .../__tests__/vdomInterop.spec.ts | 3 +- packages-private/vapor-e2e-test/index.html | 1 + .../vapor-e2e-test/teleport/App.vue | 17 ++++++ .../teleport/components/VdomComp.vue | 7 +++ .../vapor-e2e-test/teleport/index.html | 2 + .../vapor-e2e-test/teleport/main.ts | 5 ++ packages-private/vapor-e2e-test/utils.ts | 6 ++ .../vapor-e2e-test/vite.config.ts | 1 + 10 files changed, 104 insertions(+), 2 deletions(-) create mode 100644 packages-private/vapor-e2e-test/__tests__/teleport.spec.ts create mode 100644 packages-private/vapor-e2e-test/teleport/App.vue create mode 100644 packages-private/vapor-e2e-test/teleport/components/VdomComp.vue create mode 100644 packages-private/vapor-e2e-test/teleport/index.html create mode 100644 packages-private/vapor-e2e-test/teleport/main.ts create mode 100644 packages-private/vapor-e2e-test/utils.ts diff --git a/packages-private/vapor-e2e-test/__tests__/teleport.spec.ts b/packages-private/vapor-e2e-test/__tests__/teleport.spec.ts new file mode 100644 index 000000000..dc3efa13a --- /dev/null +++ b/packages-private/vapor-e2e-test/__tests__/teleport.spec.ts @@ -0,0 +1,61 @@ +import path from 'node:path' +import { + E2E_TIMEOUT, + setupPuppeteer, +} from '../../../packages/vue/__tests__/e2e/e2eUtils' +import connect from 'connect' +import sirv from 'sirv' +import { nextTick } from 'vue' +import { ports } from '../utils' +const { page, click, html } = setupPuppeteer() + +describe('vdom / vapor interop', () => { + let server: any + const port = ports.teleport + beforeAll(() => { + server = connect() + .use(sirv(path.resolve(import.meta.dirname, '../dist'))) + .listen(port) + process.on('SIGTERM', () => server && server.close()) + }) + + afterAll(() => { + server.close() + }) + + beforeEach(async () => { + const baseUrl = `http://localhost:${port}/teleport/` + await page().goto(baseUrl) + await page().waitForSelector('#app') + }) + + describe('vapor teleport', () => { + test( + 'render vdom component', + async () => { + const targetSelector = '.target' + const testSelector = '.interop-render-vdom-comp' + const containerSelector = `${testSelector} > div` + const btnSelector = `${testSelector} > button` + + // teleport is disabled + expect(await html(containerSelector)).toBe('

vdom comp

') + expect(await html(targetSelector)).toBe('') + + // enable teleport + await click(btnSelector) + await nextTick() + + expect(await html(containerSelector)).toBe('') + expect(await html(targetSelector)).toBe('

vdom comp

') + + // disable teleport + await click(btnSelector) + await nextTick() + expect(await html(containerSelector)).toBe('

vdom comp

') + expect(await html(targetSelector)).toBe('') + }, + E2E_TIMEOUT, + ) + }) +}) diff --git a/packages-private/vapor-e2e-test/__tests__/todomvc.spec.ts b/packages-private/vapor-e2e-test/__tests__/todomvc.spec.ts index 3de8392e5..035691fd6 100644 --- a/packages-private/vapor-e2e-test/__tests__/todomvc.spec.ts +++ b/packages-private/vapor-e2e-test/__tests__/todomvc.spec.ts @@ -5,6 +5,7 @@ import { } from '../../../packages/vue/__tests__/e2e/e2eUtils' import connect from 'connect' import sirv from 'sirv' +import { ports } from '../utils' describe('e2e: todomvc', () => { const { @@ -23,7 +24,7 @@ describe('e2e: todomvc', () => { } = setupPuppeteer() let server: any - const port = '8194' + const port = ports.todomvc beforeAll(() => { server = connect() .use(sirv(path.resolve(import.meta.dirname, '../dist'))) diff --git a/packages-private/vapor-e2e-test/__tests__/vdomInterop.spec.ts b/packages-private/vapor-e2e-test/__tests__/vdomInterop.spec.ts index 360f48085..a3069d1ae 100644 --- a/packages-private/vapor-e2e-test/__tests__/vdomInterop.spec.ts +++ b/packages-private/vapor-e2e-test/__tests__/vdomInterop.spec.ts @@ -5,12 +5,13 @@ import { } from '../../../packages/vue/__tests__/e2e/e2eUtils' import connect from 'connect' import sirv from 'sirv' +import { ports } from '../utils' describe('vdom / vapor interop', () => { const { page, click, text, enterValue } = setupPuppeteer() let server: any - const port = '8193' + const port = ports.vdomInterop beforeAll(() => { server = connect() .use(sirv(path.resolve(import.meta.dirname, '../dist'))) diff --git a/packages-private/vapor-e2e-test/index.html b/packages-private/vapor-e2e-test/index.html index 7dc205e5a..bb1234e8e 100644 --- a/packages-private/vapor-e2e-test/index.html +++ b/packages-private/vapor-e2e-test/index.html @@ -1,2 +1,3 @@ VDOM / Vapor interop Vapor TodoMVC +Vapor Teleport diff --git a/packages-private/vapor-e2e-test/teleport/App.vue b/packages-private/vapor-e2e-test/teleport/App.vue new file mode 100644 index 000000000..d2aeba8e1 --- /dev/null +++ b/packages-private/vapor-e2e-test/teleport/App.vue @@ -0,0 +1,17 @@ + + + diff --git a/packages-private/vapor-e2e-test/teleport/components/VdomComp.vue b/packages-private/vapor-e2e-test/teleport/components/VdomComp.vue new file mode 100644 index 000000000..6eba9134e --- /dev/null +++ b/packages-private/vapor-e2e-test/teleport/components/VdomComp.vue @@ -0,0 +1,7 @@ + + + diff --git a/packages-private/vapor-e2e-test/teleport/index.html b/packages-private/vapor-e2e-test/teleport/index.html new file mode 100644 index 000000000..79052a023 --- /dev/null +++ b/packages-private/vapor-e2e-test/teleport/index.html @@ -0,0 +1,2 @@ + +
diff --git a/packages-private/vapor-e2e-test/teleport/main.ts b/packages-private/vapor-e2e-test/teleport/main.ts new file mode 100644 index 000000000..2e962efe7 --- /dev/null +++ b/packages-private/vapor-e2e-test/teleport/main.ts @@ -0,0 +1,5 @@ +import { createVaporApp, vaporInteropPlugin } from 'vue' +import App from './App.vue' +import 'todomvc-app-css/index.css' + +createVaporApp(App).use(vaporInteropPlugin).mount('#app') diff --git a/packages-private/vapor-e2e-test/utils.ts b/packages-private/vapor-e2e-test/utils.ts new file mode 100644 index 000000000..a42064b70 --- /dev/null +++ b/packages-private/vapor-e2e-test/utils.ts @@ -0,0 +1,6 @@ +// make sure these ports are unique +export const ports = { + vdomInterop: 8193, + todomvc: 8194, + teleport: 8195, +} diff --git a/packages-private/vapor-e2e-test/vite.config.ts b/packages-private/vapor-e2e-test/vite.config.ts index 1e29a4dbd..a2816f4b6 100644 --- a/packages-private/vapor-e2e-test/vite.config.ts +++ b/packages-private/vapor-e2e-test/vite.config.ts @@ -14,6 +14,7 @@ export default defineConfig({ input: { interop: resolve(import.meta.dirname, 'interop/index.html'), todomvc: resolve(import.meta.dirname, 'todomvc/index.html'), + teleport: resolve(import.meta.dirname, 'teleport/index.html'), }, }, }, From 90c2e20e169251d05d78a51e8e0e5d63eee89768 Mon Sep 17 00:00:00 2001 From: daiwei Date: Tue, 25 Mar 2025 11:10:50 +0800 Subject: [PATCH 13/30] wip: hmr updating --- packages/runtime-vapor/src/component.ts | 7 +-- .../runtime-vapor/src/components/Teleport.ts | 53 +++++++++++++------ 2 files changed, 41 insertions(+), 19 deletions(-) diff --git a/packages/runtime-vapor/src/component.ts b/packages/runtime-vapor/src/component.ts index 2855fa9ce..4d5c6d0b7 100644 --- a/packages/runtime-vapor/src/component.ts +++ b/packages/runtime-vapor/src/component.ts @@ -172,11 +172,12 @@ export function createComponent( frag.hydrate() } + // remove the teleport content from the parent tree for HMR updates if (__DEV__) { const instance = currentInstance as VaporComponentInstance - ;(instance!.hmrEffects || (instance!.hmrEffects = [])).push(() => - frag.remove(frag.anchor.parentNode!), - ) + ;(instance!.hmrEffects || (instance!.hmrEffects = [])).push(() => { + frag.remove(frag.anchor.parentNode!) + }) } return frag as any diff --git a/packages/runtime-vapor/src/components/Teleport.ts b/packages/runtime-vapor/src/components/Teleport.ts index ba4744250..8a0365782 100644 --- a/packages/runtime-vapor/src/components/Teleport.ts +++ b/packages/runtime-vapor/src/components/Teleport.ts @@ -19,6 +19,7 @@ import type { LooseRawProps, LooseRawSlots } from '../component' import { rawPropsProxyHandlers } from '../componentProps' import { renderEffect } from '../renderEffect' import { extend } from '@vue/shared' +import { EffectScope, pauseTracking, resetTracking } from '@vue/reactivity' export const VaporTeleportImpl = { name: 'VaporTeleport', @@ -30,23 +31,28 @@ export const VaporTeleportImpl = { ? new TeleportFragment('teleport') : new TeleportFragment() - let children: Block - renderEffect(() => { - frag.updateChildren( - (children = slots.default && (slots.default as BlockFn)()), - ) - }) + pauseTracking() + const scope = (frag.scope = new EffectScope()) + scope!.run(() => { + let children: Block + renderEffect(() => { + frag.updateChildren( + (children = slots.default && (slots.default as BlockFn)()), + ) + }) - renderEffect(() => { - frag.update( - // access the props to trigger tracking - extend( - {}, - new Proxy(props, rawPropsProxyHandlers) as any as TeleportProps, - ), - children!, - ) + renderEffect(() => { + frag.update( + // access the props to trigger tracking + extend( + {}, + new Proxy(props, rawPropsProxyHandlers) as any as TeleportProps, + ), + children!, + ) + }) }) + resetTracking() return frag }, @@ -54,6 +60,7 @@ export const VaporTeleportImpl = { class TeleportFragment extends VaporFragment { anchor: Node + scope: EffectScope | undefined private targetStart?: Node private mainAnchor?: Node @@ -155,17 +162,31 @@ class TeleportFragment extends VaporFragment { } remove = (parent: ParentNode | undefined): void => { + // stop effect scope + if (this.scope) { + this.scope.stop() + this.scope = undefined + } + // remove nodes - remove(this.nodes, this.currentParent) + if (this.nodes) { + remove(this.nodes, this.currentParent) + this.nodes = [] + } // remove anchors if (this.targetStart) { remove(this.targetStart!, this.target!) + this.targetStart = undefined remove(this.targetAnchor!, this.target!) + this.targetAnchor = undefined } + if (this.placeholder) { remove(this.placeholder!, parent) + this.placeholder = undefined remove(this.mainAnchor!, parent) + this.mainAnchor = undefined } } From ec76aec619a0006054a60ecf689dd6c49045c176 Mon Sep 17 00:00:00 2001 From: daiwei Date: Tue, 25 Mar 2025 17:21:50 +0800 Subject: [PATCH 14/30] wip: test hmr updating --- packages/runtime-core/src/hmr.ts | 2 +- .../__tests__/components/Teleport.spec.ts | 146 +++++++++++++++++- packages/runtime-vapor/src/block.ts | 6 +- .../runtime-vapor/src/components/Teleport.ts | 7 + packages/runtime-vapor/src/hmr.ts | 1 + 5 files changed, 156 insertions(+), 6 deletions(-) diff --git a/packages/runtime-core/src/hmr.ts b/packages/runtime-core/src/hmr.ts index ed5d8b081..acc9593d1 100644 --- a/packages/runtime-core/src/hmr.ts +++ b/packages/runtime-core/src/hmr.ts @@ -119,7 +119,7 @@ function reload(id: string, newComp: HMRComponent): void { // create a snapshot which avoids the set being mutated during updates const instances = [...record.instances] - if (newComp.vapor) { + if (newComp.__vapor) { for (const instance of instances) { instance.hmrReload!(newComp) } diff --git a/packages/runtime-vapor/__tests__/components/Teleport.spec.ts b/packages/runtime-vapor/__tests__/components/Teleport.spec.ts index 27c1682fa..cae00f75f 100644 --- a/packages/runtime-vapor/__tests__/components/Teleport.spec.ts +++ b/packages/runtime-vapor/__tests__/components/Teleport.spec.ts @@ -1,7 +1,7 @@ import { type LooseRawProps, type VaporComponent, - createComponent as originalCreateComponent, + createComponent as createComp, } from '../../src/component' import { type VaporDirective, @@ -12,7 +12,6 @@ import { template, withVaporDirectives, } from '@vue/runtime-vapor' - import { makeRender } from '../_utils' import { nextTick, @@ -23,6 +22,10 @@ import { shallowRef, } from 'vue' +import type { HMRRuntime } from '@vue/runtime-dom' +declare var __VUE_HMR_RUNTIME__: HMRRuntime +const { createRecord, rerender, reload } = __VUE_HMR_RUNTIME__ + const define = makeRender() describe('renderer: VaporTeleport', () => { @@ -33,6 +36,141 @@ describe('renderer: VaporTeleport', () => { describe('defer mode', () => { runSharedTests(true) }) + + describe('HMR', () => { + test('rerender', async () => { + const target = document.createElement('div') + const root = document.createElement('div') + const childId = 'test1-child' + const parentId = 'test1-parent' + + const { component: Child } = define({ + __hmrId: childId, + render() { + return template('
teleported
')() + }, + }) + createRecord(childId, Child as any) + + const { mount, component: Parent } = define({ + __hmrId: parentId, + render() { + const n0 = createComp( + VaporTeleport, + { + to: () => target, + }, + { + default: () => createComp(Child), + }, + ) + const n1 = template('
root
')() + return [n0, n1] + }, + }).create() + createRecord(parentId, Parent as any) + mount(root) + + expect(root.innerHTML).toBe('
root
') + expect(target.innerHTML).toBe('
teleported
') + + // rerender child + rerender(childId, () => { + return template('
teleported 2
')() + }) + + expect(root.innerHTML).toBe('
root
') + expect(target.innerHTML).toBe('
teleported 2
') + + // rerender parent + rerender(parentId, () => { + const n0 = createComp( + VaporTeleport, + { + to: () => target, + }, + { + default: () => createComp(Child), + }, + ) + const n1 = template('
root 2
')() + return [n0, n1] + }) + + expect(root.innerHTML).toBe('
root 2
') + expect(target.innerHTML).toBe('
teleported 2
') + }) + + test('reload', async () => { + const target = document.createElement('div') + const root = document.createElement('div') + const childId = 'test2-child' + const parentId = 'test2-parent' + + const { component: Child } = define({ + __hmrId: childId, + render() { + return template('
teleported
')() + }, + }) + createRecord(childId, Child as any) + + const { mount, component: Parent } = define({ + __hmrId: parentId, + render() { + const n0 = createComp( + VaporTeleport, + { + to: () => target, + }, + { + default: () => createComp(Child), + }, + ) + const n1 = template('
root
')() + return [n0, n1] + }, + }).create() + createRecord(parentId, Parent as any) + mount(root) + + expect(root.innerHTML).toBe('
root
') + expect(target.innerHTML).toBe('
teleported
') + + // reload child + reload(childId, { + __hmrId: childId, + __vapor: true, + render() { + return template('
teleported 2
')() + }, + }) + expect(root.innerHTML).toBe('
root
') + expect(target.innerHTML).toBe('
teleported 2
') + + // reload parent + reload(parentId, { + __hmrId: parentId, + __vapor: true, + render() { + const n0 = createComp( + VaporTeleport, + { + to: () => target, + }, + { + default: () => createComp(Child), + }, + ) + const n1 = template('
root 2
')() + return [n0, n1] + }, + }) + + expect(root.innerHTML).toBe('
root 2
') + expect(target.innerHTML).toBe('
teleported 2
') + }) + }) }) function runSharedTests(deferMode: boolean): void { @@ -45,9 +183,9 @@ function runSharedTests(deferMode: boolean): void { if (component === VaporTeleport) { rawProps!.defer = () => true } - return originalCreateComponent(component, rawProps, ...args) + return createComp(component, rawProps, ...args) } - : originalCreateComponent + : createComp test('should work', () => { const target = document.createElement('div') diff --git a/packages/runtime-vapor/src/block.ts b/packages/runtime-vapor/src/block.ts index 6ec62da20..17ae71909 100644 --- a/packages/runtime-vapor/src/block.ts +++ b/packages/runtime-vapor/src/block.ts @@ -184,7 +184,11 @@ export function normalizeBlock(block: Block): Node[] { } else if (isVaporComponent(block)) { nodes.push(...normalizeBlock(block.block!)) } else { - nodes.push(...normalizeBlock(block.nodes)) + if ((block as any).getNodes) { + nodes.push(...normalizeBlock((block as any).getNodes())) + } else { + nodes.push(...normalizeBlock(block.nodes)) + } block.anchor && nodes.push(block.anchor) } return nodes diff --git a/packages/runtime-vapor/src/components/Teleport.ts b/packages/runtime-vapor/src/components/Teleport.ts index 8a0365782..6c0b089bf 100644 --- a/packages/runtime-vapor/src/components/Teleport.ts +++ b/packages/runtime-vapor/src/components/Teleport.ts @@ -54,6 +54,13 @@ export const VaporTeleportImpl = { }) resetTracking() + if (__DEV__) { + // TODO + ;(frag as any).getNodes = () => { + return frag.parent !== frag.currentParent ? [] : frag.nodes + } + } + return frag }, } diff --git a/packages/runtime-vapor/src/hmr.ts b/packages/runtime-vapor/src/hmr.ts index c960a2610..5df5d2a46 100644 --- a/packages/runtime-vapor/src/hmr.ts +++ b/packages/runtime-vapor/src/hmr.ts @@ -54,4 +54,5 @@ export function hmrReload( ) simpleSetCurrentInstance(prev, instance.parent) mountComponent(newInstance, parent, anchor) + instance.block = newInstance.block } From f3154238bb112e2680f402ba56fe184598bf283b Mon Sep 17 00:00:00 2001 From: daiwei Date: Tue, 25 Mar 2025 21:25:31 +0800 Subject: [PATCH 15/30] test: remove vapor mark --- .../vapor-e2e-test/teleport/components/VdomComp.vue | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages-private/vapor-e2e-test/teleport/components/VdomComp.vue b/packages-private/vapor-e2e-test/teleport/components/VdomComp.vue index 6eba9134e..2c7a626f2 100644 --- a/packages-private/vapor-e2e-test/teleport/components/VdomComp.vue +++ b/packages-private/vapor-e2e-test/teleport/components/VdomComp.vue @@ -1,4 +1,4 @@ - From 16b30d8ce5f75969b223c2ec64c4b6b49cb8fa96 Mon Sep 17 00:00:00 2001 From: daiwei Date: Wed, 26 Mar 2025 08:21:58 +0800 Subject: [PATCH 16/30] test: port more tests --- .../__tests__/components/Teleport.spec.ts | 84 +++++++++++++++++++ 1 file changed, 84 insertions(+) diff --git a/packages/runtime-vapor/__tests__/components/Teleport.spec.ts b/packages/runtime-vapor/__tests__/components/Teleport.spec.ts index cae00f75f..0bf21bb76 100644 --- a/packages/runtime-vapor/__tests__/components/Teleport.spec.ts +++ b/packages/runtime-vapor/__tests__/components/Teleport.spec.ts @@ -6,9 +6,13 @@ import { import { type VaporDirective, VaporTeleport, + child, createIf, createTemplateRefSetter, + defineVaporComponent, + renderEffect, setInsertionState, + setText, template, withVaporDirectives, } from '@vue/runtime-vapor' @@ -35,6 +39,86 @@ describe('renderer: VaporTeleport', () => { describe('defer mode', () => { runSharedTests(true) + + test('should be able to target content appearing later than the teleport with defer', () => { + const root = document.createElement('div') + document.body.appendChild(root) + + const { mount } = define({ + setup() { + const n1 = createComp( + VaporTeleport, + { + to: () => '#target', + defer: () => true, + }, + { + default: () => template('
teleported
')(), + }, + ) + const n2 = template('
')() + return [n1, n2] + }, + }).create() + mount(root) + + expect(root.innerHTML).toBe( + '
teleported
', + ) + }) + + test.todo('defer mode should work inside suspense', () => {}) + + test('update before mounted with defer', async () => { + const root = document.createElement('div') + document.body.appendChild(root) + + const show = ref(false) + const foo = ref('foo') + const Header = defineVaporComponent({ + props: { foo: String }, + setup(props) { + const n0 = template(`
`)() + const x0 = child(n0 as any) + renderEffect(() => setText(x0 as any, props.foo)) + return [n0] + }, + }) + const Footer = defineVaporComponent({ + setup() { + foo.value = 'bar' + return template('
Footer
')() + }, + }) + + const { mount } = define({ + setup() { + return createIf( + () => show.value, + () => { + const n1 = createComp( + VaporTeleport, + { to: () => '#targetId', defer: () => true }, + { default: () => createComp(Header, { foo: () => foo.value }) }, + ) + const n2 = createComp(Footer) + const n3 = template('
')() + return [n1, n2, n3] + }, + () => template('
')(), + ) + }, + }).create() + mount(root) + + expect(root.innerHTML).toBe('
') + + show.value = true + await nextTick() + expect(root.innerHTML).toBe( + `
Footer
bar
`, + ) + }) }) describe('HMR', () => { From b6468562e6e211519b4bd3fa3fa286cfcbef4a0f Mon Sep 17 00:00:00 2001 From: daiwei Date: Wed, 26 Mar 2025 14:01:23 +0800 Subject: [PATCH 17/30] wip: save --- .../__tests__/components/Teleport.spec.ts | 184 +++++++++++++++++- 1 file changed, 174 insertions(+), 10 deletions(-) diff --git a/packages/runtime-vapor/__tests__/components/Teleport.spec.ts b/packages/runtime-vapor/__tests__/components/Teleport.spec.ts index 0bf21bb76..20f510547 100644 --- a/packages/runtime-vapor/__tests__/components/Teleport.spec.ts +++ b/packages/runtime-vapor/__tests__/components/Teleport.spec.ts @@ -193,25 +193,40 @@ describe('renderer: VaporTeleport', () => { const { component: Child } = define({ __hmrId: childId, - render() { - return template('
teleported
')() + setup() { + const msg = ref('teleported') + return { msg } + }, + render(ctx) { + const n0 = template(`
`)() + const x0 = child(n0 as any) + renderEffect(() => setText(x0 as any, ctx.msg)) + return [n0] }, }) createRecord(childId, Child as any) const { mount, component: Parent } = define({ __hmrId: parentId, - render() { + setup() { + const msg = ref('root') + const disabled = ref(false) + return { msg, disabled } + }, + render(ctx) { const n0 = createComp( VaporTeleport, { to: () => target, + disabled: () => ctx.disabled, }, { default: () => createComp(Child), }, ) - const n1 = template('
root
')() + const n1 = template(`
`)() + const x0 = child(n1 as any) + renderEffect(() => setText(x0 as any, ctx.msg)) return [n0, n1] }, }).create() @@ -221,38 +236,187 @@ describe('renderer: VaporTeleport', () => { expect(root.innerHTML).toBe('
root
') expect(target.innerHTML).toBe('
teleported
') - // reload child + // reload child by changing msg reload(childId, { __hmrId: childId, __vapor: true, - render() { - return template('
teleported 2
')() + setup() { + const msg = ref('teleported 2') + return { msg } + }, + render(ctx: any) { + const n0 = template(`
`)() + const x0 = child(n0 as any) + renderEffect(() => setText(x0 as any, ctx.msg)) + return [n0] }, }) expect(root.innerHTML).toBe('
root
') expect(target.innerHTML).toBe('
teleported 2
') - // reload parent + // reload parent by changing msg reload(parentId, { __hmrId: parentId, __vapor: true, - render() { + setup() { + const msg = ref('root 2') + const disabled = ref(false) + return { msg, disabled } + }, + render(ctx: any) { const n0 = createComp( VaporTeleport, { to: () => target, + disabled: () => ctx.disabled, }, { default: () => createComp(Child), }, ) - const n1 = template('
root 2
')() + const n1 = template(`
`)() + const x0 = child(n1 as any) + renderEffect(() => setText(x0 as any, ctx.msg)) return [n0, n1] }, }) expect(root.innerHTML).toBe('
root 2
') expect(target.innerHTML).toBe('
teleported 2
') + + // reload parent again by changing disabled + reload(parentId, { + __hmrId: parentId, + __vapor: true, + setup() { + const msg = ref('root 2') + const disabled = ref(true) + return { msg, disabled } + }, + render(ctx: any) { + const n0 = createComp( + VaporTeleport, + { + to: () => target, + disabled: () => ctx.disabled, + }, + { + default: () => createComp(Child), + }, + ) + const n1 = template(`
`)() + const x0 = child(n1 as any) + renderEffect(() => setText(x0 as any, ctx.msg)) + return [n0, n1] + }, + }) + + expect(root.innerHTML).toBe( + '
teleported 2
root 2
', + ) + expect(target.innerHTML).toBe('') + }) + + test.todo('reload child + toggle disabled', async () => { + const target = document.createElement('div') + const root = document.createElement('div') + const childId = 'test3-child' + const parentId = 'test3-parent' + + const disabled = ref(true) + const { component: Child } = define({ + __hmrId: childId, + setup() { + const msg = ref('teleported') + return { msg } + }, + render(ctx) { + const n0 = template(`
`)() + const x0 = child(n0 as any) + renderEffect(() => setText(x0 as any, ctx.msg)) + return [n0] + }, + }) + createRecord(childId, Child as any) + + const { mount, component: Parent } = define({ + __hmrId: parentId, + setup() { + const msg = ref('root') + return { msg, disabled } + }, + render(ctx) { + const n0 = createComp( + VaporTeleport, + { + to: () => target, + disabled: () => ctx.disabled, + }, + { + default: () => createComp(Child), + }, + ) + const n1 = template(`
`)() + const x0 = child(n1 as any) + renderEffect(() => setText(x0 as any, ctx.msg)) + return [n0, n1] + }, + }).create() + createRecord(parentId, Parent as any) + mount(root) + + expect(root.innerHTML).toBe( + '
teleported
root
', + ) + expect(target.innerHTML).toBe('') + + // reload child by changing msg + reload(childId, { + __hmrId: childId, + __vapor: true, + setup() { + const msg = ref('teleported 2') + return { msg } + }, + render(ctx: any) { + const n0 = template(`
`)() + const x0 = child(n0 as any) + renderEffect(() => setText(x0 as any, ctx.msg)) + return [n0] + }, + }) + expect(root.innerHTML).toBe( + '
teleported 2
root
', + ) + expect(target.innerHTML).toBe('') + + // reload child again by changing msg + reload(childId, { + __hmrId: childId, + __vapor: true, + setup() { + const msg = ref('teleported 3') + return { msg } + }, + render(ctx: any) { + const n0 = template(`
`)() + const x0 = child(n0 as any) + renderEffect(() => setText(x0 as any, ctx.msg)) + return [n0] + }, + }) + expect(root.innerHTML).toBe( + '
teleported 3
root
', + ) + expect(target.innerHTML).toBe('') + + //bug: child reload not update teleport fragment's nodes + + // toggle disabled + disabled.value = false + await nextTick() + expect(root.innerHTML).toBe('
root
') + expect(target.innerHTML).toBe('
teleported 3
') }) }) }) From b474ce0a1e7d39d1884b23c44d684036a85fb3be Mon Sep 17 00:00:00 2001 From: daiwei Date: Wed, 26 Mar 2025 17:30:32 +0800 Subject: [PATCH 18/30] wip: fix teleport root component hmr reload --- .../__tests__/components/Teleport.spec.ts | 4 +- packages/runtime-vapor/src/block.ts | 5 +- packages/runtime-vapor/src/component.ts | 13 ++++- .../runtime-vapor/src/components/Teleport.ts | 53 ++++++++++++++++--- packages/runtime-vapor/src/hmr.ts | 3 +- 5 files changed, 62 insertions(+), 16 deletions(-) diff --git a/packages/runtime-vapor/__tests__/components/Teleport.spec.ts b/packages/runtime-vapor/__tests__/components/Teleport.spec.ts index 20f510547..d5ff41422 100644 --- a/packages/runtime-vapor/__tests__/components/Teleport.spec.ts +++ b/packages/runtime-vapor/__tests__/components/Teleport.spec.ts @@ -317,7 +317,7 @@ describe('renderer: VaporTeleport', () => { expect(target.innerHTML).toBe('') }) - test.todo('reload child + toggle disabled', async () => { + test('reload child + toggle disabled', async () => { const target = document.createElement('div') const root = document.createElement('div') const childId = 'test3-child' @@ -410,8 +410,6 @@ describe('renderer: VaporTeleport', () => { ) expect(target.innerHTML).toBe('') - //bug: child reload not update teleport fragment's nodes - // toggle disabled disabled.value = false await nextTick() diff --git a/packages/runtime-vapor/src/block.ts b/packages/runtime-vapor/src/block.ts index 17ae71909..332595aac 100644 --- a/packages/runtime-vapor/src/block.ts +++ b/packages/runtime-vapor/src/block.ts @@ -25,6 +25,7 @@ export class VaporFragment { anchor?: Node insert?: (parent: ParentNode, anchor: Node | null) => void remove?: (parent?: ParentNode) => void + getNodes?: () => Block constructor(nodes: Block) { this.nodes = nodes @@ -184,8 +185,8 @@ export function normalizeBlock(block: Block): Node[] { } else if (isVaporComponent(block)) { nodes.push(...normalizeBlock(block.block!)) } else { - if ((block as any).getNodes) { - nodes.push(...normalizeBlock((block as any).getNodes())) + if (block.getNodes) { + nodes.push(...normalizeBlock(block.getNodes())) } else { nodes.push(...normalizeBlock(block.nodes)) } diff --git a/packages/runtime-vapor/src/component.ts b/packages/runtime-vapor/src/component.ts index 4d5c6d0b7..208b2d778 100644 --- a/packages/runtime-vapor/src/component.ts +++ b/packages/runtime-vapor/src/component.ts @@ -60,7 +60,11 @@ import { import { hmrReload, hmrRerender } from './hmr' import { isHydrating, locateHydrationNode } from './dom/hydration' import { insertionAnchor, insertionParent } from './insertionState' -import type { VaporTeleportImpl } from './components/Teleport' +import { + type VaporTeleportImpl, + instanceToTeleportMap, + teleportStack, +} from './components/Teleport' export { currentInstance } from '@vue/runtime-dom' @@ -209,6 +213,11 @@ export function createComponent( ) if (__DEV__) { + let teleport = teleportStack[teleportStack.length - 1] + if (teleport) { + instanceToTeleportMap.set(instance, teleport) + } + pushWarningContext(instance) startMeasure(instance, `init`) @@ -296,7 +305,7 @@ export function createComponent( onScopeDispose(() => unmountComponent(instance), true) if (!isHydrating && _insertionParent) { - insert(instance.block, _insertionParent, _insertionAnchor) + mountComponent(instance, _insertionParent, _insertionAnchor) } return instance diff --git a/packages/runtime-vapor/src/components/Teleport.ts b/packages/runtime-vapor/src/components/Teleport.ts index 6c0b089bf..ab900951d 100644 --- a/packages/runtime-vapor/src/components/Teleport.ts +++ b/packages/runtime-vapor/src/components/Teleport.ts @@ -15,12 +15,45 @@ import { remove, } from '../block' import { createComment, createTextNode, querySelector } from '../dom/node' -import type { LooseRawProps, LooseRawSlots } from '../component' +import type { + LooseRawProps, + LooseRawSlots, + VaporComponentInstance, +} from '../component' import { rawPropsProxyHandlers } from '../componentProps' import { renderEffect } from '../renderEffect' -import { extend } from '@vue/shared' +import { extend, isArray } from '@vue/shared' import { EffectScope, pauseTracking, resetTracking } from '@vue/reactivity' +export const teleportStack: TeleportFragment[] = [] +export const instanceToTeleportMap: WeakMap< + VaporComponentInstance, + TeleportFragment +> = __DEV__ ? new WeakMap() : (null as any) + +/** + * dev only. + * when the **root** child component updates, synchronously update + * the TeleportFragment's children and nodes. + */ +export function handleTeleportChildrenHmrReload( + instance: VaporComponentInstance, + newInstance: VaporComponentInstance, +): void { + const teleport = instanceToTeleportMap.get(instance) + if (teleport) { + instanceToTeleportMap.set(newInstance, teleport) + if (teleport.nodes === instance) { + teleport.children = teleport.nodes = newInstance + } else if (isArray(teleport.nodes)) { + const i = teleport.nodes.indexOf(instance) + if (i > -1) { + ;(teleport.children as Block[])[i] = teleport.nodes[i] = newInstance + } + } + } +} + export const VaporTeleportImpl = { name: 'VaporTeleport', __isTeleport: true, @@ -34,11 +67,12 @@ export const VaporTeleportImpl = { pauseTracking() const scope = (frag.scope = new EffectScope()) scope!.run(() => { - let children: Block renderEffect(() => { + teleportStack.push(frag) frag.updateChildren( - (children = slots.default && (slots.default as BlockFn)()), + (frag.children = slots.default && (slots.default as BlockFn)()), ) + teleportStack.pop() }) renderEffect(() => { @@ -48,15 +82,17 @@ export const VaporTeleportImpl = { {}, new Proxy(props, rawPropsProxyHandlers) as any as TeleportProps, ), - children!, + frag.children!, ) }) }) resetTracking() if (__DEV__) { - // TODO - ;(frag as any).getNodes = () => { + // used in normalizeBlock to get the nodes of a TeleportFragment + // during hmr update. return empty array if the teleport content + // is mounted into the target container. + frag.getNodes = () => { return frag.parent !== frag.currentParent ? [] : frag.nodes } } @@ -68,6 +104,7 @@ export const VaporTeleportImpl = { class TeleportFragment extends VaporFragment { anchor: Node scope: EffectScope | undefined + children: Block | undefined private targetStart?: Node private mainAnchor?: Node @@ -178,7 +215,7 @@ class TeleportFragment extends VaporFragment { // remove nodes if (this.nodes) { remove(this.nodes, this.currentParent) - this.nodes = [] + this.children = this.nodes = [] } // remove anchors diff --git a/packages/runtime-vapor/src/hmr.ts b/packages/runtime-vapor/src/hmr.ts index 5df5d2a46..ba6693604 100644 --- a/packages/runtime-vapor/src/hmr.ts +++ b/packages/runtime-vapor/src/hmr.ts @@ -13,6 +13,7 @@ import { mountComponent, unmountComponent, } from './component' +import { handleTeleportChildrenHmrReload } from './components/Teleport' export function hmrRerender(instance: VaporComponentInstance): void { const normalized = normalizeBlock(instance.block) @@ -54,5 +55,5 @@ export function hmrReload( ) simpleSetCurrentInstance(prev, instance.parent) mountComponent(newInstance, parent, anchor) - instance.block = newInstance.block + handleTeleportChildrenHmrReload(instance, newInstance) } From c1547b580ca68da28e852af1944e29eae2ca8b3c Mon Sep 17 00:00:00 2001 From: daiwei Date: Wed, 26 Mar 2025 21:09:15 +0800 Subject: [PATCH 19/30] wip: save --- packages/runtime-vapor/src/components/Teleport.ts | 10 ++++++---- packages/runtime-vapor/src/hmr.ts | 4 ++-- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/packages/runtime-vapor/src/components/Teleport.ts b/packages/runtime-vapor/src/components/Teleport.ts index ab900951d..040914ef9 100644 --- a/packages/runtime-vapor/src/components/Teleport.ts +++ b/packages/runtime-vapor/src/components/Teleport.ts @@ -25,18 +25,20 @@ import { renderEffect } from '../renderEffect' import { extend, isArray } from '@vue/shared' import { EffectScope, pauseTracking, resetTracking } from '@vue/reactivity' -export const teleportStack: TeleportFragment[] = [] +export const teleportStack: TeleportFragment[] = __DEV__ + ? ([] as TeleportFragment[]) + : (undefined as any) export const instanceToTeleportMap: WeakMap< VaporComponentInstance, TeleportFragment -> = __DEV__ ? new WeakMap() : (null as any) +> = __DEV__ ? new WeakMap() : (undefined as any) /** * dev only. - * when the **root** child component updates, synchronously update + * when the root child component updates, synchronously update * the TeleportFragment's children and nodes. */ -export function handleTeleportChildrenHmrReload( +export function handleTeleportRootComponentHmrReload( instance: VaporComponentInstance, newInstance: VaporComponentInstance, ): void { diff --git a/packages/runtime-vapor/src/hmr.ts b/packages/runtime-vapor/src/hmr.ts index ba6693604..d72800cf5 100644 --- a/packages/runtime-vapor/src/hmr.ts +++ b/packages/runtime-vapor/src/hmr.ts @@ -13,7 +13,7 @@ import { mountComponent, unmountComponent, } from './component' -import { handleTeleportChildrenHmrReload } from './components/Teleport' +import { handleTeleportRootComponentHmrReload } from './components/Teleport' export function hmrRerender(instance: VaporComponentInstance): void { const normalized = normalizeBlock(instance.block) @@ -55,5 +55,5 @@ export function hmrReload( ) simpleSetCurrentInstance(prev, instance.parent) mountComponent(newInstance, parent, anchor) - handleTeleportChildrenHmrReload(instance, newInstance) + handleTeleportRootComponentHmrReload(instance, newInstance) } From 5b933f582c4a0450f980af3668a9605c3248a88d Mon Sep 17 00:00:00 2001 From: daiwei Date: Wed, 26 Mar 2025 21:40:13 +0800 Subject: [PATCH 20/30] wip: extract VaporFragment into a separate file to resolve the circular dependency caused by TeleportFragment --- .../vapor-e2e-test/__tests__/teleport.spec.ts | 53 +++++++------- .../runtime-vapor/__tests__/block.spec.ts | 9 +-- .../src/apiCreateDynamicComponent.ts | 2 +- packages/runtime-vapor/src/apiCreateFor.ts | 8 +- packages/runtime-vapor/src/apiCreateIf.ts | 3 +- packages/runtime-vapor/src/block.ts | 73 ++----------------- packages/runtime-vapor/src/componentSlots.ts | 3 +- .../runtime-vapor/src/components/Teleport.ts | 13 +--- .../runtime-vapor/src/directives/vShow.ts | 3 +- packages/runtime-vapor/src/fragment.ts | 69 ++++++++++++++++++ packages/runtime-vapor/src/index.ts | 4 +- packages/runtime-vapor/src/vdomInterop.ts | 3 +- 12 files changed, 121 insertions(+), 122 deletions(-) create mode 100644 packages/runtime-vapor/src/fragment.ts diff --git a/packages-private/vapor-e2e-test/__tests__/teleport.spec.ts b/packages-private/vapor-e2e-test/__tests__/teleport.spec.ts index dc3efa13a..ce383dadf 100644 --- a/packages-private/vapor-e2e-test/__tests__/teleport.spec.ts +++ b/packages-private/vapor-e2e-test/__tests__/teleport.spec.ts @@ -9,7 +9,7 @@ import { nextTick } from 'vue' import { ports } from '../utils' const { page, click, html } = setupPuppeteer() -describe('vdom / vapor interop', () => { +describe('vapor teleport', () => { let server: any const port = ports.teleport beforeAll(() => { @@ -29,33 +29,34 @@ describe('vdom / vapor interop', () => { await page().waitForSelector('#app') }) - describe('vapor teleport', () => { - test( - 'render vdom component', - async () => { - const targetSelector = '.target' - const testSelector = '.interop-render-vdom-comp' - const containerSelector = `${testSelector} > div` - const btnSelector = `${testSelector} > button` + test( + 'render vdom component', + async () => { + const targetSelector = '.target' + const testSelector = '.interop-render-vdom-comp' + const containerSelector = `${testSelector} > div` + const btnSelector = `${testSelector} > button` - // teleport is disabled - expect(await html(containerSelector)).toBe('

vdom comp

') - expect(await html(targetSelector)).toBe('') + const tt = await html('#app') + console.log(tt) - // enable teleport - await click(btnSelector) - await nextTick() + // teleport is disabled + expect(await html(containerSelector)).toBe('

vdom comp

') + expect(await html(targetSelector)).toBe('') - expect(await html(containerSelector)).toBe('') - expect(await html(targetSelector)).toBe('

vdom comp

') + // enable teleport + await click(btnSelector) + await nextTick() - // disable teleport - await click(btnSelector) - await nextTick() - expect(await html(containerSelector)).toBe('

vdom comp

') - expect(await html(targetSelector)).toBe('') - }, - E2E_TIMEOUT, - ) - }) + expect(await html(containerSelector)).toBe('') + expect(await html(targetSelector)).toBe('

vdom comp

') + + // disable teleport + await click(btnSelector) + await nextTick() + expect(await html(containerSelector)).toBe('

vdom comp

') + expect(await html(targetSelector)).toBe('') + }, + E2E_TIMEOUT, + ) }) diff --git a/packages/runtime-vapor/__tests__/block.spec.ts b/packages/runtime-vapor/__tests__/block.spec.ts index 9f76c7f03..f0144dee3 100644 --- a/packages/runtime-vapor/__tests__/block.spec.ts +++ b/packages/runtime-vapor/__tests__/block.spec.ts @@ -1,10 +1,5 @@ -import { - VaporFragment, - insert, - normalizeBlock, - prepend, - remove, -} from '../src/block' +import { insert, normalizeBlock, prepend, remove } from '../src/block' +import { VaporFragment } from '../src/fragment' const node1 = document.createTextNode('node1') const node2 = document.createTextNode('node2') diff --git a/packages/runtime-vapor/src/apiCreateDynamicComponent.ts b/packages/runtime-vapor/src/apiCreateDynamicComponent.ts index 2126611d7..abdc1a1cf 100644 --- a/packages/runtime-vapor/src/apiCreateDynamicComponent.ts +++ b/packages/runtime-vapor/src/apiCreateDynamicComponent.ts @@ -1,9 +1,9 @@ import { resolveDynamicComponent } from '@vue/runtime-dom' -import { DynamicFragment, type VaporFragment } from './block' import { createComponentWithFallback } from './component' import { renderEffect } from './renderEffect' import type { RawProps } from './componentProps' import type { RawSlots } from './componentSlots' +import { DynamicFragment, type VaporFragment } from './fragment' export function createDynamicComponent( getter: () => any, diff --git a/packages/runtime-vapor/src/apiCreateFor.ts b/packages/runtime-vapor/src/apiCreateFor.ts index 0cd831753..ca976aa8c 100644 --- a/packages/runtime-vapor/src/apiCreateFor.ts +++ b/packages/runtime-vapor/src/apiCreateFor.ts @@ -11,12 +11,7 @@ import { } from '@vue/reactivity' import { getSequence, isArray, isObject, isString } from '@vue/shared' import { createComment, createTextNode } from './dom/node' -import { - type Block, - VaporFragment, - insert, - remove as removeBlock, -} from './block' +import { type Block, insert, remove as removeBlock } from './block' import { warn } from '@vue/runtime-dom' import { currentInstance, isVaporComponent } from './component' import type { DynamicSlot } from './componentSlots' @@ -24,6 +19,7 @@ import { renderEffect } from './renderEffect' import { VaporVForFlags } from '../../shared/src/vaporFlags' import { isHydrating, locateHydrationNode } from './dom/hydration' import { insertionAnchor, insertionParent } from './insertionState' +import { VaporFragment } from './fragment' class ForBlock extends VaporFragment { scope: EffectScope | undefined diff --git a/packages/runtime-vapor/src/apiCreateIf.ts b/packages/runtime-vapor/src/apiCreateIf.ts index 71bfa32d5..e83b251d0 100644 --- a/packages/runtime-vapor/src/apiCreateIf.ts +++ b/packages/runtime-vapor/src/apiCreateIf.ts @@ -1,7 +1,8 @@ -import { type Block, type BlockFn, DynamicFragment, insert } from './block' +import { type Block, type BlockFn, insert } from './block' import { isHydrating, locateHydrationNode } from './dom/hydration' import { insertionAnchor, insertionParent } from './insertionState' import { renderEffect } from './renderEffect' +import { DynamicFragment } from './fragment' export function createIf( condition: () => any, diff --git a/packages/runtime-vapor/src/block.ts b/packages/runtime-vapor/src/block.ts index 332595aac..f1791904c 100644 --- a/packages/runtime-vapor/src/block.ts +++ b/packages/runtime-vapor/src/block.ts @@ -5,9 +5,12 @@ import { mountComponent, unmountComponent, } from './component' -import { createComment, createTextNode } from './dom/node' -import { EffectScope, pauseTracking, resetTracking } from '@vue/reactivity' import { isHydrating } from './dom/hydration' +import { + type DynamicFragment, + type VaporFragment, + isFragment, +} from './fragment' export type Block = | Node @@ -18,72 +21,6 @@ export type Block = export type BlockFn = (...args: any[]) => Block -export class VaporFragment { - nodes: Block - target?: ParentNode | null - targetAnchor?: Node | null - anchor?: Node - insert?: (parent: ParentNode, anchor: Node | null) => void - remove?: (parent?: ParentNode) => void - getNodes?: () => Block - - constructor(nodes: Block) { - this.nodes = nodes - } -} - -export class DynamicFragment extends VaporFragment { - anchor: Node - scope: EffectScope | undefined - current?: BlockFn - fallback?: BlockFn - - constructor(anchorLabel?: string) { - super([]) - this.anchor = - __DEV__ && anchorLabel ? createComment(anchorLabel) : createTextNode() - } - - update(render?: BlockFn, key: any = render): void { - if (key === this.current) { - return - } - this.current = key - - pauseTracking() - const parent = this.anchor.parentNode - - // teardown previous branch - if (this.scope) { - this.scope.stop() - parent && remove(this.nodes, parent) - } - - if (render) { - this.scope = new EffectScope() - this.nodes = this.scope.run(render) || [] - if (parent) insert(this.nodes, parent, this.anchor) - } else { - this.scope = undefined - this.nodes = [] - } - - if (this.fallback && !isValidBlock(this.nodes)) { - parent && remove(this.nodes, parent) - this.nodes = - (this.scope || (this.scope = new EffectScope())).run(this.fallback) || - [] - parent && insert(this.nodes, parent, this.anchor) - } - - resetTracking() - } -} - -export function isFragment(val: NonNullable): val is VaporFragment { - return val instanceof VaporFragment -} - export function isBlock(val: NonNullable): val is Block { return ( val instanceof Node || diff --git a/packages/runtime-vapor/src/componentSlots.ts b/packages/runtime-vapor/src/componentSlots.ts index 74296e094..3d17e5c0a 100644 --- a/packages/runtime-vapor/src/componentSlots.ts +++ b/packages/runtime-vapor/src/componentSlots.ts @@ -1,11 +1,12 @@ import { EMPTY_OBJ, NO, hasOwn, isArray, isFunction } from '@vue/shared' -import { type Block, type BlockFn, DynamicFragment, insert } from './block' +import { type Block, type BlockFn, insert } from './block' import { rawPropsProxyHandlers } from './componentProps' import { currentInstance, isRef } from '@vue/runtime-dom' import type { LooseRawProps, VaporComponentInstance } from './component' import { renderEffect } from './renderEffect' import { insertionAnchor, insertionParent } from './insertionState' import { isHydrating, locateHydrationNode } from './dom/hydration' +import { DynamicFragment } from './fragment' export type RawSlots = Record & { $?: DynamicSlotSource[] diff --git a/packages/runtime-vapor/src/components/Teleport.ts b/packages/runtime-vapor/src/components/Teleport.ts index 040914ef9..65fdc53e0 100644 --- a/packages/runtime-vapor/src/components/Teleport.ts +++ b/packages/runtime-vapor/src/components/Teleport.ts @@ -7,13 +7,7 @@ import { resolveTarget, warn, } from '@vue/runtime-dom' -import { - type Block, - type BlockFn, - VaporFragment, - insert, - remove, -} from '../block' +import { type Block, type BlockFn, insert, remove } from '../block' import { createComment, createTextNode, querySelector } from '../dom/node' import type { LooseRawProps, @@ -24,6 +18,7 @@ import { rawPropsProxyHandlers } from '../componentProps' import { renderEffect } from '../renderEffect' import { extend, isArray } from '@vue/shared' import { EffectScope, pauseTracking, resetTracking } from '@vue/reactivity' +import { VaporFragment } from '../fragment' export const teleportStack: TeleportFragment[] = __DEV__ ? ([] as TeleportFragment[]) @@ -70,11 +65,11 @@ export const VaporTeleportImpl = { const scope = (frag.scope = new EffectScope()) scope!.run(() => { renderEffect(() => { - teleportStack.push(frag) + __DEV__ && teleportStack.push(frag) frag.updateChildren( (frag.children = slots.default && (slots.default as BlockFn)()), ) - teleportStack.pop() + __DEV__ && teleportStack.pop() }) renderEffect(() => { diff --git a/packages/runtime-vapor/src/directives/vShow.ts b/packages/runtime-vapor/src/directives/vShow.ts index ac4c066b7..b0fc22c14 100644 --- a/packages/runtime-vapor/src/directives/vShow.ts +++ b/packages/runtime-vapor/src/directives/vShow.ts @@ -6,8 +6,9 @@ import { } from '@vue/runtime-dom' import { renderEffect } from '../renderEffect' import { isVaporComponent } from '../component' -import { type Block, DynamicFragment } from '../block' +import type { Block } from '../block' import { isArray } from '@vue/shared' +import { DynamicFragment } from '../fragment' export function applyVShow(target: Block, source: () => any): void { if (isVaporComponent(target)) { diff --git a/packages/runtime-vapor/src/fragment.ts b/packages/runtime-vapor/src/fragment.ts new file mode 100644 index 000000000..3e4fcb221 --- /dev/null +++ b/packages/runtime-vapor/src/fragment.ts @@ -0,0 +1,69 @@ +import { EffectScope, pauseTracking, resetTracking } from '@vue/reactivity' +import { createComment, createTextNode } from './dom/node' +import { type Block, type BlockFn, insert, isValidBlock, remove } from './block' + +export class VaporFragment { + nodes: Block + target?: ParentNode | null + targetAnchor?: Node | null + anchor?: Node + insert?: (parent: ParentNode, anchor: Node | null) => void + remove?: (parent?: ParentNode) => void + getNodes?: () => Block + + constructor(nodes: Block) { + this.nodes = nodes + } +} + +export class DynamicFragment extends VaporFragment { + anchor: Node + scope: EffectScope | undefined + current?: BlockFn + fallback?: BlockFn + + constructor(anchorLabel?: string) { + super([]) + this.anchor = + __DEV__ && anchorLabel ? createComment(anchorLabel) : createTextNode() + } + + update(render?: BlockFn, key: any = render): void { + if (key === this.current) { + return + } + this.current = key + + pauseTracking() + const parent = this.anchor.parentNode + + // teardown previous branch + if (this.scope) { + this.scope.stop() + parent && remove(this.nodes, parent) + } + + if (render) { + this.scope = new EffectScope() + this.nodes = this.scope.run(render) || [] + if (parent) insert(this.nodes, parent, this.anchor) + } else { + this.scope = undefined + this.nodes = [] + } + + if (this.fallback && !isValidBlock(this.nodes)) { + parent && remove(this.nodes, parent) + this.nodes = + (this.scope || (this.scope = new EffectScope())).run(this.fallback) || + [] + parent && insert(this.nodes, parent, this.anchor) + } + + resetTracking() + } +} + +export function isFragment(val: NonNullable): val is VaporFragment { + return val instanceof VaporFragment +} diff --git a/packages/runtime-vapor/src/index.ts b/packages/runtime-vapor/src/index.ts index 2edceb549..c2716059d 100644 --- a/packages/runtime-vapor/src/index.ts +++ b/packages/runtime-vapor/src/index.ts @@ -6,7 +6,7 @@ export type { VaporDirective } from './directives/custom' export { VaporTeleport } from './components/Teleport' // compiler-use only -export { insert, prepend, remove, isFragment, VaporFragment } from './block' +export { insert, prepend, remove } from './block' export { setInsertionState } from './insertionState' export { createComponent, createComponentWithFallback } from './component' export { renderEffect } from './renderEffect' @@ -43,3 +43,5 @@ export { applyDynamicModel, } from './directives/vModel' export { withVaporDirectives } from './directives/custom' +export { isFragment } from './fragment' +export { VaporFragment } from './fragment' diff --git a/packages/runtime-vapor/src/vdomInterop.ts b/packages/runtime-vapor/src/vdomInterop.ts index 77228fd72..2249bbb2f 100644 --- a/packages/runtime-vapor/src/vdomInterop.ts +++ b/packages/runtime-vapor/src/vdomInterop.ts @@ -26,13 +26,14 @@ import { mountComponent, unmountComponent, } from './component' -import { type Block, VaporFragment, insert, remove } from './block' +import { type Block, insert, remove } from './block' import { EMPTY_OBJ, extend, isFunction } from '@vue/shared' import { type RawProps, rawPropsProxyHandlers } from './componentProps' import type { RawSlots, VaporSlot } from './componentSlots' import { renderEffect } from './renderEffect' import { createTextNode } from './dom/node' import { optimizePropertyLookup } from './dom/prop' +import { VaporFragment } from './fragment' // mounting vapor components and slots in vdom const vaporInteropImpl: Omit< From ba6577fac1b61854ba35d21d9d5c407ea30185a1 Mon Sep 17 00:00:00 2001 From: daiwei Date: Thu, 27 Mar 2025 11:42:36 +0800 Subject: [PATCH 21/30] wip: add more hmr tests + refactor --- .../__tests__/components/Teleport.spec.ts | 189 +++++++++++++++++- packages/runtime-vapor/src/component.ts | 10 +- .../runtime-vapor/src/components/Teleport.ts | 98 +++++---- packages/runtime-vapor/src/hmr.ts | 10 +- packages/runtime-vapor/src/renderEffect.ts | 6 +- 5 files changed, 235 insertions(+), 78 deletions(-) diff --git a/packages/runtime-vapor/__tests__/components/Teleport.spec.ts b/packages/runtime-vapor/__tests__/components/Teleport.spec.ts index d5ff41422..6863d398b 100644 --- a/packages/runtime-vapor/__tests__/components/Teleport.spec.ts +++ b/packages/runtime-vapor/__tests__/components/Teleport.spec.ts @@ -122,11 +122,11 @@ describe('renderer: VaporTeleport', () => { }) describe('HMR', () => { - test('rerender', async () => { + test('rerender child + rerender parent', async () => { const target = document.createElement('div') const root = document.createElement('div') - const childId = 'test1-child' - const parentId = 'test1-parent' + const childId = 'test1-child-rerender' + const parentId = 'test1-parent-rerender' const { component: Child } = define({ __hmrId: childId, @@ -185,11 +185,78 @@ describe('renderer: VaporTeleport', () => { expect(target.innerHTML).toBe('
teleported 2
') }) - test('reload', async () => { + test('parent rerender + toggle disabled', async () => { const target = document.createElement('div') const root = document.createElement('div') - const childId = 'test2-child' - const parentId = 'test2-parent' + const parentId = 'test3-parent-rerender' + const disabled = ref(true) + + const Child = defineVaporComponent({ + render() { + return template('
teleported
')() + }, + }) + + const { mount, component: Parent } = define({ + __hmrId: parentId, + render() { + const n2 = template('
root
', true)() as any + setInsertionState(n2, 0) + createComp( + VaporTeleport, + { + to: () => target, + disabled: () => disabled.value, + }, + { + default: () => createComp(Child), + }, + ) + return n2 + }, + }).create() + createRecord(parentId, Parent as any) + mount(root) + + expect(root.innerHTML).toBe( + '
teleported
root
', + ) + expect(target.innerHTML).toBe('') + + // rerender parent + rerender(parentId, () => { + const n2 = template('
root 2
', true)() as any + setInsertionState(n2, 0) + createComp( + VaporTeleport, + { + to: () => target, + disabled: () => disabled.value, + }, + { + default: () => createComp(Child), + }, + ) + return n2 + }) + + expect(root.innerHTML).toBe( + '
teleported
root 2
', + ) + expect(target.innerHTML).toBe('') + + // toggle disabled + disabled.value = false + await nextTick() + expect(root.innerHTML).toBe('
root 2
') + expect(target.innerHTML).toBe('
teleported
') + }) + + test('reload child + reload parent', async () => { + const target = document.createElement('div') + const root = document.createElement('div') + const childId = 'test1-child-reload' + const parentId = 'test1-parent-reload' const { component: Child } = define({ __hmrId: childId, @@ -317,11 +384,11 @@ describe('renderer: VaporTeleport', () => { expect(target.innerHTML).toBe('') }) - test('reload child + toggle disabled', async () => { + test('reload single root child + toggle disabled', async () => { const target = document.createElement('div') const root = document.createElement('div') - const childId = 'test3-child' - const parentId = 'test3-parent' + const childId = 'test2-child-reload' + const parentId = 'test2-parent-reload' const disabled = ref(true) const { component: Child } = define({ @@ -353,6 +420,7 @@ describe('renderer: VaporTeleport', () => { disabled: () => ctx.disabled, }, { + // with single root child default: () => createComp(Child), }, ) @@ -416,6 +484,109 @@ describe('renderer: VaporTeleport', () => { expect(root.innerHTML).toBe('
root
') expect(target.innerHTML).toBe('
teleported 3
') }) + + test('reload multiple root children + toggle disabled', async () => { + const target = document.createElement('div') + const root = document.createElement('div') + const childId = 'test3-child-reload' + const parentId = 'test3-parent-reload' + + const disabled = ref(true) + const { component: Child } = define({ + __hmrId: childId, + setup() { + const msg = ref('teleported') + return { msg } + }, + render(ctx) { + const n0 = template(`
`)() + const x0 = child(n0 as any) + renderEffect(() => setText(x0 as any, ctx.msg)) + return [n0] + }, + }) + createRecord(childId, Child as any) + + const { mount, component: Parent } = define({ + __hmrId: parentId, + setup() { + const msg = ref('root') + return { msg, disabled } + }, + render(ctx) { + const n0 = createComp( + VaporTeleport, + { + to: () => target, + disabled: () => ctx.disabled, + }, + { + default: () => { + // with multiple root children + return [createComp(Child), template(`child`)()] + }, + }, + ) + const n1 = template(`
`)() + const x0 = child(n1 as any) + renderEffect(() => setText(x0 as any, ctx.msg)) + return [n0, n1] + }, + }).create() + createRecord(parentId, Parent as any) + mount(root) + + expect(root.innerHTML).toBe( + '
teleported
child
root
', + ) + expect(target.innerHTML).toBe('') + + // reload child by changing msg + reload(childId, { + __hmrId: childId, + __vapor: true, + setup() { + const msg = ref('teleported 2') + return { msg } + }, + render(ctx: any) { + const n0 = template(`
`)() + const x0 = child(n0 as any) + renderEffect(() => setText(x0 as any, ctx.msg)) + return [n0] + }, + }) + expect(root.innerHTML).toBe( + '
teleported 2
child
root
', + ) + expect(target.innerHTML).toBe('') + + // reload child again by changing msg + reload(childId, { + __hmrId: childId, + __vapor: true, + setup() { + const msg = ref('teleported 3') + return { msg } + }, + render(ctx: any) { + const n0 = template(`
`)() + const x0 = child(n0 as any) + renderEffect(() => setText(x0 as any, ctx.msg)) + return [n0] + }, + }) + expect(root.innerHTML).toBe( + '
teleported 3
child
root
', + ) + expect(target.innerHTML).toBe('') + + // toggle disabled + disabled.value = false + await nextTick() + expect(root.innerHTML).toBe('
root
') + expect(target.innerHTML).toBe('
teleported 3
child') + }) }) }) diff --git a/packages/runtime-vapor/src/component.ts b/packages/runtime-vapor/src/component.ts index 208b2d778..209bf1350 100644 --- a/packages/runtime-vapor/src/component.ts +++ b/packages/runtime-vapor/src/component.ts @@ -176,14 +176,6 @@ export function createComponent( frag.hydrate() } - // remove the teleport content from the parent tree for HMR updates - if (__DEV__) { - const instance = currentInstance as VaporComponentInstance - ;(instance!.hmrEffects || (instance!.hmrEffects = [])).push(() => { - frag.remove(frag.anchor.parentNode!) - }) - } - return frag as any } @@ -405,8 +397,8 @@ export class VaporComponentInstance implements GenericComponentInstance { setupState?: Record devtoolsRawSetupState?: any hmrRerender?: () => void + hmrRerenderEffects?: (() => void)[] hmrReload?: (newComp: VaporComponent) => void - hmrEffects?: (() => void)[] propsOptions?: NormalizedPropsOptions emitsOptions?: ObjectEmitsOptions | null isSingleRoot?: boolean diff --git a/packages/runtime-vapor/src/components/Teleport.ts b/packages/runtime-vapor/src/components/Teleport.ts index 65fdc53e0..1af56e65b 100644 --- a/packages/runtime-vapor/src/components/Teleport.ts +++ b/packages/runtime-vapor/src/components/Teleport.ts @@ -1,6 +1,7 @@ import { TeleportEndKey, type TeleportProps, + currentInstance, isTeleportDeferred, isTeleportDisabled, queuePostFlushCb, @@ -17,7 +18,6 @@ import type { import { rawPropsProxyHandlers } from '../componentProps' import { renderEffect } from '../renderEffect' import { extend, isArray } from '@vue/shared' -import { EffectScope, pauseTracking, resetTracking } from '@vue/reactivity' import { VaporFragment } from '../fragment' export const teleportStack: TeleportFragment[] = __DEV__ @@ -29,9 +29,9 @@ export const instanceToTeleportMap: WeakMap< > = __DEV__ ? new WeakMap() : (undefined as any) /** - * dev only. + * dev only * when the root child component updates, synchronously update - * the TeleportFragment's children and nodes. + * the TeleportFragment's nodes. */ export function handleTeleportRootComponentHmrReload( instance: VaporComponentInstance, @@ -41,12 +41,10 @@ export function handleTeleportRootComponentHmrReload( if (teleport) { instanceToTeleportMap.set(newInstance, teleport) if (teleport.nodes === instance) { - teleport.children = teleport.nodes = newInstance + teleport.nodes = newInstance } else if (isArray(teleport.nodes)) { const i = teleport.nodes.indexOf(instance) - if (i > -1) { - ;(teleport.children as Block[])[i] = teleport.nodes[i] = newInstance - } + if (i !== -1) teleport.nodes[i] = newInstance } } } @@ -61,37 +59,42 @@ export const VaporTeleportImpl = { ? new TeleportFragment('teleport') : new TeleportFragment() - pauseTracking() - const scope = (frag.scope = new EffectScope()) - scope!.run(() => { - renderEffect(() => { - __DEV__ && teleportStack.push(frag) - frag.updateChildren( - (frag.children = slots.default && (slots.default as BlockFn)()), - ) - __DEV__ && teleportStack.pop() - }) - - renderEffect(() => { - frag.update( - // access the props to trigger tracking - extend( - {}, - new Proxy(props, rawPropsProxyHandlers) as any as TeleportProps, - ), - frag.children!, - ) - }) + const updateChildrenEffect = renderEffect(() => { + __DEV__ && teleportStack.push(frag) + frag.updateChildren(slots.default && (slots.default as BlockFn)()) + __DEV__ && teleportStack.pop() + }) + + const updateEffect = renderEffect(() => { + frag.update( + // access the props to trigger tracking + extend( + {}, + new Proxy(props, rawPropsProxyHandlers) as any as TeleportProps, + ), + ) }) - resetTracking() if (__DEV__) { - // used in normalizeBlock to get the nodes of a TeleportFragment - // during hmr update. return empty array if the teleport content - // is mounted into the target container. + // used in `normalizeBlock` to get nodes of TeleportFragment during + // HMR updates. returns empty array if content is mounted in target + // container to prevent incorrect parent node lookup. frag.getNodes = () => { return frag.parent !== frag.currentParent ? [] : frag.nodes } + + // for HMR re-render + const instance = currentInstance as VaporComponentInstance + ;( + instance!.hmrRerenderEffects || (instance!.hmrRerenderEffects = []) + ).push(() => { + // remove the teleport content + frag.remove(frag.anchor.parentNode!) + + // stop effects + updateChildrenEffect.stop() + updateEffect.stop() + }) } return frag @@ -100,8 +103,6 @@ export const VaporTeleportImpl = { class TeleportFragment extends VaporFragment { anchor: Node - scope: EffectScope | undefined - children: Block | undefined private targetStart?: Node private mainAnchor?: Node @@ -128,19 +129,18 @@ class TeleportFragment extends VaporFragment { } updateChildren(children: Block): void { - // not mounted yet, early return - if (!this.parent) return - - // teardown previous children - remove(this.nodes, this.currentParent) - - // mount new - insert((this.nodes = children), this.currentParent, this.currentAnchor) + // not mounted yet + if (!this.parent) { + this.nodes = children + } else { + // teardown previous nodes + remove(this.nodes, this.currentParent) + // mount new nodes + insert((this.nodes = children), this.currentParent, this.currentAnchor) + } } - update(props: TeleportProps, children: Block): void { - this.nodes = children - + update(props: TeleportProps): void { const mount = (parent: ParentNode, anchor: Node | null) => { insert( this.nodes, @@ -203,16 +203,10 @@ class TeleportFragment extends VaporFragment { } remove = (parent: ParentNode | undefined): void => { - // stop effect scope - if (this.scope) { - this.scope.stop() - this.scope = undefined - } - // remove nodes if (this.nodes) { remove(this.nodes, this.currentParent) - this.children = this.nodes = [] + this.nodes = [] } // remove anchors diff --git a/packages/runtime-vapor/src/hmr.ts b/packages/runtime-vapor/src/hmr.ts index d72800cf5..63e537689 100644 --- a/packages/runtime-vapor/src/hmr.ts +++ b/packages/runtime-vapor/src/hmr.ts @@ -20,9 +20,9 @@ export function hmrRerender(instance: VaporComponentInstance): void { const parent = normalized[0].parentNode! const anchor = normalized[normalized.length - 1].nextSibling remove(instance.block, parent) - if (instance.hmrEffects) { - instance.hmrEffects.forEach(e => e()) - instance.hmrEffects.length = 0 + if (instance.hmrRerenderEffects) { + instance.hmrRerenderEffects.forEach(e => e()) + instance.hmrRerenderEffects.length = 0 } const prev = currentInstance simpleSetCurrentInstance(instance) @@ -41,10 +41,6 @@ export function hmrReload( const parent = normalized[0].parentNode! const anchor = normalized[normalized.length - 1].nextSibling unmountComponent(instance, parent) - if (instance.hmrEffects) { - instance.hmrEffects.forEach(e => e()) - instance.hmrEffects.length = 0 - } const prev = currentInstance simpleSetCurrentInstance(instance.parent) const newInstance = createComponent( diff --git a/packages/runtime-vapor/src/renderEffect.ts b/packages/runtime-vapor/src/renderEffect.ts index a9fa9b335..227d7933e 100644 --- a/packages/runtime-vapor/src/renderEffect.ts +++ b/packages/runtime-vapor/src/renderEffect.ts @@ -11,7 +11,10 @@ import { import { type VaporComponentInstance, isVaporComponent } from './component' import { invokeArrayFns } from '@vue/shared' -export function renderEffect(fn: () => void, noLifecycle = false): void { +export function renderEffect( + fn: () => void, + noLifecycle = false, +): ReactiveEffect { const instance = currentInstance as VaporComponentInstance | null const scope = getCurrentScope() if (__DEV__ && !__TEST__ && !scope && !isVaporComponent(instance)) { @@ -66,5 +69,6 @@ export function renderEffect(fn: () => void, noLifecycle = false): void { effect.scheduler = () => queueJob(job) effect.run() + return effect // TODO recurse handling } From 7ab1a30a40de234e24ec8a91bb12a0b81921f62a Mon Sep 17 00:00:00 2001 From: daiwei Date: Thu, 27 Mar 2025 13:43:08 +0800 Subject: [PATCH 22/30] wip: refactor --- packages/runtime-vapor/src/component.ts | 11 +-- .../runtime-vapor/src/components/Teleport.ts | 80 ++++++++++--------- 2 files changed, 44 insertions(+), 47 deletions(-) diff --git a/packages/runtime-vapor/src/component.ts b/packages/runtime-vapor/src/component.ts index 209bf1350..0b051420a 100644 --- a/packages/runtime-vapor/src/component.ts +++ b/packages/runtime-vapor/src/component.ts @@ -60,11 +60,7 @@ import { import { hmrReload, hmrRerender } from './hmr' import { isHydrating, locateHydrationNode } from './dom/hydration' import { insertionAnchor, insertionParent } from './insertionState' -import { - type VaporTeleportImpl, - instanceToTeleportMap, - teleportStack, -} from './components/Teleport' +import type { VaporTeleportImpl } from './components/Teleport' export { currentInstance } from '@vue/runtime-dom' @@ -205,11 +201,6 @@ export function createComponent( ) if (__DEV__) { - let teleport = teleportStack[teleportStack.length - 1] - if (teleport) { - instanceToTeleportMap.set(instance, teleport) - } - pushWarningContext(instance) startMeasure(instance, `init`) diff --git a/packages/runtime-vapor/src/components/Teleport.ts b/packages/runtime-vapor/src/components/Teleport.ts index 1af56e65b..c16d0b063 100644 --- a/packages/runtime-vapor/src/components/Teleport.ts +++ b/packages/runtime-vapor/src/components/Teleport.ts @@ -10,44 +10,19 @@ import { } from '@vue/runtime-dom' import { type Block, type BlockFn, insert, remove } from '../block' import { createComment, createTextNode, querySelector } from '../dom/node' -import type { - LooseRawProps, - LooseRawSlots, - VaporComponentInstance, +import { + type LooseRawProps, + type LooseRawSlots, + type VaporComponentInstance, + isVaporComponent, } from '../component' import { rawPropsProxyHandlers } from '../componentProps' import { renderEffect } from '../renderEffect' import { extend, isArray } from '@vue/shared' import { VaporFragment } from '../fragment' -export const teleportStack: TeleportFragment[] = __DEV__ - ? ([] as TeleportFragment[]) - : (undefined as any) -export const instanceToTeleportMap: WeakMap< - VaporComponentInstance, - TeleportFragment -> = __DEV__ ? new WeakMap() : (undefined as any) - -/** - * dev only - * when the root child component updates, synchronously update - * the TeleportFragment's nodes. - */ -export function handleTeleportRootComponentHmrReload( - instance: VaporComponentInstance, - newInstance: VaporComponentInstance, -): void { - const teleport = instanceToTeleportMap.get(instance) - if (teleport) { - instanceToTeleportMap.set(newInstance, teleport) - if (teleport.nodes === instance) { - teleport.nodes = newInstance - } else if (isArray(teleport.nodes)) { - const i = teleport.nodes.indexOf(instance) - if (i !== -1) teleport.nodes[i] = newInstance - } - } -} +const instanceToTeleportMap: WeakMap = + __DEV__ ? new WeakMap() : (undefined as any) export const VaporTeleportImpl = { name: 'VaporTeleport', @@ -59,11 +34,9 @@ export const VaporTeleportImpl = { ? new TeleportFragment('teleport') : new TeleportFragment() - const updateChildrenEffect = renderEffect(() => { - __DEV__ && teleportStack.push(frag) - frag.updateChildren(slots.default && (slots.default as BlockFn)()) - __DEV__ && teleportStack.pop() - }) + const updateChildrenEffect = renderEffect(() => + frag.updateChildren(slots.default && (slots.default as BlockFn)()), + ) const updateEffect = renderEffect(() => { frag.update( @@ -138,6 +111,18 @@ class TeleportFragment extends VaporFragment { // mount new nodes insert((this.nodes = children), this.currentParent, this.currentAnchor) } + + if (__DEV__) { + if (isVaporComponent(children)) { + instanceToTeleportMap.set(children, this) + } else if (isArray(children)) { + children.forEach(node => { + if (isVaporComponent(node)) { + instanceToTeleportMap.set(node, this) + } + }) + } + } } update(props: TeleportProps): void { @@ -256,3 +241,24 @@ export const VaporTeleport = VaporTeleportImpl as unknown as { } } } + +/** + * dev only + * when the root child component updates, synchronously update + * the TeleportFragment's nodes. + */ +export function handleTeleportRootComponentHmrReload( + instance: VaporComponentInstance, + newInstance: VaporComponentInstance, +): void { + const teleport = instanceToTeleportMap.get(instance) + if (teleport) { + instanceToTeleportMap.set(newInstance, teleport) + if (teleport.nodes === instance) { + teleport.nodes = newInstance + } else if (isArray(teleport.nodes)) { + const i = teleport.nodes.indexOf(instance) + if (i !== -1) teleport.nodes[i] = newInstance + } + } +} From fd6f1632461348d75a9677a65b8832a6ae1a1b8f Mon Sep 17 00:00:00 2001 From: daiwei Date: Thu, 27 Mar 2025 14:21:29 +0800 Subject: [PATCH 23/30] wip: save --- .../runtime-vapor/src/components/Teleport.ts | 43 ++++++++++--------- 1 file changed, 22 insertions(+), 21 deletions(-) diff --git a/packages/runtime-vapor/src/components/Teleport.ts b/packages/runtime-vapor/src/components/Teleport.ts index c16d0b063..1e4a12f83 100644 --- a/packages/runtime-vapor/src/components/Teleport.ts +++ b/packages/runtime-vapor/src/components/Teleport.ts @@ -56,18 +56,29 @@ export const VaporTeleportImpl = { return frag.parent !== frag.currentParent ? [] : frag.nodes } - // for HMR re-render + // for HMR rerender const instance = currentInstance as VaporComponentInstance ;( instance!.hmrRerenderEffects || (instance!.hmrRerenderEffects = []) ).push(() => { // remove the teleport content - frag.remove(frag.anchor.parentNode!) + frag.remove() // stop effects updateChildrenEffect.stop() updateEffect.stop() }) + + // for HMR reload + const nodes = frag.nodes + if (isVaporComponent(nodes)) { + instanceToTeleportMap.set(nodes, frag) + } else if (isArray(nodes)) { + nodes.forEach( + node => + isVaporComponent(node) && instanceToTeleportMap.set(node, frag), + ) + } } return frag @@ -105,24 +116,13 @@ class TeleportFragment extends VaporFragment { // not mounted yet if (!this.parent) { this.nodes = children - } else { - // teardown previous nodes - remove(this.nodes, this.currentParent) - // mount new nodes - insert((this.nodes = children), this.currentParent, this.currentAnchor) + return } - if (__DEV__) { - if (isVaporComponent(children)) { - instanceToTeleportMap.set(children, this) - } else if (isArray(children)) { - children.forEach(node => { - if (isVaporComponent(node)) { - instanceToTeleportMap.set(node, this) - } - }) - } - } + // teardown previous nodes + remove(this.nodes, this.currentParent) + // mount new nodes + insert((this.nodes = children), this.currentParent, this.currentAnchor) } update(props: TeleportProps): void { @@ -187,7 +187,7 @@ class TeleportFragment extends VaporFragment { } } - remove = (parent: ParentNode | undefined): void => { + remove = (parent: ParentNode | undefined = this.parent!): void => { // remove nodes if (this.nodes) { remove(this.nodes, this.currentParent) @@ -244,8 +244,9 @@ export const VaporTeleport = VaporTeleportImpl as unknown as { /** * dev only - * when the root child component updates, synchronously update - * the TeleportFragment's nodes. + * during root component HMR reload, since the old component will be unmounted + * and a new one will be mounted, we need to update the teleport's nodes + * to ensure they are up to date. */ export function handleTeleportRootComponentHmrReload( instance: VaporComponentInstance, From 4de819c267794a3e57cd357f9013b10e83677982 Mon Sep 17 00:00:00 2001 From: daiwei Date: Thu, 27 Mar 2025 14:36:02 +0800 Subject: [PATCH 24/30] wip: refactor --- packages/runtime-vapor/src/component.ts | 11 +++-------- packages/runtime-vapor/src/components/Teleport.ts | 8 +++++++- 2 files changed, 10 insertions(+), 9 deletions(-) diff --git a/packages/runtime-vapor/src/component.ts b/packages/runtime-vapor/src/component.ts index 0b051420a..672bb75dc 100644 --- a/packages/runtime-vapor/src/component.ts +++ b/packages/runtime-vapor/src/component.ts @@ -60,7 +60,7 @@ import { import { hmrReload, hmrRerender } from './hmr' import { isHydrating, locateHydrationNode } from './dom/hydration' import { insertionAnchor, insertionParent } from './insertionState' -import type { VaporTeleportImpl } from './components/Teleport' +import { isVaporTeleport } from './components/Teleport' export { currentInstance } from '@vue/runtime-dom' @@ -93,8 +93,6 @@ export interface ObjectVaporComponent name?: string vapor?: boolean - - __isTeleport?: boolean } interface SharedInternalOptions { @@ -161,11 +159,8 @@ export function createComponent( } // teleport - if (component.__isTeleport) { - const frag = (component as typeof VaporTeleportImpl).process( - rawProps!, - rawSlots!, - ) + if (isVaporTeleport(component)) { + const frag = component.process(rawProps!, rawSlots!) if (!isHydrating && _insertionParent) { insert(frag, _insertionParent, _insertionAnchor) } else { diff --git a/packages/runtime-vapor/src/components/Teleport.ts b/packages/runtime-vapor/src/components/Teleport.ts index 1e4a12f83..50ddceb62 100644 --- a/packages/runtime-vapor/src/components/Teleport.ts +++ b/packages/runtime-vapor/src/components/Teleport.ts @@ -85,7 +85,7 @@ export const VaporTeleportImpl = { }, } -class TeleportFragment extends VaporFragment { +export class TeleportFragment extends VaporFragment { anchor: Node private targetStart?: Node @@ -242,6 +242,12 @@ export const VaporTeleport = VaporTeleportImpl as unknown as { } } +export function isVaporTeleport( + value: unknown, +): value is typeof VaporTeleportImpl { + return value === VaporTeleportImpl +} + /** * dev only * during root component HMR reload, since the old component will be unmounted From 177c6a61e63f7375bac187ddac2407d951aa80d4 Mon Sep 17 00:00:00 2001 From: daiwei Date: Thu, 27 Mar 2025 14:51:05 +0800 Subject: [PATCH 25/30] wip: save --- packages/runtime-vapor/src/components/Teleport.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/runtime-vapor/src/components/Teleport.ts b/packages/runtime-vapor/src/components/Teleport.ts index 50ddceb62..7268a631f 100644 --- a/packages/runtime-vapor/src/components/Teleport.ts +++ b/packages/runtime-vapor/src/components/Teleport.ts @@ -208,6 +208,9 @@ export class TeleportFragment extends VaporFragment { remove(this.mainAnchor!, parent) this.mainAnchor = undefined } + + this.mountContainer = undefined + this.mountAnchor = undefined } hydrate(): void { From 5574fbf52151d3d423d9c308be75c90dd995246b Mon Sep 17 00:00:00 2001 From: daiwei Date: Thu, 27 Mar 2025 14:58:47 +0800 Subject: [PATCH 26/30] wip: refactor --- packages/runtime-core/src/index.ts | 3 +-- .../runtime-vapor/src/components/Teleport.ts | 24 ++++--------------- 2 files changed, 5 insertions(+), 22 deletions(-) diff --git a/packages/runtime-core/src/index.ts b/packages/runtime-core/src/index.ts index 2d721b058..b3811e929 100644 --- a/packages/runtime-core/src/index.ts +++ b/packages/runtime-core/src/index.ts @@ -561,8 +561,7 @@ export { initFeatureFlags } from './featureFlags' * @internal */ export { - resolveTarget, + resolveTarget as resolveTeleportTarget, isTeleportDisabled, isTeleportDeferred, - TeleportEndKey, } from './components/Teleport' diff --git a/packages/runtime-vapor/src/components/Teleport.ts b/packages/runtime-vapor/src/components/Teleport.ts index 7268a631f..8f7468870 100644 --- a/packages/runtime-vapor/src/components/Teleport.ts +++ b/packages/runtime-vapor/src/components/Teleport.ts @@ -1,11 +1,10 @@ import { - TeleportEndKey, type TeleportProps, currentInstance, isTeleportDeferred, isTeleportDisabled, queuePostFlushCb, - resolveTarget, + resolveTeleportTarget, warn, } from '@vue/runtime-dom' import { type Block, type BlockFn, insert, remove } from '../block' @@ -135,7 +134,7 @@ export class TeleportFragment extends VaporFragment { } const mountToTarget = () => { - const target = (this.target = resolveTarget(props, querySelector)) + const target = (this.target = resolveTeleportTarget(props, querySelector)) if (target) { if ( // initial mount into target @@ -143,7 +142,8 @@ export class TeleportFragment extends VaporFragment { // target changed this.targetAnchor.parentNode !== target ) { - ;[this.targetAnchor, this.targetStart] = prepareAnchor(target) + insert((this.targetStart = createTextNode('')), target) + insert((this.targetAnchor = createTextNode('')), target) } mount(target, this.targetAnchor!) @@ -218,22 +218,6 @@ export class TeleportFragment extends VaporFragment { } } -function prepareAnchor(target: ParentNode | null) { - const targetStart = createTextNode('') as Text & { [TeleportEndKey]: Node } - const targetAnchor = createTextNode('') - - // attach a special property, so we can skip teleported content in - // renderer's nextSibling search - targetStart[TeleportEndKey] = targetAnchor - - if (target) { - insert(targetStart, target) - insert(targetAnchor, target) - } - - return [targetAnchor, targetStart] -} - export const VaporTeleport = VaporTeleportImpl as unknown as { __vapor: true __isTeleport: true From ba9db343792ef00632b7e537784367ce8051e6da Mon Sep 17 00:00:00 2001 From: daiwei Date: Thu, 27 Mar 2025 15:33:02 +0800 Subject: [PATCH 27/30] test: add e2e tests for vdom teleport vapor interop --- .../__tests__/vdomInterop.spec.ts | 42 ++++++++++++++++--- .../vapor-e2e-test/interop/App.vue | 16 ++++++- .../interop/components/SimpleVaporComp.vue | 6 +++ .../interop/{ => components}/VaporComp.vue | 0 .../interop/{ => components}/VdomComp.vue | 0 5 files changed, 58 insertions(+), 6 deletions(-) create mode 100644 packages-private/vapor-e2e-test/interop/components/SimpleVaporComp.vue rename packages-private/vapor-e2e-test/interop/{ => components}/VaporComp.vue (100%) rename packages-private/vapor-e2e-test/interop/{ => components}/VdomComp.vue (100%) diff --git a/packages-private/vapor-e2e-test/__tests__/vdomInterop.spec.ts b/packages-private/vapor-e2e-test/__tests__/vdomInterop.spec.ts index a3069d1ae..734c9fde1 100644 --- a/packages-private/vapor-e2e-test/__tests__/vdomInterop.spec.ts +++ b/packages-private/vapor-e2e-test/__tests__/vdomInterop.spec.ts @@ -6,10 +6,10 @@ import { import connect from 'connect' import sirv from 'sirv' import { ports } from '../utils' +import { nextTick } from 'vue' +const { page, click, text, enterValue, html } = setupPuppeteer() describe('vdom / vapor interop', () => { - const { page, click, text, enterValue } = setupPuppeteer() - let server: any const port = ports.vdomInterop beforeAll(() => { @@ -19,6 +19,12 @@ describe('vdom / vapor interop', () => { process.on('SIGTERM', () => server && server.close()) }) + beforeEach(async () => { + const baseUrl = `http://localhost:${port}/interop/` + await page().goto(baseUrl) + await page().waitForSelector('#app') + }) + afterAll(() => { server.close() }) @@ -26,9 +32,6 @@ describe('vdom / vapor interop', () => { test( 'should work', async () => { - const baseUrl = `http://localhost:${port}/interop/` - await page().goto(baseUrl) - expect(await text('.vapor > h2')).toContain('Vapor component in VDOM') expect(await text('.vapor-prop')).toContain('hello') @@ -82,4 +85,33 @@ describe('vdom / vapor interop', () => { }, E2E_TIMEOUT, ) + + describe('teleport', () => { + const testSelector = '.teleport' + test('render vapor component', async () => { + const targetSelector = `${testSelector} .teleport-target` + const containerSelector = `${testSelector} .render-vapor-comp` + const buttonSelector = `${containerSelector} button` + + // teleport is disabled by default + expect(await html(containerSelector)).toBe( + `
vapor comp
`, + ) + expect(await html(targetSelector)).toBe('') + + // disabled -> enabled + await click(buttonSelector) + await nextTick() + expect(await html(containerSelector)).toBe(``) + expect(await html(targetSelector)).toBe('
vapor comp
') + + // enabled -> disabled + await click(buttonSelector) + await nextTick() + expect(await html(containerSelector)).toBe( + `
vapor comp
`, + ) + expect(await html(targetSelector)).toBe('') + }) + }) }) diff --git a/packages-private/vapor-e2e-test/interop/App.vue b/packages-private/vapor-e2e-test/interop/App.vue index 772a6989d..dcdd5f99a 100644 --- a/packages-private/vapor-e2e-test/interop/App.vue +++ b/packages-private/vapor-e2e-test/interop/App.vue @@ -1,9 +1,11 @@ diff --git a/packages-private/vapor-e2e-test/interop/components/SimpleVaporComp.vue b/packages-private/vapor-e2e-test/interop/components/SimpleVaporComp.vue new file mode 100644 index 000000000..d2d7be568 --- /dev/null +++ b/packages-private/vapor-e2e-test/interop/components/SimpleVaporComp.vue @@ -0,0 +1,6 @@ + + diff --git a/packages-private/vapor-e2e-test/interop/VaporComp.vue b/packages-private/vapor-e2e-test/interop/components/VaporComp.vue similarity index 100% rename from packages-private/vapor-e2e-test/interop/VaporComp.vue rename to packages-private/vapor-e2e-test/interop/components/VaporComp.vue diff --git a/packages-private/vapor-e2e-test/interop/VdomComp.vue b/packages-private/vapor-e2e-test/interop/components/VdomComp.vue similarity index 100% rename from packages-private/vapor-e2e-test/interop/VdomComp.vue rename to packages-private/vapor-e2e-test/interop/components/VdomComp.vue From 43d279932a10b94dbb0bc70cd0a297563b0d373a Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Thu, 27 Mar 2025 07:34:00 +0000 Subject: [PATCH 28/30] [autofix.ci] apply automated fixes --- .../vapor-e2e-test/interop/components/SimpleVaporComp.vue | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages-private/vapor-e2e-test/interop/components/SimpleVaporComp.vue b/packages-private/vapor-e2e-test/interop/components/SimpleVaporComp.vue index d2d7be568..65661740c 100644 --- a/packages-private/vapor-e2e-test/interop/components/SimpleVaporComp.vue +++ b/packages-private/vapor-e2e-test/interop/components/SimpleVaporComp.vue @@ -2,5 +2,5 @@ const msg = 'vapor comp' From 016596c429b1204b73c4b3b356bfcc7448e1a624 Mon Sep 17 00:00:00 2001 From: daiwei Date: Mon, 7 Apr 2025 11:35:15 +0800 Subject: [PATCH 29/30] chore: update --- packages/runtime-vapor/src/components/Teleport.ts | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/packages/runtime-vapor/src/components/Teleport.ts b/packages/runtime-vapor/src/components/Teleport.ts index 8f7468870..ca1f184d5 100644 --- a/packages/runtime-vapor/src/components/Teleport.ts +++ b/packages/runtime-vapor/src/components/Teleport.ts @@ -29,10 +29,7 @@ export const VaporTeleportImpl = { __vapor: true, process(props: LooseRawProps, slots: LooseRawSlots): TeleportFragment { - const frag = __DEV__ - ? new TeleportFragment('teleport') - : new TeleportFragment() - + const frag = new TeleportFragment() const updateChildrenEffect = renderEffect(() => frag.updateChildren(slots.default && (slots.default as BlockFn)()), ) @@ -93,10 +90,9 @@ export class TeleportFragment extends VaporFragment { private mountContainer?: ParentNode | null private mountAnchor?: Node | null - constructor(anchorLabel?: string) { + constructor() { super([]) - this.anchor = - __DEV__ && anchorLabel ? createComment(anchorLabel) : createTextNode() + this.anchor = __DEV__ ? createComment('teleport') : createTextNode() } get currentParent(): ParentNode { From d9772db54e71910c13427bb78c11b587a0919a1f Mon Sep 17 00:00:00 2001 From: daiwei Date: Thu, 10 Apr 2025 15:56:28 +0800 Subject: [PATCH 30/30] chore: update --- packages/runtime-vapor/src/components/Teleport.ts | 11 ----------- packages/runtime-vapor/src/index.ts | 2 +- 2 files changed, 1 insertion(+), 12 deletions(-) diff --git a/packages/runtime-vapor/src/components/Teleport.ts b/packages/runtime-vapor/src/components/Teleport.ts index ca1f184d5..dc4dab68c 100644 --- a/packages/runtime-vapor/src/components/Teleport.ts +++ b/packages/runtime-vapor/src/components/Teleport.ts @@ -214,17 +214,6 @@ export class TeleportFragment extends VaporFragment { } } -export const VaporTeleport = VaporTeleportImpl as unknown as { - __vapor: true - __isTeleport: true - new (): { - $props: TeleportProps - $slots: { - default(): Block - } - } -} - export function isVaporTeleport( value: unknown, ): value is typeof VaporTeleportImpl { diff --git a/packages/runtime-vapor/src/index.ts b/packages/runtime-vapor/src/index.ts index c2716059d..051944443 100644 --- a/packages/runtime-vapor/src/index.ts +++ b/packages/runtime-vapor/src/index.ts @@ -3,7 +3,7 @@ export { createVaporApp, createVaporSSRApp } from './apiCreateApp' export { defineVaporComponent } from './apiDefineComponent' export { vaporInteropPlugin } from './vdomInterop' export type { VaporDirective } from './directives/custom' -export { VaporTeleport } from './components/Teleport' +export { VaporTeleportImpl as VaporTeleport } from './components/Teleport' // compiler-use only export { insert, prepend, remove } from './block'