]> git.ipfire.org Git - thirdparty/vuejs/core.git/commitdiff
feat(suspense): introduce suspensible option for `<Suspense>` (#6736)
authorAnthony Fu <anthonyfu117@hotmail.com>
Fri, 21 Apr 2023 06:43:30 +0000 (08:43 +0200)
committerGitHub <noreply@github.com>
Fri, 21 Apr 2023 06:43:30 +0000 (14:43 +0800)
close #5513

packages/runtime-core/__tests__/components/Suspense.spec.ts
packages/runtime-core/src/components/Suspense.ts

index b748f52011f3e6e20956df084bed84346a3f1089..80d213fc790acf7f7821a7eaa2534cbc1e3bc91f 100644 (file)
@@ -16,7 +16,8 @@ import {
   watchEffect,
   onUnmounted,
   onErrorCaptured,
-  shallowRef
+  shallowRef,
+  Fragment
 } from '@vue/runtime-test'
 import { createApp } from 'vue'
 
@@ -1257,4 +1258,146 @@ describe('Suspense', () => {
       `A component with async setup() must be nested in a <Suspense>`
     ).toHaveBeenWarned()
   })
+
+  test('nested suspense with suspensible', async () => {
+    const calls: string[] = []
+    let expected = ''
+
+    const InnerA = defineAsyncComponent(
+      {
+        setup: () => {
+          calls.push('innerA created')
+          onMounted(() => {
+            calls.push('innerA mounted')
+          })
+          return () => h('div', 'innerA')
+        }
+      },
+      10
+    )
+
+    const InnerB = defineAsyncComponent(
+      {
+        setup: () => {
+          calls.push('innerB created')
+          onMounted(() => {
+            calls.push('innerB mounted')
+          })
+          return () => h('div', 'innerB')
+        }
+      },
+      10
+    )
+
+    const OuterA = defineAsyncComponent(
+      {
+        setup: (_, { slots }: any) => {
+          calls.push('outerA created')
+          onMounted(() => {
+            calls.push('outerA mounted')
+          })
+          return () =>
+            h(Fragment, null, [h('div', 'outerA'), slots.default?.()])
+        }
+      },
+      5
+    )
+
+    const OuterB = defineAsyncComponent(
+      {
+        setup: (_, { slots }: any) => {
+          calls.push('outerB created')
+          onMounted(() => {
+            calls.push('outerB mounted')
+          })
+          return () =>
+            h(Fragment, null, [h('div', 'outerB'), slots.default?.()])
+        }
+      },
+      5
+    )
+
+    const outerToggle = ref(false)
+    const innerToggle = ref(false)
+
+    /**
+     *  <Suspense>
+     *    <component :is="outerToggle ? outerB : outerA">
+     *      <Suspense suspensible>
+     *        <component :is="innerToggle ? innerB : innerA" />
+     *      </Suspense>
+     *    </component>
+     *  </Suspense>
+     */
+    const Comp = {
+      setup() {
+        return () =>
+          h(Suspense, null, {
+            default: [
+              h(outerToggle.value ? OuterB : OuterA, null, {
+                default: () => h(Suspense, { suspensible: true },{
+                  default: h(innerToggle.value ? InnerB : InnerA)
+                })
+              })
+            ],
+            fallback: h('div', 'fallback outer')
+          })
+      }
+    }
+
+    expected = `<div>fallback outer</div>`
+    const root = nodeOps.createElement('div')
+    render(h(Comp), root)
+    expect(serializeInner(root)).toBe(expected)
+
+    // mount outer component
+    await Promise.all(deps)
+    await nextTick()
+
+    expect(serializeInner(root)).toBe(expected)
+    expect(calls).toEqual([`outerA created`])
+
+    // mount inner component
+    await Promise.all(deps)
+    await nextTick()
+    expected = `<div>outerA</div><div>innerA</div>`
+    expect(serializeInner(root)).toBe(expected)
+
+    expect(calls).toEqual([
+      'outerA created',
+      'innerA created',
+      'outerA mounted',
+      'innerA mounted'
+    ])
+
+    // toggle outer component
+    calls.length = 0
+    deps.length = 0
+    outerToggle.value = true
+    await nextTick()
+
+    await Promise.all(deps)
+    await nextTick()
+    expect(serializeInner(root)).toBe(expected) // expect not change
+
+    await Promise.all(deps)
+    await nextTick()
+    expected = `<div>outerB</div><div>innerA</div>`
+    expect(serializeInner(root)).toBe(expected)
+    expect(calls).toContain('outerB mounted')
+    expect(calls).toContain('innerA mounted')
+
+    // toggle inner component
+    calls.length = 0
+    deps.length = 0
+    innerToggle.value = true
+    await nextTick()
+    expect(serializeInner(root)).toBe(expected) // expect not change
+
+    await Promise.all(deps)
+    await nextTick()
+    expected = `<div>outerB</div><div>innerB</div>`
+    expect(serializeInner(root)).toBe(expected)
+    expect(calls).toContain('innerB mounted')
+  })
 })
index 2862178758f85a422c66374cce6e099bdd3f9c22..aef62db4a314a79fc2ad2498d811d09718184596 100644 (file)
@@ -35,6 +35,12 @@ export interface SuspenseProps {
   onPending?: () => void
   onFallback?: () => void
   timeout?: string | number
+  /**
+   * Allow suspense to be captured by parent suspense
+   *
+   * @default false
+   */
+  suspensible?: boolean
 }
 
 export const isSuspense = (type: any): boolean => type.__isSuspense
@@ -395,7 +401,7 @@ let hasWarned = false
 
 function createSuspenseBoundary(
   vnode: VNode,
-  parent: SuspenseBoundary | null,
+  parentSuspense: SuspenseBoundary | null,
   parentComponent: ComponentInternalInstance | null,
   container: RendererElement,
   hiddenContainer: RendererElement,
@@ -423,6 +429,17 @@ function createSuspenseBoundary(
     o: { parentNode, remove }
   } = rendererInternals
 
+  // if set `suspensible: true`, set the current suspense as a dep of parent suspense
+  let parentSuspenseId: number | undefined
+  const isSuspensible =
+    vnode.props?.suspensible != null && vnode.props.suspensible !== false
+  if (isSuspensible) {
+    if (parentSuspense?.pendingBranch) {
+      parentSuspenseId = parentSuspense?.pendingId
+      parentSuspense.deps++
+    }
+  }
+
   const timeout = vnode.props ? toNumber(vnode.props.timeout) : undefined
   if (__DEV__) {
     assertNumber(timeout, `Suspense timeout`)
@@ -430,7 +447,7 @@ function createSuspenseBoundary(
 
   const suspense: SuspenseBoundary = {
     vnode,
-    parent,
+    parent: parentSuspense,
     parentComponent,
     isSVG,
     container,
@@ -522,6 +539,20 @@ function createSuspenseBoundary(
       }
       suspense.effects = []
 
+      // resolve parent suspense if all async deps are resolved
+      if (isSuspensible) {
+        if (
+          parentSuspense &&
+          parentSuspense.pendingBranch &&
+          parentSuspenseId === parentSuspense.pendingId
+        ) {
+          parentSuspense.deps--
+          if (parentSuspense.deps === 0) {
+            parentSuspense.resolve()
+          }
+        }
+      }
+
       // invoke @resolve event
       triggerEvent(vnode, 'onResolve')
     },