]> git.ipfire.org Git - thirdparty/vuejs/core.git/commitdiff
fix(runtime-core): fix suspense crash when patching non-resolved async setup componen...
authormmis1000 <2993977+mmis1000@users.noreply.github.com>
Tue, 12 Dec 2023 13:55:15 +0000 (21:55 +0800)
committerGitHub <noreply@github.com>
Tue, 12 Dec 2023 13:55:15 +0000 (21:55 +0800)
close #5993
close #6463
close #6949
close #6095
close #8121

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

index 6fec755106a196bdfcf91f51d30716b970a95946..d647a96ecddad22948558540fbd86f7776dd43b7 100644 (file)
@@ -537,6 +537,51 @@ describe('Suspense', () => {
     expect(unmounted).not.toHaveBeenCalled()
   })
 
+  // vuetifyjs/vuetify#15207
+  test('update prop of async element before suspense resolve', async () => {
+    let resolve: () => void
+    const mounted = new Promise<void>(r => {
+      resolve = r
+    })
+    const Async = {
+      async setup() {
+        onMounted(() => {
+          resolve()
+        })
+        const p = new Promise(r => setTimeout(r, 1))
+        await p
+        return () => h('div', 'async')
+      }
+    }
+
+    const Comp: ComponentOptions<{ data: string }> = {
+      props: ['data'],
+      setup(props) {
+        return () => h(Async, { 'data-test': props.data })
+      }
+    }
+
+    const Root = {
+      setup() {
+        const data = ref('1')
+        onMounted(() => {
+          data.value = '2'
+        })
+        return () =>
+          h(Suspense, null, {
+            default: h(Comp, { data: data.value }),
+            fallback: h('div', 'fallback')
+          })
+      }
+    }
+
+    const root = nodeOps.createElement('div')
+    render(h(Root), root)
+    expect(serializeInner(root)).toBe(`<div>fallback</div>`)
+    await mounted
+    expect(serializeInner(root)).toBe(`<div data-test="2">async</div>`)
+  })
+
   test('nested suspense (parent resolves first)', async () => {
     const calls: string[] = []
 
index a5f056f385c7bfe92844d7cc49fff52d1bf610cd..0805d5e3f13503b596598d470a5b42b92adb7592 100644 (file)
@@ -812,17 +812,17 @@ describe('SSR hydration', () => {
         })
     )
 
-    const bol = ref(true)
+    const toggle = ref(true)
     const App = {
       setup() {
         onMounted(() => {
           // change state, this makes updateComponent(AsyncComp) execute before
           // the async component is resolved
-          bol.value = false
+          toggle.value = false
         })
 
         return () => {
-          return [bol.value ? 'hello' : 'world', h(AsyncComp)]
+          return [toggle.value ? 'hello' : 'world', h(AsyncComp)]
         }
       }
     }
@@ -859,6 +859,147 @@ describe('SSR hydration', () => {
     )
   })
 
