Ver código fonte

fix(hydration): keep lazy hydration on the async wrapper path

daiwei 2 semanas atrás
pai
commit
d5cd4c8a58

+ 191 - 7
packages/runtime-vapor/__tests__/hydration.spec.ts

@@ -4611,9 +4611,9 @@ describe('Vapor Mode hydration', () => {
       clientResolve(Comp)
       await new Promise(r => setTimeout(r))
 
-      // prevent lazy hydration since the component has been patched
-      expect('Skipping lazy hydration for component').toHaveBeenWarned()
-      expect(`Hydration node mismatch`).not.toHaveBeenWarned()
+      // vapor lazy hydration always proceeds; drift is corrected by the
+      // mismatch handling path.
+      expect(`Hydration text mismatch`).toHaveBeenWarned()
       expect(container.innerHTML).toMatchInlineSnapshot(
         `"<h1>Updated async component</h1><!--async component-->"`,
       )
@@ -4684,12 +4684,196 @@ describe('Vapor Mode hydration', () => {
       clientResolve(Comp)
       await new Promise(r => setTimeout(r))
 
-      // prevent lazy hydration since the component has been patched
-      expect('Skipping lazy hydration for component').toHaveBeenWarned()
-      expect(`Hydration node mismatch`).not.toHaveBeenWarned()
+      // vapor lazy hydration always proceeds; drift is corrected by the
+      // mismatch handling path.
+      expect(`Hydration text mismatch`).toHaveBeenWarned()
       expect(container.innerHTML).toMatchInlineSnapshot(
-        `"<!--[--><h1>Updated async component</h1><h2>fragment root</h2><!--async component--><!--]-->"`,
+        `"<!--[--><h1>Updated async component</h1><h2>fragment root</h2><!--]--><!--async component-->"`,
+      )
+    })
+
+    test('update async component fallthrough attrs after parent mount before async component resolve', async () => {
+      const data = ref({
+        cls: 'foo',
+      })
+      const compCode = `<div>Async component</div>`
+      const SSRComp = compileVaporComponent(
+        compCode,
+        undefined,
+        undefined,
+        true,
+      )
+      let serverResolve: any
+      let AsyncComp = defineAsyncComponent(
+        () =>
+          new Promise(r => {
+            serverResolve = r
+          }),
+      )
+      const appCode = `<components.AsyncComp :class="data.cls"/>`
+      const SSRApp = compileVaporComponent(appCode, data, { AsyncComp }, true)
+
+      const htmlPromise = VueServerRenderer.renderToString(
+        runtimeDom.createSSRApp(SSRApp),
+      )
+      serverResolve(SSRComp)
+      const html = await htmlPromise
+      expect(html).toMatchInlineSnapshot(
+        `"<div class=\"foo\">Async component</div>"`,
+      )
+
+      let clientResolve: any
+      AsyncComp = defineVaporAsyncComponent(
+        () =>
+          new Promise(r => {
+            clientResolve = r
+          }),
+      ) as any
+
+      const Comp = compileVaporComponent(compCode)
+      const App = compileVaporComponent(appCode, data, { AsyncComp })
+
+      const container = document.createElement('div')
+      container.innerHTML = html
+      document.body.appendChild(container)
+      createVaporSSRApp(App).mount(container)
+
+      data.value.cls = 'bar'
+      await nextTick()
+
+      clientResolve(Comp)
+      await new Promise(r => setTimeout(r))
+
+      expect(`Hydration class mismatch`).toHaveBeenWarned()
+      expect(container.innerHTML).toMatchInlineSnapshot(
+        `"<div class="foo bar">Async component</div><!--async component-->"`,
+      )
+    })
+
+    test('update async component slot content after parent mount before async component resolve', async () => {
+      const data = ref({
+        msg: 'foo',
+      })
+      const compCode = `<div><slot/></div>`
+      const SSRComp = compileVaporComponent(
+        compCode,
+        undefined,
+        undefined,
+        true,
+      )
+      let serverResolve: any
+      let AsyncComp = defineAsyncComponent(
+        () =>
+          new Promise(r => {
+            serverResolve = r
+          }),
+      )
+      const appCode = `<components.AsyncComp><span>{{data.msg}}</span></components.AsyncComp>`
+      const SSRApp = compileVaporComponent(appCode, data, { AsyncComp }, true)
+
+      const htmlPromise = VueServerRenderer.renderToString(
+        runtimeDom.createSSRApp(SSRApp),
+      )
+      serverResolve(SSRComp)
+      const html = await htmlPromise
+      expect(formatHtml(html)).toMatchInlineSnapshot(`
+      	"<div>
+      	<!--[--><span>foo</span><!--]-->
+      	</div>"
+      `)
+
+      let clientResolve: any
+      AsyncComp = defineVaporAsyncComponent(
+        () =>
+          new Promise(r => {
+            clientResolve = r
+          }),
+      ) as any
+
+      const Comp = compileVaporComponent(compCode)
+      const App = compileVaporComponent(appCode, data, { AsyncComp })
+
+      const container = document.createElement('div')
+      container.innerHTML = html
+      document.body.appendChild(container)
+      createVaporSSRApp(App).mount(container)
+
+      data.value.msg = 'bar'
+      await nextTick()
+
+      clientResolve(Comp)
+      await new Promise(r => setTimeout(r))
+
+      expect(`Hydration text mismatch`).toHaveBeenWarned()
+      expect(formatHtml(container.innerHTML)).toMatchInlineSnapshot(`
+      	"<div>
+      	<!--[--><span>bar</span><!--]-->
+      	</div><!--async component-->"
+      `)
+    })
+
+    test.todo('update async component slot structure after parent mount before async component resolve', async () => {
+      const data = ref({
+        show: false,
+        msg: 'bar',
+      })
+      const compCode = `<div><slot/></div>`
+      const SSRComp = compileVaporComponent(
+        compCode,
+        undefined,
+        undefined,
+        true,
       )
+      let serverResolve: any
+      let AsyncComp = defineAsyncComponent(
+        () =>
+          new Promise(r => {
+            serverResolve = r
+          }),
+      )
+      const appCode = `<components.AsyncComp><span v-if="data.show">{{data.msg}}</span></components.AsyncComp>`
+      const SSRApp = compileVaporComponent(appCode, data, { AsyncComp }, true)
+
+      const htmlPromise = VueServerRenderer.renderToString(
+        runtimeDom.createSSRApp(SSRApp),
+      )
+      serverResolve(SSRComp)
+      const html = await htmlPromise
+      expect(formatHtml(html)).toMatchInlineSnapshot(`
+      	"<div>
+      	<!--[--><!--]-->
+      	</div>"
+      `)
+
+      let clientResolve: any
+      AsyncComp = defineVaporAsyncComponent(
+        () =>
+          new Promise(r => {
+            clientResolve = r
+          }),
+      ) as any
+
+      const Comp = compileVaporComponent(compCode)
+      const App = compileVaporComponent(appCode, data, { AsyncComp })
+
+      const container = document.createElement('div')
+      container.innerHTML = html
+      document.body.appendChild(container)
+      createVaporSSRApp(App).mount(container)
+
+      data.value.show = true
+      await nextTick()
+
+      clientResolve(Comp)
+      await new Promise(r => setTimeout(r))
+
+      expect(`Hydration node mismatch`).toHaveBeenWarned()
+      expect(`Hydration text mismatch`).toHaveBeenWarned()
+      expect(formatHtml(container.innerHTML)).toMatchInlineSnapshot(`
+      	"<div>
+      	<!--[--><span>bar</span><!--if--><!--]-->
+      	</div><!--async component-->"
+      `)
     })
 
     describe('suspense', () => {

+ 3 - 41
packages/runtime-vapor/src/apiDefineAsyncComponent.ts

@@ -9,7 +9,6 @@ import {
   performAsyncHydrate,
   setCurrentInstance,
   useAsyncComponentState,
-  watch,
 } from '@vue/runtime-dom'
 import { defineVaporComponent } from './apiDefineComponent'
 import {
@@ -24,12 +23,10 @@ import {
   isComment,
   isHydrating,
   locateEndAnchor,
-  removeFragmentNodes,
   setCurrentHydrationNode,
 } from './dom/hydration'
-import { type TransitionOptions, insert, remove } from './block'
-import { _next, parentNode } from './dom/node'
-import { invokeArrayFns } from '@vue/shared'
+import type { TransitionOptions } from './block'
+import { _next } from './dom/node'
 
 /*@ __NO_SIDE_EFFECTS__ */
 export function defineVaporAsyncComponent<T extends VaporComponent>(
@@ -92,45 +89,10 @@ export function defineVaporAsyncComponent<T extends VaporComponent>(
         isComment(el, '[') ? locateEndAnchor(el)! : el.nextSibling,
       )
 
-      // If async component needs to be updated before hydration, hydration is no longer needed.
-      let isHydrated = false
-      watch(
-        () => instance.attrs,
-        () => {
-          // early return if already hydrated
-          if (isHydrated) return
-
-          // call the beforeUpdate hook to avoid calling hydrate in performAsyncHydrate
-          instance.bu && invokeArrayFns(instance.bu)
-
-          // mount the inner component and remove the placeholder
-          const parent = parentNode(el)!
-          load().then(() => {
-            if (instance.isUnmounted) return
-            hydrate()
-            if (isComment(el, '[')) {
-              const endAnchor = locateEndAnchor(el)!
-              removeFragmentNodes(el, endAnchor)
-              insert(instance.block, parent, endAnchor)
-            } else {
-              insert(instance.block, parent, el)
-              remove(el, parent)
-            }
-          })
-        },
-        { deep: true, once: true },
-      )
-
       performAsyncHydrate(
         el,
         instance,
-        () => {
-          hydrateNode(el, () => {
-            hydrate()
-            insert(instance.block, parentNode(el)!, el)
-            isHydrated = true
-          })
-        },
+        () => hydrateNode(el, hydrate),
         getResolvedComp,
         load,
         hydrateStrategy,

+ 21 - 5
packages/vue/__tests__/e2e/hydrationStrategies.spec.ts

@@ -114,18 +114,34 @@ describe('async component hydration strategies', () => {
       // patch
       await page().evaluate(() => (window.show.value = false))
       await click('button')
-      expect(await text('button')).toBe('1')
+      if (!vapor) {
+        expect(await text('button')).toBe('1')
+      } else {
+        // Vapor lazy hydration does not run the async component's setup until
+        // hydration actually starts, so there is no pre-hydration patch here.
+        expect(await text('button')).toBe('0')
+      }
 
       // resize
       await page().setViewport({ width: 400, height: 600 })
       await page().waitForFunction(() => window.isHydrated)
-      await assertHydrationSuccess('2')
+      if (!vapor) {
+        await assertHydrationSuccess('2')
+      } else {
+        await assertHydrationSuccess('1')
+      }
 
       expect(spy).toBeCalledTimes(0)
       currentPage.off('pageerror', spy)
-      expect(
-        warn.some(w => w.includes('Skipping lazy hydration for component')),
-      ).toBe(true)
+
+      // Vapor still enters lazy hydration after the strategy triggers, so it
+      // corrects the drift through mismatch recovery instead of taking the
+      // shared "Skipping lazy hydration..." short-circuit path.
+      if (!vapor) {
+        expect(
+          warn.some(w => w.includes('Skipping lazy hydration for component')),
+        ).toBe(true)
+      }
     })
 
     test('interaction', async () => {