feat: update Suspense usage (#2099)

See https://github.com/vuejs/vue-next/pull/2099 for details.
This commit is contained in:
Evan You 2020-09-15 12:45:06 -04:00 committed by GitHub
parent 37e686f25e
commit 5ae7380b4a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
17 changed files with 815 additions and 247 deletions

View File

@ -406,7 +406,7 @@ describe('api: defineAsyncComponent', () => {
const app = createApp({ const app = createApp({
render: () => render: () =>
h(Suspense, null, { h(Suspense, null, {
default: () => [h(Foo), ' & ', h(Foo)], default: () => h('div', [h(Foo), ' & ', h(Foo)]),
fallback: () => 'loading' fallback: () => 'loading'
}) })
}) })
@ -416,7 +416,7 @@ describe('api: defineAsyncComponent', () => {
resolve!(() => 'resolved') resolve!(() => 'resolved')
await timeout() await timeout()
expect(serializeInner(root)).toBe('resolved & resolved') expect(serializeInner(root)).toBe('<div>resolved & resolved</div>')
}) })
test('suspensible: false', async () => { test('suspensible: false', async () => {
@ -433,18 +433,18 @@ describe('api: defineAsyncComponent', () => {
const app = createApp({ const app = createApp({
render: () => render: () =>
h(Suspense, null, { h(Suspense, null, {
default: () => [h(Foo), ' & ', h(Foo)], default: () => h('div', [h(Foo), ' & ', h(Foo)]),
fallback: () => 'loading' fallback: () => 'loading'
}) })
}) })
app.mount(root) app.mount(root)
// should not show suspense fallback // should not show suspense fallback
expect(serializeInner(root)).toBe('<!----> & <!---->') expect(serializeInner(root)).toBe('<div><!----> & <!----></div>')
resolve!(() => 'resolved') resolve!(() => 'resolved')
await timeout() await timeout()
expect(serializeInner(root)).toBe('resolved & resolved') expect(serializeInner(root)).toBe('<div>resolved & resolved</div>')
}) })
test('suspense with error handling', async () => { test('suspense with error handling', async () => {
@ -460,7 +460,7 @@ describe('api: defineAsyncComponent', () => {
const app = createApp({ const app = createApp({
render: () => render: () =>
h(Suspense, null, { h(Suspense, null, {
default: () => [h(Foo), ' & ', h(Foo)], default: () => h('div', [h(Foo), ' & ', h(Foo)]),
fallback: () => 'loading' fallback: () => 'loading'
}) })
}) })
@ -472,7 +472,7 @@ describe('api: defineAsyncComponent', () => {
reject!(new Error('no')) reject!(new Error('no'))
await timeout() await timeout()
expect(handler).toHaveBeenCalled() expect(handler).toHaveBeenCalled()
expect(serializeInner(root)).toBe('<!----> & <!---->') expect(serializeInner(root)).toBe('<div><!----> & <!----></div>')
}) })
test('retry (success)', async () => { test('retry (success)', async () => {

View File

@ -11,7 +11,8 @@ import {
watch, watch,
watchEffect, watchEffect,
onUnmounted, onUnmounted,
onErrorCaptured onErrorCaptured,
shallowRef
} from '@vue/runtime-test' } from '@vue/runtime-test'
describe('Suspense', () => { describe('Suspense', () => {
@ -490,7 +491,7 @@ describe('Suspense', () => {
setup() { setup() {
return () => return () =>
h(Suspense, null, { h(Suspense, null, {
default: [h(AsyncOuter), h(Inner)], default: h('div', [h(AsyncOuter), h(Inner)]),
fallback: h('div', 'fallback outer') fallback: h('div', 'fallback outer')
}) })
} }
@ -503,14 +504,14 @@ describe('Suspense', () => {
await deps[0] await deps[0]
await nextTick() await nextTick()
expect(serializeInner(root)).toBe( expect(serializeInner(root)).toBe(
`<div>async outer</div><div>fallback inner</div>` `<div><div>async outer</div><div>fallback inner</div></div>`
) )
expect(calls).toEqual([`outer mounted`]) expect(calls).toEqual([`outer mounted`])
await Promise.all(deps) await Promise.all(deps)
await nextTick() await nextTick()
expect(serializeInner(root)).toBe( expect(serializeInner(root)).toBe(
`<div>async outer</div><div>async inner</div>` `<div><div>async outer</div><div>async inner</div></div>`
) )
expect(calls).toEqual([`outer mounted`, `inner mounted`]) expect(calls).toEqual([`outer mounted`, `inner mounted`])
}) })
@ -556,7 +557,7 @@ describe('Suspense', () => {
setup() { setup() {
return () => return () =>
h(Suspense, null, { h(Suspense, null, {
default: [h(AsyncOuter), h(Inner)], default: h('div', [h(AsyncOuter), h(Inner)]),
fallback: h('div', 'fallback outer') fallback: h('div', 'fallback outer')
}) })
} }
@ -574,7 +575,7 @@ describe('Suspense', () => {
await Promise.all(deps) await Promise.all(deps)
await nextTick() await nextTick()
expect(serializeInner(root)).toBe( expect(serializeInner(root)).toBe(
`<div>async outer</div><div>async inner</div>` `<div><div>async outer</div><div>async inner</div></div>`
) )
expect(calls).toEqual([`inner mounted`, `outer mounted`]) expect(calls).toEqual([`inner mounted`, `outer mounted`])
}) })
@ -683,12 +684,12 @@ describe('Suspense', () => {
setup() { setup() {
return () => return () =>
h(Suspense, null, { h(Suspense, null, {
default: [ default: h('div', [
h(MiddleComponent), h(MiddleComponent),
h(AsyncChildParent, { h(AsyncChildParent, {
msg: 'root async' msg: 'root async'
}) })
], ]),
fallback: h('div', 'root fallback') fallback: h('div', 'root fallback')
}) })
} }
@ -722,7 +723,7 @@ describe('Suspense', () => {
await deps[3] await deps[3]
await nextTick() await nextTick()
expect(serializeInner(root)).toBe( expect(serializeInner(root)).toBe(
`<div>nested fallback</div><div>root async</div>` `<div><div>nested fallback</div><div>root async</div></div>`
) )
expect(calls).toEqual([0, 1, 3]) expect(calls).toEqual([0, 1, 3])
@ -733,7 +734,7 @@ describe('Suspense', () => {
await Promise.all(deps) await Promise.all(deps)
await nextTick() await nextTick()
expect(serializeInner(root)).toBe( expect(serializeInner(root)).toBe(
`<div>nested changed</div><div>root async</div>` `<div><div>nested changed</div><div>root async</div></div>`
) )
expect(calls).toEqual([0, 1, 3, 2]) expect(calls).toEqual([0, 1, 3, 2])
@ -741,51 +742,316 @@ describe('Suspense', () => {
msg.value = 'nested changed again' msg.value = 'nested changed again'
await nextTick() await nextTick()
expect(serializeInner(root)).toBe( expect(serializeInner(root)).toBe(
`<div>nested changed again</div><div>root async</div>` `<div><div>nested changed again</div><div>root async</div></div>`
) )
}) })
test('new async dep after resolve should cause suspense to restart', async () => { test('switching branches', async () => {
const toggle = ref(false) const calls: string[] = []
const toggle = ref(true)
const ChildA = defineAsyncComponent({ const Foo = defineAsyncComponent({
setup() { setup() {
return () => h('div', 'Child A') onMounted(() => {
calls.push('foo mounted')
})
onUnmounted(() => {
calls.push('foo unmounted')
})
return () => h('div', ['foo', h(FooNested)])
} }
}) })
const ChildB = defineAsyncComponent({ const FooNested = defineAsyncComponent(
setup() { {
return () => h('div', 'Child B') setup() {
} onMounted(() => {
}) calls.push('foo nested mounted')
})
onUnmounted(() => {
calls.push('foo nested unmounted')
})
return () => h('div', 'foo nested')
}
},
10
)
const Bar = defineAsyncComponent(
{
setup() {
onMounted(() => {
calls.push('bar mounted')
})
onUnmounted(() => {
calls.push('bar unmounted')
})
return () => h('div', 'bar')
}
},
10
)
const Comp = { const Comp = {
setup() { setup() {
return () => return () =>
h(Suspense, null, { h(Suspense, null, {
default: [h(ChildA), toggle.value ? h(ChildB) : null], default: toggle.value ? h(Foo) : h(Bar),
fallback: h('div', 'root fallback') fallback: h('div', 'fallback')
}) })
} }
} }
const root = nodeOps.createElement('div') const root = nodeOps.createElement('div')
render(h(Comp), root) render(h(Comp), root)
expect(serializeInner(root)).toBe(`<div>root fallback</div>`) expect(serializeInner(root)).toBe(`<div>fallback</div>`)
expect(calls).toEqual([])
await deps[0] await deps[0]
await nextTick() await nextTick()
expect(serializeInner(root)).toBe(`<div>Child A</div><!---->`) expect(serializeInner(root)).toBe(`<div>fallback</div>`)
expect(calls).toEqual([])
await Promise.all(deps)
await nextTick()
expect(calls).toEqual([`foo mounted`, `foo nested mounted`])
expect(serializeInner(root)).toBe(`<div>foo<div>foo nested</div></div>`)
// toggle
toggle.value = false
await nextTick()
expect(deps.length).toBe(3)
// should remain on current view
expect(calls).toEqual([`foo mounted`, `foo nested mounted`])
expect(serializeInner(root)).toBe(`<div>foo<div>foo nested</div></div>`)
await Promise.all(deps)
await nextTick()
const tempCalls = [
`foo mounted`,
`foo nested mounted`,
`bar mounted`,
`foo nested unmounted`,
`foo unmounted`
]
expect(calls).toEqual(tempCalls)
expect(serializeInner(root)).toBe(`<div>bar</div>`)
// toggle back
toggle.value = true toggle.value = true
await nextTick() await nextTick()
expect(serializeInner(root)).toBe(`<div>root fallback</div>`) // should remain
expect(calls).toEqual(tempCalls)
expect(serializeInner(root)).toBe(`<div>bar</div>`)
await deps[3]
await nextTick()
// still pending...
expect(calls).toEqual(tempCalls)
expect(serializeInner(root)).toBe(`<div>bar</div>`)
await Promise.all(deps)
await nextTick()
expect(calls).toEqual([
...tempCalls,
`foo mounted`,
`foo nested mounted`,
`bar unmounted`
])
expect(serializeInner(root)).toBe(`<div>foo<div>foo nested</div></div>`)
})
test('branch switch to 3rd branch before resolve', async () => {
const calls: string[] = []
const makeComp = (name: string, delay = 0) =>
defineAsyncComponent(
{
setup() {
onMounted(() => {
calls.push(`${name} mounted`)
})
onUnmounted(() => {
calls.push(`${name} unmounted`)
})
return () => h('div', [name])
}
},
delay
)
const One = makeComp('one')
const Two = makeComp('two', 10)
const Three = makeComp('three', 20)
const view = shallowRef(One)
const Comp = {
setup() {
return () =>
h(Suspense, null, {
default: h(view.value),
fallback: h('div', 'fallback')
})
}
}
const root = nodeOps.createElement('div')
render(h(Comp), root)
expect(serializeInner(root)).toBe(`<div>fallback</div>`)
expect(calls).toEqual([])
await deps[0]
await nextTick()
expect(serializeInner(root)).toBe(`<div>one</div>`)
expect(calls).toEqual([`one mounted`])
view.value = Two
await nextTick()
expect(deps.length).toBe(2)
// switch before two resovles
view.value = Three
await nextTick()
expect(deps.length).toBe(3)
// dep for two resolves
await deps[1]
await nextTick()
// should still be on view one
expect(serializeInner(root)).toBe(`<div>one</div>`)
expect(calls).toEqual([`one mounted`])
await deps[2]
await nextTick()
expect(serializeInner(root)).toBe(`<div>three</div>`)
expect(calls).toEqual([`one mounted`, `three mounted`, `one unmounted`])
})
test('branch switch back before resolve', async () => {
const calls: string[] = []
const makeComp = (name: string, delay = 0) =>
defineAsyncComponent(
{
setup() {
onMounted(() => {
calls.push(`${name} mounted`)
})
onUnmounted(() => {
calls.push(`${name} unmounted`)
})
return () => h('div', [name])
}
},
delay
)
const One = makeComp('one')
const Two = makeComp('two', 10)
const view = shallowRef(One)
const Comp = {
setup() {
return () =>
h(Suspense, null, {
default: h(view.value),
fallback: h('div', 'fallback')
})
}
}
const root = nodeOps.createElement('div')
render(h(Comp), root)
expect(serializeInner(root)).toBe(`<div>fallback</div>`)
expect(calls).toEqual([])
await deps[0]
await nextTick()
expect(serializeInner(root)).toBe(`<div>one</div>`)
expect(calls).toEqual([`one mounted`])
view.value = Two
await nextTick()
expect(deps.length).toBe(2)
// switch back before two resovles
view.value = One
await nextTick()
expect(deps.length).toBe(2)
// dep for two resolves
await deps[1]
await nextTick()
// should still be on view one
expect(serializeInner(root)).toBe(`<div>one</div>`)
expect(calls).toEqual([`one mounted`])
})
test('branch switch timeout + fallback', async () => {
const calls: string[] = []
const makeComp = (name: string, delay = 0) =>
defineAsyncComponent(
{
setup() {
onMounted(() => {
calls.push(`${name} mounted`)
})
onUnmounted(() => {
calls.push(`${name} unmounted`)
})
return () => h('div', [name])
}
},
delay
)
const One = makeComp('one')
const Two = makeComp('two', 20)
const view = shallowRef(One)
const Comp = {
setup() {
return () =>
h(
Suspense,
{
timeout: 10
},
{
default: h(view.value),
fallback: h('div', 'fallback')
}
)
}
}
const root = nodeOps.createElement('div')
render(h(Comp), root)
expect(serializeInner(root)).toBe(`<div>fallback</div>`)
expect(calls).toEqual([])
await deps[0]
await nextTick()
expect(serializeInner(root)).toBe(`<div>one</div>`)
expect(calls).toEqual([`one mounted`])
view.value = Two
await nextTick()
expect(serializeInner(root)).toBe(`<div>one</div>`)
expect(calls).toEqual([`one mounted`])
await new Promise(r => setTimeout(r, 10))
await nextTick()
expect(serializeInner(root)).toBe(`<div>fallback</div>`)
expect(calls).toEqual([`one mounted`, `one unmounted`])
await deps[1] await deps[1]
await nextTick() await nextTick()
expect(serializeInner(root)).toBe(`<div>Child A</div><div>Child B</div>`) expect(serializeInner(root)).toBe(`<div>two</div>`)
expect(calls).toEqual([`one mounted`, `one unmounted`, `two mounted`])
}) })
test.todo('teleport inside suspense')
}) })

View File

@ -506,8 +506,10 @@ describe('SSR hydration', () => {
const App = { const App = {
template: ` template: `
<Suspense @resolve="done"> <Suspense @resolve="done">
<AsyncChild :n="1" /> <div>
<AsyncChild :n="2" /> <AsyncChild :n="1" />
<AsyncChild :n="2" />
</div>
</Suspense>`, </Suspense>`,
components: { components: {
AsyncChild AsyncChild
@ -521,7 +523,7 @@ describe('SSR hydration', () => {
// server render // server render
container.innerHTML = await renderToString(h(App)) container.innerHTML = await renderToString(h(App))
expect(container.innerHTML).toMatchInlineSnapshot( expect(container.innerHTML).toMatchInlineSnapshot(
`"<!--[--><span>1</span><span>2</span><!--]-->"` `"<div><span>1</span><span>2</span></div>"`
) )
// reset asyncDeps from ssr // reset asyncDeps from ssr
asyncDeps.length = 0 asyncDeps.length = 0
@ -537,17 +539,23 @@ describe('SSR hydration', () => {
// should flush buffered effects // should flush buffered effects
expect(mountedCalls).toMatchObject([1, 2]) expect(mountedCalls).toMatchObject([1, 2])
expect(container.innerHTML).toMatch(`<span>1</span><span>2</span>`) expect(container.innerHTML).toMatch(
`<div><span>1</span><span>2</span></div>`
)
const span1 = container.querySelector('span')! const span1 = container.querySelector('span')!
triggerEvent('click', span1) triggerEvent('click', span1)
await nextTick() await nextTick()
expect(container.innerHTML).toMatch(`<span>2</span><span>2</span>`) expect(container.innerHTML).toMatch(
`<div><span>2</span><span>2</span></div>`
)
const span2 = span1.nextSibling as Element const span2 = span1.nextSibling as Element
triggerEvent('click', span2) triggerEvent('click', span2)
await nextTick() await nextTick()
expect(container.innerHTML).toMatch(`<span>2</span><span>3</span>`) expect(container.innerHTML).toMatch(
`<div><span>2</span><span>3</span></div>`
)
}) })
test('async component', async () => { test('async component', async () => {

View File

@ -317,6 +317,11 @@ export interface ComponentInternalInstance {
* @internal * @internal
*/ */
suspense: SuspenseBoundary | null suspense: SuspenseBoundary | null
/**
* suspense pending batch id
* @internal
*/
suspenseId: number
/** /**
* @internal * @internal
*/ */
@ -440,6 +445,7 @@ export function createComponentInstance(
// suspense related // suspense related
suspense, suspense,
suspenseId: suspense ? suspense.pendingId : 0,
asyncDep: null, asyncDep: null,
asyncResolved: false, asyncResolved: false,

View File

@ -52,10 +52,13 @@ export interface BaseTransitionProps<HostElement = RendererElement> {
export interface TransitionHooks< export interface TransitionHooks<
HostElement extends RendererElement = RendererElement HostElement extends RendererElement = RendererElement
> { > {
mode: BaseTransitionProps['mode']
persisted: boolean persisted: boolean
beforeEnter(el: HostElement): void beforeEnter(el: HostElement): void
enter(el: HostElement): void enter(el: HostElement): void
leave(el: HostElement, remove: () => void): void leave(el: HostElement, remove: () => void): void
clone(vnode: VNode): TransitionHooks<HostElement>
// optional
afterLeave?(): void afterLeave?(): void
delayLeave?( delayLeave?(
el: HostElement, el: HostElement,
@ -174,12 +177,13 @@ const BaseTransitionImpl = {
return emptyPlaceholder(child) return emptyPlaceholder(child)
} }
const enterHooks = (innerChild.transition = resolveTransitionHooks( const enterHooks = resolveTransitionHooks(
innerChild, innerChild,
rawProps, rawProps,
state, state,
instance instance
)) )
setTransitionHooks(innerChild, enterHooks)
const oldChild = instance.subTree const oldChild = instance.subTree
const oldInnerChild = oldChild && getKeepAliveChild(oldChild) const oldInnerChild = oldChild && getKeepAliveChild(oldChild)
@ -271,8 +275,13 @@ function getLeavingNodesForType(
// and will be called at appropriate timing in the renderer. // and will be called at appropriate timing in the renderer.
export function resolveTransitionHooks( export function resolveTransitionHooks(
vnode: VNode, vnode: VNode,
{ props: BaseTransitionProps<any>,
state: TransitionState,
instance: ComponentInternalInstance
): TransitionHooks {
const {
appear, appear,
mode,
persisted = false, persisted = false,
onBeforeEnter, onBeforeEnter,
onEnter, onEnter,
@ -286,10 +295,7 @@ export function resolveTransitionHooks(
onAppear, onAppear,
onAfterAppear, onAfterAppear,
onAppearCancelled onAppearCancelled
}: BaseTransitionProps<any>, } = props
state: TransitionState,
instance: ComponentInternalInstance
): TransitionHooks {
const key = String(vnode.key) const key = String(vnode.key)
const leavingVNodesCache = getLeavingNodesForType(state, vnode) const leavingVNodesCache = getLeavingNodesForType(state, vnode)
@ -304,6 +310,7 @@ export function resolveTransitionHooks(
} }
const hooks: TransitionHooks<TransitionElement> = { const hooks: TransitionHooks<TransitionElement> = {
mode,
persisted, persisted,
beforeEnter(el) { beforeEnter(el) {
let hook = onBeforeEnter let hook = onBeforeEnter
@ -401,6 +408,10 @@ export function resolveTransitionHooks(
} else { } else {
done() done()
} }
},
clone(vnode) {
return resolveTransitionHooks(vnode, props, state, instance)
} }
} }
@ -430,6 +441,9 @@ function getKeepAliveChild(vnode: VNode): VNode | undefined {
export function setTransitionHooks(vnode: VNode, hooks: TransitionHooks) { export function setTransitionHooks(vnode: VNode, hooks: TransitionHooks) {
if (vnode.shapeFlag & ShapeFlags.COMPONENT && vnode.component) { if (vnode.shapeFlag & ShapeFlags.COMPONENT && vnode.component) {
setTransitionHooks(vnode.component.subTree, hooks) setTransitionHooks(vnode.component.subTree, hooks)
} else if (__FEATURE_SUSPENSE__ && vnode.shapeFlag & ShapeFlags.SUSPENSE) {
vnode.ssContent!.transition = hooks.clone(vnode.ssContent!)
vnode.ssFallback!.transition = hooks.clone(vnode.ssFallback!)
} else { } else {
vnode.transition = hooks vnode.transition = hooks
} }

View File

@ -184,7 +184,7 @@ const KeepAliveImpl = {
const cacheSubtree = () => { const cacheSubtree = () => {
// fix #1621, the pendingCacheKey could be 0 // fix #1621, the pendingCacheKey could be 0
if (pendingCacheKey != null) { if (pendingCacheKey != null) {
cache.set(pendingCacheKey, instance.subTree) cache.set(pendingCacheKey, getInnerChild(instance.subTree))
} }
} }
onMounted(cacheSubtree) onMounted(cacheSubtree)
@ -193,11 +193,12 @@ const KeepAliveImpl = {
onBeforeUnmount(() => { onBeforeUnmount(() => {
cache.forEach(cached => { cache.forEach(cached => {
const { subTree, suspense } = instance const { subTree, suspense } = instance
if (cached.type === subTree.type) { const vnode = getInnerChild(subTree)
if (cached.type === vnode.type) {
// current instance will be unmounted as part of keep-alive's unmount // current instance will be unmounted as part of keep-alive's unmount
resetShapeFlag(subTree) resetShapeFlag(vnode)
// but invoke its deactivated hook here // but invoke its deactivated hook here
const da = subTree.component!.da const da = vnode.component!.da
da && queuePostRenderEffect(da, suspense) da && queuePostRenderEffect(da, suspense)
return return
} }
@ -213,7 +214,7 @@ const KeepAliveImpl = {
} }
const children = slots.default() const children = slots.default()
let vnode = children[0] const rawVNode = children[0]
if (children.length > 1) { if (children.length > 1) {
if (__DEV__) { if (__DEV__) {
warn(`KeepAlive should contain exactly one component child.`) warn(`KeepAlive should contain exactly one component child.`)
@ -221,13 +222,15 @@ const KeepAliveImpl = {
current = null current = null
return children return children
} else if ( } else if (
!isVNode(vnode) || !isVNode(rawVNode) ||
!(vnode.shapeFlag & ShapeFlags.STATEFUL_COMPONENT) (!(rawVNode.shapeFlag & ShapeFlags.STATEFUL_COMPONENT) &&
!(rawVNode.shapeFlag & ShapeFlags.SUSPENSE))
) { ) {
current = null current = null
return vnode return rawVNode
} }
let vnode = getInnerChild(rawVNode)
const comp = vnode.type as ConcreteComponent const comp = vnode.type as ConcreteComponent
const name = getName(comp) const name = getName(comp)
const { include, exclude, max } = props const { include, exclude, max } = props
@ -236,7 +239,8 @@ const KeepAliveImpl = {
(include && (!name || !matches(include, name))) || (include && (!name || !matches(include, name))) ||
(exclude && name && matches(exclude, name)) (exclude && name && matches(exclude, name))
) { ) {
return (current = vnode) current = vnode
return rawVNode
} }
const key = vnode.key == null ? comp : vnode.key const key = vnode.key == null ? comp : vnode.key
@ -245,6 +249,9 @@ const KeepAliveImpl = {
// clone vnode if it's reused because we are going to mutate it // clone vnode if it's reused because we are going to mutate it
if (vnode.el) { if (vnode.el) {
vnode = cloneVNode(vnode) vnode = cloneVNode(vnode)
if (rawVNode.shapeFlag & ShapeFlags.SUSPENSE) {
rawVNode.ssContent = vnode
}
} }
// #1513 it's possible for the returned vnode to be cloned due to attr // #1513 it's possible for the returned vnode to be cloned due to attr
// fallthrough or scopeId, so the vnode here may not be the final vnode // fallthrough or scopeId, so the vnode here may not be the final vnode
@ -277,7 +284,7 @@ const KeepAliveImpl = {
vnode.shapeFlag |= ShapeFlags.COMPONENT_SHOULD_KEEP_ALIVE vnode.shapeFlag |= ShapeFlags.COMPONENT_SHOULD_KEEP_ALIVE
current = vnode current = vnode
return vnode return rawVNode
} }
} }
} }
@ -383,3 +390,7 @@ function resetShapeFlag(vnode: VNode) {
} }
vnode.shapeFlag = shapeFlag vnode.shapeFlag = shapeFlag
} }
function getInnerChild(vnode: VNode) {
return vnode.shapeFlag & ShapeFlags.SUSPENSE ? vnode.ssContent! : vnode
}

View File

@ -1,5 +1,11 @@
import { VNode, normalizeVNode, VNodeChild, VNodeProps } from '../vnode' import {
import { isFunction, isArray, ShapeFlags } from '@vue/shared' VNode,
normalizeVNode,
VNodeChild,
VNodeProps,
isSameVNodeType
} from '../vnode'
import { isFunction, isArray, ShapeFlags, toNumber } from '@vue/shared'
import { ComponentInternalInstance, handleSetupResult } from '../component' import { ComponentInternalInstance, handleSetupResult } from '../component'
import { Slots } from '../componentSlots' import { Slots } from '../componentSlots'
import { import {
@ -9,14 +15,16 @@ import {
RendererNode, RendererNode,
RendererElement RendererElement
} from '../renderer' } from '../renderer'
import { queuePostFlushCb, queueJob } from '../scheduler' import { queuePostFlushCb } from '../scheduler'
import { updateHOCHostEl } from '../componentRenderUtils' import { filterSingleRoot, updateHOCHostEl } from '../componentRenderUtils'
import { pushWarningContext, popWarningContext } from '../warning' import { pushWarningContext, popWarningContext, warn } from '../warning'
import { handleError, ErrorCodes } from '../errorHandling' import { handleError, ErrorCodes } from '../errorHandling'
export interface SuspenseProps { export interface SuspenseProps {
onResolve?: () => void onResolve?: () => void
onRecede?: () => void onPending?: () => void
onFallback?: () => void
timeout?: string | number
} }
export const isSuspense = (type: any): boolean => type.__isSuspense export const isSuspense = (type: any): boolean => type.__isSuspense
@ -66,7 +74,8 @@ export const SuspenseImpl = {
) )
} }
}, },
hydrate: hydrateSuspense hydrate: hydrateSuspense,
create: createSuspenseBoundary
} }
// Force-casted public typing for h and TSX props inference // Force-casted public typing for h and TSX props inference
@ -78,7 +87,7 @@ export const Suspense = ((__FEATURE_SUSPENSE__
} }
function mountSuspense( function mountSuspense(
n2: VNode, vnode: VNode,
container: RendererElement, container: RendererElement,
anchor: RendererNode | null, anchor: RendererNode | null,
parentComponent: ComponentInternalInstance | null, parentComponent: ComponentInternalInstance | null,
@ -92,8 +101,8 @@ function mountSuspense(
o: { createElement } o: { createElement }
} = rendererInternals } = rendererInternals
const hiddenContainer = createElement('div') const hiddenContainer = createElement('div')
const suspense = (n2.suspense = createSuspenseBoundary( const suspense = (vnode.suspense = createSuspenseBoundary(
n2, vnode,
parentSuspense, parentSuspense,
parentComponent, parentComponent,
container, container,
@ -107,7 +116,7 @@ function mountSuspense(
// start mounting the content subtree in an off-dom container // start mounting the content subtree in an off-dom container
patch( patch(
null, null,
suspense.subTree, (suspense.pendingBranch = vnode.ssContent!),
hiddenContainer, hiddenContainer,
null, null,
parentComponent, parentComponent,
@ -117,10 +126,11 @@ function mountSuspense(
) )
// now check if we have encountered any async deps // now check if we have encountered any async deps
if (suspense.deps > 0) { if (suspense.deps > 0) {
// has async
// mount the fallback tree // mount the fallback tree
patch( patch(
null, null,
suspense.fallbackTree, vnode.ssFallback!,
container, container,
anchor, anchor,
parentComponent, parentComponent,
@ -128,7 +138,7 @@ function mountSuspense(
isSVG, isSVG,
optimized optimized
) )
n2.el = suspense.fallbackTree.el setActiveBranch(suspense, vnode.ssFallback!)
} else { } else {
// Suspense has no async deps. Just resolve. // Suspense has no async deps. Just resolve.
suspense.resolve() suspense.resolve()
@ -143,57 +153,172 @@ function patchSuspense(
parentComponent: ComponentInternalInstance | null, parentComponent: ComponentInternalInstance | null,
isSVG: boolean, isSVG: boolean,
optimized: boolean, optimized: boolean,
{ p: patch }: RendererInternals { p: patch, um: unmount, o: { createElement } }: RendererInternals
) { ) {
const suspense = (n2.suspense = n1.suspense)! const suspense = (n2.suspense = n1.suspense)!
suspense.vnode = n2 suspense.vnode = n2
const { content, fallback } = normalizeSuspenseChildren(n2) n2.el = n1.el
const oldSubTree = suspense.subTree const newBranch = n2.ssContent!
const oldFallbackTree = suspense.fallbackTree const newFallback = n2.ssFallback!
if (!suspense.isResolved) {
patch( const { activeBranch, pendingBranch, isInFallback, isHydrating } = suspense
oldSubTree, if (pendingBranch) {
content, suspense.pendingBranch = newBranch
suspense.hiddenContainer, if (isSameVNodeType(newBranch, pendingBranch)) {
null, // same root type but content may have changed.
parentComponent,
suspense,
isSVG,
optimized
)
if (suspense.deps > 0) {
// still pending. patch the fallback tree.
patch( patch(
oldFallbackTree, pendingBranch,
fallback, newBranch,
container, suspense.hiddenContainer,
anchor, null,
parentComponent, parentComponent,
null, // fallback tree will not have suspense context suspense,
isSVG, isSVG,
optimized optimized
) )
n2.el = fallback.el if (suspense.deps <= 0) {
suspense.resolve()
} else if (isInFallback) {
patch(
activeBranch,
newFallback,
container,
anchor,
parentComponent,
null, // fallback tree will not have suspense context
isSVG,
optimized
)
setActiveBranch(suspense, newFallback)
}
} else {
// toggled before pending tree is resolved
suspense.pendingId++
if (isHydrating) {
// if toggled before hydration is finished, the current DOM tree is
// no longer valid. set it as the active branch so it will be unmounted
// when resolved
suspense.isHydrating = false
suspense.activeBranch = pendingBranch
} else {
unmount(pendingBranch, parentComponent, null)
}
// increment pending ID. this is used to invalidate async callbacks
// reset suspense state
suspense.deps = 0
suspense.effects.length = 0
// discard previous container
suspense.hiddenContainer = createElement('div')
if (isInFallback) {
// already in fallback state
patch(
null,
newBranch,
suspense.hiddenContainer,
null,
parentComponent,
suspense,
isSVG,
optimized
)
if (suspense.deps <= 0) {
suspense.resolve()
} else {
patch(
activeBranch,
newFallback,
container,
anchor,
parentComponent,
null, // fallback tree will not have suspense context
isSVG,
optimized
)
setActiveBranch(suspense, newFallback)
}
} else if (activeBranch && isSameVNodeType(newBranch, activeBranch)) {
// toggled "back" to current active branch
patch(
activeBranch,
newBranch,
container,
anchor,
parentComponent,
suspense,
isSVG,
optimized
)
// force resolve
suspense.resolve(true)
} else {
// switched to a 3rd branch
patch(
null,
newBranch,
suspense.hiddenContainer,
null,
parentComponent,
suspense,
isSVG,
optimized
)
if (suspense.deps <= 0) {
suspense.resolve()
}
}
} }
// If deps somehow becomes 0 after the patch it means the patch caused an
// async dep component to unmount and removed its dep. It will cause the
// suspense to resolve and we don't need to do anything here.
} else { } else {
// just normal patch inner content as a fragment if (activeBranch && isSameVNodeType(newBranch, activeBranch)) {
patch( // root did not change, just normal patch
oldSubTree, patch(
content, activeBranch,
container, newBranch,
anchor, container,
parentComponent, anchor,
suspense, parentComponent,
isSVG, suspense,
optimized isSVG,
) optimized
n2.el = content.el )
setActiveBranch(suspense, newBranch)
} else {
// root node toggled
// invoke @pending event
const onPending = n2.props && n2.props.onPending
if (isFunction(onPending)) {
onPending()
}
// mount pending branch in off-dom container
suspense.pendingBranch = newBranch
suspense.pendingId++
patch(
null,
newBranch,
suspense.hiddenContainer,
null,
parentComponent,
suspense,
isSVG,
optimized
)
if (suspense.deps <= 0) {
// incoming branch has no async deps, resolve now.
suspense.resolve()
} else {
const { timeout, pendingId } = suspense
if (timeout > 0) {
setTimeout(() => {
if (suspense.pendingId === pendingId) {
suspense.fallback(newFallback)
}
}, timeout)
} else if (timeout === 0) {
suspense.fallback(newFallback)
}
}
}
} }
suspense.subTree = content
suspense.fallbackTree = fallback
} }
export interface SuspenseBoundary { export interface SuspenseBoundary {
@ -205,15 +330,17 @@ export interface SuspenseBoundary {
container: RendererElement container: RendererElement
hiddenContainer: RendererElement hiddenContainer: RendererElement
anchor: RendererNode | null anchor: RendererNode | null
subTree: VNode activeBranch: VNode | null
fallbackTree: VNode pendingBranch: VNode | null
deps: number deps: number
pendingId: number
timeout: number
isInFallback: boolean
isHydrating: boolean isHydrating: boolean
isResolved: boolean
isUnmounted: boolean isUnmounted: boolean
effects: Function[] effects: Function[]
resolve(): void resolve(force?: boolean): void
recede(): void fallback(fallbackVNode: VNode): void
move( move(
container: RendererElement, container: RendererElement,
anchor: RendererNode | null, anchor: RendererNode | null,
@ -255,15 +382,10 @@ function createSuspenseBoundary(
m: move, m: move,
um: unmount, um: unmount,
n: next, n: next,
o: { parentNode } o: { parentNode, remove }
} = rendererInternals } = rendererInternals
const getCurrentTree = () => const timeout = toNumber(vnode.props && vnode.props.timeout)
suspense.isResolved || suspense.isHydrating
? suspense.subTree
: suspense.fallbackTree
const { content, fallback } = normalizeSuspenseChildren(vnode)
const suspense: SuspenseBoundary = { const suspense: SuspenseBoundary = {
vnode, vnode,
parent, parent,
@ -274,30 +396,33 @@ function createSuspenseBoundary(
hiddenContainer, hiddenContainer,
anchor, anchor,
deps: 0, deps: 0,
subTree: content, pendingId: 0,
fallbackTree: fallback, timeout: typeof timeout === 'number' ? timeout : -1,
activeBranch: null,
pendingBranch: null,
isInFallback: true,
isHydrating, isHydrating,
isResolved: false,
isUnmounted: false, isUnmounted: false,
effects: [], effects: [],
resolve() { resolve(resume = false) {
if (__DEV__) { if (__DEV__) {
if (suspense.isResolved) { if (!resume && !suspense.pendingBranch) {
throw new Error( throw new Error(
`resolveSuspense() is called on an already resolved suspense boundary.` `suspense.resolve() is called without a pending branch.`
) )
} }
if (suspense.isUnmounted) { if (suspense.isUnmounted) {
throw new Error( throw new Error(
`resolveSuspense() is called on an already unmounted suspense boundary.` `suspense.resolve() is called on an already unmounted suspense boundary.`
) )
} }
} }
const { const {
vnode, vnode,
subTree, activeBranch,
fallbackTree, pendingBranch,
pendingId,
effects, effects,
parentComponent, parentComponent,
container container
@ -305,31 +430,43 @@ function createSuspenseBoundary(
if (suspense.isHydrating) { if (suspense.isHydrating) {
suspense.isHydrating = false suspense.isHydrating = false
} else { } else if (!resume) {
const delayEnter =
activeBranch &&
pendingBranch!.transition &&
pendingBranch!.transition.mode === 'out-in'
if (delayEnter) {
activeBranch!.transition!.afterLeave = () => {
if (pendingId === suspense.pendingId) {
move(pendingBranch!, container, anchor, MoveType.ENTER)
}
}
}
// this is initial anchor on mount // this is initial anchor on mount
let { anchor } = suspense let { anchor } = suspense
// unmount fallback tree // unmount current active tree
if (fallbackTree.el) { if (activeBranch) {
// if the fallback tree was mounted, it may have been moved // if the fallback tree was mounted, it may have been moved
// as part of a parent suspense. get the latest anchor for insertion // as part of a parent suspense. get the latest anchor for insertion
anchor = next(fallbackTree) anchor = next(activeBranch)
unmount(fallbackTree, parentComponent, suspense, true) unmount(activeBranch, parentComponent, suspense, true)
}
if (!delayEnter) {
// move content from off-dom container to actual container
move(pendingBranch!, container, anchor, MoveType.ENTER)
} }
// move content from off-dom container to actual container
move(subTree, container, anchor, MoveType.ENTER)
} }
const el = (vnode.el = subTree.el!) setActiveBranch(suspense, pendingBranch!)
// suspense as the root node of a component... suspense.pendingBranch = null
if (parentComponent && parentComponent.subTree === vnode) { suspense.isInFallback = false
parentComponent.vnode.el = el
updateHOCHostEl(parentComponent, el) // flush buffered effects
}
// check if there is a pending parent suspense // check if there is a pending parent suspense
let parent = suspense.parent let parent = suspense.parent
let hasUnresolvedAncestor = false let hasUnresolvedAncestor = false
while (parent) { while (parent) {
if (!parent.isResolved) { if (parent.pendingBranch) {
// found a pending parent suspense, merge buffered post jobs // found a pending parent suspense, merge buffered post jobs
// into that parent // into that parent
parent.effects.push(...effects) parent.effects.push(...effects)
@ -342,8 +479,8 @@ function createSuspenseBoundary(
if (!hasUnresolvedAncestor) { if (!hasUnresolvedAncestor) {
queuePostFlushCb(effects) queuePostFlushCb(effects)
} }
suspense.isResolved = true
suspense.effects = [] suspense.effects = []
// invoke @resolve event // invoke @resolve event
const onResolve = vnode.props && vnode.props.onResolve const onResolve = vnode.props && vnode.props.onResolve
if (isFunction(onResolve)) { if (isFunction(onResolve)) {
@ -351,64 +488,77 @@ function createSuspenseBoundary(
} }
}, },
recede() { fallback(fallbackVNode) {
suspense.isResolved = false if (!suspense.pendingBranch) {
return
}
const { const {
vnode, vnode,
subTree, activeBranch,
fallbackTree,
parentComponent, parentComponent,
container, container,
hiddenContainer,
isSVG, isSVG,
optimized optimized
} = suspense } = suspense
// move content tree back to the off-dom container // invoke @recede event
const anchor = next(subTree) const onFallback = vnode.props && vnode.props.onFallback
move(subTree, hiddenContainer, null, MoveType.LEAVE) if (isFunction(onFallback)) {
// remount the fallback tree onFallback()
patch(
null,
fallbackTree,
container,
anchor,
parentComponent,
null, // fallback tree will not have suspense context
isSVG,
optimized
)
const el = (vnode.el = fallbackTree.el!)
// suspense as the root node of a component...
if (parentComponent && parentComponent.subTree === vnode) {
parentComponent.vnode.el = el
updateHOCHostEl(parentComponent, el)
} }
// invoke @recede event const anchor = next(activeBranch!)
const onRecede = vnode.props && vnode.props.onRecede const mountFallback = () => {
if (isFunction(onRecede)) { if (!suspense.isInFallback) {
onRecede() return
}
// mount the fallback tree
patch(
null,
fallbackVNode,
container,
anchor,
parentComponent,
null, // fallback tree will not have suspense context
isSVG,
optimized
)
setActiveBranch(suspense, fallbackVNode)
}
const delayEnter =
fallbackVNode.transition && fallbackVNode.transition.mode === 'out-in'
if (delayEnter) {
activeBranch!.transition!.afterLeave = mountFallback
}
// unmount current active branch
unmount(
activeBranch!,
parentComponent,
null, // no suspense so unmount hooks fire now
true // shouldRemove
)
suspense.isInFallback = true
if (!delayEnter) {
mountFallback()
} }
}, },
move(container, anchor, type) { move(container, anchor, type) {
move(getCurrentTree(), container, anchor, type) suspense.activeBranch &&
move(suspense.activeBranch, container, anchor, type)
suspense.container = container suspense.container = container
}, },
next() { next() {
return next(getCurrentTree()) return suspense.activeBranch && next(suspense.activeBranch)
}, },
registerDep(instance, setupRenderEffect) { registerDep(instance, setupRenderEffect) {
// suspense is already resolved, need to recede. if (!suspense.pendingBranch) {
// use queueJob so it's handled synchronously after patching the current return
// suspense tree
if (suspense.isResolved) {
queueJob(() => {
suspense.recede()
})
} }
const hydratedEl = instance.vnode.el const hydratedEl = instance.vnode.el
@ -420,7 +570,11 @@ function createSuspenseBoundary(
.then(asyncSetupResult => { .then(asyncSetupResult => {
// retry when the setup() promise resolves. // retry when the setup() promise resolves.
// component may have been unmounted before resolve. // component may have been unmounted before resolve.
if (instance.isUnmounted || suspense.isUnmounted) { if (
instance.isUnmounted ||
suspense.isUnmounted ||
suspense.pendingId !== instance.suspenseId
) {
return return
} }
suspense.deps-- suspense.deps--
@ -436,15 +590,14 @@ function createSuspenseBoundary(
// async dep is resolved. // async dep is resolved.
vnode.el = hydratedEl vnode.el = hydratedEl
} }
const placeholder = !hydratedEl && instance.subTree.el
setupRenderEffect( setupRenderEffect(
instance, instance,
vnode, vnode,
// component may have been moved before resolve. // component may have been moved before resolve.
// if this is not a hydration, instance.subTree will be the comment // if this is not a hydration, instance.subTree will be the comment
// placeholder. // placeholder.
hydratedEl parentNode(hydratedEl || instance.subTree.el!)!,
? parentNode(hydratedEl)!
: parentNode(instance.subTree.el!)!,
// anchor will not be used if this is hydration, so only need to // anchor will not be used if this is hydration, so only need to
// consider the comment placeholder case. // consider the comment placeholder case.
hydratedEl ? null : next(instance.subTree), hydratedEl ? null : next(instance.subTree),
@ -452,6 +605,9 @@ function createSuspenseBoundary(
isSVG, isSVG,
optimized optimized
) )
if (placeholder) {
remove(placeholder)
}
updateHOCHostEl(instance, vnode.el) updateHOCHostEl(instance, vnode.el)
if (__DEV__) { if (__DEV__) {
popWarningContext() popWarningContext()
@ -464,10 +620,17 @@ function createSuspenseBoundary(
unmount(parentSuspense, doRemove) { unmount(parentSuspense, doRemove) {
suspense.isUnmounted = true suspense.isUnmounted = true
unmount(suspense.subTree, parentComponent, parentSuspense, doRemove) if (suspense.activeBranch) {
if (!suspense.isResolved) {
unmount( unmount(
suspense.fallbackTree, suspense.activeBranch,
parentComponent,
parentSuspense,
doRemove
)
}
if (suspense.pendingBranch) {
unmount(
suspense.pendingBranch,
parentComponent, parentComponent,
parentSuspense, parentSuspense,
doRemove doRemove
@ -516,7 +679,7 @@ function hydrateSuspense(
// need to construct a suspense boundary first // need to construct a suspense boundary first
const result = hydrateNode( const result = hydrateNode(
node, node,
suspense.subTree, (suspense.pendingBranch = vnode.ssContent!),
parentComponent, parentComponent,
suspense, suspense,
optimized optimized
@ -535,25 +698,40 @@ export function normalizeSuspenseChildren(
fallback: VNode fallback: VNode
} { } {
const { shapeFlag, children } = vnode const { shapeFlag, children } = vnode
let content: VNode
let fallback: VNode
if (shapeFlag & ShapeFlags.SLOTS_CHILDREN) { if (shapeFlag & ShapeFlags.SLOTS_CHILDREN) {
const { default: d, fallback } = children as Slots content = normalizeSuspenseSlot((children as Slots).default)
return { fallback = normalizeSuspenseSlot((children as Slots).fallback)
content: normalizeVNode(isFunction(d) ? d() : d),
fallback: normalizeVNode(isFunction(fallback) ? fallback() : fallback)
}
} else { } else {
return { content = normalizeSuspenseSlot(children as VNodeChild)
content: normalizeVNode(children as VNodeChild), fallback = normalizeVNode(null)
fallback: normalizeVNode(null)
}
} }
return {
content,
fallback
}
}
function normalizeSuspenseSlot(s: any) {
if (isFunction(s)) {
s = s()
}
if (isArray(s)) {
const singleChild = filterSingleRoot(s)
if (__DEV__ && !singleChild) {
warn(`<Suspense> slots expect a single root node.`)
}
s = singleChild
}
return normalizeVNode(s)
} }
export function queueEffectWithSuspense( export function queueEffectWithSuspense(
fn: Function | Function[], fn: Function | Function[],
suspense: SuspenseBoundary | null suspense: SuspenseBoundary | null
): void { ): void {
if (suspense && !suspense.isResolved) { if (suspense && suspense.pendingBranch) {
if (isArray(fn)) { if (isArray(fn)) {
suspense.effects.push(...fn) suspense.effects.push(...fn)
} else { } else {
@ -563,3 +741,15 @@ export function queueEffectWithSuspense(
queuePostFlushCb(fn) queuePostFlushCb(fn)
} }
} }
function setActiveBranch(suspense: SuspenseBoundary, branch: VNode) {
suspense.activeBranch = branch
const { vnode, parentComponent } = suspense
const el = (vnode.el = branch.el)
// in case suspense is the root node of a component,
// recursively update the HOC el
if (parentComponent && parentComponent.subTree === vnode) {
parentComponent.vnode.el = el
updateHOCHostEl(parentComponent, el)
}
}

View File

@ -326,14 +326,14 @@ export function createHydrationFunctions(
const hydrateChildren = ( const hydrateChildren = (
node: Node | null, node: Node | null,
vnode: VNode, parentVNode: VNode,
container: Element, container: Element,
parentComponent: ComponentInternalInstance | null, parentComponent: ComponentInternalInstance | null,
parentSuspense: SuspenseBoundary | null, parentSuspense: SuspenseBoundary | null,
optimized: boolean optimized: boolean
): Node | null => { ): Node | null => {
optimized = optimized || !!vnode.dynamicChildren optimized = optimized || !!parentVNode.dynamicChildren
const children = vnode.children as VNode[] const children = parentVNode.children as VNode[]
const l = children.length const l = children.length
let hasWarned = false let hasWarned = false
for (let i = 0; i < l; i++) { for (let i = 0; i < l; i++) {

View File

@ -250,7 +250,6 @@ import {
setCurrentRenderingInstance setCurrentRenderingInstance
} from './componentRenderUtils' } from './componentRenderUtils'
import { isVNode, normalizeVNode } from './vnode' import { isVNode, normalizeVNode } from './vnode'
import { normalizeSuspenseChildren } from './components/Suspense'
const _ssrUtils = { const _ssrUtils = {
createComponentInstance, createComponentInstance,
@ -258,8 +257,7 @@ const _ssrUtils = {
renderComponentRoot, renderComponentRoot,
setCurrentRenderingInstance, setCurrentRenderingInstance,
isVNode, isVNode,
normalizeVNode, normalizeVNode
normalizeSuspenseChildren
} }
/** /**

View File

@ -778,7 +778,7 @@ function baseCreateRenderer(
// #1583 For inside suspense + suspense not resolved case, enter hook should call when suspense resolved // #1583 For inside suspense + suspense not resolved case, enter hook should call when suspense resolved
// #1689 For inside suspense + suspense resolved case, just call it // #1689 For inside suspense + suspense resolved case, just call it
const needCallTransitionHooks = const needCallTransitionHooks =
(!parentSuspense || (parentSuspense && parentSuspense!.isResolved)) && (!parentSuspense || (parentSuspense && !parentSuspense.pendingBranch)) &&
transition && transition &&
!transition.persisted !transition.persisted
if (needCallTransitionHooks) { if (needCallTransitionHooks) {
@ -1253,14 +1253,10 @@ function baseCreateRenderer(
// setup() is async. This component relies on async logic to be resolved // setup() is async. This component relies on async logic to be resolved
// before proceeding // before proceeding
if (__FEATURE_SUSPENSE__ && instance.asyncDep) { if (__FEATURE_SUSPENSE__ && instance.asyncDep) {
if (!parentSuspense) { parentSuspense && parentSuspense.registerDep(instance, setupRenderEffect)
if (__DEV__) warn('async setup() is used without a suspense boundary!')
return
}
parentSuspense.registerDep(instance, setupRenderEffect)
// Give it a placeholder if this is not hydration // Give it a placeholder if this is not hydration
// TODO handle self-defined fallback
if (!initialVNode.el) { if (!initialVNode.el) {
const placeholder = (instance.subTree = createVNode(Comment)) const placeholder = (instance.subTree = createVNode(Comment))
processCommentNode(null, placeholder, container!, anchor) processCommentNode(null, placeholder, container!, anchor)
@ -2124,10 +2120,11 @@ function baseCreateRenderer(
if ( if (
__FEATURE_SUSPENSE__ && __FEATURE_SUSPENSE__ &&
parentSuspense && parentSuspense &&
!parentSuspense.isResolved && parentSuspense.pendingBranch &&
!parentSuspense.isUnmounted && !parentSuspense.isUnmounted &&
instance.asyncDep && instance.asyncDep &&
!instance.asyncResolved !instance.asyncResolved &&
instance.suspenseId === parentSuspense.pendingId
) { ) {
parentSuspense.deps-- parentSuspense.deps--
if (parentSuspense.deps === 0) { if (parentSuspense.deps === 0) {

View File

@ -25,7 +25,8 @@ import { AppContext } from './apiCreateApp'
import { import {
SuspenseImpl, SuspenseImpl,
isSuspense, isSuspense,
SuspenseBoundary SuspenseBoundary,
normalizeSuspenseChildren
} from './components/Suspense' } from './components/Suspense'
import { DirectiveBinding } from './directives' import { DirectiveBinding } from './directives'
import { TransitionHooks } from './components/BaseTransition' import { TransitionHooks } from './components/BaseTransition'
@ -134,7 +135,6 @@ export interface VNode<
scopeId: string | null // SFC only scopeId: string | null // SFC only
children: VNodeNormalizedChildren children: VNodeNormalizedChildren
component: ComponentInternalInstance | null component: ComponentInternalInstance | null
suspense: SuspenseBoundary | null
dirs: DirectiveBinding[] | null dirs: DirectiveBinding[] | null
transition: TransitionHooks<HostElement> | null transition: TransitionHooks<HostElement> | null
@ -145,6 +145,11 @@ export interface VNode<
targetAnchor: HostNode | null // teleport target anchor targetAnchor: HostNode | null // teleport target anchor
staticCount: number // number of elements contained in a static vnode staticCount: number // number of elements contained in a static vnode
// suspense
suspense: SuspenseBoundary | null
ssContent: VNode | null
ssFallback: VNode | null
// optimization only // optimization only
shapeFlag: number shapeFlag: number
patchFlag: number patchFlag: number
@ -395,6 +400,8 @@ function _createVNode(
children: null, children: null,
component: null, component: null,
suspense: null, suspense: null,
ssContent: null,
ssFallback: null,
dirs: null, dirs: null,
transition: null, transition: null,
el: null, el: null,
@ -416,6 +423,13 @@ function _createVNode(
normalizeChildren(vnode, children) normalizeChildren(vnode, children)
// normalize suspense children
if (__FEATURE_SUSPENSE__ && shapeFlag & ShapeFlags.SUSPENSE) {
const { content, fallback } = normalizeSuspenseChildren(vnode)
vnode.ssContent = content
vnode.ssFallback = fallback
}
if ( if (
shouldTrack > 0 && shouldTrack > 0 &&
// avoid a block node from tracking itself // avoid a block node from tracking itself
@ -491,6 +505,8 @@ export function cloneVNode<T, U>(
// they will simply be overwritten. // they will simply be overwritten.
component: vnode.component, component: vnode.component,
suspense: vnode.suspense, suspense: vnode.suspense,
ssContent: vnode.ssContent && cloneVNode(vnode.ssContent),
ssFallback: vnode.ssFallback && cloneVNode(vnode.ssFallback),
el: vnode.el, el: vnode.el,
anchor: vnode.anchor anchor: vnode.anchor
} }

View File

@ -40,14 +40,12 @@ function setVarsOnVNode(
prefix: string prefix: string
) { ) {
if (__FEATURE_SUSPENSE__ && vnode.shapeFlag & ShapeFlags.SUSPENSE) { if (__FEATURE_SUSPENSE__ && vnode.shapeFlag & ShapeFlags.SUSPENSE) {
const { isResolved, isHydrating, fallbackTree, subTree } = vnode.suspense! const suspense = vnode.suspense!
if (isResolved || isHydrating) { vnode = suspense.activeBranch!
vnode = subTree if (suspense.pendingBranch && !suspense.isHydrating) {
} else { suspense.effects.push(() => {
vnode.suspense!.effects.push(() => { setVarsOnVNode(suspense.activeBranch!, vars, prefix)
setVarsOnVNode(subTree, vars, prefix)
}) })
vnode = fallbackTree
} }
} }

View File

@ -5,9 +5,7 @@ export async function ssrRenderSuspense(
{ default: renderContent }: Record<string, (() => void) | undefined> { default: renderContent }: Record<string, (() => void) | undefined>
) { ) {
if (renderContent) { if (renderContent) {
push(`<!--[-->`)
renderContent() renderContent()
push(`<!--]-->`)
} else { } else {
push(`<!---->`) push(`<!---->`)
} }

View File

@ -33,8 +33,7 @@ const {
setCurrentRenderingInstance, setCurrentRenderingInstance,
setupComponent, setupComponent,
renderComponentRoot, renderComponentRoot,
normalizeVNode, normalizeVNode
normalizeSuspenseChildren
} = ssrUtils } = ssrUtils
export type SSRBuffer = SSRBufferItem[] & { hasAsync?: boolean } export type SSRBuffer = SSRBufferItem[] & { hasAsync?: boolean }
@ -200,11 +199,7 @@ export function renderVNode(
} else if (shapeFlag & ShapeFlags.TELEPORT) { } else if (shapeFlag & ShapeFlags.TELEPORT) {
renderTeleportVNode(push, vnode, parentComponent) renderTeleportVNode(push, vnode, parentComponent)
} else if (shapeFlag & ShapeFlags.SUSPENSE) { } else if (shapeFlag & ShapeFlags.SUSPENSE) {
renderVNode( renderVNode(push, vnode.ssContent!, parentComponent)
push,
normalizeSuspenseChildren(vnode).content,
parentComponent
)
} else { } else {
warn( warn(
'[@vue/server-renderer] Invalid VNode type:', '[@vue/server-renderer] Invalid VNode type:',

View File

@ -1115,12 +1115,11 @@ describe('e2e: Transition', () => {
createApp({ createApp({
template: ` template: `
<div id="container"> <div id="container">
<Suspense> <transition @enter="onEnterSpy" @leave="onLeaveSpy">
<transition @enter="onEnterSpy" <Suspense>
@leave="onLeaveSpy">
<Comp v-if="toggle" class="test">content</Comp> <Comp v-if="toggle" class="test">content</Comp>
</transition> </Suspense>
</Suspense> </transition>
</div> </div>
<button id="toggleBtn" @click="click">button</button> <button id="toggleBtn" @click="click">button</button>
`, `,
@ -1138,6 +1137,13 @@ describe('e2e: Transition', () => {
} }
}).mount('#app') }).mount('#app')
}) })
expect(onEnterSpy).toBeCalledTimes(1)
await nextFrame()
expect(await html('#container')).toBe(
'<div class="test v-enter-active v-enter-to">content</div>'
)
await transitionFinish()
expect(await html('#container')).toBe('<div class="test">content</div>') expect(await html('#container')).toBe('<div class="test">content</div>')
// leave // leave
@ -1174,7 +1180,7 @@ describe('e2e: Transition', () => {
'v-enter-active', 'v-enter-active',
'v-enter-from' 'v-enter-from'
]) ])
expect(onEnterSpy).toBeCalledTimes(1) expect(onEnterSpy).toBeCalledTimes(2)
await nextFrame() await nextFrame()
expect(await classList('.test')).toStrictEqual([ expect(await classList('.test')).toStrictEqual([
'test', 'test',
@ -1196,11 +1202,11 @@ describe('e2e: Transition', () => {
createApp({ createApp({
template: ` template: `
<div id="container"> <div id="container">
<Suspense> <transition>
<transition> <Suspense>
<div v-if="toggle" class="test">content</div> <div v-if="toggle" class="test">content</div>
</transition> </Suspense>
</Suspense> </transition>
</div> </div>
<button id="toggleBtn" @click="click">button</button> <button id="toggleBtn" @click="click">button</button>
`, `,
@ -1245,6 +1251,71 @@ describe('e2e: Transition', () => {
}, },
E2E_TIMEOUT E2E_TIMEOUT
) )
test(
'out-in mode with Suspense',
async () => {
const onLeaveSpy = jest.fn()
const onEnterSpy = jest.fn()
await page().exposeFunction('onLeaveSpy', onLeaveSpy)
await page().exposeFunction('onEnterSpy', onEnterSpy)
await page().evaluate(() => {
const { createApp, shallowRef, h } = (window as any).Vue
const One = {
async setup() {
return () => h('div', { class: 'test' }, 'one')
}
}
const Two = {
async setup() {
return () => h('div', { class: 'test' }, 'two')
}
}
createApp({
template: `
<div id="container">
<transition mode="out-in">
<Suspense>
<component :is="view"/>
</Suspense>
</transition>
</div>
<button id="toggleBtn" @click="click">button</button>
`,
setup: () => {
const view = shallowRef(One)
const click = () => {
view.value = view.value === One ? Two : One
}
return { view, click }
}
}).mount('#app')
})
await nextFrame()
expect(await html('#container')).toBe(
'<div class="test v-enter-active v-enter-to">one</div>'
)
await transitionFinish()
expect(await html('#container')).toBe('<div class="test">one</div>')
// leave
await classWhenTransitionStart()
await nextFrame()
expect(await html('#container')).toBe(
'<div class="test v-leave-active v-leave-to">one</div>'
)
await transitionFinish()
expect(await html('#container')).toBe(
'<div class="test v-enter-active v-enter-to">two</div>'
)
await transitionFinish()
expect(await html('#container')).toBe('<div class="test">two</div>')
},
E2E_TIMEOUT
)
}) })
describe('transition with v-show', () => { describe('transition with v-show', () => {

View File

@ -8,7 +8,7 @@
</div> </div>
<script> <script>
const delay = window.location.hash === '#test' ? 16 : 300 const delay = window.location.hash === '#test' ? 50 : 300
Vue.createApp({ Vue.createApp({
data: () => ({ data: () => ({

View File

@ -49,6 +49,6 @@ expectError(<KeepAlive include={123} />)
// Suspense // Suspense
expectType<JSX.Element>(<Suspense />) expectType<JSX.Element>(<Suspense />)
expectType<JSX.Element>(<Suspense key="1" />) expectType<JSX.Element>(<Suspense key="1" />)
expectType<JSX.Element>(<Suspense onResolve={() => {}} onRecede={() => {}} />) expectType<JSX.Element>(<Suspense onResolve={() => {}} onFallback={() => {}} />)
// @ts-expect-error // @ts-expect-error
expectError(<Suspense onResolve={123} />) expectError(<Suspense onResolve={123} />)