+  test('hydrate safely when property used by async setup changed before render', async () => {
+    const toggle = ref(true)
+
+    const AsyncComp = {
+      async setup() {
+        await new Promise<void>(r => setTimeout(r, 10))
+        return () => h('h1', 'Async component')
+      }
+    }
+
+    const AsyncWrapper = {
+      render() {
+        return h(AsyncComp)
+      }
+    }
+
+    const SiblingComp = {
+      setup() {
+        toggle.value = false
+        return () => h('span')
+      }
+    }
+
+    const App = {
+      setup() {
+        return () =>
+          h(
+            Suspense,
+            {},
+            {
+              default: () => [
+                h('main', {}, [
+                  h(AsyncWrapper, {
+                    prop: toggle.value ? 'hello' : 'world'
+                  }),
+                  h(SiblingComp)
+                ])
+              ]
+            }
+          )
+      }
+    }
+
+    // server render
+    const html = await renderToString(h(App))
+
+    expect(html).toMatchInlineSnapshot(
+      `"<main><h1 prop="hello">Async component</h1><span></span></main>"`
+    )
+
+    expect(toggle.value).toBe(false)
+
+    // hydration
+
+    // reset the value
+    toggle.value = true
+    expect(toggle.value).toBe(true)
+
+    const container = document.createElement('div')
+    container.innerHTML = html
+    createSSRApp(App).mount(container)
+
+    await new Promise(r => setTimeout(r, 10))
+
+    expect(toggle.value).toBe(false)
+
+    // should be hydrated now
+    expect(container.innerHTML).toMatchInlineSnapshot(
+      `"<main><h1 prop="world">Async component</h1><span></span></main>"`
+    )
+  })
+
+  test('hydrate safely when property used by deep nested async setup changed before render', async () => {
+    const toggle = ref(true)
+
+    const AsyncComp = {
+      async setup() {
+        await new Promise<void>(r => setTimeout(r, 10))
+        return () => h('h1', 'Async component')
+      }
+    }
+
+    const AsyncWrapper = { render: () => h(AsyncComp) }
+    const AsyncWrapperWrapper = { render: () => h(AsyncWrapper) }
+
+    const SiblingComp = {
+      setup() {
+        toggle.value = false
+        return () => h('span')
+      }
+    }
+
+    const App = {
+      setup() {
+        return () =>
+          h(
+            Suspense,
+            {},
+            {
+              default: () => [
+                h('main', {}, [
+                  h(AsyncWrapperWrapper, {
+                    prop: toggle.value ? 'hello' : 'world'
+                  }),
+                  h(SiblingComp)
+                ])
+              ]
+            }
+          )
+      }
+    }
+
+    // server render
+    const html = await renderToString(h(App))
+
+    expect(html).toMatchInlineSnapshot(
+      `"<main><h1 prop="hello">Async component</h1><span></span></main>"`
+    )
+
+    expect(toggle.value).toBe(false)
+
+    // hydration
+
+    // reset the value
+    toggle.value = true
+    expect(toggle.value).toBe(true)
+
+    const container = document.createElement('div')
+    container.innerHTML = html
+    createSSRApp(App).mount(container)
+
+    await new Promise(r => setTimeout(r, 10))
+
+    expect(toggle.value).toBe(false)
+
+    // should be hydrated now
+    expect(container.innerHTML).toMatchInlineSnapshot(
+      `"<main><h1 prop="world">Async component</h1><span></span></main>"`
+    )
+  })
+
   // #3787
   test('unmount async wrapper before load', async () => {
     let resolve: any
index 85f58c589166a144f4a0b9b48f4433e45751a572..469dd87cbd9e0f44d84207724ef7521d825dbb8a 100644 (file)
@@ -226,18 +226,26 @@ function patchSuspense(
       if (suspense.deps <= 0) {
         suspense.resolve()
       } else if (isInFallback) {
-        patch(
-          activeBranch,
-          newFallback,
-          container,
-          anchor,
-          parentComponent,
-          null, // fallback tree will not have suspense context
-          namespace,
-          slotScopeIds,
-          optimized
-        )
-        setActiveBranch(suspense, newFallback)
+        // It's possible that the app is in hydrating state when patching the
+        // suspense instance. If someone updates the dependency during component
+        // setup in children of suspense boundary, that would be problemtic
+        // because we aren't actually showing a fallback content when
+        // patchSuspense is called. In such case, patch of fallback content
+        // should be no op
+        if (!isHydrating) {
+          patch(
+            activeBranch,
+            newFallback,
+            container,
+            anchor,
+            parentComponent,
+            null, // fallback tree will not have suspense context
+            namespace,
+            slotScopeIds,
+            optimized
+          )
+          setActiveBranch(suspense, newFallback)
+        }
       }
     } else {
       // toggled before pending tree is resolved
index 8e65b4271c4fa91531624a8bc9c8664df507903b..0411072671994c3929c695684f877936a443b70c 100644 (file)
@@ -1241,6 +1241,10 @@ function baseCreateRenderer(
       if (!initialVNode.el) {
         const placeholder = (instance.subTree = createVNode(Comment))
         processCommentNode(null, placeholder, container!, anchor)
+        // This noramlly gets setup by the following `setupRenderEffect`.
+        // But the call is skipped in initial mounting of async element.
+        // Thus, manually patching is required here or it will result in a crash during parent component update.
+        initialVNode.el = placeholder.el
       }
       return
     }
@@ -1447,10 +1451,34 @@ function baseCreateRenderer(
         // #2458: deference mount-only object parameters to prevent memleaks
         initialVNode = container = anchor = null as any
       } else {
+        let { next, bu, u, parent, vnode } = instance
+
+        if (__FEATURE_SUSPENSE__) {
+          const nonHydratedAsyncRoot = locateNonHydratedAsyncRoot(instance)
+          // we are trying to update some async comp before hydration
+          // this will cause crash because we don't know the root node yet
+          if (nonHydratedAsyncRoot) {
+            // only sync the properties and abort the rest of operations
+            toggleRecurse(instance, false)
+            if (next) {
+              next.el = vnode.el
+              updateComponentPreRender(instance, next, optimized)
+            }
+            toggleRecurse(instance, true)
+            // and continue the rest of operations once the deps are resolved
+            nonHydratedAsyncRoot.asyncDep!.then(() => {
+              // the instance may be destroyed during the time period
+              if (!instance.isUnmounted) {
+                componentUpdateFn()
+              }
+            })
+            return
+          }
+        }
+
         // updateComponent
         // This is triggered by mutation of component's own state (next: null)
         // OR parent calling processComponent (next: VNode)
-        let { next, bu, u, parent, vnode } = instance
         let originNext = next
         let vnodeHook: VNodeHook | null | undefined
         if (__DEV__) {
@@ -2489,3 +2517,16 @@ function getSequence(arr: number[]): number[] {
   }
   return result
 }
+
+function locateNonHydratedAsyncRoot(
+  instance: ComponentInternalInstance
+): ComponentInternalInstance | undefined {
+  const subComponent = instance.subTree.component
+  if (subComponent) {
+    if (subComponent.asyncDep && !subComponent.asyncResolved) {
+      return subComponent
+    } else {
+      return locateNonHydratedAsyncRoot(subComponent)
+    }
+  }
+}