feat(teleport): support deferred Teleport (#11387)

close #2015
close #11386
This commit is contained in:
Evan You 2024-07-18 21:06:48 +08:00 committed by GitHub
parent 3ba70e49b5
commit 59a3e88903
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 701 additions and 592 deletions

View File

@ -7,19 +7,98 @@ 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', () => {
describe('eager mode', () => {
runSharedTests(false)
})
describe('defer mode', () => {
runSharedTests(true)
const h = originalH
test('should be able to target content appearing later than the teleport with defer', () => {
const root = document.createElement('div')
document.body.appendChild(root)
createDOMApp({
render() {
return [
h(Teleport, { to: '#target', defer: true }, h('div', 'teleported')),
h('div', { id: 'target' }),
]
},
}).mount(root)
expect(root.innerHTML).toMatchInlineSnapshot(
`"<!--teleport start--><!--teleport end--><div id="target"><div>teleported</div></div>"`,
)
})
test('defer mode should work inside suspense', async () => {
const root = document.createElement('div')
document.body.appendChild(root)
let p: Promise<any>
const Comp = defineComponent({
template: `
<suspense>
<div>
<async />
<teleport defer to="#target-suspense">
<div ref="tel">teleported</div>
</teleport>
<div id="target-suspense" />
</div>
</suspense>`,
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(
`<div>` +
`async` +
`<!--teleport start--><!--teleport end-->` +
`<div id="target-suspense"><div>teleported</div></div>` +
`</div>`,
)
})
})
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 work', () => {
const target = nodeOps.createElement('div')
const root = nodeOps.createElement('div')
@ -135,7 +214,10 @@ describe('renderer: teleport', () => {
function testUnmount(props: any) {
render(
h(() => [h(Teleport, props, h('div', 'teleported')), h('div', 'root')]),
h(() => [
h(Teleport, props, h('div', 'teleported')),
h('div', 'root'),
]),
root,
)
expect(serializeInner(target)).toMatchInlineSnapshot(
@ -212,7 +294,9 @@ describe('renderer: teleport', () => {
expect(serializeInner(root)).toMatchInlineSnapshot(
`"<div><!--teleport start--><!--teleport end--><!--teleport start--><!--teleport end--></div>"`,
)
expect(serializeInner(target)).toMatchInlineSnapshot(`"<div>one</div>two"`)
expect(serializeInner(target)).toMatchInlineSnapshot(
`"<div>one</div>two"`,
)
// update existing content
render(
@ -490,6 +574,7 @@ describe('renderer: teleport', () => {
`"<!--teleport start--><!--teleport end-->"`,
)
expect(serializeInner(target)).toMatchInlineSnapshot(`"<div>foo</div>"`)
await nextTick()
expect(dir.mounted).toHaveBeenCalledTimes(1)
expect(dir.unmounted).toHaveBeenCalledTimes(0)
@ -620,4 +705,5 @@ describe('renderer: teleport', () => {
await nextTick()
expect(root.innerHTML).toMatchInlineSnapshot('"<!--v-if-->"')
})
}
})

View File

@ -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(
`"<div><!--teleport start--><!--teleport end--></div><div>foo</div>"`,
)

View File

@ -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<RendererNode, RendererElement, TeleportProps>
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)
}
},
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

View File

@ -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