From 7204cb614eb8f04b96d6f7099566ceb9637e1026 Mon Sep 17 00:00:00 2001 From: edison Date: Mon, 20 Oct 2025 16:10:07 +0800 Subject: [PATCH] feat(vapor): vapor teleport (#13082) --- packages/compiler-vapor/src/utils.ts | 9 +- .../runtime-core/src/components/Teleport.ts | 6 +- packages/runtime-core/src/index.ts | 11 +- .../runtime-vapor/__tests__/block.spec.ts | 9 +- .../__tests__/components/Teleport.spec.ts | 1258 +++++++++++++++++ .../src/apiCreateDynamicComponent.ts | 3 +- packages/runtime-vapor/src/apiCreateFor.ts | 12 +- .../runtime-vapor/src/apiCreateFragment.ts | 3 +- packages/runtime-vapor/src/apiCreateIf.ts | 3 +- .../src/apiDefineAsyncComponent.ts | 2 +- packages/runtime-vapor/src/apiTemplateRef.ts | 2 +- packages/runtime-vapor/src/block.ts | 190 +-- packages/runtime-vapor/src/component.ts | 22 +- packages/runtime-vapor/src/componentSlots.ts | 3 +- .../runtime-vapor/src/components/KeepAlive.ts | 9 +- .../runtime-vapor/src/components/Teleport.ts | 203 +++ .../src/components/Transition.ts | 8 +- .../src/components/TransitionGroup.ts | 3 +- .../runtime-vapor/src/directives/vShow.ts | 8 +- packages/runtime-vapor/src/fragment.ts | 189 +++ packages/runtime-vapor/src/hmr.ts | 32 + packages/runtime-vapor/src/index.ts | 4 +- packages/runtime-vapor/src/renderEffect.ts | 7 +- packages/runtime-vapor/src/vdomInterop.ts | 11 +- 24 files changed, 1767 insertions(+), 240 deletions(-) create mode 100644 packages/runtime-vapor/__tests__/components/Teleport.spec.ts create mode 100644 packages/runtime-vapor/src/components/Teleport.ts create mode 100644 packages/runtime-vapor/src/fragment.ts diff --git a/packages/compiler-vapor/src/utils.ts b/packages/compiler-vapor/src/utils.ts index 3ddd7ece58..955273b1ed 100644 --- a/packages/compiler-vapor/src/utils.ts +++ b/packages/compiler-vapor/src/utils.ts @@ -120,8 +120,15 @@ export function isKeepAliveTag(tag: string): boolean { return tag === 'keepalive' || tag === 'vaporkeepalive' } +export function isTeleportTag(tag: string): boolean { + tag = tag.toLowerCase() + return tag === 'teleport' || tag === 'vaporteleport' +} + export function isBuiltInComponent(tag: string): string | undefined { - if (isKeepAliveTag(tag)) { + if (isTeleportTag(tag)) { + return 'VaporTeleport' + } else if (isKeepAliveTag(tag)) { return 'VaporKeepAlive' } else if (isTransitionTag(tag)) { return 'VaporTransition' diff --git a/packages/runtime-core/src/components/Teleport.ts b/packages/runtime-core/src/components/Teleport.ts index 7e3b132902..346d2f813e 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 d0fae060a3..c77b341352 100644 --- a/packages/runtime-core/src/index.ts +++ b/packages/runtime-core/src/index.ts @@ -354,6 +354,7 @@ export type { HydrationStrategyFactory, } from './hydrationStrategies' export type { HMRRuntime } from './hmr' +export type { SchedulerJob } from './scheduler' // Internal API ---------------------------------------------------------------- @@ -530,7 +531,7 @@ export { baseEmit, isEmitListener } from './componentEmits' /** * @internal */ -export { type SchedulerJob, queueJob, flushOnAppMount } from './scheduler' +export { queueJob, flushOnAppMount } from './scheduler' /** * @internal */ @@ -567,6 +568,14 @@ export { startMeasure, endMeasure } from './profiling' * @internal */ export { initFeatureFlags } from './featureFlags' +/** + * @internal + */ +export { + resolveTarget as resolveTeleportTarget, + isTeleportDisabled, + isTeleportDeferred, +} from './components/Teleport' /** * @internal */ diff --git a/packages/runtime-vapor/__tests__/block.spec.ts b/packages/runtime-vapor/__tests__/block.spec.ts index 9f76c7f033..f0144dee3d 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/__tests__/components/Teleport.spec.ts b/packages/runtime-vapor/__tests__/components/Teleport.spec.ts new file mode 100644 index 0000000000..45a2e5858c --- /dev/null +++ b/packages/runtime-vapor/__tests__/components/Teleport.spec.ts @@ -0,0 +1,1258 @@ +import { + type LooseRawProps, + type VaporComponent, + createComponent as createComp, + createComponent, +} from '../../src/component' +import { + type VaporDirective, + VaporTeleport, + child, + createIf, + createTemplateRefSetter, + createVaporApp, + defineVaporComponent, + renderEffect, + setInsertionState, + setText, + template, + vaporInteropPlugin, + withVaporDirectives, +} from '@vue/runtime-vapor' +import { makeRender } from '../_utils' +import { + h, + nextTick, + onBeforeUnmount, + onMounted, + onUnmounted, + ref, + 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', () => { + describe('eager mode', () => { + runSharedTests(false) + }) + + 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', () => { + test('rerender child + rerender parent', async () => { + const target = document.createElement('div') + const root = document.createElement('div') + const childId = 'test1-child-rerender' + const parentId = 'test1-parent-rerender' + + 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.todo('parent rerender + toggle disabled', async () => { + const target = document.createElement('div') + const root = document.createElement('div') + 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, + 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') + const disabled = ref(false) + 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( + '
root
', + ) + expect(target.innerHTML).toBe('
teleported
') + + // 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( + '
root
', + ) + expect(target.innerHTML).toBe('
teleported 2
') + + // reload parent by changing msg + reload(parentId, { + __hmrId: parentId, + __vapor: true, + 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(`
`)() + 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('reload single root child + toggle disabled', async () => { + const target = document.createElement('div') + const root = document.createElement('div') + const childId = 'test2-child-reload' + const parentId = 'test2-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, + }, + { + // with single root child + 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('') + + // toggle disabled + disabled.value = false + await nextTick() + 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') + }) + }) + + describe('VDOM interop', () => { + test('render vdom component', async () => { + const target = document.createElement('div') + const root = document.createElement('div') + + const VDOMComp = { + setup() { + return () => h('h1', null, 'vdom comp') + }, + } + + const disabled = ref(true) + const App = defineVaporComponent({ + setup() { + const n1 = createComponent( + VaporTeleport, + { + to: () => target, + defer: () => '', + disabled: () => disabled.value, + }, + { + default: () => { + const n0 = createComponent(VDOMComp) + return n0 + }, + }, + true, + ) + return n1 + }, + }) + + const app = createVaporApp(App) + app.use(vaporInteropPlugin) + app.mount(root) + + expect(target.innerHTML).toBe('') + expect(root.innerHTML).toBe( + '

vdom comp

', + ) + + disabled.value = false + await nextTick() + expect(root.innerHTML).toBe('') + expect(target.innerHTML).toBe('

vdom comp

') + }) + }) +}) + +function runSharedTests(deferMode: boolean): void { + const createComponent = deferMode + ? ( + component: VaporComponent, + rawProps?: LooseRawProps | null, + ...args: any[] + ) => { + if (component === VaporTeleport) { + rawProps!.defer = () => true + } + return createComp(component, rawProps, ...args) + } + : createComp + + 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('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() + 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, + }, + ) + setInsertionState(n0 as any) + 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(`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) + }) +} diff --git a/packages/runtime-vapor/src/apiCreateDynamicComponent.ts b/packages/runtime-vapor/src/apiCreateDynamicComponent.ts index 8a127c2daf..1dc00dbb3d 100644 --- a/packages/runtime-vapor/src/apiCreateDynamicComponent.ts +++ b/packages/runtime-vapor/src/apiCreateDynamicComponent.ts @@ -1,5 +1,5 @@ import { currentInstance, resolveDynamicComponent } from '@vue/runtime-dom' -import { DynamicFragment, type VaporFragment, insert } from './block' +import { insert } from './block' import { createComponentWithFallback, emptyContext } from './component' import { renderEffect } from './renderEffect' import type { RawProps } from './componentProps' @@ -10,6 +10,7 @@ import { resetInsertionState, } from './insertionState' import { isHydrating, locateHydrationNode } from './dom/hydration' +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 71a448d2f7..25126315e1 100644 --- a/packages/runtime-vapor/src/apiCreateFor.ts +++ b/packages/runtime-vapor/src/apiCreateFor.ts @@ -13,14 +13,7 @@ import { } from '@vue/reactivity' import { isArray, isObject, isString } from '@vue/shared' import { createComment, createTextNode } from './dom/node' -import { - type Block, - ForFragment, - VaporFragment, - insert, - remove, - remove as removeBlock, -} from './block' +import { type Block, insert, remove } from './block' import { warn } from '@vue/runtime-dom' import { currentInstance, isVaporComponent } from './component' import type { DynamicSlot } from './componentSlots' @@ -28,6 +21,7 @@ import { renderEffect } from './renderEffect' import { VaporVForFlags } from '../../shared/src/vaporFlags' import { applyTransitionHooks } from './components/Transition' import { isHydrating, locateHydrationNode } from './dom/hydration' +import { ForFragment, VaporFragment } from './fragment' import { insertionAnchor, insertionParent, @@ -435,7 +429,7 @@ export const createFor = ( block.scope!.stop() } if (doRemove) { - removeBlock(block.nodes, parent!) + remove(block.nodes, parent!) } if (doDeregister) { for (const selector of selectors) { diff --git a/packages/runtime-vapor/src/apiCreateFragment.ts b/packages/runtime-vapor/src/apiCreateFragment.ts index 50179b89ef..d4bb876368 100644 --- a/packages/runtime-vapor/src/apiCreateFragment.ts +++ b/packages/runtime-vapor/src/apiCreateFragment.ts @@ -1,4 +1,5 @@ -import { type Block, type BlockFn, DynamicFragment } from './block' +import type { Block, BlockFn } from './block' +import { DynamicFragment } from './fragment' import { renderEffect } from './renderEffect' export function createKeyedFragment(key: () => any, render: BlockFn): Block { diff --git a/packages/runtime-vapor/src/apiCreateIf.ts b/packages/runtime-vapor/src/apiCreateIf.ts index f573a61b16..37f6077b0f 100644 --- a/packages/runtime-vapor/src/apiCreateIf.ts +++ b/packages/runtime-vapor/src/apiCreateIf.ts @@ -1,4 +1,4 @@ -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, @@ -6,6 +6,7 @@ import { resetInsertionState, } from './insertionState' import { renderEffect } from './renderEffect' +import { DynamicFragment } from './fragment' export function createIf( condition: () => any, diff --git a/packages/runtime-vapor/src/apiDefineAsyncComponent.ts b/packages/runtime-vapor/src/apiDefineAsyncComponent.ts index b06e255f79..9021ab160d 100644 --- a/packages/runtime-vapor/src/apiDefineAsyncComponent.ts +++ b/packages/runtime-vapor/src/apiDefineAsyncComponent.ts @@ -14,8 +14,8 @@ import { type VaporComponentInstance, createComponent, } from './component' -import { DynamicFragment } from './block' import { renderEffect } from './renderEffect' +import { DynamicFragment } from './fragment' /*! #__NO_SIDE_EFFECTS__ */ export function defineVaporAsyncComponent( diff --git a/packages/runtime-vapor/src/apiTemplateRef.ts b/packages/runtime-vapor/src/apiTemplateRef.ts index 5ddba415ab..bbe60d829a 100644 --- a/packages/runtime-vapor/src/apiTemplateRef.ts +++ b/packages/runtime-vapor/src/apiTemplateRef.ts @@ -22,7 +22,7 @@ import { isString, remove, } from '@vue/shared' -import { DynamicFragment, isFragment } from './block' +import { DynamicFragment, isFragment } from './fragment' export type NodeRef = | string diff --git a/packages/runtime-vapor/src/block.ts b/packages/runtime-vapor/src/block.ts index 146c2ec5ee..3543f7c18b 100644 --- a/packages/runtime-vapor/src/block.ts +++ b/packages/runtime-vapor/src/block.ts @@ -1,29 +1,24 @@ import { isArray } from '@vue/shared' import { type VaporComponentInstance, - currentInstance, isVaporComponent, mountComponent, unmountComponent, } from './component' -import { createComment, createTextNode } from './dom/node' -import { EffectScope, setActiveSub } from '@vue/reactivity' import { isHydrating } from './dom/hydration' -import type { NodeRef } from './apiTemplateRef' +import { + type DynamicFragment, + type VaporFragment, + isFragment, +} from './fragment' +import { TeleportFragment } from './components/Teleport' import { type TransitionHooks, type TransitionProps, type TransitionState, - type VNode, - isKeepAlive, performTransitionEnter, performTransitionLeave, } from '@vue/runtime-dom' -import { - applyTransitionHooks, - applyTransitionLeaveHooks, -} from './components/Transition' -import type { KeepAliveInstance } from './components/KeepAlive' export interface TransitionOptions { $key?: any @@ -48,171 +43,6 @@ export type Block = TransitionBlock | VaporComponentInstance | Block[] export type BlockFn = (...args: any[]) => Block -export class VaporFragment - implements TransitionOptions -{ - nodes: T - vnode?: VNode | null = null - anchor?: Node - setRef?: ( - instance: VaporComponentInstance, - ref: NodeRef, - refFor: boolean, - refKey: string | undefined, - ) => void - fallback?: BlockFn - $key?: any - $transition?: VaporTransitionHooks | undefined - insert?: ( - parent: ParentNode, - anchor: Node | null, - transitionHooks?: TransitionHooks, - ) => void - remove?: (parent?: ParentNode, transitionHooks?: TransitionHooks) => void - - constructor(nodes: T) { - this.nodes = nodes - } -} - -export class ForFragment extends VaporFragment { - constructor(nodes: Block[]) { - super(nodes) - } -} - -export class DynamicFragment extends VaporFragment { - anchor: Node - scope: EffectScope | undefined - current?: 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 - - const prevSub = setActiveSub() - const parent = this.anchor.parentNode - const transition = this.$transition - const renderBranch = () => { - if (render) { - this.scope = new EffectScope() - this.nodes = this.scope.run(render) || [] - if (isKeepAlive(instance)) { - ;(instance as KeepAliveInstance).process(this.nodes) - } - if (transition) { - this.$transition = applyTransitionHooks(this.nodes, transition) - } - if (parent) insert(this.nodes, parent, this.anchor) - } else { - this.scope = undefined - this.nodes = [] - } - } - const instance = currentInstance! - // teardown previous branch - if (this.scope) { - if (isKeepAlive(instance)) { - ;(instance as KeepAliveInstance).process(this.nodes) - } else { - this.scope.stop() - } - const mode = transition && transition.mode - if (mode) { - applyTransitionLeaveHooks(this.nodes, transition, renderBranch) - parent && remove(this.nodes, parent) - if (mode === 'out-in') { - setActiveSub(prevSub) - return - } - } else { - parent && remove(this.nodes, parent) - } - } - - renderBranch() - - if (this.fallback) { - // set fallback for nested fragments - const hasNestedFragment = isFragment(this.nodes) - if (hasNestedFragment) { - setFragmentFallback(this.nodes as VaporFragment, this.fallback) - } - - const invalidFragment = findInvalidFragment(this) - if (invalidFragment) { - parent && remove(this.nodes, parent) - const scope = this.scope || (this.scope = new EffectScope()) - scope.run(() => { - // for nested fragments, render invalid fragment's fallback - if (hasNestedFragment) { - renderFragmentFallback(invalidFragment) - } else { - this.nodes = this.fallback!() || [] - } - }) - parent && insert(this.nodes, parent, this.anchor) - } - } - - setActiveSub(prevSub) - } -} - -export function setFragmentFallback( - fragment: VaporFragment, - fallback: BlockFn, -): void { - if (fragment.fallback) { - const originalFallback = fragment.fallback - // if the original fallback also renders invalid blocks, - // this ensures proper fallback chaining - fragment.fallback = () => { - const fallbackNodes = originalFallback() - if (isValidBlock(fallbackNodes)) { - return fallbackNodes - } - return fallback() - } - } else { - fragment.fallback = fallback - } - - if (isFragment(fragment.nodes)) { - setFragmentFallback(fragment.nodes, fragment.fallback) - } -} - -function renderFragmentFallback(fragment: VaporFragment): void { - if (fragment instanceof ForFragment) { - fragment.nodes[0] = [fragment.fallback!() || []] as Block[] - } else if (fragment instanceof DynamicFragment) { - fragment.update(fragment.fallback) - } else { - // vdom slots - } -} - -function findInvalidFragment(fragment: VaporFragment): VaporFragment | null { - if (isValidBlock(fragment.nodes)) return null - - return isFragment(fragment.nodes) - ? findInvalidFragment(fragment.nodes) || fragment - : fragment -} - -export function isFragment(val: NonNullable): val is VaporFragment { - return val instanceof VaporFragment -} - export function isBlock(val: NonNullable): val is Block { return ( val instanceof Node || @@ -340,8 +170,12 @@ export function normalizeBlock(block: Block): Node[] { } else if (isVaporComponent(block)) { nodes.push(...normalizeBlock(block.block!)) } else { - nodes.push(...normalizeBlock(block.nodes)) - block.anchor && nodes.push(block.anchor) + if (block instanceof TeleportFragment) { + nodes.push(block.placeholder!, block.anchor!) + } else { + nodes.push(...normalizeBlock(block.nodes)) + block.anchor && nodes.push(block.anchor) + } } return nodes } diff --git a/packages/runtime-vapor/src/component.ts b/packages/runtime-vapor/src/component.ts index 0ab6334211..d4c5ce006e 100644 --- a/packages/runtime-vapor/src/component.ts +++ b/packages/runtime-vapor/src/component.ts @@ -26,7 +26,7 @@ import { unregisterHMR, warn, } from '@vue/runtime-dom' -import { type Block, DynamicFragment, insert, isBlock, remove } from './block' +import { type Block, insert, isBlock, remove } from './block' import { type ShallowRef, markRaw, @@ -52,7 +52,7 @@ import { resolveDynamicProps, setupPropsValidation, } from './componentProps' -import { renderEffect } from './renderEffect' +import { type RenderEffect, renderEffect } from './renderEffect' import { emit, normalizeEmitsOptions } from './componentEmits' import { setDynamicProps } from './dom/prop' import { @@ -66,12 +66,14 @@ import { import { hmrReload, hmrRerender } from './hmr' import { createElement } from './dom/node' import { isHydrating, locateHydrationNode } from './dom/hydration' +import { type TeleportFragment, isVaporTeleport } from './components/Teleport' import type { KeepAliveInstance } from './components/KeepAlive' import { insertionAnchor, insertionParent, resetInsertionState, } from './insertionState' +import { DynamicFragment } from './fragment' export { currentInstance } from '@vue/runtime-dom' @@ -187,7 +189,7 @@ export function createComponent( const cached = (currentInstance as KeepAliveInstance).getCachedComponent( component, ) - // @ts-expect-error cached may be a fragment + // @ts-expect-error if (cached) return cached } @@ -204,6 +206,18 @@ export function createComponent( return frag } + // teleport + if (isVaporTeleport(component)) { + const frag = component.process(rawProps!, rawSlots!) + if (!isHydrating && _insertionParent) { + insert(frag, _insertionParent, _insertionAnchor) + } else { + frag.hydrate() + } + + return frag as any + } + const instance = new VaporComponentInstance( component, rawProps as RawProps, @@ -421,6 +435,8 @@ export class VaporComponentInstance implements GenericComponentInstance { devtoolsRawSetupState?: any hmrRerender?: () => void hmrReload?: (newComp: VaporComponent) => void + renderEffects?: RenderEffect[] + parentTeleport?: TeleportFragment | null propsOptions?: NormalizedPropsOptions emitsOptions?: ObjectEmitsOptions | null isSingleRoot?: boolean diff --git a/packages/runtime-vapor/src/componentSlots.ts b/packages/runtime-vapor/src/componentSlots.ts index 78d823b367..49b577ec3d 100644 --- a/packages/runtime-vapor/src/componentSlots.ts +++ b/packages/runtime-vapor/src/componentSlots.ts @@ -1,5 +1,5 @@ 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' @@ -10,6 +10,7 @@ import { resetInsertionState, } 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/KeepAlive.ts b/packages/runtime-vapor/src/components/KeepAlive.ts index 37c137cb40..3367ea089b 100644 --- a/packages/runtime-vapor/src/components/KeepAlive.ts +++ b/packages/runtime-vapor/src/components/KeepAlive.ts @@ -12,13 +12,7 @@ import { warn, watch, } from '@vue/runtime-dom' -import { - type Block, - type VaporFragment, - insert, - isFragment, - remove, -} from '../block' +import { type Block, insert, remove } from '../block' import { type ObjectVaporComponent, type VaporComponent, @@ -28,6 +22,7 @@ import { import { defineVaporComponent } from '../apiDefineComponent' import { ShapeFlags, invokeArrayFns, isArray } from '@vue/shared' import { createElement } from '../dom/node' +import { type VaporFragment, isFragment } from '../fragment' export interface KeepAliveInstance extends VaporComponentInstance { activate: ( diff --git a/packages/runtime-vapor/src/components/Teleport.ts b/packages/runtime-vapor/src/components/Teleport.ts new file mode 100644 index 0000000000..8609303df2 --- /dev/null +++ b/packages/runtime-vapor/src/components/Teleport.ts @@ -0,0 +1,203 @@ +import { + type TeleportProps, + isTeleportDeferred, + isTeleportDisabled, + queuePostFlushCb, + resolveTeleportTarget, + warn, +} from '@vue/runtime-dom' +import { type Block, type BlockFn, insert, remove } from '../block' +import { createComment, createTextNode, querySelector } from '../dom/node' +import { + type LooseRawProps, + type LooseRawSlots, + isVaporComponent, +} from '../component' +import { rawPropsProxyHandlers } from '../componentProps' +import { renderEffect } from '../renderEffect' +import { extend, isArray } from '@vue/shared' +import { VaporFragment } from '../fragment' + +export const VaporTeleportImpl = { + name: 'VaporTeleport', + __isTeleport: true, + __vapor: true, + + process(props: LooseRawProps, slots: LooseRawSlots): TeleportFragment { + return new TeleportFragment(props, slots) + }, +} + +export class TeleportFragment extends VaporFragment { + anchor?: Node + private rawProps?: LooseRawProps + private resolvedProps?: TeleportProps + private rawSlots?: LooseRawSlots + + target?: ParentNode | null + targetAnchor?: Node | null + targetStart?: Node | null + + placeholder?: Node + mountContainer?: ParentNode | null + mountAnchor?: Node | null + + constructor(props: LooseRawProps, slots: LooseRawSlots) { + super([]) + this.rawProps = props + this.rawSlots = slots + this.anchor = __DEV__ ? createComment('teleport end') : createTextNode() + + renderEffect(() => { + // access the props to trigger tracking + this.resolvedProps = extend( + {}, + new Proxy( + this.rawProps!, + rawPropsProxyHandlers, + ) as any as TeleportProps, + ) + this.handlePropsUpdate() + }) + + this.initChildren() + } + + get parent(): ParentNode | null { + return this.anchor ? this.anchor.parentNode : null + } + + private initChildren(): void { + renderEffect(() => { + this.handleChildrenUpdate( + this.rawSlots!.default && (this.rawSlots!.default as BlockFn)(), + ) + }) + + // for hmr + if (__DEV__) { + const nodes = this.nodes + if (isVaporComponent(nodes)) { + nodes.parentTeleport = this + } else if (isArray(nodes)) { + nodes.forEach( + node => isVaporComponent(node) && (node.parentTeleport = this), + ) + } + } + } + + private handleChildrenUpdate(children: Block): void { + // not mounted yet + if (!this.parent) { + this.nodes = children + return + } + + // teardown previous nodes + remove(this.nodes, this.mountContainer!) + // mount new nodes + insert((this.nodes = children), this.mountContainer!, this.mountAnchor!) + } + + private handlePropsUpdate(): void { + // not mounted yet + if (!this.parent) return + + const mount = (parent: ParentNode, anchor: Node | null) => { + insert( + this.nodes, + (this.mountContainer = parent), + (this.mountAnchor = anchor), + ) + } + + const mountToTarget = () => { + const target = (this.target = resolveTeleportTarget( + this.resolvedProps!, + querySelector, + )) + if (target) { + if ( + // initial mount into target + !this.targetAnchor || + // target changed + this.targetAnchor.parentNode !== target + ) { + insert((this.targetStart = createTextNode('')), target) + insert((this.targetAnchor = createTextNode('')), target) + } + + mount(target, this.targetAnchor!) + } else if (__DEV__) { + warn( + `Invalid Teleport target on ${this.targetAnchor ? 'update' : 'mount'}:`, + target, + `(${typeof target})`, + ) + } + } + + // mount into main container + if (isTeleportDisabled(this.resolvedProps!)) { + mount(this.parent, this.anchor!) + } + // mount into target container + else { + if (isTeleportDeferred(this.resolvedProps!)) { + queuePostFlushCb(mountToTarget) + } else { + mountToTarget() + } + } + } + + insert = (container: ParentNode, anchor: Node | null): void => { + // insert anchors in the main view + this.placeholder = __DEV__ + ? createComment('teleport start') + : createTextNode() + insert(this.placeholder, container, anchor) + insert(this.anchor!, container, anchor) + this.handlePropsUpdate() + } + + remove = (parent: ParentNode | undefined = this.parent!): void => { + // remove nodes + if (this.nodes) { + remove(this.nodes, this.mountContainer!) + 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.anchor) { + remove(this.anchor, this.anchor.parentNode!) + this.anchor = undefined + } + + if (this.placeholder) { + remove(this.placeholder!, parent) + this.placeholder = undefined + } + + this.mountContainer = undefined + this.mountAnchor = undefined + } + + hydrate = (): void => { + //TODO + } +} + +export function isVaporTeleport( + value: unknown, +): value is typeof VaporTeleportImpl { + return value === VaporTeleportImpl +} diff --git a/packages/runtime-vapor/src/components/Transition.ts b/packages/runtime-vapor/src/components/Transition.ts index 017cb0fd5c..c72bc0d517 100644 --- a/packages/runtime-vapor/src/components/Transition.ts +++ b/packages/runtime-vapor/src/components/Transition.ts @@ -14,12 +14,7 @@ import { useTransitionState, warn, } from '@vue/runtime-dom' -import { - type Block, - type TransitionBlock, - type VaporTransitionHooks, - isFragment, -} from '../block' +import type { Block, TransitionBlock, VaporTransitionHooks } from '../block' import { type FunctionalVaporComponent, type VaporComponentInstance, @@ -28,6 +23,7 @@ import { } from '../component' import { extend, isArray } from '@vue/shared' import { renderEffect } from '../renderEffect' +import { isFragment } from '../fragment' const decorate = (t: typeof VaporTransition) => { t.displayName = 'VaporTransition' diff --git a/packages/runtime-vapor/src/components/TransitionGroup.ts b/packages/runtime-vapor/src/components/TransitionGroup.ts index 074a28c4ac..8c54fa25a5 100644 --- a/packages/runtime-vapor/src/components/TransitionGroup.ts +++ b/packages/runtime-vapor/src/components/TransitionGroup.ts @@ -17,11 +17,9 @@ import { import { extend, isArray } from '@vue/shared' import { type Block, - DynamicFragment, type TransitionBlock, type VaporTransitionHooks, insert, - isFragment, } from '../block' import { resolveTransitionHooks, @@ -37,6 +35,7 @@ import { import { isForBlock } from '../apiCreateFor' import { renderEffect } from '../renderEffect' import { createElement } from '../dom/node' +import { DynamicFragment, isFragment } from '../fragment' const positionMap = new WeakMap() const newPositionMap = new WeakMap() diff --git a/packages/runtime-vapor/src/directives/vShow.ts b/packages/runtime-vapor/src/directives/vShow.ts index bb94acf95c..68888b0dde 100644 --- a/packages/runtime-vapor/src/directives/vShow.ts +++ b/packages/runtime-vapor/src/directives/vShow.ts @@ -6,13 +6,9 @@ import { } from '@vue/runtime-dom' import { renderEffect } from '../renderEffect' import { isVaporComponent } from '../component' -import { - type Block, - DynamicFragment, - type TransitionBlock, - VaporFragment, -} from '../block' +import type { Block, TransitionBlock } from '../block' import { isArray } from '@vue/shared' +import { DynamicFragment, VaporFragment } 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 0000000000..3ac1e09bf8 --- /dev/null +++ b/packages/runtime-vapor/src/fragment.ts @@ -0,0 +1,189 @@ +import { EffectScope, setActiveSub } from '@vue/reactivity' +import { createComment, createTextNode } from './dom/node' +import { + type Block, + type BlockFn, + type TransitionOptions, + type VaporTransitionHooks, + insert, + isValidBlock, + remove, +} from './block' +import { + type TransitionHooks, + type VNode, + currentInstance, + isKeepAlive, +} from '@vue/runtime-dom' +import type { VaporComponentInstance } from './component' +import type { NodeRef } from './apiTemplateRef' +import type { KeepAliveInstance } from './components/KeepAlive' +import { + applyTransitionHooks, + applyTransitionLeaveHooks, +} from './components/Transition' + +export class VaporFragment + implements TransitionOptions +{ + nodes: T + vnode?: VNode | null = null + anchor?: Node + setRef?: ( + instance: VaporComponentInstance, + ref: NodeRef, + refFor: boolean, + refKey: string | undefined, + ) => void + fallback?: BlockFn + $key?: any + $transition?: VaporTransitionHooks | undefined + insert?: ( + parent: ParentNode, + anchor: Node | null, + transitionHooks?: TransitionHooks, + ) => void + remove?: (parent?: ParentNode, transitionHooks?: TransitionHooks) => void + + constructor(nodes: T) { + this.nodes = nodes + } +} + +export class ForFragment extends VaporFragment { + constructor(nodes: Block[]) { + super(nodes) + } +} + +export class DynamicFragment extends VaporFragment { + anchor: Node + scope: EffectScope | undefined + current?: 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 + + const prevSub = setActiveSub() + const parent = this.anchor.parentNode + const transition = this.$transition + const renderBranch = () => { + if (render) { + this.scope = new EffectScope() + this.nodes = this.scope.run(render) || [] + if (isKeepAlive(instance)) { + ;(instance as KeepAliveInstance).process(this.nodes) + } + if (transition) { + this.$transition = applyTransitionHooks(this.nodes, transition) + } + if (parent) insert(this.nodes, parent, this.anchor) + } else { + this.scope = undefined + this.nodes = [] + } + } + const instance = currentInstance! + // teardown previous branch + if (this.scope) { + if (isKeepAlive(instance)) { + ;(instance as KeepAliveInstance).process(this.nodes) + } else { + this.scope.stop() + } + const mode = transition && transition.mode + if (mode) { + applyTransitionLeaveHooks(this.nodes, transition, renderBranch) + parent && remove(this.nodes, parent) + if (mode === 'out-in') { + setActiveSub(prevSub) + return + } + } else { + parent && remove(this.nodes, parent) + } + } + + renderBranch() + + if (this.fallback) { + // set fallback for nested fragments + const hasNestedFragment = isFragment(this.nodes) + if (hasNestedFragment) { + setFragmentFallback(this.nodes as VaporFragment, this.fallback) + } + + const invalidFragment = findInvalidFragment(this) + if (invalidFragment) { + parent && remove(this.nodes, parent) + const scope = this.scope || (this.scope = new EffectScope()) + scope.run(() => { + // for nested fragments, render invalid fragment's fallback + if (hasNestedFragment) { + renderFragmentFallback(invalidFragment) + } else { + this.nodes = this.fallback!() || [] + } + }) + parent && insert(this.nodes, parent, this.anchor) + } + } + + setActiveSub(prevSub) + } +} + +export function setFragmentFallback( + fragment: VaporFragment, + fallback: BlockFn, +): void { + if (fragment.fallback) { + const originalFallback = fragment.fallback + // if the original fallback also renders invalid blocks, + // this ensures proper fallback chaining + fragment.fallback = () => { + const fallbackNodes = originalFallback() + if (isValidBlock(fallbackNodes)) { + return fallbackNodes + } + return fallback() + } + } else { + fragment.fallback = fallback + } + + if (isFragment(fragment.nodes)) { + setFragmentFallback(fragment.nodes, fragment.fallback) + } +} + +function renderFragmentFallback(fragment: VaporFragment): void { + if (fragment instanceof ForFragment) { + fragment.nodes[0] = [fragment.fallback!() || []] as Block[] + } else if (fragment instanceof DynamicFragment) { + fragment.update(fragment.fallback) + } else { + // vdom slots + } +} + +function findInvalidFragment(fragment: VaporFragment): VaporFragment | null { + if (isValidBlock(fragment.nodes)) return null + + return isFragment(fragment.nodes) + ? findInvalidFragment(fragment.nodes) || fragment + : fragment +} + +export function isFragment(val: NonNullable): val is VaporFragment { + return val instanceof VaporFragment +} diff --git a/packages/runtime-vapor/src/hmr.ts b/packages/runtime-vapor/src/hmr.ts index 1a88ec7dc2..4e9927ee9a 100644 --- a/packages/runtime-vapor/src/hmr.ts +++ b/packages/runtime-vapor/src/hmr.ts @@ -20,6 +20,10 @@ export function hmrRerender(instance: VaporComponentInstance): void { const anchor = normalized[normalized.length - 1].nextSibling remove(instance.block, parent) const prev = setCurrentInstance(instance) + if (instance.renderEffects) { + instance.renderEffects.forEach(e => e.stop()) + instance.renderEffects = [] + } pushWarningContext(instance) devRender(instance) popWarningContext() @@ -47,6 +51,7 @@ export function hmrReload( mountComponent(newInstance, parent, anchor) updateParentBlockOnHmrReload(parentInstance, instance, newInstance) + updateParentTeleportOnHmrReload(instance, newInstance) } /** @@ -73,3 +78,30 @@ function updateParentBlockOnHmrReload( } } } + +/** + * dev only + * 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 that the correct parent and anchor are found during parentInstance + * HMR rerender/reload, as `normalizeBlock` relies on the current instance.block + */ +export function updateParentTeleportOnHmrReload( + instance: VaporComponentInstance, + newInstance: VaporComponentInstance, +): void { + const teleport = instance.parentTeleport + if (teleport) { + newInstance.parentTeleport = teleport + if (teleport.nodes === instance) { + teleport.nodes = newInstance + } else if (isArray(teleport.nodes)) { + for (let i = 0; i < teleport.nodes.length; i++) { + if (teleport.nodes[i] === instance) { + teleport.nodes[i] = newInstance + break + } + } + } + } +} diff --git a/packages/runtime-vapor/src/index.ts b/packages/runtime-vapor/src/index.ts index 61128da8f5..f98c7477ba 100644 --- a/packages/runtime-vapor/src/index.ts +++ b/packages/runtime-vapor/src/index.ts @@ -4,10 +4,11 @@ export { defineVaporComponent } from './apiDefineComponent' export { defineVaporAsyncComponent } from './apiDefineAsyncComponent' export { vaporInteropPlugin } from './vdomInterop' export type { VaporDirective } from './directives/custom' +export { VaporTeleportImpl as VaporTeleport } from './components/Teleport' export { VaporKeepAliveImpl as VaporKeepAlive } from './components/KeepAlive' // compiler-use only -export { insert, prepend, remove, isFragment, VaporFragment } from './block' +export { insert, prepend, remove } from './block' export { setInsertionState } from './insertionState' export { createComponent, @@ -52,5 +53,6 @@ export { applyDynamicModel, } from './directives/vModel' export { withVaporDirectives } from './directives/custom' +export { isFragment, VaporFragment } from './fragment' export { VaporTransition } from './components/Transition' export { VaporTransitionGroup } from './components/TransitionGroup' diff --git a/packages/runtime-vapor/src/renderEffect.ts b/packages/runtime-vapor/src/renderEffect.ts index ac34e8863d..d41ee35719 100644 --- a/packages/runtime-vapor/src/renderEffect.ts +++ b/packages/runtime-vapor/src/renderEffect.ts @@ -11,7 +11,7 @@ import { import { type VaporComponentInstance, isVaporComponent } from './component' import { invokeArrayFns } from '@vue/shared' -class RenderEffect extends ReactiveEffect { +export class RenderEffect extends ReactiveEffect { i: VaporComponentInstance | null job: SchedulerJob updateJob: SchedulerJob @@ -71,6 +71,11 @@ class RenderEffect extends ReactiveEffect { setCurrentInstance(...prev) if (__DEV__ && instance) { startMeasure(instance, `renderEffect`) + + if (instance.renderEffects) { + instance.renderEffects.forEach(e => e.stop()) + instance.renderEffects = [] + } } } diff --git a/packages/runtime-vapor/src/vdomInterop.ts b/packages/runtime-vapor/src/vdomInterop.ts index dcd234ef57..3d6fba608d 100644 --- a/packages/runtime-vapor/src/vdomInterop.ts +++ b/packages/runtime-vapor/src/vdomInterop.ts @@ -42,15 +42,7 @@ import { mountComponent, unmountComponent, } from './component' -import { - type Block, - VaporFragment, - type VaporTransitionHooks, - insert, - isFragment, - remove, - setFragmentFallback, -} from './block' +import { type Block, type VaporTransitionHooks, insert, remove } from './block' import { EMPTY_OBJ, ShapeFlags, @@ -64,6 +56,7 @@ import type { RawSlots, VaporSlot } from './componentSlots' import { renderEffect } from './renderEffect' import { createTextNode } from './dom/node' import { optimizePropertyLookup } from './dom/prop' +import { VaporFragment, isFragment, setFragmentFallback } from './fragment' import type { NodeRef } from './apiTemplateRef' import { setTransitionHooks as setVaporTransitionHooks } from './components/Transition' import { -- 2.47.3