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 00000000000..ce383dadf1c --- /dev/null +++ b/packages-private/vapor-e2e-test/__tests__/teleport.spec.ts @@ -0,0 +1,62 @@ +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('vapor teleport', () => { + 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') + }) + + test( + 'render vdom component', + async () => { + const targetSelector = '.target' + const testSelector = '.interop-render-vdom-comp' + const containerSelector = `${testSelector} > div` + const btnSelector = `${testSelector} > button` + + const tt = await html('#app') + console.log(tt) + + // 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 3de8392e5e2..035691fd69b 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 360f48085a1..734c9fde190 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' +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 = '8193' + const port = ports.vdomInterop beforeAll(() => { server = connect() .use(sirv(path.resolve(import.meta.dirname, '../dist'))) @@ -18,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() }) @@ -25,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') @@ -81,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/index.html b/packages-private/vapor-e2e-test/index.html index 7dc205e5ab0..bb1234e8e10 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/interop/App.vue b/packages-private/vapor-e2e-test/interop/App.vue index 772a6989dd7..dcdd5f99ace 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 00000000000..65661740cd9 --- /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 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 00000000000..d2aeba8e1f9 --- /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 00000000000..2c7a626f21a --- /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 00000000000..79052a023ba --- /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 00000000000..2e962efe731 --- /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 00000000000..a42064b7050 --- /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 1e29a4dbd13..a2816f4b6db 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'), }, }, }, diff --git a/packages/compiler-vapor/src/generators/component.ts b/packages/compiler-vapor/src/generators/component.ts index 7c232db754b..7dbf8c9a2f1 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 dceb3fd6121..07f88ae0c58 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 728281914fd..9b99ef869cf 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' + } +} diff --git a/packages/runtime-core/src/components/Teleport.ts b/packages/runtime-core/src/components/Teleport.ts index a6445df7b05..c365ad0a217 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/hmr.ts b/packages/runtime-core/src/hmr.ts index ed5d8b081a0..acc9593d137 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-core/src/index.ts b/packages/runtime-core/src/index.ts index e309554f2f6..fabe46dc310 100644 --- a/packages/runtime-core/src/index.ts +++ b/packages/runtime-core/src/index.ts @@ -557,3 +557,11 @@ export { startMeasure, endMeasure } from './profiling' * @internal */ export { initFeatureFlags } from './featureFlags' +/** + * @internal + */ +export { + resolveTarget as resolveTeleportTarget, + isTeleportDisabled, + isTeleportDeferred, +} from './components/Teleport' diff --git a/packages/runtime-vapor/__tests__/block.spec.ts b/packages/runtime-vapor/__tests__/block.spec.ts index 9f76c7f0333..f0144dee3df 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 00000000000..6863d398bfc --- /dev/null +++ b/packages/runtime-vapor/__tests__/components/Teleport.spec.ts @@ -0,0 +1,1168 @@ +import { + type LooseRawProps, + type VaporComponent, + createComponent as createComp, +} from '../../src/component' +import { + type VaporDirective, + VaporTeleport, + child, + createIf, + createTemplateRefSetter, + defineVaporComponent, + renderEffect, + setInsertionState, + setText, + template, + withVaporDirectives, +} from '@vue/runtime-vapor' +import { makeRender } from '../_utils' +import { + 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('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') + }) + }) +}) + +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, + }, + ) + 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 2126611d718..abdc1a1cf24 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 0cd8317532f..ca976aa8ccd 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 71bfa32d5d3..e83b251d069 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 b782afd38d3..f1791904ce8 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,69 +21,6 @@ export type Block = export type BlockFn = (...args: any[]) => Block -export class VaporFragment { - nodes: Block - anchor?: Node - insert?: (parent: ParentNode, anchor: Node | null) => void - remove?: (parent?: ParentNode) => void - - 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 || @@ -129,7 +69,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) } @@ -182,7 +122,11 @@ export function normalizeBlock(block: Block): Node[] { } else if (isVaporComponent(block)) { nodes.push(...normalizeBlock(block.block!)) } else { - nodes.push(...normalizeBlock(block.nodes)) + if (block.getNodes) { + nodes.push(...normalizeBlock(block.getNodes())) + } 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 548babebf8b..672bb75dc1e 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 { isVaporTeleport } from './components/Teleport' export { currentInstance } from '@vue/runtime-dom' @@ -157,6 +158,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 + } + if ( isSingleRoot && component.inheritAttrs !== false && @@ -270,7 +283,7 @@ export function createComponent( onScopeDispose(() => unmountComponent(instance), true) if (!isHydrating && _insertionParent) { - insert(instance.block, _insertionParent, _insertionAnchor) + mountComponent(instance, _insertionParent, _insertionAnchor) } return instance @@ -370,6 +383,7 @@ export class VaporComponentInstance implements GenericComponentInstance { setupState?: Record devtoolsRawSetupState?: any hmrRerender?: () => void + hmrRerenderEffects?: (() => void)[] hmrReload?: (newComp: VaporComponent) => void propsOptions?: NormalizedPropsOptions emitsOptions?: ObjectEmitsOptions | null diff --git a/packages/runtime-vapor/src/componentSlots.ts b/packages/runtime-vapor/src/componentSlots.ts index 74296e09466..3d17e5c0a5b 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 new file mode 100644 index 00000000000..ca1f184d5dd --- /dev/null +++ b/packages/runtime-vapor/src/components/Teleport.ts @@ -0,0 +1,254 @@ +import { + type TeleportProps, + currentInstance, + 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, + type VaporComponentInstance, + isVaporComponent, +} from '../component' +import { rawPropsProxyHandlers } from '../componentProps' +import { renderEffect } from '../renderEffect' +import { extend, isArray } from '@vue/shared' +import { VaporFragment } from '../fragment' + +const instanceToTeleportMap: WeakMap = + __DEV__ ? new WeakMap() : (undefined as any) + +export const VaporTeleportImpl = { + name: 'VaporTeleport', + __isTeleport: true, + __vapor: true, + + process(props: LooseRawProps, slots: LooseRawSlots): TeleportFragment { + const frag = new TeleportFragment() + const updateChildrenEffect = renderEffect(() => + frag.updateChildren(slots.default && (slots.default as BlockFn)()), + ) + + const updateEffect = renderEffect(() => { + frag.update( + // access the props to trigger tracking + extend( + {}, + new Proxy(props, rawPropsProxyHandlers) as any as TeleportProps, + ), + ) + }) + + if (__DEV__) { + // 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 rerender + const instance = currentInstance as VaporComponentInstance + ;( + instance!.hmrRerenderEffects || (instance!.hmrRerenderEffects = []) + ).push(() => { + // remove the teleport content + 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 + }, +} + +export class TeleportFragment extends VaporFragment { + anchor: Node + + private targetStart?: Node + private mainAnchor?: Node + private placeholder?: Node + private mountContainer?: ParentNode | null + private mountAnchor?: Node | null + + constructor() { + super([]) + this.anchor = __DEV__ ? createComment('teleport') : 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 { + // not mounted yet + if (!this.parent) { + this.nodes = children + return + } + + // teardown previous nodes + remove(this.nodes, this.currentParent) + // mount new nodes + insert((this.nodes = children), this.currentParent, this.currentAnchor) + } + + update(props: TeleportProps): void { + const mount = (parent: ParentNode, anchor: Node | null) => { + insert( + this.nodes, + (this.mountContainer = parent), + (this.mountAnchor = anchor), + ) + } + + const mountToTarget = () => { + const target = (this.target = resolveTeleportTarget(props, 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(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(this.parent, this.mainAnchor) + } + } + // mount into target container + else { + if (isTeleportDeferred(props)) { + queuePostFlushCb(mountToTarget) + } else { + mountToTarget() + } + } + } + + remove = (parent: ParentNode | undefined = this.parent!): void => { + // remove nodes + 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 + } + + this.mountContainer = undefined + this.mountAnchor = undefined + } + + hydrate(): void { + // TODO + } +} + +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 { + return value === VaporTeleportImpl +} + +/** + * 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 they are up to date. + */ +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 + } + } +} diff --git a/packages/runtime-vapor/src/directives/vShow.ts b/packages/runtime-vapor/src/directives/vShow.ts index ac4c066b71d..b0fc22c14c8 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 00000000000..3e4fcb221c3 --- /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/hmr.ts b/packages/runtime-vapor/src/hmr.ts index 741f385861d..63e5376896a 100644 --- a/packages/runtime-vapor/src/hmr.ts +++ b/packages/runtime-vapor/src/hmr.ts @@ -13,12 +13,17 @@ import { mountComponent, unmountComponent, } from './component' +import { handleTeleportRootComponentHmrReload } from './components/Teleport' export function hmrRerender(instance: VaporComponentInstance): void { const normalized = normalizeBlock(instance.block) const parent = normalized[0].parentNode! const anchor = normalized[normalized.length - 1].nextSibling remove(instance.block, parent) + if (instance.hmrRerenderEffects) { + instance.hmrRerenderEffects.forEach(e => e()) + instance.hmrRerenderEffects.length = 0 + } const prev = currentInstance simpleSetCurrentInstance(instance) pushWarningContext(instance) @@ -46,4 +51,5 @@ export function hmrReload( ) simpleSetCurrentInstance(prev, instance.parent) mountComponent(newInstance, parent, anchor) + handleTeleportRootComponentHmrReload(instance, newInstance) } diff --git a/packages/runtime-vapor/src/index.ts b/packages/runtime-vapor/src/index.ts index 682532fa4d8..c2716059df2 100644 --- a/packages/runtime-vapor/src/index.ts +++ b/packages/runtime-vapor/src/index.ts @@ -3,9 +3,10 @@ export { createVaporApp, createVaporSSRApp } from './apiCreateApp' export { defineVaporComponent } from './apiDefineComponent' export { vaporInteropPlugin } from './vdomInterop' 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' @@ -42,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/renderEffect.ts b/packages/runtime-vapor/src/renderEffect.ts index a9fa9b33562..227d7933e78 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 } diff --git a/packages/runtime-vapor/src/vdomInterop.ts b/packages/runtime-vapor/src/vdomInterop.ts index 77228fd72a0..2249bbb2fdc 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<