]> git.ipfire.org Git - thirdparty/vuejs/core.git/commitdiff
feat: update Suspense usage (#2099)
authorEvan You <yyx990803@gmail.com>
Tue, 15 Sep 2020 16:45:06 +0000 (12:45 -0400)
committerGitHub <noreply@github.com>
Tue, 15 Sep 2020 16:45:06 +0000 (12:45 -0400)
See https://github.com/vuejs/vue-next/pull/2099 for details.

17 files changed:
packages/runtime-core/__tests__/apiAsyncComponent.spec.ts
packages/runtime-core/__tests__/components/Suspense.spec.ts
packages/runtime-core/__tests__/hydration.spec.ts
packages/runtime-core/src/component.ts
packages/runtime-core/src/components/BaseTransition.ts
packages/runtime-core/src/components/KeepAlive.ts
packages/runtime-core/src/components/Suspense.ts
packages/runtime-core/src/hydration.ts
packages/runtime-core/src/index.ts
packages/runtime-core/src/renderer.ts
packages/runtime-core/src/vnode.ts
packages/runtime-dom/src/helpers/useCssVars.ts
packages/server-renderer/src/helpers/ssrRenderSuspense.ts
packages/server-renderer/src/render.ts
packages/vue/__tests__/Transition.spec.ts
packages/vue/examples/classic/markdown.html
test-dts/tsx.test-d.tsx

index 5c3f6a6b85e17153d64aa30f74b1a94c69020a29..0ba6079ab3da13b630cd10723c97df87e5f260ad 100644 (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 () => {
index ffc9f903ffac6a923ed0b24b5971c1e991effe87..e4a578043147e04d64a5ae6ce426ba58179ffb49 100644 (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() {
+          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(`<div>fallback</div>`)
+    expect(calls).toEqual([])
+
+    await deps[0]
+    await nextTick()
+    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()
+    // 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(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(`<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>one</div>`)
+    expect(calls).toEqual([`one mounted`])
 
-    toggle.value = true
+    view.value = Two
     await nextTick()
-    expect(serializeInner(root)).toBe(`<div>root fallback</div>`)
+    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(`<div>Child A</div><div>Child B</div>`)
+    // 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.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(`<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>two</div>`)
+    expect(calls).toEqual([`one mounted`, `one unmounted`, `two mounted`])
+  })
 })
index fa89631d2017e655e252e631438c09239dfc933a..60978622bc6a564de481f67fd0218b35b7036a1d 100644 (file)
@@ -506,8 +506,10 @@ describe('SSR hydration', () => {
     const App = {
       template: `
       <Suspense @resolve="done">
-        <AsyncChild :n="1" />
-        <AsyncChild :n="2" />
+        <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 () => {
index 5a74541eb74e90725e07a9ed4a87ee4fe8617eb6..ac482d6707a7b1879eedebca54cce967220e056f 100644 (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,
 
index 3ca38498d9b2991cb4de219d6d5f9140b2af143f..fc0ebb9e42c040c07055a113aa9d0ebddfa41a10 100644 (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
   }
index 11fdd0fc6218a363b1f58599f1b39335842c3990..3259ca685c6f50845716250abb50060af2521ed6 100644 (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
+}
index 89ed331b0b7f72db410acde9241d9ab5a53c6fd0..129e62c2f8fdea13f1bcce3e4b3e90c2e52fd0b9 100644 (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,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(`<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)
+  }
+}
index c2d13a1986d59749710925ba09ba5482b50b3de9..f54beecf988e0bb257eb49d0fee2d42f0a84e935 100644 (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++) {
index 9449266111373409c5c277d3646b4cb506acb1d6..bc6cedb517b87251e029d688b59345a57d9efc9c 100644 (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
 }
 
 /**
index 5246fcb1baa478c93a43b235fa44c13025a09163..f38fc9da13497c4ca90c758ea6805301af1ff014 100644 (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) {
index a34358a9ccc0e8bdf72a893f8d96ba4f47f6bd43..9a11090a37cf7d1fba36e55c504a39779c155d40 100644 (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
   }
index b41a0657ca304682d827ba92b06c06daf57bcf2a..86d571c2c356b23d2b23da5742530af1b2c57583 100644 (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
     }
   }
 
index 3d6df47fef771e6797cb8d4512982365ca53e284..f02b85d17962ec9047862577864e69f29c2d1181 100644 (file)
@@ -5,9 +5,7 @@ export async function ssrRenderSuspense(
   { default: renderContent }: Record<string, (() => void) | undefined>
 ) {
   if (renderContent) {
-    push(`<!--[-->`)
     renderContent()
-    push(`<!--]-->`)
   } else {
     push(`<!---->`)
   }
index 44ec2be14fe790934163554c74ca47bbb10cf335..f92e3d2bea9840e467e19a51cf85a833e0407340 100644 (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:',
index 70f038609ba7de3d1d7982a3757fff0a093569c8..93959e45d5008332f1d69b5ad1fa42bc99d007cf 100644 (file)
@@ -1115,12 +1115,11 @@ describe('e2e: Transition', () => {
           createApp({
             template: `
             <div id="container">
-              <Suspense>
-                <transition @enter="onEnterSpy"
-                            @leave="onLeaveSpy">
+              <transition @enter="onEnterSpy" @leave="onLeaveSpy">
+                <Suspense>
                   <Comp v-if="toggle" class="test">content</Comp>
-                </transition>
-              </Suspense>
+                </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>
+              <transition>
+                <Suspense>
                   <div v-if="toggle" class="test">content</div>
-                </transition>
-              </Suspense>
+                </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', () => {
index 3d9126dd33824b6a94bf49c09f7512d141491932..da6e5baa44918810c5b2c798b67e397d1bfa2119 100644 (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: () => ({
index 516576362087fb3c1f1c171f904862ff2ba0f20a..70db5f40158ba645444bebe5b0199322d71f1f3a 100644 (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} />)