diff --git a/packages/runtime-core/__tests__/apiAsyncComponent.spec.ts b/packages/runtime-core/__tests__/apiAsyncComponent.spec.ts index 5c3f6a6b85e..0ba6079ab3d 100644 --- a/packages/runtime-core/__tests__/apiAsyncComponent.spec.ts +++ b/packages/runtime-core/__tests__/apiAsyncComponent.spec.ts @@ -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('
resolved & resolved
') }) 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('
&
') resolve!(() => 'resolved') await timeout() - expect(serializeInner(root)).toBe('resolved & resolved') + expect(serializeInner(root)).toBe('
resolved & resolved
') }) 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('
&
') }) test('retry (success)', async () => { diff --git a/packages/runtime-core/__tests__/components/Suspense.spec.ts b/packages/runtime-core/__tests__/components/Suspense.spec.ts index ffc9f903ffa..e4a57804314 100644 --- a/packages/runtime-core/__tests__/components/Suspense.spec.ts +++ b/packages/runtime-core/__tests__/components/Suspense.spec.ts @@ -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( - `
async outer
fallback inner
` + `
async outer
fallback inner
` ) expect(calls).toEqual([`outer mounted`]) await Promise.all(deps) await nextTick() expect(serializeInner(root)).toBe( - `
async outer
async inner
` + `
async outer
async inner
` ) 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( - `
async outer
async inner
` + `
async outer
async inner
` ) 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( - `
nested fallback
root async
` + `
nested fallback
root async
` ) expect(calls).toEqual([0, 1, 3]) @@ -733,7 +734,7 @@ describe('Suspense', () => { await Promise.all(deps) await nextTick() expect(serializeInner(root)).toBe( - `
nested changed
root async
` + `
nested changed
root async
` ) expect(calls).toEqual([0, 1, 3, 2]) @@ -741,51 +742,316 @@ describe('Suspense', () => { msg.value = 'nested changed again' await nextTick() expect(serializeInner(root)).toBe( - `
nested changed again
root async
` + `
nested changed again
root async
` ) }) - 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() { + 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('div', 'Child B') + return () => + h(Suspense, null, { + default: toggle.value ? h(Foo) : h(Bar), + fallback: h('div', 'fallback') + }) } - }) + } + + const root = nodeOps.createElement('div') + render(h(Comp), root) + expect(serializeInner(root)).toBe(`
fallback
`) + expect(calls).toEqual([]) + + await deps[0] + await nextTick() + expect(serializeInner(root)).toBe(`
fallback
`) + expect(calls).toEqual([]) + + await Promise.all(deps) + await nextTick() + expect(calls).toEqual([`foo mounted`, `foo nested mounted`]) + expect(serializeInner(root)).toBe(`
foo
foo nested
`) + + // 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(`
foo
foo nested
`) + + 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(`
bar
`) + + // toggle back + toggle.value = true + await nextTick() + // should remain + expect(calls).toEqual(tempCalls) + expect(serializeInner(root)).toBe(`
bar
`) + + await deps[3] + await nextTick() + // still pending... + expect(calls).toEqual(tempCalls) + expect(serializeInner(root)).toBe(`
bar
`) + + await Promise.all(deps) + await nextTick() + expect(calls).toEqual([ + ...tempCalls, + `foo mounted`, + `foo nested mounted`, + `bar unmounted` + ]) + expect(serializeInner(root)).toBe(`
foo
foo nested
`) + }) + + 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(ChildA), toggle.value ? h(ChildB) : null], - fallback: h('div', 'root fallback') + default: h(view.value), + fallback: h('div', 'fallback') }) } } const root = nodeOps.createElement('div') render(h(Comp), root) - expect(serializeInner(root)).toBe(`
root fallback
`) + expect(serializeInner(root)).toBe(`
fallback
`) + expect(calls).toEqual([]) await deps[0] await nextTick() - expect(serializeInner(root)).toBe(`
Child A
`) + expect(serializeInner(root)).toBe(`
one
`) + expect(calls).toEqual([`one mounted`]) - toggle.value = true + view.value = Two await nextTick() - expect(serializeInner(root)).toBe(`
root fallback
`) + 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() - expect(serializeInner(root)).toBe(`
Child A
Child B
`) + // should still be on view one + expect(serializeInner(root)).toBe(`
one
`) + expect(calls).toEqual([`one mounted`]) + + await deps[2] + await nextTick() + expect(serializeInner(root)).toBe(`
three
`) + expect(calls).toEqual([`one mounted`, `three mounted`, `one unmounted`]) }) - test.todo('teleport inside suspense') + 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(`
fallback
`) + expect(calls).toEqual([]) + + await deps[0] + await nextTick() + expect(serializeInner(root)).toBe(`
one
`) + 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(`
one
`) + 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(`
fallback
`) + expect(calls).toEqual([]) + + await deps[0] + await nextTick() + expect(serializeInner(root)).toBe(`
one
`) + expect(calls).toEqual([`one mounted`]) + + view.value = Two + await nextTick() + expect(serializeInner(root)).toBe(`
one
`) + expect(calls).toEqual([`one mounted`]) + + await new Promise(r => setTimeout(r, 10)) + await nextTick() + expect(serializeInner(root)).toBe(`
fallback
`) + expect(calls).toEqual([`one mounted`, `one unmounted`]) + + await deps[1] + await nextTick() + expect(serializeInner(root)).toBe(`
two
`) + expect(calls).toEqual([`one mounted`, `one unmounted`, `two mounted`]) + }) }) diff --git a/packages/runtime-core/__tests__/hydration.spec.ts b/packages/runtime-core/__tests__/hydration.spec.ts index fa89631d201..60978622bc6 100644 --- a/packages/runtime-core/__tests__/hydration.spec.ts +++ b/packages/runtime-core/__tests__/hydration.spec.ts @@ -506,8 +506,10 @@ describe('SSR hydration', () => { const App = { template: ` - - +
+ + +
`, components: { AsyncChild @@ -521,7 +523,7 @@ describe('SSR hydration', () => { // server render container.innerHTML = await renderToString(h(App)) expect(container.innerHTML).toMatchInlineSnapshot( - `"12"` + `"
12
"` ) // 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(`12`) + expect(container.innerHTML).toMatch( + `
12
` + ) const span1 = container.querySelector('span')! triggerEvent('click', span1) await nextTick() - expect(container.innerHTML).toMatch(`22`) + expect(container.innerHTML).toMatch( + `
22
` + ) const span2 = span1.nextSibling as Element triggerEvent('click', span2) await nextTick() - expect(container.innerHTML).toMatch(`23`) + expect(container.innerHTML).toMatch( + `
23
` + ) }) test('async component', async () => { diff --git a/packages/runtime-core/src/component.ts b/packages/runtime-core/src/component.ts index 5a74541eb74..ac482d6707a 100644 --- a/packages/runtime-core/src/component.ts +++ b/packages/runtime-core/src/component.ts @@ -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, diff --git a/packages/runtime-core/src/components/BaseTransition.ts b/packages/runtime-core/src/components/BaseTransition.ts index 3ca38498d9b..fc0ebb9e42c 100644 --- a/packages/runtime-core/src/components/BaseTransition.ts +++ b/packages/runtime-core/src/components/BaseTransition.ts @@ -52,10 +52,13 @@ export interface BaseTransitionProps { 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 + // 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, + state: TransitionState, + instance: ComponentInternalInstance +): TransitionHooks { + const { appear, + mode, persisted = false, onBeforeEnter, onEnter, @@ -286,10 +295,7 @@ export function resolveTransitionHooks( onAppear, onAfterAppear, onAppearCancelled - }: BaseTransitionProps, - 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 = { + 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 } diff --git a/packages/runtime-core/src/components/KeepAlive.ts b/packages/runtime-core/src/components/KeepAlive.ts index 11fdd0fc621..3259ca685c6 100644 --- a/packages/runtime-core/src/components/KeepAlive.ts +++ b/packages/runtime-core/src/components/KeepAlive.ts @@ -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 +} diff --git a/packages/runtime-core/src/components/Suspense.ts b/packages/runtime-core/src/components/Suspense.ts index 89ed331b0b7..129e62c2f8f 100644 --- a/packages/runtime-core/src/components/Suspense.ts +++ b/packages/runtime-core/src/components/Suspense.ts @@ -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,57 +153,172 @@ 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) { - patch( - oldSubTree, - content, - suspense.hiddenContainer, - null, - parentComponent, - suspense, - isSVG, - optimized - ) - if (suspense.deps > 0) { - // still pending. patch the fallback tree. + 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( - oldFallbackTree, - fallback, + pendingBranch, + newBranch, + suspense.hiddenContainer, + null, + parentComponent, + suspense, + isSVG, + optimized + ) + 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() + } + } + } + } else { + if (activeBranch && isSameVNodeType(newBranch, activeBranch)) { + // root did not change, just normal patch + patch( + activeBranch, + newBranch, container, anchor, parentComponent, - null, // fallback tree will not have suspense context + suspense, isSVG, optimized ) - n2.el = fallback.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) + } + } } - // 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 - patch( - oldSubTree, - content, - container, - anchor, - parentComponent, - suspense, - isSVG, - optimized - ) - n2.el = content.el } - 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(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!) - // 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,64 +488,77 @@ 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 - patch( - null, - fallbackTree, - container, - anchor, + // 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, + 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, // fallback tree will not have suspense context - isSVG, - optimized + null, // no suspense so unmount hooks fire now + true // shouldRemove ) - 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 onRecede = vnode.props && vnode.props.onRecede - if (isFunction(onRecede)) { - onRecede() + 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.activeBranch, + parentComponent, + parentSuspense, + doRemove + ) + } + if (suspense.pendingBranch) { unmount( - suspense.fallbackTree, + 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 { - return { - content: normalizeVNode(children as VNodeChild), - fallback: normalizeVNode(null) + content = normalizeSuspenseSlot(children as VNodeChild) + 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(` 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) + } +} diff --git a/packages/runtime-core/src/hydration.ts b/packages/runtime-core/src/hydration.ts index c2d13a1986d..f54beecf988 100644 --- a/packages/runtime-core/src/hydration.ts +++ b/packages/runtime-core/src/hydration.ts @@ -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++) { diff --git a/packages/runtime-core/src/index.ts b/packages/runtime-core/src/index.ts index 94492661113..bc6cedb517b 100644 --- a/packages/runtime-core/src/index.ts +++ b/packages/runtime-core/src/index.ts @@ -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 } /** diff --git a/packages/runtime-core/src/renderer.ts b/packages/runtime-core/src/renderer.ts index 5246fcb1baa..f38fc9da134 100644 --- a/packages/runtime-core/src/renderer.ts +++ b/packages/runtime-core/src/renderer.ts @@ -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) { diff --git a/packages/runtime-core/src/vnode.ts b/packages/runtime-core/src/vnode.ts index a34358a9ccc..9a11090a37c 100644 --- a/packages/runtime-core/src/vnode.ts +++ b/packages/runtime-core/src/vnode.ts @@ -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 | 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( // 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 } diff --git a/packages/runtime-dom/src/helpers/useCssVars.ts b/packages/runtime-dom/src/helpers/useCssVars.ts index b41a0657ca3..86d571c2c35 100644 --- a/packages/runtime-dom/src/helpers/useCssVars.ts +++ b/packages/runtime-dom/src/helpers/useCssVars.ts @@ -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 } } diff --git a/packages/server-renderer/src/helpers/ssrRenderSuspense.ts b/packages/server-renderer/src/helpers/ssrRenderSuspense.ts index 3d6df47fef7..f02b85d1796 100644 --- a/packages/server-renderer/src/helpers/ssrRenderSuspense.ts +++ b/packages/server-renderer/src/helpers/ssrRenderSuspense.ts @@ -5,9 +5,7 @@ export async function ssrRenderSuspense( { default: renderContent }: Record void) | undefined> ) { if (renderContent) { - push(``) renderContent() - push(``) } else { push(``) } diff --git a/packages/server-renderer/src/render.ts b/packages/server-renderer/src/render.ts index 44ec2be14fe..f92e3d2bea9 100644 --- a/packages/server-renderer/src/render.ts +++ b/packages/server-renderer/src/render.ts @@ -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:', diff --git a/packages/vue/__tests__/Transition.spec.ts b/packages/vue/__tests__/Transition.spec.ts index 70f038609ba..93959e45d50 100644 --- a/packages/vue/__tests__/Transition.spec.ts +++ b/packages/vue/__tests__/Transition.spec.ts @@ -1115,12 +1115,11 @@ describe('e2e: Transition', () => { createApp({ template: `
- - + + content - - + +
`, @@ -1138,6 +1137,13 @@ describe('e2e: Transition', () => { } }).mount('#app') }) + + expect(onEnterSpy).toBeCalledTimes(1) + await nextFrame() + expect(await html('#container')).toBe( + '
content
' + ) + await transitionFinish() expect(await html('#container')).toBe('
content
') // 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: `
- - + +
content
-
-
+ +
`, @@ -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: ` +
+ + + + + +
+ + `, + 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( + '
one
' + ) + await transitionFinish() + expect(await html('#container')).toBe('
one
') + + // leave + await classWhenTransitionStart() + await nextFrame() + expect(await html('#container')).toBe( + '
one
' + ) + await transitionFinish() + expect(await html('#container')).toBe( + '
two
' + ) + await transitionFinish() + expect(await html('#container')).toBe('
two
') + }, + E2E_TIMEOUT + ) }) describe('transition with v-show', () => { diff --git a/packages/vue/examples/classic/markdown.html b/packages/vue/examples/classic/markdown.html index 3d9126dd338..da6e5baa449 100644 --- a/packages/vue/examples/classic/markdown.html +++ b/packages/vue/examples/classic/markdown.html @@ -8,7 +8,7 @@