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

View File

@ -11,7 +11,8 @@ import {
watch,
watchEffect,
onUnmounted,
onErrorCaptured
onErrorCaptured,
shallowRef
} from '@vue/runtime-test'
describe('Suspense', () => {
@ -490,7 +491,7 @@ describe('Suspense', () => {
setup() {
return () =>
h(Suspense, null, {
default: [h(AsyncOuter), h(Inner)],
default: h('div', [h(AsyncOuter), h(Inner)]),
fallback: h('div', 'fallback outer')
})
}
@ -503,14 +504,14 @@ describe('Suspense', () => {
await deps[0]
await nextTick()
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`])
await Promise.all(deps)
await nextTick()
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`])
})
@ -556,7 +557,7 @@ describe('Suspense', () => {
setup() {
return () =>
h(Suspense, null, {
default: [h(AsyncOuter), h(Inner)],
default: h('div', [h(AsyncOuter), h(Inner)]),
fallback: h('div', 'fallback outer')
})
}
@ -574,7 +575,7 @@ describe('Suspense', () => {
await Promise.all(deps)
await nextTick()
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`])
})
@ -683,12 +684,12 @@ describe('Suspense', () => {
setup() {
return () =>
h(Suspense, null, {
default: [
default: h('div', [
h(MiddleComponent),
h(AsyncChildParent, {
msg: 'root async'
})
],
]),
fallback: h('div', 'root fallback')
})
}
@ -722,7 +723,7 @@ describe('Suspense', () => {
await deps[3]
await nextTick()
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])
@ -733,7 +734,7 @@ describe('Suspense', () => {
await Promise.all(deps)
await nextTick()
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])
@ -741,51 +742,316 @@ describe('Suspense', () => {
msg.value = 'nested changed again'
await nextTick()
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 () => {
const toggle = ref(false)
test('switching branches', async () => {
const calls: string[] = []
const toggle = ref(true)
const ChildA = defineAsyncComponent({
const Foo = defineAsyncComponent({
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')
}
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 = {
setup() {
return () =>
h(Suspense, null, {
default: [h(ChildA), toggle.value ? h(ChildB) : null],
fallback: h('div', 'root fallback')
default: toggle.value ? h(Foo) : h(Bar),
fallback: h('div', 'fallback')
})
}
}
const root = nodeOps.createElement('div')
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 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
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 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 = {
template: `
<Suspense @resolve="done">
<div>
<AsyncChild :n="1" />
<AsyncChild :n="2" />
</div>
</Suspense>`,
components: {
AsyncChild
@ -521,7 +523,7 @@ describe('SSR hydration', () => {
// server render
container.innerHTML = await renderToString(h(App))
expect(container.innerHTML).toMatchInlineSnapshot(
`"<!--[--><span>1</span><span>2</span><!--]-->"`
`"<div><span>1</span><span>2</span></div>"`
)
// reset asyncDeps from ssr
asyncDeps.length = 0
@ -537,17 +539,23 @@ describe('SSR hydration', () => {
// should flush buffered effects
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')!
triggerEvent('click', span1)
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
triggerEvent('click', span2)
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 () => {

View File

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

View File

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

View File

@ -184,7 +184,7 @@ const KeepAliveImpl = {
const cacheSubtree = () => {
// fix #1621, the pendingCacheKey could be 0
if (pendingCacheKey != null) {
cache.set(pendingCacheKey, instance.subTree)
cache.set(pendingCacheKey, getInnerChild(instance.subTree))
}
}
onMounted(cacheSubtree)
@ -193,11 +193,12 @@ const KeepAliveImpl = {
onBeforeUnmount(() => {
cache.forEach(cached => {
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
resetShapeFlag(subTree)
resetShapeFlag(vnode)
// but invoke its deactivated hook here
const da = subTree.component!.da
const da = vnode.component!.da
da && queuePostRenderEffect(da, suspense)
return
}
@ -213,7 +214,7 @@ const KeepAliveImpl = {
}
const children = slots.default()
let vnode = children[0]
const rawVNode = children[0]
if (children.length > 1) {
if (__DEV__) {
warn(`KeepAlive should contain exactly one component child.`)
@ -221,13 +222,15 @@ const KeepAliveImpl = {
current = null
return children
} else if (
!isVNode(vnode) ||
!(vnode.shapeFlag & ShapeFlags.STATEFUL_COMPONENT)
!isVNode(rawVNode) ||
(!(rawVNode.shapeFlag & ShapeFlags.STATEFUL_COMPONENT) &&
!(rawVNode.shapeFlag & ShapeFlags.SUSPENSE))
) {
current = null
return vnode
return rawVNode
}
let vnode = getInnerChild(rawVNode)
const comp = vnode.type as ConcreteComponent
const name = getName(comp)
const { include, exclude, max } = props
@ -236,7 +239,8 @@ const KeepAliveImpl = {
(include && (!name || !matches(include, name))) ||
(exclude && name && matches(exclude, name))
) {
return (current = vnode)
current = vnode
return rawVNode
}
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
if (vnode.el) {
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
// 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
current = vnode
return vnode
return rawVNode
}
}
}
@ -383,3 +390,7 @@ function resetShapeFlag(vnode: VNode) {
}
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 { isFunction, isArray, ShapeFlags } from '@vue/shared'
import {
VNode,
normalizeVNode,
VNodeChild,
VNodeProps,
isSameVNodeType
} from '../vnode'
import { isFunction, isArray, ShapeFlags, toNumber } from '@vue/shared'
import { ComponentInternalInstance, handleSetupResult } from '../component'
import { Slots } from '../componentSlots'
import {
@ -9,14 +15,16 @@ import {
RendererNode,
RendererElement
} from '../renderer'
import { queuePostFlushCb, queueJob } from '../scheduler'
import { updateHOCHostEl } from '../componentRenderUtils'
import { pushWarningContext, popWarningContext } from '../warning'
import { queuePostFlushCb } from '../scheduler'
import { filterSingleRoot, updateHOCHostEl } from '../componentRenderUtils'
import { pushWarningContext, popWarningContext, warn } from '../warning'
import { handleError, ErrorCodes } from '../errorHandling'
export interface SuspenseProps {
onResolve?: () => void
onRecede?: () => void
onPending?: () => void
onFallback?: () => void
timeout?: string | number
}
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
@ -78,7 +87,7 @@ export const Suspense = ((__FEATURE_SUSPENSE__
}
function mountSuspense(
n2: VNode,
vnode: VNode,
container: RendererElement,
anchor: RendererNode | null,
parentComponent: ComponentInternalInstance | null,
@ -92,8 +101,8 @@ function mountSuspense(
o: { createElement }
} = rendererInternals
const hiddenContainer = createElement('div')
const suspense = (n2.suspense = createSuspenseBoundary(
n2,
const suspense = (vnode.suspense = createSuspenseBoundary(
vnode,
parentSuspense,
parentComponent,
container,
@ -107,7 +116,7 @@ function mountSuspense(
// start mounting the content subtree in an off-dom container
patch(
null,
suspense.subTree,
(suspense.pendingBranch = vnode.ssContent!),
hiddenContainer,
null,
parentComponent,
@ -117,10 +126,11 @@ function mountSuspense(
)
// now check if we have encountered any async deps
if (suspense.deps > 0) {
// has async
// mount the fallback tree
patch(
null,
suspense.fallbackTree,
vnode.ssFallback!,
container,
anchor,
parentComponent,
@ -128,7 +138,7 @@ function mountSuspense(
isSVG,
optimized
)
n2.el = suspense.fallbackTree.el
setActiveBranch(suspense, vnode.ssFallback!)
} else {
// Suspense has no async deps. Just resolve.
suspense.resolve()
@ -143,17 +153,22 @@ function patchSuspense(
parentComponent: ComponentInternalInstance | null,
isSVG: boolean,
optimized: boolean,
{ p: patch }: RendererInternals
{ p: patch, um: unmount, o: { createElement } }: RendererInternals
) {
const suspense = (n2.suspense = n1.suspense)!
suspense.vnode = n2
const { content, fallback } = normalizeSuspenseChildren(n2)
const oldSubTree = suspense.subTree
const oldFallbackTree = suspense.fallbackTree
if (!suspense.isResolved) {
n2.el = n1.el
const newBranch = n2.ssContent!
const newFallback = n2.ssFallback!
const { activeBranch, pendingBranch, isInFallback, isHydrating } = suspense
if (pendingBranch) {
suspense.pendingBranch = newBranch
if (isSameVNodeType(newBranch, pendingBranch)) {
// same root type but content may have changed.
patch(
oldSubTree,
content,
pendingBranch,
newBranch,
suspense.hiddenContainer,
null,
parentComponent,
@ -161,11 +176,12 @@ function patchSuspense(
isSVG,
optimized
)
if (suspense.deps > 0) {
// still pending. patch the fallback tree.
if (suspense.deps <= 0) {
suspense.resolve()
} else if (isInFallback) {
patch(
oldFallbackTree,
fallback,
activeBranch,
newFallback,
container,
anchor,
parentComponent,
@ -173,16 +189,59 @@ function patchSuspense(
isSVG,
optimized
)
n2.el = fallback.el
setActiveBranch(suspense, newFallback)
}
// 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 {
// just normal patch inner content as a fragment
// 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(
oldSubTree,
content,
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,
@ -190,10 +249,76 @@ function patchSuspense(
isSVG,
optimized
)
n2.el = content.el
// 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()
}
}
}
} else {
if (activeBranch && isSameVNodeType(newBranch, activeBranch)) {
// root did not change, just normal patch
patch(
activeBranch,
newBranch,
container,
anchor,
parentComponent,
suspense,
isSVG,
optimized
)
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 {
@ -205,15 +330,17 @@ export interface SuspenseBoundary {
container: RendererElement
hiddenContainer: RendererElement
anchor: RendererNode | null
subTree: VNode
fallbackTree: VNode
activeBranch: VNode | null
pendingBranch: VNode | null
deps: number
pendingId: number
timeout: number
isInFallback: boolean
isHydrating: boolean
isResolved: boolean
isUnmounted: boolean
effects: Function[]
resolve(): void
recede(): void
resolve(force?: boolean): void
fallback(fallbackVNode: VNode): void
move(
container: RendererElement,
anchor: RendererNode | null,
@ -255,15 +382,10 @@ function createSuspenseBoundary(
m: move,
um: unmount,
n: next,
o: { parentNode }
o: { parentNode, remove }
} = rendererInternals
const getCurrentTree = () =>
suspense.isResolved || suspense.isHydrating
? suspense.subTree
: suspense.fallbackTree
const { content, fallback } = normalizeSuspenseChildren(vnode)
const timeout = toNumber(vnode.props && vnode.props.timeout)
const suspense: SuspenseBoundary = {
vnode,
parent,
@ -274,30 +396,33 @@ function createSuspenseBoundary(
hiddenContainer,
anchor,
deps: 0,
subTree: content,
fallbackTree: fallback,
pendingId: 0,
timeout: typeof timeout === 'number' ? timeout : -1,
activeBranch: null,
pendingBranch: null,
isInFallback: true,
isHydrating,
isResolved: false,
isUnmounted: false,
effects: [],
resolve() {
resolve(resume = false) {
if (__DEV__) {
if (suspense.isResolved) {
if (!resume && !suspense.pendingBranch) {
throw new Error(
`resolveSuspense() is called on an already resolved suspense boundary.`
`suspense.resolve() is called without a pending branch.`
)
}
if (suspense.isUnmounted) {
throw new Error(
`resolveSuspense() is called on an already unmounted suspense boundary.`
`suspense.resolve() is called on an already unmounted suspense boundary.`
)
}
}
const {
vnode,
subTree,
fallbackTree,
activeBranch,
pendingBranch,
pendingId,
effects,
parentComponent,
container
@ -305,31 +430,43 @@ function createSuspenseBoundary(
if (suspense.isHydrating) {
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
let { anchor } = suspense
// unmount fallback tree
if (fallbackTree.el) {
// unmount current active tree
if (activeBranch) {
// if the fallback tree was mounted, it may have been moved
// as part of a parent suspense. get the latest anchor for insertion
anchor = next(fallbackTree)
unmount(fallbackTree, parentComponent, suspense, true)
anchor = next(activeBranch)
unmount(activeBranch, parentComponent, suspense, true)
}
if (!delayEnter) {
// move content from off-dom container to actual container
move(subTree, container, anchor, MoveType.ENTER)
move(pendingBranch!, container, anchor, MoveType.ENTER)
}
}
const el = (vnode.el = subTree.el!)
// suspense as the root node of a component...
if (parentComponent && parentComponent.subTree === vnode) {
parentComponent.vnode.el = el
updateHOCHostEl(parentComponent, el)
}
setActiveBranch(suspense, pendingBranch!)
suspense.pendingBranch = null
suspense.isInFallback = false
// flush buffered effects
// check if there is a pending parent suspense
let parent = suspense.parent
let hasUnresolvedAncestor = false
while (parent) {
if (!parent.isResolved) {
if (parent.pendingBranch) {
// found a pending parent suspense, merge buffered post jobs
// into that parent
parent.effects.push(...effects)
@ -342,8 +479,8 @@ function createSuspenseBoundary(
if (!hasUnresolvedAncestor) {
queuePostFlushCb(effects)
}
suspense.isResolved = true
suspense.effects = []
// invoke @resolve event
const onResolve = vnode.props && vnode.props.onResolve
if (isFunction(onResolve)) {
@ -351,26 +488,35 @@ function createSuspenseBoundary(
}
},
recede() {
suspense.isResolved = false
fallback(fallbackVNode) {
if (!suspense.pendingBranch) {
return
}
const {
vnode,
subTree,
fallbackTree,
activeBranch,
parentComponent,
container,
hiddenContainer,
isSVG,
optimized
} = suspense
// move content tree back to the off-dom container
const anchor = next(subTree)
move(subTree, hiddenContainer, null, MoveType.LEAVE)
// remount the fallback tree
// invoke @recede event
const onFallback = vnode.props && vnode.props.onFallback
if (isFunction(onFallback)) {
onFallback()
}
const anchor = next(activeBranch!)
const mountFallback = () => {
if (!suspense.isInFallback) {
return
}
// mount the fallback tree
patch(
null,
fallbackTree,
fallbackVNode,
container,
anchor,
parentComponent,
@ -378,37 +524,41 @@ function createSuspenseBoundary(
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)
setActiveBranch(suspense, fallbackVNode)
}
// invoke @recede event
const onRecede = vnode.props && vnode.props.onRecede
if (isFunction(onRecede)) {
onRecede()
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(getCurrentTree(), container, anchor, type)
suspense.activeBranch &&
move(suspense.activeBranch, container, anchor, type)
suspense.container = container
},
next() {
return next(getCurrentTree())
return suspense.activeBranch && next(suspense.activeBranch)
},
registerDep(instance, setupRenderEffect) {
// suspense is already resolved, need to recede.
// use queueJob so it's handled synchronously after patching the current
// suspense tree
if (suspense.isResolved) {
queueJob(() => {
suspense.recede()
})
if (!suspense.pendingBranch) {
return
}
const hydratedEl = instance.vnode.el
@ -420,7 +570,11 @@ function createSuspenseBoundary(
.then(asyncSetupResult => {
// retry when the setup() promise resolves.
// component may have been unmounted before resolve.
if (instance.isUnmounted || suspense.isUnmounted) {
if (
instance.isUnmounted ||
suspense.isUnmounted ||
suspense.pendingId !== instance.suspenseId
) {
return
}
suspense.deps--
@ -436,15 +590,14 @@ function createSuspenseBoundary(
// async dep is resolved.
vnode.el = hydratedEl
}
const placeholder = !hydratedEl && instance.subTree.el
setupRenderEffect(
instance,
vnode,
// component may have been moved before resolve.
// if this is not a hydration, instance.subTree will be the comment
// placeholder.
hydratedEl
? parentNode(hydratedEl)!
: parentNode(instance.subTree.el!)!,
parentNode(hydratedEl || instance.subTree.el!)!,
// anchor will not be used if this is hydration, so only need to
// consider the comment placeholder case.
hydratedEl ? null : next(instance.subTree),
@ -452,6 +605,9 @@ function createSuspenseBoundary(
isSVG,
optimized
)
if (placeholder) {
remove(placeholder)
}
updateHOCHostEl(instance, vnode.el)
if (__DEV__) {
popWarningContext()
@ -464,10 +620,17 @@ function createSuspenseBoundary(
unmount(parentSuspense, doRemove) {
suspense.isUnmounted = true
unmount(suspense.subTree, parentComponent, parentSuspense, doRemove)
if (!suspense.isResolved) {
if (suspense.activeBranch) {
unmount(
suspense.fallbackTree,
suspense.activeBranch,
parentComponent,
parentSuspense,
doRemove
)
}
if (suspense.pendingBranch) {
unmount(
suspense.pendingBranch,
parentComponent,
parentSuspense,
doRemove
@ -516,7 +679,7 @@ function hydrateSuspense(
// need to construct a suspense boundary first
const result = hydrateNode(
node,
suspense.subTree,
(suspense.pendingBranch = vnode.ssContent!),
parentComponent,
suspense,
optimized
@ -535,25 +698,40 @@ export function normalizeSuspenseChildren(
fallback: VNode
} {
const { shapeFlag, children } = vnode
let content: VNode
let fallback: VNode
if (shapeFlag & ShapeFlags.SLOTS_CHILDREN) {
const { default: d, fallback } = children as Slots
return {
content: normalizeVNode(isFunction(d) ? d() : d),
fallback: normalizeVNode(isFunction(fallback) ? fallback() : fallback)
}
content = normalizeSuspenseSlot((children as Slots).default)
fallback = normalizeSuspenseSlot((children as Slots).fallback)
} else {
content = normalizeSuspenseSlot(children as VNodeChild)
fallback = normalizeVNode(null)
}
return {
content: normalizeVNode(children as VNodeChild),
fallback: normalizeVNode(null)
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(
fn: Function | Function[],
suspense: SuspenseBoundary | null
): void {
if (suspense && !suspense.isResolved) {
if (suspense && suspense.pendingBranch) {
if (isArray(fn)) {
suspense.effects.push(...fn)
} else {
@ -563,3 +741,15 @@ export function queueEffectWithSuspense(
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 = (
node: Node | null,
vnode: VNode,
parentVNode: VNode,
container: Element,
parentComponent: ComponentInternalInstance | null,
parentSuspense: SuspenseBoundary | null,
optimized: boolean
): Node | null => {
optimized = optimized || !!vnode.dynamicChildren
const children = vnode.children as VNode[]
optimized = optimized || !!parentVNode.dynamicChildren
const children = parentVNode.children as VNode[]
const l = children.length
let hasWarned = false
for (let i = 0; i < l; i++) {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1115,12 +1115,11 @@ describe('e2e: Transition', () => {
createApp({
template: `
<div id="container">
<transition @enter="onEnterSpy" @leave="onLeaveSpy">
<Suspense>
<transition @enter="onEnterSpy"
@leave="onLeaveSpy">
<Comp v-if="toggle" class="test">content</Comp>
</transition>
</Suspense>
</transition>
</div>
<button id="toggleBtn" @click="click">button</button>
`,
@ -1138,6 +1137,13 @@ describe('e2e: Transition', () => {
}
}).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>')
// leave
@ -1174,7 +1180,7 @@ describe('e2e: Transition', () => {
'v-enter-active',
'v-enter-from'
])
expect(onEnterSpy).toBeCalledTimes(1)
expect(onEnterSpy).toBeCalledTimes(2)
await nextFrame()
expect(await classList('.test')).toStrictEqual([
'test',
@ -1196,11 +1202,11 @@ describe('e2e: Transition', () => {
createApp({
template: `
<div id="container">
<Suspense>
<transition>
<Suspense>
<div v-if="toggle" class="test">content</div>
</transition>
</Suspense>
</transition>
</div>
<button id="toggleBtn" @click="click">button</button>
`,
@ -1245,6 +1251,71 @@ describe('e2e: Transition', () => {
},
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', () => {

View File

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

View File

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