Ver código fonte

feat(runtime-vapor): hydrate async setup component under VDOM Suspense (#14691)

edison 2 semanas atrás
pai
commit
3be149e265

+ 17 - 4
packages/runtime-core/src/apiSetupHelpers.ts

@@ -531,6 +531,10 @@ export function createPropsRestProxy(
 export function withAsyncContext(getAwaitable: () => any): [any, () => void] {
   const ctx = getCurrentGenericInstance()!
   const inSSRSetup = isInSSRComponentSetup
+  const restoreAsyncContext =
+    ctx && ctx.restoreAsyncContext
+      ? ctx.restoreAsyncContext.bind(ctx)
+      : undefined
   if (__DEV__ && !ctx) {
     warn(
       `withAsyncContext called without active current instance. ` +
@@ -548,6 +552,7 @@ export function withAsyncContext(getAwaitable: () => any): [any, () => void] {
     if (inSSRSetup) {
       setInSSRSetupState(true)
     }
+    return restoreAsyncContext && restoreAsyncContext()
   }
 
   // Never restore a captured "prev" instance here: in concurrent async setup
@@ -562,19 +567,27 @@ export function withAsyncContext(getAwaitable: () => any): [any, () => void] {
 
   if (isPromise(awaitable)) {
     awaitable = awaitable.catch(e => {
-      restore()
+      const reset = restore()
       // Defer cleanup so the async function's catch continuation
       // still runs with the restored instance.
-      Promise.resolve().then(() => Promise.resolve().then(cleanup))
+      Promise.resolve().then(() =>
+        Promise.resolve().then(() => {
+          if (reset) reset()
+          cleanup()
+        }),
+      )
       throw e
     })
   }
   return [
     awaitable,
     () => {
-      restore()
+      const reset = restore()
       // Keep instance for the current continuation, then cleanup.
-      Promise.resolve().then(cleanup)
+      Promise.resolve().then(() => {
+        if (reset) reset()
+        cleanup()
+      })
     },
   ]
 }

+ 5 - 0
packages/runtime-core/src/component.ts

@@ -485,6 +485,11 @@ export interface GenericComponentInstance {
    * @internal
    */
   asyncResolved: boolean
+  /**
+   * restore renderer-specific async context after `withAsyncContext()`
+   * @internal
+   */
+  restoreAsyncContext?: () => void | (() => void)
   /**
    * `updateTeleportCssVars`
    * For updating css vars on contained teleports

+ 296 - 2
packages/runtime-vapor/__tests__/hydration.spec.ts

@@ -4767,8 +4767,302 @@ describe('Vapor Mode hydration', () => {
           expect(beforeMount).toHaveBeenCalledTimes(1)
         })
 
-        test.todo('hydrate safely when property used by async setup changed before render', async () => {})
-        test.todo('hydrate safely when property used by deep nested async setup changed before render', async () => {})
+        test('hydrate safely when property used by async setup changed before render', async () => {
+          const data = ref({ toggle: true })
+          const vaporChildCode = `
+            <script vapor>
+              defineProps(['prop'])
+              await new Promise(r => setTimeout(r, 10))
+            </script>
+            <template><h1>{{ prop }}</h1></template>
+          `
+          const wrapperCode = `
+            <script setup>
+              const props = defineProps(['prop'])
+              const components = _components
+            </script>
+            <template>
+              <components.VaporChild :prop="props.prop" />
+            </template>
+          `
+          const siblingCode = `
+            <script setup>
+              const data = _data
+              data.value.toggle = false
+            </script>
+            <template><span/></template>
+          `
+          const appCode = `
+            <script setup>
+              import { Suspense } from 'vue'
+              const data = _data
+              const components = _components
+            </script>
+            <template>
+              <Suspense>
+                <main>
+                  <components.AsyncWrapper :prop="data.toggle ? 'hello' : 'world'" />
+                  <components.SiblingComp />
+                </main>
+              </Suspense>
+            </template>
+          `
+
+          const serverComponents: any = {}
+          const clientComponents: any = {}
+          serverComponents.VaporChild = compile(
+            vaporChildCode,
+            data,
+            serverComponents,
+            {
+              vapor: true,
+              ssr: true,
+            },
+          )
+          clientComponents.VaporChild = compile(
+            vaporChildCode,
+            data,
+            clientComponents,
+            {
+              vapor: true,
+              ssr: false,
+            },
+          )
+          serverComponents.AsyncWrapper = compile(
+            wrapperCode,
+            data,
+            serverComponents,
+            {
+              vapor: false,
+              ssr: true,
+            },
+          )
+          clientComponents.AsyncWrapper = compile(
+            wrapperCode,
+            data,
+            clientComponents,
+            {
+              vapor: false,
+              ssr: false,
+            },
+          )
+          serverComponents.SiblingComp = compile(
+            siblingCode,
+            data,
+            serverComponents,
+            {
+              vapor: false,
+              ssr: true,
+            },
+          )
+          clientComponents.SiblingComp = compile(
+            siblingCode,
+            data,
+            clientComponents,
+            {
+              vapor: false,
+              ssr: false,
+            },
+          )
+
+          const serverApp = compile(appCode, data, serverComponents, {
+            vapor: false,
+            ssr: true,
+          })
+          const html = await VueServerRenderer.renderToString(
+            runtimeDom.createSSRApp(serverApp),
+          )
+
+          expect(html).toBe('<main><h1>hello</h1><span></span></main>')
+          expect(data.value.toggle).toBe(false)
+
+          data.value.toggle = true
+
+          const clientApp = compile(appCode, data, clientComponents, {
+            vapor: false,
+            ssr: false,
+          })
+
+          const container = document.createElement('div')
+          container.innerHTML = html
+          document.body.appendChild(container)
+
+          const app = runtimeDom.createSSRApp(clientApp)
+          app.use(runtimeVapor.vaporInteropPlugin)
+          app.mount(container)
+          expect(container.innerHTML).toBe(html)
+
+          await new Promise(r => setTimeout(r, 10))
+          await nextTick()
+
+          expect(data.value.toggle).toBe(false)
+          expect(`Hydration node mismatch`).not.toHaveBeenWarned()
+          expect(`Hydration children mismatch`).not.toHaveBeenWarned()
+          expect(container.innerHTML).toBe(
+            '<main><h1>world</h1><span></span></main>',
+          )
+        })
+
+        test('hydrate safely when property used by deep nested async setup changed before render', async () => {
+          const data = ref({ toggle: true })
+          const vaporChildCode = `
+            <script vapor>
+              defineProps(['prop'])
+              await new Promise(r => setTimeout(r, 10))
+            </script>
+            <template><h1>{{ prop }}</h1></template>
+          `
+          const wrapperCode = `
+            <script setup>
+              const components = _components
+            </script>
+            <template>
+              <components.VaporChild v-bind="$attrs" />
+            </template>
+          `
+          const wrapperWrapperCode = `
+            <script setup>
+              const components = _components
+            </script>
+            <template>
+              <components.AsyncWrapper v-bind="$attrs" />
+            </template>
+          `
+          const siblingCode = `
+            <script setup>
+              const data = _data
+              data.value.toggle = false
+            </script>
+            <template><span/></template>
+          `
+          const appCode = `
+            <script setup>
+              import { Suspense } from 'vue'
+              const data = _data
+              const components = _components
+            </script>
+            <template>
+              <Suspense>
+                <main>
+                  <components.AsyncWrapperWrapper :prop="data.toggle ? 'hello' : 'world'" />
+                  <components.SiblingComp />
+                </main>
+              </Suspense>
+            </template>
+          `
+
+          const serverComponents: any = {}
+          const clientComponents: any = {}
+          serverComponents.VaporChild = compile(
+            vaporChildCode,
+            data,
+            serverComponents,
+            {
+              vapor: true,
+              ssr: true,
+            },
+          )
+          clientComponents.VaporChild = compile(
+            vaporChildCode,
+            data,
+            clientComponents,
+            {
+              vapor: true,
+              ssr: false,
+            },
+          )
+          serverComponents.AsyncWrapper = compile(
+            wrapperCode,
+            data,
+            serverComponents,
+            {
+              vapor: false,
+              ssr: true,
+            },
+          )
+          clientComponents.AsyncWrapper = compile(
+            wrapperCode,
+            data,
+            clientComponents,
+            {
+              vapor: false,
+              ssr: false,
+            },
+          )
+          serverComponents.AsyncWrapperWrapper = compile(
+            wrapperWrapperCode,
+            data,
+            serverComponents,
+            {
+              vapor: false,
+              ssr: true,
+            },
+          )
+          clientComponents.AsyncWrapperWrapper = compile(
+            wrapperWrapperCode,
+            data,
+            clientComponents,
+            {
+              vapor: false,
+              ssr: false,
+            },
+          )
+          serverComponents.SiblingComp = compile(
+            siblingCode,
+            data,
+            serverComponents,
+            {
+              vapor: false,
+              ssr: true,
+            },
+          )
+          clientComponents.SiblingComp = compile(
+            siblingCode,
+            data,
+            clientComponents,
+            {
+              vapor: false,
+              ssr: false,
+            },
+          )
+
+          const serverApp = compile(appCode, data, serverComponents, {
+            vapor: false,
+            ssr: true,
+          })
+          const html = await VueServerRenderer.renderToString(
+            runtimeDom.createSSRApp(serverApp),
+          )
+
+          expect(html).toBe('<main><h1>hello</h1><span></span></main>')
+          expect(data.value.toggle).toBe(false)
+
+          data.value.toggle = true
+
+          const clientApp = compile(appCode, data, clientComponents, {
+            vapor: false,
+            ssr: false,
+          })
+
+          const container = document.createElement('div')
+          container.innerHTML = html
+          document.body.appendChild(container)
+
+          const app = runtimeDom.createSSRApp(clientApp)
+          app.use(runtimeVapor.vaporInteropPlugin)
+          app.mount(container)
+          expect(container.innerHTML).toBe(html)
+
+          await new Promise(r => setTimeout(r, 10))
+          await nextTick()
+
+          expect(data.value.toggle).toBe(false)
+          expect(`Hydration node mismatch`).not.toHaveBeenWarned()
+          expect(`Hydration children mismatch`).not.toHaveBeenWarned()
+          expect(container.innerHTML).toBe(
+            '<main><h1>world</h1><span></span></main>',
+          )
+        })
       })
 
       // required vapor Suspense

+ 22 - 0
packages/runtime-vapor/src/apiSetupHelpers.ts

@@ -0,0 +1,22 @@
+import {
+  withAsyncContext as baseWithAsyncContext,
+  currentInstance,
+} from '@vue/runtime-dom'
+import {
+  currentHydrationNode,
+  enterHydration,
+  isHydrating,
+} from './dom/hydration'
+import type { VaporComponentInstance } from './component'
+
+export function withAsyncContext(getAwaitable: () => any): [any, () => void] {
+  const instance = currentInstance as VaporComponentInstance | null
+  if (isHydrating && instance && instance.vapor) {
+    const hydrationNode = currentHydrationNode!
+    // After `__restore()` brings back the component instance, vapor still needs
+    // its own hydration state restored so setup can continue adopting SSR nodes.
+    instance.restoreAsyncContext = () => enterHydration(hydrationNode)
+  }
+
+  return baseWithAsyncContext(getAwaitable)
+}

+ 12 - 2
packages/runtime-vapor/src/component.ts

@@ -579,6 +579,7 @@ export class VaporComponentInstance<
   suspenseId: number
   asyncDep: Promise<any> | null
   asyncResolved: boolean
+  restoreAsyncContext?: () => void | (() => void)
 
   // for vapor custom element
   renderEffects?: RenderEffect[]
@@ -867,8 +868,17 @@ export function mountComponent(
   ) {
     const component = instance.type
     instance.suspense.registerDep(instance, setupResult => {
-      handleSetupResult(setupResult, component, instance)
-      mountComponent(instance, parent, anchor)
+      // Final suspense retry after async setup resolves. Restore hydrating
+      // mode so the last mount does not fall back to fresh DOM insertion.
+      const reset =
+        instance.restoreAsyncContext && instance.restoreAsyncContext()
+      try {
+        handleSetupResult(setupResult, component, instance)
+        mountComponent(instance, parent, anchor)
+      } finally {
+        instance.restoreAsyncContext = undefined
+        if (reset) reset()
+      }
     })
     return
   }

+ 19 - 0
packages/runtime-vapor/src/dom/hydration.ts

@@ -89,6 +89,25 @@ export function hydrateNode(node: Node, fn: () => void): void {
   return performHydration(fn, setup, cleanup)
 }
 
+export function enterHydration(node: Node): () => void {
+  const prevHydrationEnabled = isHydratingEnabled
+  if (!prevHydrationEnabled) {
+    setIsHydratingEnabled(true)
+  }
+
+  const prev = setIsHydrating(true)
+  const prevHydrationNode = currentHydrationNode
+  currentHydrationNode = node
+
+  return () => {
+    currentHydrationNode = prevHydrationNode
+    setIsHydrating(prev)
+    if (!prevHydrationEnabled) {
+      setIsHydratingEnabled(false)
+    }
+  }
+}
+
 export let adoptTemplate: (node: Node, template: string) => Node | null
 export let locateHydrationNode: (consumeFragmentStart?: boolean) => void
 

+ 1 - 0
packages/runtime-vapor/src/index.ts

@@ -66,6 +66,7 @@ export { createTemplateRefSetter } from './apiTemplateRef'
 export { useVaporCssVars } from './helpers/useCssVars'
 export { setBlockKey } from './helpers/setKey'
 export { createDynamicComponent } from './apiCreateDynamicComponent'
+export { withAsyncContext } from './apiSetupHelpers'
 export { applyVShow } from './directives/vShow'
 export {
   applyTextModel,

+ 1 - 0
packages/vue/src/index-with-vapor.ts

@@ -1,3 +1,4 @@
 // for type generation only
 export * from './index'
 export * from '@vue/runtime-vapor'
+export { withAsyncContext } from '@vue/runtime-vapor'

+ 1 - 0
packages/vue/src/runtime-with-vapor.ts

@@ -1,2 +1,3 @@
 export * from './runtime'
 export * from '@vue/runtime-vapor'
+export { withAsyncContext } from '@vue/runtime-vapor'