diff --git a/packages/runtime-core/__tests__/components/Teleport.spec.ts b/packages/runtime-core/__tests__/components/Teleport.spec.ts index aca9432b6e1..24400f6ed40 100644 --- a/packages/runtime-core/__tests__/components/Teleport.spec.ts +++ b/packages/runtime-core/__tests__/components/Teleport.spec.ts @@ -7,617 +7,703 @@ import { Text, createApp, defineComponent, - h, markRaw, nextTick, nodeOps, + h as originalH, ref, render, serializeInner, withDirectives, } from '@vue/runtime-test' import { Fragment, createCommentVNode, createVNode } from '../../src/vnode' -import { compile, render as domRender } from 'vue' +import { compile, createApp as createDOMApp, render as domRender } from 'vue' describe('renderer: teleport', () => { - test('should work', () => { - const target = nodeOps.createElement('div') - const root = nodeOps.createElement('div') - - render( - h(() => [ - h(Teleport, { to: target }, h('div', 'teleported')), - h('div', 'root'), - ]), - root, - ) - - expect(serializeInner(root)).toMatchInlineSnapshot( - `"
root
"`, - ) - expect(serializeInner(target)).toMatchInlineSnapshot( - `"
teleported
"`, - ) + describe('eager mode', () => { + runSharedTests(false) }) - test('should work with SVG', async () => { - const root = document.createElement('div') - const svg = ref() - const circle = ref() + describe('defer mode', () => { + runSharedTests(true) - const Comp = defineComponent({ - setup() { - return { - svg, - circle, - } - }, - template: ` - - - - `, - }) + const h = originalH - domRender(h(Comp), root) + test('should be able to target content appearing later than the teleport with defer', () => { + const root = document.createElement('div') + document.body.appendChild(root) - await nextTick() + createDOMApp({ + render() { + return [ + h(Teleport, { to: '#target', defer: true }, h('div', 'teleported')), + h('div', { id: 'target' }), + ] + }, + }).mount(root) - expect(root.innerHTML).toMatchInlineSnapshot( - `""`, - ) + expect(root.innerHTML).toMatchInlineSnapshot( + `"
teleported
"`, + ) + }) - expect(svg.value.namespaceURI).toBe('http://www.w3.org/2000/svg') - expect(circle.value.namespaceURI).toBe('http://www.w3.org/2000/svg') + test('defer mode should work inside suspense', async () => { + const root = document.createElement('div') + document.body.appendChild(root) + + let p: Promise + + const Comp = defineComponent({ + template: ` + +
+ + +
teleported
+
+
+
+ `, + components: { + async: { + setup() { + p = Promise.resolve(() => 'async') + return p + }, + }, + }, + }) + + domRender(h(Comp), root) + expect(root.innerHTML).toBe(``) + + await p!.then(() => Promise.resolve()) + await nextTick() + expect(root.innerHTML).toBe( + `
` + + `async` + + `` + + `
teleported
` + + `
`, + ) + }) }) - test('should update target', async () => { - const targetA = nodeOps.createElement('div') - const targetB = nodeOps.createElement('div') - const target = ref(targetA) - const root = nodeOps.createElement('div') - - render( - h(() => [ - h(Teleport, { to: target.value }, h('div', 'teleported')), - h('div', 'root'), - ]), - root, - ) - - expect(serializeInner(root)).toMatchInlineSnapshot( - `"
root
"`, - ) - expect(serializeInner(targetA)).toMatchInlineSnapshot( - `"
teleported
"`, - ) - expect(serializeInner(targetB)).toMatchInlineSnapshot(`""`) - - target.value = targetB - await nextTick() - - expect(serializeInner(root)).toMatchInlineSnapshot( - `"
root
"`, - ) - expect(serializeInner(targetA)).toMatchInlineSnapshot(`""`) - expect(serializeInner(targetB)).toMatchInlineSnapshot( - `"
teleported
"`, - ) - }) + function runSharedTests(deferMode: boolean) { + const h = (deferMode + ? (type: any, props: any, ...args: any[]) => { + if (type === Teleport) { + props.defer = true + } + return originalH(type, props, ...args) + } + : originalH) as unknown as typeof originalH - test('should update children', async () => { - const target = nodeOps.createElement('div') - const root = nodeOps.createElement('div') - const children = ref([h('div', 'teleported')]) + test('should work', () => { + const target = nodeOps.createElement('div') + const root = nodeOps.createElement('div') - render( - h(() => h(Teleport, { to: target }, children.value)), - root, - ) - expect(serializeInner(target)).toMatchInlineSnapshot( - `"
teleported
"`, - ) + render( + h(() => [ + h(Teleport, { to: target }, h('div', 'teleported')), + h('div', 'root'), + ]), + root, + ) - children.value = [] - await nextTick() + expect(serializeInner(root)).toMatchInlineSnapshot( + `"
root
"`, + ) + expect(serializeInner(target)).toMatchInlineSnapshot( + `"
teleported
"`, + ) + }) - expect(serializeInner(target)).toMatchInlineSnapshot(`""`) + test('should work with SVG', async () => { + const root = document.createElement('div') + const svg = ref() + const circle = ref() + + const Comp = defineComponent({ + setup() { + return { + svg, + circle, + } + }, + template: ` + + + + `, + }) + + domRender(h(Comp), root) + + await nextTick() + + expect(root.innerHTML).toMatchInlineSnapshot( + `""`, + ) - children.value = [createVNode(Text, null, 'teleported')] - await nextTick() + expect(svg.value.namespaceURI).toBe('http://www.w3.org/2000/svg') + expect(circle.value.namespaceURI).toBe('http://www.w3.org/2000/svg') + }) - expect(serializeInner(target)).toMatchInlineSnapshot(`"teleported"`) - }) + test('should update target', async () => { + const targetA = nodeOps.createElement('div') + const targetB = nodeOps.createElement('div') + const target = ref(targetA) + const root = nodeOps.createElement('div') - test('should remove children when unmounted', () => { - const target = nodeOps.createElement('div') - const root = nodeOps.createElement('div') + render( + h(() => [ + h(Teleport, { to: target.value }, h('div', 'teleported')), + h('div', 'root'), + ]), + root, + ) + + expect(serializeInner(root)).toMatchInlineSnapshot( + `"
root
"`, + ) + expect(serializeInner(targetA)).toMatchInlineSnapshot( + `"
teleported
"`, + ) + expect(serializeInner(targetB)).toMatchInlineSnapshot(`""`) + + target.value = targetB + await nextTick() + + expect(serializeInner(root)).toMatchInlineSnapshot( + `"
root
"`, + ) + expect(serializeInner(targetA)).toMatchInlineSnapshot(`""`) + expect(serializeInner(targetB)).toMatchInlineSnapshot( + `"
teleported
"`, + ) + }) + + test('should update children', async () => { + const target = nodeOps.createElement('div') + const root = nodeOps.createElement('div') + const children = ref([h('div', 'teleported')]) - function testUnmount(props: any) { render( - h(() => [h(Teleport, props, h('div', 'teleported')), h('div', 'root')]), + h(() => h(Teleport, { to: target }, children.value)), root, ) expect(serializeInner(target)).toMatchInlineSnapshot( - props.disabled ? `""` : `"
teleported
"`, + `"
teleported
"`, + ) + + children.value = [] + await nextTick() + + expect(serializeInner(target)).toMatchInlineSnapshot(`""`) + + children.value = [createVNode(Text, null, 'teleported')] + await nextTick() + + expect(serializeInner(target)).toMatchInlineSnapshot(`"teleported"`) + }) + + test('should remove children when unmounted', () => { + const target = nodeOps.createElement('div') + const root = nodeOps.createElement('div') + + function testUnmount(props: any) { + render( + h(() => [ + h(Teleport, props, h('div', 'teleported')), + h('div', 'root'), + ]), + root, + ) + expect(serializeInner(target)).toMatchInlineSnapshot( + props.disabled ? `""` : `"
teleported
"`, + ) + + render(null, root) + expect(serializeInner(target)).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', () => { + const target = nodeOps.createElement('div') + const root = nodeOps.createElement('div') + + const Comp = { + render() { + return [h('p'), h('p')] + }, + } + + render( + h(() => [h(Teleport, { to: target }, h(Comp)), h('div', 'root')]), + root, ) + expect(serializeInner(target)).toMatchInlineSnapshot(`"

"`) render(null, root) expect(serializeInner(target)).toBe('') - expect(target.children.length).toBe(0) - } + }) - testUnmount({ to: target, disabled: false }) - testUnmount({ to: target, disabled: true }) - testUnmount({ to: null, disabled: true }) - }) + // #6347 + test('descendent component should be unmounted when teleport is disabled and unmounted', () => { + const root = nodeOps.createElement('div') - test('component with multi roots should be removed when unmounted', () => { - const target = nodeOps.createElement('div') - const root = nodeOps.createElement('div') + const CompWithHook = { + render() { + return [h('p'), h('p')] + }, + beforeUnmount: vi.fn(), + unmounted: vi.fn(), + } - const Comp = { - render() { - return [h('p'), h('p')] - }, - } + render( + h(() => [h(Teleport, { to: null, disabled: true }, h(CompWithHook))]), + root, + ) + expect(CompWithHook.beforeUnmount).toBeCalledTimes(0) + expect(CompWithHook.unmounted).toBeCalledTimes(0) - render( - h(() => [h(Teleport, { to: target }, h(Comp)), h('div', 'root')]), - root, - ) - expect(serializeInner(target)).toMatchInlineSnapshot(`"

"`) + render(null, root) - render(null, root) - expect(serializeInner(target)).toBe('') - }) + expect(CompWithHook.beforeUnmount).toBeCalledTimes(1) + expect(CompWithHook.unmounted).toBeCalledTimes(1) + }) - // #6347 - test('descendent component should be unmounted when teleport is disabled and unmounted', () => { - const root = nodeOps.createElement('div') - - const CompWithHook = { - render() { - return [h('p'), h('p')] - }, - beforeUnmount: vi.fn(), - unmounted: vi.fn(), - } - - render( - h(() => [h(Teleport, { to: null, disabled: true }, h(CompWithHook))]), - root, - ) - expect(CompWithHook.beforeUnmount).toBeCalledTimes(0) - expect(CompWithHook.unmounted).toBeCalledTimes(0) - - render(null, root) - - expect(CompWithHook.beforeUnmount).toBeCalledTimes(1) - expect(CompWithHook.unmounted).toBeCalledTimes(1) - }) + test('multiple teleport with same target', () => { + const target = nodeOps.createElement('div') + const root = nodeOps.createElement('div') - test('multiple teleport with same target', () => { - const target = nodeOps.createElement('div') - const root = nodeOps.createElement('div') - - render( - h('div', [ - h(Teleport, { to: target }, h('div', 'one')), - h(Teleport, { to: target }, 'two'), - ]), - root, - ) - - expect(serializeInner(root)).toMatchInlineSnapshot( - `"
"`, - ) - expect(serializeInner(target)).toMatchInlineSnapshot(`"
one
two"`) - - // update existing content - render( - h('div', [ - h(Teleport, { to: target }, [h('div', 'one'), h('div', 'two')]), - h(Teleport, { to: target }, 'three'), - ]), - root, - ) - expect(serializeInner(target)).toMatchInlineSnapshot( - `"
one
two
three"`, - ) - - // toggling - render(h('div', [null, h(Teleport, { to: target }, 'three')]), root) - expect(serializeInner(root)).toMatchInlineSnapshot( - `"
"`, - ) - expect(serializeInner(target)).toMatchInlineSnapshot(`"three"`) - - // toggle back - render( - h('div', [ - h(Teleport, { to: target }, [h('div', 'one'), h('div', 'two')]), - h(Teleport, { to: target }, 'three'), - ]), - root, - ) - expect(serializeInner(root)).toMatchInlineSnapshot( - `"
"`, - ) - // should append - expect(serializeInner(target)).toMatchInlineSnapshot( - `"three
one
two
"`, - ) - - // toggle the other teleport - render( - h('div', [ - h(Teleport, { to: target }, [h('div', 'one'), h('div', 'two')]), - null, - ]), - root, - ) - expect(serializeInner(root)).toMatchInlineSnapshot( - `"
"`, - ) - expect(serializeInner(target)).toMatchInlineSnapshot( - `"
one
two
"`, - ) - }) + render( + h('div', [ + h(Teleport, { to: target }, h('div', 'one')), + h(Teleport, { to: target }, 'two'), + ]), + root, + ) - test('should work when using template ref as target', async () => { - const root = nodeOps.createElement('div') - const target = ref(null) - const disabled = ref(true) - - const App = { - setup() { - return () => - h(Fragment, [ - h('div', { ref: target }), - h( - Teleport, - { to: target.value, disabled: disabled.value }, - h('div', 'teleported'), - ), - ]) - }, - } - render(h(App), root) - expect(serializeInner(root)).toMatchInlineSnapshot( - `"
teleported
"`, - ) - - disabled.value = false - await nextTick() - expect(serializeInner(root)).toMatchInlineSnapshot( - `"
teleported
"`, - ) - }) + expect(serializeInner(root)).toMatchInlineSnapshot( + `"
"`, + ) + expect(serializeInner(target)).toMatchInlineSnapshot( + `"
one
two"`, + ) - test('disabled', () => { - const target = nodeOps.createElement('div') - const root = nodeOps.createElement('div') - - const renderWithDisabled = (disabled: boolean) => { - return h(Fragment, [ - h(Teleport, { to: target, disabled }, h('div', 'teleported')), - h('div', 'root'), - ]) - } - - render(renderWithDisabled(false), root) - expect(serializeInner(root)).toMatchInlineSnapshot( - `"
root
"`, - ) - expect(serializeInner(target)).toMatchInlineSnapshot( - `"
teleported
"`, - ) - - render(renderWithDisabled(true), root) - expect(serializeInner(root)).toMatchInlineSnapshot( - `"
teleported
root
"`, - ) - expect(serializeInner(target)).toBe(``) - - // toggle back - render(renderWithDisabled(false), root) - expect(serializeInner(root)).toMatchInlineSnapshot( - `"
root
"`, - ) - expect(serializeInner(target)).toMatchInlineSnapshot( - `"
teleported
"`, - ) - }) + // update existing content + render( + h('div', [ + h(Teleport, { to: target }, [h('div', 'one'), h('div', 'two')]), + h(Teleport, { to: target }, 'three'), + ]), + root, + ) + expect(serializeInner(target)).toMatchInlineSnapshot( + `"
one
two
three"`, + ) - test('moving teleport while enabled', () => { - const target = nodeOps.createElement('div') - const root = nodeOps.createElement('div') - - render( - h(Fragment, [ - h(Teleport, { to: target }, h('div', 'teleported')), - h('div', 'root'), - ]), - root, - ) - expect(serializeInner(root)).toMatchInlineSnapshot( - `"
root
"`, - ) - expect(serializeInner(target)).toMatchInlineSnapshot( - `"
teleported
"`, - ) - - render( - h(Fragment, [ - h('div', 'root'), - h(Teleport, { to: target }, h('div', 'teleported')), - ]), - root, - ) - expect(serializeInner(root)).toMatchInlineSnapshot( - `"
root
"`, - ) - expect(serializeInner(target)).toMatchInlineSnapshot( - `"
teleported
"`, - ) - - render( - h(Fragment, [ - h(Teleport, { to: target }, h('div', 'teleported')), - h('div', 'root'), - ]), - root, - ) - expect(serializeInner(root)).toMatchInlineSnapshot( - `"
root
"`, - ) - expect(serializeInner(target)).toMatchInlineSnapshot( - `"
teleported
"`, - ) - }) + // toggling + render(h('div', [null, h(Teleport, { to: target }, 'three')]), root) + expect(serializeInner(root)).toMatchInlineSnapshot( + `"
"`, + ) + expect(serializeInner(target)).toMatchInlineSnapshot(`"three"`) - test('moving teleport while disabled', () => { - const target = nodeOps.createElement('div') - const root = nodeOps.createElement('div') - - render( - h(Fragment, [ - h(Teleport, { to: target, disabled: true }, h('div', 'teleported')), - h('div', 'root'), - ]), - root, - ) - expect(serializeInner(root)).toMatchInlineSnapshot( - `"
teleported
root
"`, - ) - expect(serializeInner(target)).toBe('') - - render( - h(Fragment, [ - h('div', 'root'), - h(Teleport, { to: target, disabled: true }, h('div', 'teleported')), - ]), - root, - ) - expect(serializeInner(root)).toMatchInlineSnapshot( - `"
root
teleported
"`, - ) - expect(serializeInner(target)).toBe('') - - render( - h(Fragment, [ - h(Teleport, { to: target, disabled: true }, h('div', 'teleported')), - h('div', 'root'), - ]), - root, - ) - expect(serializeInner(root)).toMatchInlineSnapshot( - `"
teleported
root
"`, - ) - expect(serializeInner(target)).toBe('') - }) + // toggle back + render( + h('div', [ + h(Teleport, { to: target }, [h('div', 'one'), h('div', 'two')]), + h(Teleport, { to: target }, 'three'), + ]), + root, + ) + expect(serializeInner(root)).toMatchInlineSnapshot( + `"
"`, + ) + // should append + expect(serializeInner(target)).toMatchInlineSnapshot( + `"three
one
two
"`, + ) - test('should work with block tree', async () => { - const target = nodeOps.createElement('div') - const root = nodeOps.createElement('div') - const disabled = ref(false) + // toggle the other teleport + render( + h('div', [ + h(Teleport, { to: target }, [h('div', 'one'), h('div', 'two')]), + null, + ]), + root, + ) + expect(serializeInner(root)).toMatchInlineSnapshot( + `"
"`, + ) + expect(serializeInner(target)).toMatchInlineSnapshot( + `"
one
two
"`, + ) + }) - const App = { - setup() { - return { - target: markRaw(target), - disabled, - } - }, - render: compile(` - -
teleported
{{ disabled }} -
-
root
- `), - } - render(h(App), root) - expect(serializeInner(root)).toMatchInlineSnapshot( - `"
root
"`, - ) - expect(serializeInner(target)).toMatchInlineSnapshot( - `"
teleported
false"`, - ) - - disabled.value = true - await nextTick() - expect(serializeInner(root)).toMatchInlineSnapshot( - `"
teleported
true
root
"`, - ) - expect(serializeInner(target)).toBe(``) - - // toggle back - disabled.value = false - await nextTick() - expect(serializeInner(root)).toMatchInlineSnapshot( - `"
root
"`, - ) - expect(serializeInner(target)).toMatchInlineSnapshot( - `"
teleported
false"`, - ) - }) + test('should work when using template ref as target', async () => { + const root = nodeOps.createElement('div') + const target = ref(null) + const disabled = ref(true) + + const App = { + setup() { + return () => + h(Fragment, [ + h('div', { ref: target }), + h( + Teleport, + { to: target.value, disabled: disabled.value }, + h('div', 'teleported'), + ), + ]) + }, + } + render(h(App), root) + expect(serializeInner(root)).toMatchInlineSnapshot( + `"
teleported
"`, + ) - // #3497 - test(`the dir hooks of the Teleport's children should be called correctly`, async () => { - const target = nodeOps.createElement('div') - const root = nodeOps.createElement('div') - const toggle = ref(true) - const dir = { - mounted: vi.fn(), - unmounted: vi.fn(), - } - - const app = createApp({ - setup() { - return () => { - return toggle.value - ? h(Teleport, { to: target }, [ - withDirectives(h('div', ['foo']), [[dir]]), - ]) - : null - } - }, + disabled.value = false + await nextTick() + expect(serializeInner(root)).toMatchInlineSnapshot( + `"
teleported
"`, + ) }) - app.mount(root) - - expect(serializeInner(root)).toMatchInlineSnapshot( - `""`, - ) - expect(serializeInner(target)).toMatchInlineSnapshot(`"
foo
"`) - expect(dir.mounted).toHaveBeenCalledTimes(1) - expect(dir.unmounted).toHaveBeenCalledTimes(0) - - toggle.value = false - await nextTick() - expect(serializeInner(root)).toMatchInlineSnapshot(`""`) - expect(serializeInner(target)).toMatchInlineSnapshot(`""`) - expect(dir.mounted).toHaveBeenCalledTimes(1) - expect(dir.unmounted).toHaveBeenCalledTimes(1) - }) - // #7835 - test(`ensure that target changes when disabled are updated correctly when enabled`, async () => { - const root = nodeOps.createElement('div') - const target1 = nodeOps.createElement('div') - const target2 = nodeOps.createElement('div') - const target3 = nodeOps.createElement('div') - const target = ref(target1) - const disabled = ref(true) - - const App = { - setup() { - return () => - h(Fragment, [ - h( - Teleport, - { to: target.value, disabled: disabled.value }, - h('div', 'teleported'), - ), - ]) - }, - } - render(h(App), root) - disabled.value = false - await nextTick() - expect(serializeInner(target1)).toMatchInlineSnapshot( - `"
teleported
"`, - ) - expect(serializeInner(target2)).toMatchInlineSnapshot(`""`) - expect(serializeInner(target3)).toMatchInlineSnapshot(`""`) - - disabled.value = true - await nextTick() - target.value = target2 - await nextTick() - expect(serializeInner(target1)).toMatchInlineSnapshot(`""`) - expect(serializeInner(target2)).toMatchInlineSnapshot(`""`) - expect(serializeInner(target3)).toMatchInlineSnapshot(`""`) - - target.value = target3 - await nextTick() - expect(serializeInner(target1)).toMatchInlineSnapshot(`""`) - expect(serializeInner(target2)).toMatchInlineSnapshot(`""`) - expect(serializeInner(target3)).toMatchInlineSnapshot(`""`) - - disabled.value = false - await nextTick() - expect(serializeInner(target1)).toMatchInlineSnapshot(`""`) - expect(serializeInner(target2)).toMatchInlineSnapshot(`""`) - expect(serializeInner(target3)).toMatchInlineSnapshot( - `"
teleported
"`, - ) - }) + test('disabled', () => { + const target = nodeOps.createElement('div') + const root = nodeOps.createElement('div') - //#9071 - test('toggle sibling node inside target node', async () => { - const root = document.createElement('div') - const show = ref(false) - const App = defineComponent({ - setup() { - return () => { - return show.value - ? h(Teleport, { to: root }, [h('div', 'teleported')]) - : h('div', 'foo') - } - }, + const renderWithDisabled = (disabled: boolean) => { + return h(Fragment, [ + h(Teleport, { to: target, disabled }, h('div', 'teleported')), + h('div', 'root'), + ]) + } + + render(renderWithDisabled(false), root) + expect(serializeInner(root)).toMatchInlineSnapshot( + `"
root
"`, + ) + expect(serializeInner(target)).toMatchInlineSnapshot( + `"
teleported
"`, + ) + + render(renderWithDisabled(true), root) + expect(serializeInner(root)).toMatchInlineSnapshot( + `"
teleported
root
"`, + ) + expect(serializeInner(target)).toBe(``) + + // toggle back + render(renderWithDisabled(false), root) + expect(serializeInner(root)).toMatchInlineSnapshot( + `"
root
"`, + ) + expect(serializeInner(target)).toMatchInlineSnapshot( + `"
teleported
"`, + ) }) - domRender(h(App), root) - expect(root.innerHTML).toMatchInlineSnapshot('"
foo
"') + test('moving teleport while enabled', () => { + const target = nodeOps.createElement('div') + const root = nodeOps.createElement('div') - show.value = true - await nextTick() + render( + h(Fragment, [ + h(Teleport, { to: target }, h('div', 'teleported')), + h('div', 'root'), + ]), + root, + ) + expect(serializeInner(root)).toMatchInlineSnapshot( + `"
root
"`, + ) + expect(serializeInner(target)).toMatchInlineSnapshot( + `"
teleported
"`, + ) - expect(root.innerHTML).toMatchInlineSnapshot( - '"
teleported
"', - ) + render( + h(Fragment, [ + h('div', 'root'), + h(Teleport, { to: target }, h('div', 'teleported')), + ]), + root, + ) + expect(serializeInner(root)).toMatchInlineSnapshot( + `"
root
"`, + ) + expect(serializeInner(target)).toMatchInlineSnapshot( + `"
teleported
"`, + ) - show.value = false - await nextTick() + render( + h(Fragment, [ + h(Teleport, { to: target }, h('div', 'teleported')), + h('div', 'root'), + ]), + root, + ) + expect(serializeInner(root)).toMatchInlineSnapshot( + `"
root
"`, + ) + expect(serializeInner(target)).toMatchInlineSnapshot( + `"
teleported
"`, + ) + }) - expect(root.innerHTML).toMatchInlineSnapshot('"
foo
"') - }) + test('moving teleport while disabled', () => { + const target = nodeOps.createElement('div') + const root = nodeOps.createElement('div') - test('unmount previous sibling node inside target node', async () => { - const root = document.createElement('div') - const parentShow = ref(false) - const childShow = ref(true) - - const Comp = { - setup() { - return () => h(Teleport, { to: root }, [h('div', 'foo')]) - }, - } - - const App = defineComponent({ - setup() { - return () => { - return parentShow.value - ? h(Fragment, { key: 0 }, [ - childShow.value ? h(Comp) : createCommentVNode('v-if'), - ]) - : createCommentVNode('v-if') - } - }, + render( + h(Fragment, [ + h(Teleport, { to: target, disabled: true }, h('div', 'teleported')), + h('div', 'root'), + ]), + root, + ) + expect(serializeInner(root)).toMatchInlineSnapshot( + `"
teleported
root
"`, + ) + expect(serializeInner(target)).toBe('') + + render( + h(Fragment, [ + h('div', 'root'), + h(Teleport, { to: target, disabled: true }, h('div', 'teleported')), + ]), + root, + ) + expect(serializeInner(root)).toMatchInlineSnapshot( + `"
root
teleported
"`, + ) + expect(serializeInner(target)).toBe('') + + render( + h(Fragment, [ + h(Teleport, { to: target, disabled: true }, h('div', 'teleported')), + h('div', 'root'), + ]), + root, + ) + expect(serializeInner(root)).toMatchInlineSnapshot( + `"
teleported
root
"`, + ) + expect(serializeInner(target)).toBe('') }) - domRender(h(App), root) - expect(root.innerHTML).toMatchInlineSnapshot('""') + test('should work with block tree', async () => { + const target = nodeOps.createElement('div') + const root = nodeOps.createElement('div') + const disabled = ref(false) + + const App = { + setup() { + return { + target: markRaw(target), + disabled, + } + }, + render: compile(` + +
teleported
{{ disabled }} +
+
root
+ `), + } + render(h(App), root) + expect(serializeInner(root)).toMatchInlineSnapshot( + `"
root
"`, + ) + expect(serializeInner(target)).toMatchInlineSnapshot( + `"
teleported
false"`, + ) - parentShow.value = true - await nextTick() - expect(root.innerHTML).toMatchInlineSnapshot( - '"
foo
"', - ) + disabled.value = true + await nextTick() + expect(serializeInner(root)).toMatchInlineSnapshot( + `"
teleported
true
root
"`, + ) + expect(serializeInner(target)).toBe(``) - parentShow.value = false - await nextTick() - expect(root.innerHTML).toMatchInlineSnapshot('""') - }) + // toggle back + disabled.value = false + await nextTick() + expect(serializeInner(root)).toMatchInlineSnapshot( + `"
root
"`, + ) + expect(serializeInner(target)).toMatchInlineSnapshot( + `"
teleported
false"`, + ) + }) + + // #3497 + test(`the dir hooks of the Teleport's children should be called correctly`, async () => { + const target = nodeOps.createElement('div') + const root = nodeOps.createElement('div') + const toggle = ref(true) + const dir = { + mounted: vi.fn(), + unmounted: vi.fn(), + } + + const app = createApp({ + setup() { + return () => { + return toggle.value + ? h(Teleport, { to: target }, [ + withDirectives(h('div', ['foo']), [[dir]]), + ]) + : null + } + }, + }) + app.mount(root) + + expect(serializeInner(root)).toMatchInlineSnapshot( + `""`, + ) + expect(serializeInner(target)).toMatchInlineSnapshot(`"
foo
"`) + await nextTick() + expect(dir.mounted).toHaveBeenCalledTimes(1) + expect(dir.unmounted).toHaveBeenCalledTimes(0) + + toggle.value = false + await nextTick() + expect(serializeInner(root)).toMatchInlineSnapshot(`""`) + expect(serializeInner(target)).toMatchInlineSnapshot(`""`) + expect(dir.mounted).toHaveBeenCalledTimes(1) + expect(dir.unmounted).toHaveBeenCalledTimes(1) + }) + + // #7835 + test(`ensure that target changes when disabled are updated correctly when enabled`, async () => { + const root = nodeOps.createElement('div') + const target1 = nodeOps.createElement('div') + const target2 = nodeOps.createElement('div') + const target3 = nodeOps.createElement('div') + const target = ref(target1) + const disabled = ref(true) + + const App = { + setup() { + return () => + h(Fragment, [ + h( + Teleport, + { to: target.value, disabled: disabled.value }, + h('div', 'teleported'), + ), + ]) + }, + } + render(h(App), root) + disabled.value = false + await nextTick() + expect(serializeInner(target1)).toMatchInlineSnapshot( + `"
teleported
"`, + ) + expect(serializeInner(target2)).toMatchInlineSnapshot(`""`) + expect(serializeInner(target3)).toMatchInlineSnapshot(`""`) + + disabled.value = true + await nextTick() + target.value = target2 + await nextTick() + expect(serializeInner(target1)).toMatchInlineSnapshot(`""`) + expect(serializeInner(target2)).toMatchInlineSnapshot(`""`) + expect(serializeInner(target3)).toMatchInlineSnapshot(`""`) + + target.value = target3 + await nextTick() + expect(serializeInner(target1)).toMatchInlineSnapshot(`""`) + expect(serializeInner(target2)).toMatchInlineSnapshot(`""`) + expect(serializeInner(target3)).toMatchInlineSnapshot(`""`) + + disabled.value = false + await nextTick() + expect(serializeInner(target1)).toMatchInlineSnapshot(`""`) + expect(serializeInner(target2)).toMatchInlineSnapshot(`""`) + expect(serializeInner(target3)).toMatchInlineSnapshot( + `"
teleported
"`, + ) + }) + + //#9071 + test('toggle sibling node inside target node', async () => { + const root = document.createElement('div') + const show = ref(false) + const App = defineComponent({ + setup() { + return () => { + return show.value + ? h(Teleport, { to: root }, [h('div', 'teleported')]) + : h('div', 'foo') + } + }, + }) + + domRender(h(App), root) + expect(root.innerHTML).toMatchInlineSnapshot('"
foo
"') + + show.value = true + await nextTick() + + expect(root.innerHTML).toMatchInlineSnapshot( + '"
teleported
"', + ) + + show.value = false + await nextTick() + + expect(root.innerHTML).toMatchInlineSnapshot('"
foo
"') + }) + + test('unmount previous sibling node inside target node', async () => { + const root = document.createElement('div') + const parentShow = ref(false) + const childShow = ref(true) + + const Comp = { + setup() { + return () => h(Teleport, { to: root }, [h('div', 'foo')]) + }, + } + + const App = defineComponent({ + setup() { + return () => { + return parentShow.value + ? h(Fragment, { key: 0 }, [ + childShow.value ? h(Comp) : createCommentVNode('v-if'), + ]) + : createCommentVNode('v-if') + } + }, + }) + + domRender(h(App), root) + expect(root.innerHTML).toMatchInlineSnapshot('""') + + parentShow.value = true + await nextTick() + expect(root.innerHTML).toMatchInlineSnapshot( + '"
foo
"', + ) + + parentShow.value = false + await nextTick() + expect(root.innerHTML).toMatchInlineSnapshot('""') + }) + } }) diff --git a/packages/runtime-core/__tests__/rendererOptimizedMode.spec.ts b/packages/runtime-core/__tests__/rendererOptimizedMode.spec.ts index f4d5eba9a9a..2658f40718f 100644 --- a/packages/runtime-core/__tests__/rendererOptimizedMode.spec.ts +++ b/packages/runtime-core/__tests__/rendererOptimizedMode.spec.ts @@ -618,7 +618,7 @@ describe('renderer: optimized mode', () => { }) //#3623 - test('nested teleport unmount need exit the optimization mode', () => { + test('nested teleport unmount need exit the optimization mode', async () => { const target = nodeOps.createElement('div') const root = nodeOps.createElement('div') @@ -647,6 +647,7 @@ describe('renderer: optimized mode', () => { ])), root, ) + await nextTick() expect(inner(target)).toMatchInlineSnapshot( `"
foo
"`, ) diff --git a/packages/runtime-core/src/components/Teleport.ts b/packages/runtime-core/src/components/Teleport.ts index 65437300cff..997b83cc520 100644 --- a/packages/runtime-core/src/components/Teleport.ts +++ b/packages/runtime-core/src/components/Teleport.ts @@ -7,6 +7,7 @@ import { type RendererInternals, type RendererNode, type RendererOptions, + queuePostRenderEffect, traverseStaticChildren, } from '../renderer' import type { VNode, VNodeArrayChildren, VNodeProps } from '../vnode' @@ -19,6 +20,7 @@ export type TeleportVNode = VNode export interface TeleportProps { to: string | RendererElement | null | undefined disabled?: boolean + defer?: boolean } export const TeleportEndKey = Symbol('_vte') @@ -28,6 +30,9 @@ export const isTeleport = (type: any): boolean => type.__isTeleport const isTeleportDisabled = (props: VNode['props']): boolean => props && (props.disabled || props.disabled === '') +const isTeleportDeferred = (props: VNode['props']): boolean => + props && (props.defer || props.defer === '') + const isTargetSVG = (target: RendererElement): boolean => typeof SVGElement !== 'undefined' && target instanceof SVGElement @@ -107,7 +112,6 @@ export const TeleportImpl = { const mainAnchor = (n2.anchor = __DEV__ ? createComment('teleport end') : createText('')) - const target = (n2.target = resolveTarget(n2.props, querySelector)) const targetStart = (n2.targetStart = createText('')) const targetAnchor = (n2.targetAnchor = createText('')) insert(placeholder, container, anchor) @@ -115,18 +119,6 @@ export const TeleportImpl = { // attach a special property so we can skip teleported content in // renderer's nextSibling search targetStart[TeleportEndKey] = targetAnchor - if (target) { - insert(targetStart, target) - insert(targetAnchor, target) - // #2652 we could be teleporting from a non-SVG tree into an SVG tree - if (namespace === 'svg' || isTargetSVG(target)) { - namespace = 'svg' - } else if (namespace === 'mathml' || isTargetMathML(target)) { - namespace = 'mathml' - } - } else if (__DEV__ && !disabled) { - warn('Invalid Teleport target on mount:', target, `(${typeof target})`) - } const mount = (container: RendererElement, anchor: RendererNode) => { // Teleport *always* has Array children. This is enforced in both the @@ -145,10 +137,39 @@ export const TeleportImpl = { } } + const mountToTarget = () => { + const target = (n2.target = resolveTarget(n2.props, querySelector)) + if (target) { + insert(targetStart, target) + insert(targetAnchor, target) + // #2652 we could be teleporting from a non-SVG tree into an SVG tree + if (namespace !== 'svg' && isTargetSVG(target)) { + namespace = 'svg' + } else if (namespace !== 'mathml' && isTargetMathML(target)) { + namespace = 'mathml' + } + if (!disabled) { + mount(target, targetAnchor) + updateCssVars(n2) + } + } else if (__DEV__ && !disabled) { + warn( + 'Invalid Teleport target on mount:', + target, + `(${typeof target})`, + ) + } + } + if (disabled) { mount(container, mainAnchor) - } else if (target) { - mount(target, targetAnchor) + updateCssVars(n2) + } + + if (isTeleportDeferred(n2.props)) { + queuePostRenderEffect(mountToTarget, parentSuspense) + } else { + mountToTarget() } } else { // update content @@ -249,9 +270,8 @@ export const TeleportImpl = { ) } } + updateCssVars(n2) } - - updateCssVars(n2) }, remove( @@ -441,7 +461,7 @@ function updateCssVars(vnode: VNode) { // code path here can assume browser environment. const ctx = vnode.ctx if (ctx && ctx.ut) { - let node = (vnode.children as VNode[])[0].el! + let node = vnode.targetStart while (node && node !== vnode.targetAnchor) { if (node.nodeType === 1) node.setAttribute('data-v-owner', ctx.uid) node = node.nextSibling diff --git a/packages/runtime-core/src/scheduler.ts b/packages/runtime-core/src/scheduler.ts index cf730195dbc..ac90bc4f55d 100644 --- a/packages/runtime-core/src/scheduler.ts +++ b/packages/runtime-core/src/scheduler.ts @@ -127,7 +127,9 @@ export function invalidateJob(job: SchedulerJob) { export function queuePostFlushCb(cb: SchedulerJobs) { if (!isArray(cb)) { - if (!(cb.flags! & SchedulerJobFlags.QUEUED)) { + if (activePostFlushCbs && cb.id === -1) { + activePostFlushCbs.splice(postFlushIndex + 1, 0, cb) + } else if (!(cb.flags! & SchedulerJobFlags.QUEUED)) { pendingPostFlushCbs.push(cb) if (!(cb.flags! & SchedulerJobFlags.ALLOW_RECURSE)) { cb.flags! |= SchedulerJobFlags.QUEUED