]> git.ipfire.org Git - thirdparty/vuejs/core.git/commitdiff
feat(hydration): hydrate vapor async component (#14003)
authoredison <daiwei521@126.com>
Tue, 21 Oct 2025 01:40:50 +0000 (09:40 +0800)
committerGitHub <noreply@github.com>
Tue, 21 Oct 2025 01:40:50 +0000 (09:40 +0800)
22 files changed:
package.json
packages/runtime-core/src/apiAsyncComponent.ts
packages/runtime-core/src/component.ts
packages/runtime-core/src/componentOptions.ts
packages/runtime-core/src/hydrationStrategies.ts
packages/runtime-core/src/index.ts
packages/runtime-vapor/__tests__/hydration.spec.ts
packages/runtime-vapor/src/apiDefineAsyncComponent.ts
packages/runtime-vapor/src/component.ts
packages/vue/__tests__/e2e/hydration-strat-custom-vapor.html [new file with mode: 0644]
packages/vue/__tests__/e2e/hydration-strat-idle-vapor.html [new file with mode: 0644]
packages/vue/__tests__/e2e/hydration-strat-interaction-vapor.html [new file with mode: 0644]
packages/vue/__tests__/e2e/hydration-strat-media-vapor.html [new file with mode: 0644]
packages/vue/__tests__/e2e/hydration-strat-visible-vapor.html [new file with mode: 0644]
packages/vue/__tests__/e2e/hydrationStrategies.spec.ts
packages/vue/src/index-with-vapor.ts
packages/vue/src/index.ts
packages/vue/src/indexBase.ts [new file with mode: 0644]
packages/vue/src/runtime-with-vapor.ts
packages/vue/src/runtime.ts
packages/vue/src/runtimeBase.ts [new file with mode: 0644]
packages/vue/src/vaporAliases.ts [new file with mode: 0644]

index 26e4f5f6ca64c8a3846e38fc59753a6da33e7a20..f72036f7b07b053328ac797927630adec805763f 100644 (file)
@@ -18,7 +18,7 @@
     "format-check": "prettier --check --cache .",
     "test": "vitest",
     "test-unit": "vitest --project unit --project unit-jsdom",
-    "test-e2e": "node scripts/build.js vue -f global -d && vitest --project e2e",
+    "test-e2e": "node scripts/build.js vue -f global+esm-browser-vapor -d && vitest --project e2e",
     "test-e2e-vapor": "pnpm run prepare-e2e-vapor && vitest --project e2e-vapor",
     "prepare-e2e-vapor": "node scripts/build.js -f cjs+esm-bundler+esm-bundler-runtime && pnpm run -C packages-private/vapor-e2e-test build",
     "test-dts": "run-s build-dts test-dts-only",
index 1b7d60c8b23b85dd7d52ce8de68caf3202f84c45..068a77e209a9040d90e74ce52b70e40c1eebb388 100644 (file)
@@ -3,6 +3,7 @@ import {
   type ComponentInternalInstance,
   type ComponentOptions,
   type ConcreteComponent,
+  type GenericComponent,
   type GenericComponentInstance,
   currentInstance,
   getComponentName,
@@ -68,37 +69,14 @@ export function defineAsyncComponent<
     __asyncLoader: load,
 
     __asyncHydrate(el, instance, hydrate) {
-      let patched = false
-      ;(instance.bu || (instance.bu = [])).push(() => (patched = true))
-      const performHydrate = () => {
-        // skip hydration if the component has been patched
-        if (patched) {
-          if (__DEV__) {
-            const resolvedComp = getResolvedComp()!
-            warn(
-              `Skipping lazy hydration for component '${getComponentName(resolvedComp!) || resolvedComp!.__file}': ` +
-                `it was updated before lazy hydration performed.`,
-            )
-          }
-          return
-        }
-        hydrate()
-      }
-      const doHydrate = hydrateStrategy
-        ? () => {
-            const teardown = hydrateStrategy(performHydrate, cb =>
-              forEachElement(el, cb),
-            )
-            if (teardown) {
-              ;(instance.bum || (instance.bum = [])).push(teardown)
-            }
-          }
-        : performHydrate
-      if (getResolvedComp()) {
-        doHydrate()
-      } else {
-        load().then(() => !instance.isUnmounted && doHydrate())
-      }
+      performAsyncHydrate(
+        el,
+        instance,
+        hydrate,
+        getResolvedComp,
+        load,
+        hydrateStrategy,
+      )
     },
 
     get __asyncResolved() {
@@ -311,3 +289,48 @@ export const useAsyncComponentState = (
 
   return { loaded, error, delayed }
 }
+
+/**
+ * shared between core and vapor
+ * @internal
+ */
+export function performAsyncHydrate(
+  el: Element,
+  instance: GenericComponentInstance,
+  hydrate: () => void,
+  getResolvedComp: () => GenericComponent | undefined,
+  load: () => Promise<GenericComponent>,
+  hydrateStrategy: HydrationStrategy | undefined,
+): void {
+  let patched = false
+  ;(instance.bu || (instance.bu = [])).push(() => (patched = true))
+  const performHydrate = () => {
+    // skip hydration if the component has been patched
+    if (patched) {
+      if (__DEV__) {
+        const resolvedComp = getResolvedComp()! as GenericComponent
+        warn(
+          `Skipping lazy hydration for component '${getComponentName(resolvedComp) || resolvedComp.__file}': ` +
+            `it was updated before lazy hydration performed.`,
+        )
+      }
+      return
+    }
+    hydrate()
+  }
+  const doHydrate = hydrateStrategy
+    ? () => {
+        const teardown = hydrateStrategy(performHydrate, cb =>
+          forEachElement(el, cb),
+        )
+        if (teardown) {
+          ;(instance.bum || (instance.bum = [])).push(teardown)
+        }
+      }
+    : performHydrate
+  if (getResolvedComp()) {
+    doHydrate()
+  } else {
+    load().then(() => !instance.isUnmounted && doHydrate())
+  }
+}
index 78314be69d12b3d91a6693637e43797b4e36ab1a..a2e94e1b1c816ee200294d5fd90e10676ee54cbc 100644 (file)
@@ -227,6 +227,27 @@ export interface ComponentInternalOptions {
   __name?: string
 }
 
+export interface AsyncComponentInternalOptions<
+  R = ConcreteComponent,
+  I = ComponentInternalInstance,
+> {
+  /**
+   * marker for AsyncComponentWrapper
+   * @internal
+   */
+  __asyncLoader?: () => Promise<R>
+  /**
+   * the inner component resolved by the AsyncComponentWrapper
+   * @internal
+   */
+  __asyncResolved?: R
+  /**
+   * Exposed for lazy hydration
+   * @internal
+   */
+  __asyncHydrate?: (el: Element, instance: I, hydrate: () => void) => void
+}
+
 export interface FunctionalComponent<
   P = {},
   E extends EmitsOptions | Record<string, any[]> = {},
index 47e8f8e274353f53f30529a53d79672d78fbda22..8dc5777592239571e59275cc3adff0f31a146599 100644 (file)
@@ -1,8 +1,8 @@
 import {
+  type AsyncComponentInternalOptions,
   type Component,
   type ComponentInternalInstance,
   type ComponentInternalOptions,
-  type ConcreteComponent,
   type Data,
   type InternalRenderFunction,
   type SetupContext,
@@ -127,6 +127,7 @@ export interface ComponentOptionsBase<
   Provide extends ComponentProvideOptions = ComponentProvideOptions,
 > extends LegacyOptions<Props, D, C, M, Mixin, Extends, I, II, Provide>,
     ComponentInternalOptions,
+    AsyncComponentInternalOptions,
     ComponentCustomOptions {
   setup?: (
     this: void,
@@ -190,26 +191,6 @@ export interface ComponentOptionsBase<
    */
   __ssrInlineRender?: boolean
 
-  /**
-   * marker for AsyncComponentWrapper
-   * @internal
-   */
-  __asyncLoader?: () => Promise<ConcreteComponent>
-  /**
-   * the inner component resolved by the AsyncComponentWrapper
-   * @internal
-   */
-  __asyncResolved?: ConcreteComponent
-  /**
-   * Exposed for lazy hydration
-   * @internal
-   */
-  __asyncHydrate?: (
-    el: Element,
-    instance: ComponentInternalInstance,
-    hydrate: () => void,
-  ) => void
-
   // Type differentiators ------------------------------------------------------
 
   // Note these are internal but need to be exposed in d.ts for type inference
index bad39884830ea2d262464ca2f56bdc87289fa8ee..5802d5a40d065a5307df98936eda723f0ff299fd 100644 (file)
@@ -91,8 +91,10 @@ export const hydrateOnInteraction: HydrationStrategyFactory<
         hasHydrated = true
         teardown()
         hydrate()
-        // replay event
-        e.target!.dispatchEvent(new (e.constructor as any)(e.type, e))
+        // replay event if the event is not delegated
+        if (!(`$evt${e.type}` in e.target!)) {
+          e.target!.dispatchEvent(new (e.constructor as any)(e.type, e))
+        }
       }
     }
     const teardown = () => {
index 0565f4fbd35979df3b980c4fd0bd4232ecbed5a7..b15fe1e6960bf0f9c294337a85d5f602604bd0eb 100644 (file)
@@ -274,6 +274,7 @@ export type {
   GlobalDirectives,
   ComponentInstance,
   ComponentCustomElementInterface,
+  AsyncComponentInternalOptions,
 } from './component'
 export type {
   DefineComponent,
@@ -587,6 +588,7 @@ export {
   createAsyncComponentContext,
   useAsyncComponentState,
   isAsyncWrapper,
+  performAsyncHydrate,
 } from './apiAsyncComponent'
 /**
  * @internal
index 2e48ae5ceee011212bfb1f4c7dabc6f1a97daf6e..fea78e9be16328611edb0bc0592e320ca76b6488 100644 (file)
@@ -1,5 +1,9 @@
-import { createVaporSSRApp, delegateEvents } from '../src'
-import { nextTick, reactive, ref } from '@vue/runtime-dom'
+import {
+  createVaporSSRApp,
+  defineVaporAsyncComponent,
+  delegateEvents,
+} from '../src'
+import { defineAsyncComponent, nextTick, reactive, ref } from '@vue/runtime-dom'
 import { compileScript, parse } from '@vue/compiler-sfc'
 import * as runtimeVapor from '../src'
 import * as runtimeDom from '@vue/runtime-dom'
@@ -3591,6 +3595,415 @@ describe('Vapor Mode hydration', () => {
     })
   })
 
+  describe('async component', async () => {
+    test('async component', async () => {
+      const data = ref({
+        spy: vi.fn(),
+      })
+
+      const compCode = `<button @click="data.spy">hello!</button>`
+      const SSRComp = compileVaporComponent(compCode, data, undefined, true)
+      let serverResolve: any
+      // use defineAsyncComponent in SSR
+      let AsyncComp = defineAsyncComponent(
+        () =>
+          new Promise(r => {
+            serverResolve = r
+          }),
+      )
+      const appCode = `hello<components.AsyncComp/>world`
+      const SSRApp = compileVaporComponent(appCode, data, { AsyncComp }, true)
+
+      // server render
+      const htmlPromise = VueServerRenderer.renderToString(
+        runtimeDom.createSSRApp(SSRApp),
+      )
+      serverResolve(SSRComp)
+      const html = await htmlPromise
+      expect(html).toMatchInlineSnapshot(
+        `"<!--[-->hello<button>hello!</button>world<!--]-->"`,
+      )
+
+      // hydration
+      let clientResolve: any
+      AsyncComp = defineVaporAsyncComponent(
+        () =>
+          new Promise(r => {
+            clientResolve = r
+          }),
+      ) as any
+
+      const Comp = compileVaporComponent(compCode, data)
+      const App = compileVaporComponent(appCode, data, { AsyncComp })
+
+      const container = document.createElement('div')
+      container.innerHTML = html
+      document.body.appendChild(container)
+      createVaporSSRApp(App).mount(container)
+
+      // hydration not complete yet
+      triggerEvent('click', container.querySelector('button')!)
+      expect(data.value.spy).not.toHaveBeenCalled()
+
+      // resolve
+      clientResolve(Comp)
+      await new Promise(r => setTimeout(r))
+
+      // should be hydrated now
+      triggerEvent('click', container.querySelector('button')!)
+      expect(data.value.spy).toHaveBeenCalled()
+    })
+
+    // No longer needed, parent component updates in vapor mode no longer
+    // cause child components to re-render
+    // test.todo('update async wrapper before resolve', async () => {})
+
+    test('update async component after parent mount before async component resolve', async () => {
+      const data = ref({
+        toggle: true,
+      })
+      const compCode = `
+          <script vapor>
+            defineProps(['toggle'])
+          </script>
+          <template>
+            <h1>{{ toggle ? 'Async component' : 'Updated async component' }}</h1>
+          </template>
+        `
+      const SSRComp = compileVaporComponent(
+        compCode,
+        undefined,
+        undefined,
+        true,
+      )
+      let serverResolve: any
+      // use defineAsyncComponent in SSR
+      let AsyncComp = defineAsyncComponent(
+        () =>
+          new Promise(r => {
+            serverResolve = r
+          }),
+      )
+      const appCode = `<components.AsyncComp :toggle="data.toggle"/>`
+      const SSRApp = compileVaporComponent(appCode, data, { AsyncComp }, true)
+
+      // server render
+      const htmlPromise = VueServerRenderer.renderToString(
+        runtimeDom.createSSRApp(SSRApp),
+      )
+      serverResolve(SSRComp)
+      const html = await htmlPromise
+      expect(html).toMatchInlineSnapshot(`"<h1>Async component</h1>"`)
+
+      // hydration
+      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)
+
+      // update before resolve
+      data.value.toggle = false
+      await nextTick()
+
+      // resolve
+      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()
+      expect(container.innerHTML).toMatchInlineSnapshot(
+        `"<h1>Updated async component</h1><!--async component-->"`,
+      )
+    })
+
+    test('update async component (fragment root) after parent mount before async component resolve', async () => {
+      const data = ref({
+        toggle: true,
+      })
+      const compCode = `
+          <script vapor>
+            defineProps(['toggle'])
+          </script>
+          <template>
+            <h1>{{ toggle ? 'Async component' : 'Updated async component' }}</h1>
+            <h2>fragment root</h2>
+          </template>
+        `
+      const SSRComp = compileVaporComponent(
+        compCode,
+        undefined,
+        undefined,
+        true,
+      )
+      let serverResolve: any
+      // use defineAsyncComponent in SSR
+      let AsyncComp = defineAsyncComponent(
+        () =>
+          new Promise(r => {
+            serverResolve = r
+          }),
+      )
+      const appCode = `<components.AsyncComp :toggle="data.toggle"/>`
+      const SSRApp = compileVaporComponent(appCode, data, { AsyncComp }, true)
+
+      // server render
+      const htmlPromise = VueServerRenderer.renderToString(
+        runtimeDom.createSSRApp(SSRApp),
+      )
+      serverResolve(SSRComp)
+      const html = await htmlPromise
+      expect(html).toMatchInlineSnapshot(
+        `"<!--[--><h1>Async component</h1><h2>fragment root</h2><!--]-->"`,
+      )
+
+      // hydration
+      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)
+
+      // update before resolve
+      data.value.toggle = false
+      await nextTick()
+
+      // resolve
+      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()
+      expect(container.innerHTML).toMatchInlineSnapshot(
+        `"<!--[--><h1>Updated async component</h1><h2>fragment root</h2><!--async component--><!--]-->"`,
+      )
+    })
+
+    // required vapor Suspense
+    test.todo(
+      'hydrate safely when property used by async setup changed before render',
+      async () => {},
+    )
+
+    // required vapor Suspense
+    test.todo(
+      'hydrate safely when property used by deep nested async setup changed before render',
+      async () => {},
+    )
+
+    test('unmount async wrapper before load', async () => {
+      const data = ref({
+        toggle: true,
+      })
+      const compCode = `<div>async</div>`
+      const appCode = `
+        <div>
+          <components.AsyncComp v-if="data.toggle"/>
+          <div v-else>hi</div>
+        </div>
+      `
+
+      // hydration
+      let clientResolve: any
+      const AsyncComp = defineVaporAsyncComponent(
+        () =>
+          new Promise(r => {
+            clientResolve = r
+          }),
+      )
+
+      const Comp = compileVaporComponent(compCode)
+      const App = compileVaporComponent(appCode, data, {
+        AsyncComp,
+      })
+
+      const container = document.createElement('div')
+      container.innerHTML = '<div><div>async</div></div>'
+      createVaporSSRApp(App).mount(container)
+
+      // unmount before resolve
+      data.value.toggle = false
+      await nextTick()
+      expect(container.innerHTML).toBe(`<div><div>hi</div><!--if--></div>`)
+
+      // resolve
+      clientResolve(Comp)
+      await new Promise(r => setTimeout(r))
+      // should remain unmounted
+      expect(container.innerHTML).toBe(`<div><div>hi</div><!--if--></div>`)
+    })
+
+    test('unmount async wrapper before load (fragment)', async () => {
+      const data = ref({
+        toggle: true,
+      })
+      const compCode = `<div>async</div><div>fragment</div>`
+      const appCode = `
+        <div>
+          <components.AsyncComp v-if="data.toggle"/>
+          <div v-else>hi</div>
+        </div>
+      `
+
+      // hydration
+      let clientResolve: any
+      const AsyncComp = defineVaporAsyncComponent(
+        () =>
+          new Promise(r => {
+            clientResolve = r
+          }),
+      )
+
+      const Comp = compileVaporComponent(compCode)
+      const App = compileVaporComponent(appCode, data, {
+        AsyncComp,
+      })
+
+      const container = document.createElement('div')
+      container.innerHTML =
+        '<div><!--[--><div>async</div><div>fragment</div><!--]--></div>'
+      createVaporSSRApp(App).mount(container)
+
+      // unmount before resolve
+      data.value.toggle = false
+      await nextTick()
+      expect(container.innerHTML).toBe(`<div><div>hi</div><!--if--></div>`)
+
+      // resolve
+      clientResolve(Comp)
+      await new Promise(r => setTimeout(r))
+      // should remain unmounted
+      expect(container.innerHTML).toBe(`<div><div>hi</div><!--if--></div>`)
+    })
+
+    test('nested async wrapper', async () => {
+      const toggleCode = `
+      <script vapor>
+        import { onMounted, ref, nextTick } from 'vue'
+        const show = ref(false)
+        onMounted(() => {
+          nextTick(() => {
+            show.value = true
+          })
+        })
+      </script>
+      <template>
+        <div v-show="show">
+          <slot />
+        </div>
+      </template>
+      `
+
+      const SSRToggle = compileVaporComponent(
+        toggleCode,
+        undefined,
+        undefined,
+        true,
+      )
+
+      const wrapperCode = `<slot/>`
+      const SSRWrapper = compileVaporComponent(
+        wrapperCode,
+        undefined,
+        undefined,
+        true,
+      )
+
+      const data = ref({
+        count: 0,
+        fn: vi.fn(),
+      })
+
+      const childCode = `
+        <script vapor>
+          import { onMounted } from 'vue'
+          const data = _data; const components = _components;
+          onMounted(() => {
+            data.value.fn()
+            data.value.count++
+          })
+        </script>
+        <template>
+          <div>{{data.count}}</div>
+        </template>
+      `
+
+      const SSRChild = compileVaporComponent(childCode, data, undefined, true)
+
+      const appCode = `
+      <components.Toggle>
+        <components.Wrapper>
+          <components.Wrapper>
+            <components.Child/>
+          </components.Wrapper>
+        </components.Wrapper>
+      </components.Toggle>
+      `
+
+      const SSRApp = compileVaporComponent(
+        appCode,
+        undefined,
+        {
+          Toggle: SSRToggle,
+          Wrapper: SSRWrapper,
+          Child: SSRChild,
+        },
+        true,
+      )
+
+      const root = document.createElement('div')
+
+      // server render
+      root.innerHTML = await VueServerRenderer.renderToString(
+        runtimeDom.createSSRApp(SSRApp),
+      )
+      expect(root.innerHTML).toMatchInlineSnapshot(
+        `"<div style="display:none;"><!--[--><!--[--><!--[--><div>0</div><!--]--><!--]--><!--]--></div>"`,
+      )
+
+      const Toggle = compileVaporComponent(toggleCode)
+      const Wrapper = compileVaporComponent(wrapperCode)
+      const Child = compileVaporComponent(childCode, data)
+
+      const App = compileVaporComponent(appCode, undefined, {
+        Toggle,
+        Wrapper,
+        Child,
+      })
+
+      // hydration
+      createVaporSSRApp(App).mount(root)
+      await nextTick()
+      await nextTick()
+      expect(root.innerHTML).toMatchInlineSnapshot(
+        `"<div style=""><!--[--><!--[--><!--[--><div>1</div><!--]--><!--]--><!--]--></div>"`,
+      )
+      expect(data.value.fn).toBeCalledTimes(1)
+    })
+  })
+
   describe.todo('Suspense')
 
   describe('force hydrate prop', async () => {
index 9021ab160d6dee8421b57ba9718deeaa26ac75c7..7fe1cfc2ac1747925b66002c8471542036a0e981 100644 (file)
@@ -6,7 +6,9 @@ import {
   currentInstance,
   handleError,
   markAsyncBoundary,
+  performAsyncHydrate,
   useAsyncComponentState,
+  watch,
 } from '@vue/runtime-dom'
 import { defineVaporComponent } from './apiDefineComponent'
 import {
@@ -16,8 +18,18 @@ import {
 } from './component'
 import { renderEffect } from './renderEffect'
 import { DynamicFragment } from './fragment'
-
-/*! #__NO_SIDE_EFFECTS__ */
+import {
+  hydrateNode,
+  isComment,
+  isHydrating,
+  locateEndAnchor,
+  removeFragmentNodes,
+} from './dom/hydration'
+import { invokeArrayFns } from '@vue/shared'
+import { insert, remove } from './block'
+import { parentNode } from './dom/node'
+
+/*@ __NO_SIDE_EFFECTS__ */
 export function defineVaporAsyncComponent<T extends VaporComponent>(
   source: AsyncComponentLoader<T> | AsyncComponentOptions<T>,
 ): T {
@@ -29,9 +41,9 @@ export function defineVaporAsyncComponent<T extends VaporComponent>(
       loadingComponent,
       errorComponent,
       delay,
-      // hydrate: hydrateStrategy,
+      hydrate: hydrateStrategy,
       timeout,
-      // suspensible = true,
+      suspensible = true,
     },
   } = createAsyncComponentContext<T, VaporComponent>(source)
 
@@ -40,9 +52,57 @@ export function defineVaporAsyncComponent<T extends VaporComponent>(
 
     __asyncLoader: load,
 
-    // __asyncHydrate(el, instance, hydrate) {
-    //   // TODO async hydrate
-    // },
+    __asyncHydrate(
+      el: Element,
+      instance: VaporComponentInstance,
+      // Note: this hydrate function essentially calls the setup method of the component
+      // not the actual hydrate function
+      hydrate: () => void,
+    ) {
+      // 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
+          })
+        },
+        getResolvedComp,
+        load,
+        hydrateStrategy,
+      )
+    },
 
     get __asyncResolved() {
       return getResolvedComp()
@@ -52,14 +112,15 @@ export function defineVaporAsyncComponent<T extends VaporComponent>(
       const instance = currentInstance as VaporComponentInstance
       markAsyncBoundary(instance)
 
-      const frag = __DEV__
-        ? new DynamicFragment('async component')
-        : new DynamicFragment()
+      const frag =
+        __DEV__ || isHydrating
+          ? new DynamicFragment('async component')
+          : new DynamicFragment()
 
       // already resolved
       let resolvedComp = getResolvedComp()
       if (resolvedComp) {
-        frag.update(() => createInnerComp(resolvedComp!, instance))
+        frag!.update(() => createInnerComp(resolvedComp!, instance))
         return frag
       }
 
@@ -73,7 +134,9 @@ export function defineVaporAsyncComponent<T extends VaporComponent>(
         )
       }
 
-      // TODO suspense-controlled or SSR.
+      // TODO suspense-controlled
+      if (__FEATURE_SUSPENSE__ && suspensible && instance.suspense) {
+      }
 
       const { loaded, error, delayed } = useAsyncComponentState(
         delay,
@@ -103,7 +166,7 @@ export function defineVaporAsyncComponent<T extends VaporComponent>(
         } else if (loadingComponent && !delayed.value) {
           render = () => createComponent(loadingComponent)
         }
-        frag.update(render)
+        frag!.update(render)
       })
 
       return frag
index 6ea662583dbb87fda341068e4994b9ab40d4e77d..1a3acf5c4d18bebbeafecedcbf330c0de359c19b 100644 (file)
@@ -1,4 +1,5 @@
 import {
+  type AsyncComponentInternalOptions,
   type ComponentInternalOptions,
   type ComponentPropsOptions,
   EffectScope,
@@ -15,6 +16,7 @@ import {
   currentInstance,
   endMeasure,
   expose,
+  isAsyncWrapper,
   isKeepAlive,
   nextUid,
   popWarningContext,
@@ -67,12 +69,14 @@ import {
   adoptTemplate,
   advanceHydrationNode,
   currentHydrationNode,
+  isComment,
   isHydrating,
+  locateEndAnchor,
   locateHydrationNode,
   locateNextNode,
   setCurrentHydrationNode,
 } from './dom/hydration'
-import { createElement } from './dom/node'
+import { _next, createElement } from './dom/node'
 import { type TeleportFragment, isVaporTeleport } from './components/Teleport'
 import type { KeepAliveInstance } from './components/KeepAlive'
 import {
@@ -99,6 +103,7 @@ export type FunctionalVaporComponent = VaporSetupFn &
 
 export interface ObjectVaporComponent
   extends ComponentInternalOptions,
+    AsyncComponentInternalOptions<ObjectVaporComponent, VaporComponentInstance>,
     SharedInternalOptions {
   setup?: VaporSetupFn
   inheritAttrs?: boolean
@@ -260,6 +265,63 @@ export function createComponent(
     instance.emitsOptions = normalizeEmitsOptions(component)
   }
 
+  // hydrating async component
+  if (
+    isHydrating &&
+    isAsyncWrapper(instance) &&
+    component.__asyncHydrate &&
+    !component.__asyncResolved
+  ) {
+    // it may get unmounted before its inner component is loaded,
+    // so we need to give it a placeholder block that matches its
+    // adopted DOM
+    const el = currentHydrationNode!
+    if (isComment(el, '[')) {
+      const end = _next(locateEndAnchor(el)!)
+      const block = (instance.block = [el as Node])
+      let cur = el as Node
+      while (true) {
+        let n = _next(cur)
+        if (n && n !== end) {
+          block.push((cur = n))
+        } else {
+          break
+        }
+      }
+    } else {
+      instance.block = el
+    }
+    // also mark it as mounted to ensure it can be unmounted before
+    // its inner component is resolved
+    instance.isMounted = true
+
+    // advance current hydration node to the nextSibling
+    setCurrentHydrationNode(
+      isComment(el, '[') ? locateEndAnchor(el)! : el.nextSibling,
+    )
+    component.__asyncHydrate(el as Element, instance, () =>
+      setupComponent(instance, component),
+    )
+  } else {
+    setupComponent(instance, component)
+  }
+
+  onScopeDispose(() => unmountComponent(instance), true)
+
+  if (_insertionParent || isHydrating) {
+    mountComponent(instance, _insertionParent!, _insertionAnchor)
+  }
+
+  if (isHydrating && _insertionAnchor !== undefined) {
+    advanceHydrationNode(_insertionParent!)
+  }
+  return instance
+}
+
+export function setupComponent(
+  instance: VaporComponentInstance,
+  component: VaporComponent,
+): void {
   const prevInstance = setCurrentInstance(instance)
   const prevSub = setActiveSub()
 
@@ -317,6 +379,8 @@ export function createComponent(
     }
   }
 
+  // TODO: scopeid
+
   setActiveSub(prevSub)
   setCurrentInstance(...prevInstance)
 
@@ -324,18 +388,6 @@ export function createComponent(
     popWarningContext()
     endMeasure(instance, 'init')
   }
-
-  onScopeDispose(() => unmountComponent(instance), true)
-
-  if (_insertionParent) {
-    mountComponent(instance, _insertionParent, _insertionAnchor)
-  }
-
-  if (isHydrating && _isLastInsertion) {
-    advanceHydrationNode(_insertionParent!)
-  }
-
-  return instance
 }
 
 export let isApplyingFallthroughProps = false
@@ -617,7 +669,9 @@ export function mountComponent(
     startMeasure(instance, `mount`)
   }
   if (instance.bm) invokeArrayFns(instance.bm)
-  insert(instance.block, parent, anchor)
+  if (!isHydrating) {
+    insert(instance.block, parent, anchor)
+  }
   if (instance.m) queuePostFlushCb(instance.m!)
   if (
     instance.shapeFlag! & ShapeFlags.COMPONENT_SHOULD_KEEP_ALIVE &&
diff --git a/packages/vue/__tests__/e2e/hydration-strat-custom-vapor.html b/packages/vue/__tests__/e2e/hydration-strat-custom-vapor.html
new file mode 100644 (file)
index 0000000..a8bb037
--- /dev/null
@@ -0,0 +1,69 @@
+<div><span id="custom-trigger">click here to hydrate</span></div>
+<div id="app"><button>0</button></div>
+
+<script type="module">
+  import {
+    createVaporSSRApp,
+    defineVaporAsyncComponent,
+    ref,
+    onMounted,
+    delegateEvents,
+    template,
+    createIf,
+    createComponent,
+    child,
+    renderEffect,
+    setText,
+  } from '../../dist/vue.runtime-with-vapor.esm-browser.js'
+
+  delegateEvents('click')
+
+  window.isHydrated = false
+  const Comp = {
+    setup() {
+      const count = ref(0)
+      onMounted(() => {
+        console.log('hydrated')
+        window.isHydrated = true
+      })
+
+      const n0 = template('<button> </button>', true)()
+      const x0 = child(n0)
+      n0.$evtclick = () => count.value++
+      renderEffect(() => setText(x0, count.value))
+      return n0
+    },
+  }
+
+  const AsyncComp = defineVaporAsyncComponent({
+    loader: () => Promise.resolve(Comp),
+    hydrate: (hydrate, el) => {
+      const triggerEl = document.getElementById('custom-trigger')
+      triggerEl.addEventListener('click', hydrate, { once: true })
+      return () => {
+        window.teardownCalled = true
+        triggerEl.removeEventListener('click', hydrate)
+      }
+    },
+  })
+
+  const show = (window.show = ref(true))
+  createVaporSSRApp({
+    setup() {
+      onMounted(() => {
+        window.isRootMounted = true
+      })
+
+      const n0 = createIf(
+        () => show.value,
+        () => {
+          return createComponent(AsyncComp)
+        },
+        () => {
+          return template('off')()
+        },
+      )
+      return n0
+    },
+  }).mount('#app')
+</script>
diff --git a/packages/vue/__tests__/e2e/hydration-strat-idle-vapor.html b/packages/vue/__tests__/e2e/hydration-strat-idle-vapor.html
new file mode 100644 (file)
index 0000000..ef3ab7a
--- /dev/null
@@ -0,0 +1,56 @@
+<div id="app"><button>0</button></div>
+
+<script type="module">
+  import {
+    createVaporSSRApp,
+    defineVaporAsyncComponent,
+    ref,
+    onMounted,
+    hydrateOnIdle,
+    delegateEvents,
+    template,
+    createComponent,
+    child,
+    renderEffect,
+    setText,
+  } from '../../dist/vue.runtime-with-vapor.esm-browser.js'
+
+  delegateEvents('click')
+
+  window.isHydrated = false
+  const Comp = {
+    setup() {
+      const count = ref(0)
+      onMounted(() => {
+        console.log('hydrated')
+        window.isHydrated = true
+      })
+
+      const n0 = template('<button> </button>', true)()
+      const x0 = child(n0)
+      n0.$evtclick = () => count.value++
+      renderEffect(() => setText(x0, count.value))
+      return n0
+    },
+  }
+
+  const AsyncComp = defineVaporAsyncComponent({
+    loader: () =>
+      new Promise(resolve => {
+        setTimeout(() => {
+          console.log('resolve')
+          resolve(Comp)
+          requestIdleCallback(() => {
+            console.log('busy')
+          })
+        }, 10)
+      }),
+    hydrate: hydrateOnIdle(),
+  })
+
+  createVaporSSRApp({
+    setup() {
+      return createComponent(AsyncComp)
+    },
+  }).mount('#app')
+</script>
diff --git a/packages/vue/__tests__/e2e/hydration-strat-interaction-vapor.html b/packages/vue/__tests__/e2e/hydration-strat-interaction-vapor.html
new file mode 100644 (file)
index 0000000..6d448c7
--- /dev/null
@@ -0,0 +1,73 @@
+<div>click to hydrate</div>
+<div id="app"><button>0</button></div>
+<style>
+  body {
+    margin: 0;
+  }
+</style>
+
+<script type="module">
+  import {
+    createVaporSSRApp,
+    defineVaporAsyncComponent,
+    ref,
+    onMounted,
+    hydrateOnInteraction,
+    delegateEvents,
+    template,
+    createComponent,
+    child,
+    renderEffect,
+    setText,
+  } from '../../dist/vue.runtime-with-vapor.esm-browser.js'
+
+  delegateEvents('click')
+
+  const isFragment = location.search.includes('?fragment')
+  if (isFragment) {
+    document.getElementById('app').innerHTML =
+      `<!--[--><!--[--><span>one</span><!--]--><button>0</button><span>two</span><!--]-->`
+  }
+
+  window.isHydrated = false
+
+  const Comp = {
+    setup() {
+      const count = ref(0)
+      onMounted(() => {
+        console.log('hydrated')
+        window.isHydrated = true
+      })
+
+      if (isFragment) {
+        const n1 = template('<span>one</span>')()
+        const n0 = template('<button> </button>', true)()
+        const x0 = child(n0)
+        n0.$evtclick = () => count.value++
+        renderEffect(() => setText(x0, count.value))
+        const n2 = template('<span>two</span>')()
+        return [n1, n0, n2]
+      } else {
+        const n0 = template('<button> </button>', true)()
+        const x0 = child(n0)
+        n0.$evtclick = () => count.value++
+        renderEffect(() => setText(x0, count.value))
+        return n0
+      }
+    },
+  }
+
+  const AsyncComp = defineVaporAsyncComponent({
+    loader: () => Promise.resolve(Comp),
+    hydrate: hydrateOnInteraction(['click', 'wheel']),
+  })
+
+  createVaporSSRApp({
+    setup() {
+      onMounted(() => {
+        window.isRootMounted = true
+      })
+      return createComponent(AsyncComp)
+    },
+  }).mount('#app')
+</script>
diff --git a/packages/vue/__tests__/e2e/hydration-strat-media-vapor.html b/packages/vue/__tests__/e2e/hydration-strat-media-vapor.html
new file mode 100644 (file)
index 0000000..9aaa4d8
--- /dev/null
@@ -0,0 +1,57 @@
+<div>resize the window width to < 500px to hydrate</div>
+<div id="app"><button>0</button></div>
+
+<script type="module">
+  import {
+    createVaporSSRApp,
+    defineVaporAsyncComponent,
+    ref,
+    onMounted,
+    hydrateOnMediaQuery,
+    delegateEvents,
+    template,
+    createComponent,
+    child,
+    renderEffect,
+    setText,
+  } from '../../dist/vue.runtime-with-vapor.esm-browser.js'
+
+  delegateEvents('click')
+
+  window.isHydrated = false
+  const Comp = {
+    props: {
+      value: Boolean,
+    },
+    setup(props) {
+      const count = ref(0)
+      onMounted(() => {
+        console.log('hydrated')
+        window.isHydrated = true
+      })
+
+      props.value
+      const n0 = template('<button> </button>', true)()
+      const x0 = child(n0)
+      n0.$evtclick = () => count.value++
+      renderEffect(() => setText(x0, count.value))
+      return n0
+    },
+  }
+
+  const AsyncComp = defineVaporAsyncComponent({
+    loader: () => Promise.resolve(Comp),
+    hydrate: hydrateOnMediaQuery('(max-width:500px)'),
+  })
+
+  createVaporSSRApp({
+    setup() {
+      onMounted(() => {
+        window.isRootMounted = true
+      })
+
+      const show = (window.show = ref(true))
+      return createComponent(AsyncComp, { value: () => show.value })
+    },
+  }).mount('#app')
+</script>
diff --git a/packages/vue/__tests__/e2e/hydration-strat-visible-vapor.html b/packages/vue/__tests__/e2e/hydration-strat-visible-vapor.html
new file mode 100644 (file)
index 0000000..a1c738a
--- /dev/null
@@ -0,0 +1,81 @@
+<script src="../../dist/vue.global.js"></script>
+
+<div style="height: 1000px">scroll to the bottom to hydrate</div>
+<div id="app"><button>0</button></div>
+<style>
+  body {
+    margin: 0;
+  }
+</style>
+
+<script type="module">
+  import {
+    createVaporSSRApp,
+    defineVaporAsyncComponent,
+    ref,
+    onMounted,
+    hydrateOnVisible,
+    delegateEvents,
+    template,
+    createComponent,
+    child,
+    renderEffect,
+    setText,
+  } from '../../dist/vue.runtime-with-vapor.esm-browser.js'
+
+  delegateEvents('click')
+
+  const rootMargin = location.search.match(/rootMargin=(\d+)/)?.[1] ?? 0
+  const isFragment = location.search.includes('?fragment')
+  const isVIf = location.search.includes('?v-if')
+  if (isFragment) {
+    document.getElementById('app').innerHTML =
+      `<!--[--><!--[--><span>one</span><!--]--><button>0</button><span>two</span><!--]-->`
+  } else if (isVIf) {
+    document.getElementById('app').innerHTML = `<!---->`
+  }
+
+  window.isHydrated = false
+
+  const Comp = {
+    setup() {
+      const count = ref(0)
+      onMounted(() => {
+        console.log('hydrated')
+        window.isHydrated = true
+      })
+
+      if (isVIf) {
+        return template('<!--v-if-->')()
+      } else if (isFragment) {
+        const n1 = template('<span>one</span>')()
+        const n0 = template('<button> </button>', true)()
+        const x0 = child(n0)
+        n0.$evtclick = () => count.value++
+        renderEffect(() => setText(x0, count.value))
+        const n2 = template('<span>two</span>')()
+        return [n1, n0, n2]
+      } else {
+        const n0 = template('<button> </button>', true)()
+        const x0 = child(n0)
+        n0.$evtclick = () => count.value++
+        renderEffect(() => setText(x0, count.value))
+        return n0
+      }
+    },
+  }
+
+  const AsyncComp = defineVaporAsyncComponent({
+    loader: () => Promise.resolve(Comp),
+    hydrate: hydrateOnVisible({ rootMargin: rootMargin + 'px' }),
+  })
+
+  createVaporSSRApp({
+    setup() {
+      onMounted(() => {
+        window.isRootMounted = true
+      })
+      return createComponent(AsyncComp)
+    },
+  }).mount('#app')
+</script>
index d792edf1960cd4b390535eeaf33ec57e6c150a3f..1fb29124524eb99d1efc4b078f373301e168f51f 100644 (file)
@@ -10,10 +10,13 @@ declare const window: Window & {
 }
 
 describe('async component hydration strategies', () => {
-  const { page, click, text, count } = setupPuppeteer(['--window-size=800,600'])
+  const { page, click, text, count } = setupPuppeteer([
+    '--window-size=800,600',
+    '--disable-web-security',
+  ])
 
-  async function goToCase(name: string, query = '') {
-    const file = `file://${path.resolve(__dirname, `./hydration-strat-${name}.html${query}`)}`
+  async function goToCase(name: string, query = '', vapor = false) {
+    const file = `file://${path.resolve(__dirname, `./hydration-strat-${name}${vapor ? '-vapor' : ''}.html${query}`)}`
     await page().goto(file)
   }
 
@@ -22,138 +25,148 @@ describe('async component hydration strategies', () => {
     expect(await text('button')).toBe(n)
   }
 
-  test('idle', async () => {
-    const messages: string[] = []
-    page().on('console', e => messages.push(e.text()))
-
-    await goToCase('idle')
-    // not hydrated yet
-    expect(await page().evaluate(() => window.isHydrated)).toBe(false)
-    // wait for hydration
-    await page().waitForFunction(() => window.isHydrated)
-    // assert message order: hyration should happen after already queued main thread work
-    expect(messages.slice(1)).toMatchObject(['resolve', 'busy', 'hydrated'])
-    await assertHydrationSuccess()
+  describe('vdom', () => {
+    runSharedTests(false)
   })
 
-  test('visible', async () => {
-    await goToCase('visible')
-    await page().waitForFunction(() => window.isRootMounted)
-    expect(await page().evaluate(() => window.isHydrated)).toBe(false)
-    // scroll down
-    await page().evaluate(() => window.scrollTo({ top: 1000 }))
-    await page().waitForFunction(() => window.isHydrated)
-    await assertHydrationSuccess()
+  describe('vapor', () => {
+    runSharedTests(true)
   })
 
-  test('visible (with rootMargin)', async () => {
-    await goToCase('visible', '?rootMargin=1000')
-    await page().waitForFunction(() => window.isRootMounted)
-    // should hydrate without needing to scroll
-    await page().waitForFunction(() => window.isHydrated)
-    await assertHydrationSuccess()
-  })
-
-  test('visible (fragment)', async () => {
-    await goToCase('visible', '?fragment')
-    await page().waitForFunction(() => window.isRootMounted)
-    expect(await page().evaluate(() => window.isHydrated)).toBe(false)
-    expect(await count('span')).toBe(2)
-    // scroll down
-    await page().evaluate(() => window.scrollTo({ top: 1000 }))
-    await page().waitForFunction(() => window.isHydrated)
-    await assertHydrationSuccess()
-  })
-
-  test('visible (root v-if) should not throw error', async () => {
-    const spy = vi.fn()
-    const currentPage = page()
-    currentPage.on('pageerror', spy)
-    await goToCase('visible', '?v-if')
-    await page().waitForFunction(() => window.isRootMounted)
-    expect(await page().evaluate(() => window.isHydrated)).toBe(false)
-    expect(spy).toBeCalledTimes(0)
-    currentPage.off('pageerror', spy)
-  })
-
-  test('media query', async () => {
-    await goToCase('media')
-    await page().waitForFunction(() => window.isRootMounted)
-    expect(await page().evaluate(() => window.isHydrated)).toBe(false)
-    // resize
-    await page().setViewport({ width: 400, height: 600 })
-    await page().waitForFunction(() => window.isHydrated)
-    await assertHydrationSuccess()
-  })
-
-  // #13255
-  test('media query (patched before hydration)', async () => {
-    const spy = vi.fn()
-    const currentPage = page()
-    currentPage.on('pageerror', spy)
-
-    const warn: any[] = []
-    currentPage.on('console', e => warn.push(e.text()))
-
-    await goToCase('media')
-    await page().waitForFunction(() => window.isRootMounted)
-    expect(await page().evaluate(() => window.isHydrated)).toBe(false)
-
-    // patch
-    await page().evaluate(() => (window.show.value = false))
-    await click('button')
-    expect(await text('button')).toBe('1')
-
-    // resize
-    await page().setViewport({ width: 400, height: 600 })
-    await page().waitForFunction(() => window.isHydrated)
-    await assertHydrationSuccess('2')
-
-    expect(spy).toBeCalledTimes(0)
-    currentPage.off('pageerror', spy)
-    expect(
-      warn.some(w => w.includes('Skipping lazy hydration for component')),
-    ).toBe(true)
-  })
-
-  test('interaction', async () => {
-    await goToCase('interaction')
-    await page().waitForFunction(() => window.isRootMounted)
-    expect(await page().evaluate(() => window.isHydrated)).toBe(false)
-    await click('button')
-    await page().waitForFunction(() => window.isHydrated)
-    // should replay event
-    expect(await text('button')).toBe('1')
-    await assertHydrationSuccess('2')
-  })
-
-  test('interaction (fragment)', async () => {
-    await goToCase('interaction', '?fragment')
-    await page().waitForFunction(() => window.isRootMounted)
-    expect(await page().evaluate(() => window.isHydrated)).toBe(false)
-    await click('button')
-    await page().waitForFunction(() => window.isHydrated)
-    // should replay event
-    expect(await text('button')).toBe('1')
-    await assertHydrationSuccess('2')
-  })
-
-  test('custom', async () => {
-    await goToCase('custom')
-    await page().waitForFunction(() => window.isRootMounted)
-    expect(await page().evaluate(() => window.isHydrated)).toBe(false)
-    await click('#custom-trigger')
-    await page().waitForFunction(() => window.isHydrated)
-    await assertHydrationSuccess()
-  })
-
-  test('custom teardown', async () => {
-    await goToCase('custom')
-    await page().waitForFunction(() => window.isRootMounted)
-    expect(await page().evaluate(() => window.isHydrated)).toBe(false)
-    await page().evaluate(() => (window.show.value = false))
-    expect(await text('#app')).toBe('off')
-    expect(await page().evaluate(() => window.isHydrated)).toBe(false)
-    expect(await page().evaluate(() => window.teardownCalled)).toBe(true)
-  })
+  function runSharedTests(vapor: boolean) {
+    test('idle', async () => {
+      const messages: string[] = []
+      page().on('console', e => messages.push(e.text()))
+
+      await goToCase('idle', '', vapor)
+      // not hydrated yet
+      expect(await page().evaluate(() => window.isHydrated)).toBe(false)
+      // wait for hydration
+      await page().waitForFunction(() => window.isHydrated)
+      // assert message order: hyration should happen after already queued main thread work
+      expect(messages.slice(1)).toMatchObject(['resolve', 'busy', 'hydrated'])
+      await assertHydrationSuccess()
+    })
+
+    test('visible', async () => {
+      await goToCase('visible', '', vapor)
+      await page().waitForFunction(() => window.isRootMounted)
+      expect(await page().evaluate(() => window.isHydrated)).toBe(false)
+      // scroll down
+      await page().evaluate(() => window.scrollTo({ top: 1000 }))
+      await page().waitForFunction(() => window.isHydrated)
+      await assertHydrationSuccess()
+    })
+
+    test('visible (with rootMargin)', async () => {
+      await goToCase('visible', '?rootMargin=1000', vapor)
+      await page().waitForFunction(() => window.isRootMounted)
+      // should hydrate without needing to scroll
+      await page().waitForFunction(() => window.isHydrated)
+      await assertHydrationSuccess()
+    })
+
+    test('visible (fragment)', async () => {
+      await goToCase('visible', '?fragment', vapor)
+      await page().waitForFunction(() => window.isRootMounted)
+      expect(await page().evaluate(() => window.isHydrated)).toBe(false)
+      expect(await count('span')).toBe(2)
+      // scroll down
+      await page().evaluate(() => window.scrollTo({ top: 1000 }))
+      await page().waitForFunction(() => window.isHydrated)
+      await assertHydrationSuccess()
+    })
+
+    test('visible (root v-if) should not throw error', async () => {
+      const spy = vi.fn()
+      const currentPage = page()
+      currentPage.on('pageerror', spy)
+      await goToCase('visible', '?v-if', vapor)
+      await page().waitForFunction(() => window.isRootMounted)
+      expect(await page().evaluate(() => window.isHydrated)).toBe(false)
+      expect(spy).toBeCalledTimes(0)
+      currentPage.off('pageerror', spy)
+    })
+
+    test('media query', async () => {
+      await goToCase('media', '', vapor)
+      await page().waitForFunction(() => window.isRootMounted)
+      expect(await page().evaluate(() => window.isHydrated)).toBe(false)
+      // resize
+      await page().setViewport({ width: 400, height: 600 })
+      await page().waitForFunction(() => window.isHydrated)
+      await assertHydrationSuccess()
+    })
+
+    // #13255
+    test('media query (patched before hydration)', async () => {
+      const spy = vi.fn()
+      const currentPage = page()
+      currentPage.on('pageerror', spy)
+
+      const warn: any[] = []
+      currentPage.on('console', e => warn.push(e.text()))
+
+      await goToCase('media', '', vapor)
+      await page().waitForFunction(() => window.isRootMounted)
+      expect(await page().evaluate(() => window.isHydrated)).toBe(false)
+
+      // patch
+      await page().evaluate(() => (window.show.value = false))
+      await click('button')
+      expect(await text('button')).toBe('1')
+
+      // resize
+      await page().setViewport({ width: 400, height: 600 })
+      await page().waitForFunction(() => window.isHydrated)
+      await assertHydrationSuccess('2')
+
+      expect(spy).toBeCalledTimes(0)
+      currentPage.off('pageerror', spy)
+      expect(
+        warn.some(w => w.includes('Skipping lazy hydration for component')),
+      ).toBe(true)
+    })
+
+    test('interaction', async () => {
+      await goToCase('interaction', '', vapor)
+      await page().waitForFunction(() => window.isRootMounted)
+      expect(await page().evaluate(() => window.isHydrated)).toBe(false)
+      await click('button')
+      await page().waitForFunction(() => window.isHydrated)
+      // should replay event
+      expect(await text('button')).toBe('1')
+      await assertHydrationSuccess('2')
+    })
+
+    test('interaction (fragment)', async () => {
+      await goToCase('interaction', '?fragment', vapor)
+      await page().waitForFunction(() => window.isRootMounted)
+      expect(await page().evaluate(() => window.isHydrated)).toBe(false)
+      await click('button')
+      await page().waitForFunction(() => window.isHydrated)
+      // should replay event
+      expect(await text('button')).toBe('1')
+      await assertHydrationSuccess('2')
+    })
+
+    test('custom', async () => {
+      await goToCase('custom', '', vapor)
+      await page().waitForFunction(() => window.isRootMounted)
+      expect(await page().evaluate(() => window.isHydrated)).toBe(false)
+      await click('#custom-trigger')
+      await page().waitForFunction(() => window.isHydrated)
+      await assertHydrationSuccess()
+    })
+
+    test('custom teardown', async () => {
+      await goToCase('custom', '', vapor)
+      await page().waitForFunction(() => window.isRootMounted)
+      expect(await page().evaluate(() => window.isHydrated)).toBe(false)
+      await page().evaluate(() => (window.show.value = false))
+      expect(await text('#app')).toBe('off')
+      expect(await page().evaluate(() => window.isHydrated)).toBe(false)
+      expect(await page().evaluate(() => window.teardownCalled)).toBe(true)
+    })
+  }
 })
index 21f4c8073cfda0496534132f60499fe439775218..9e139113ced83ace7a08ffa15f085dc768ecafa7 100644 (file)
@@ -1,3 +1,3 @@
 // for type generation only
-export * from './index'
+export * from './indexBase'
 export * from '@vue/runtime-vapor'
index 785f3fd4bb4fa8ee0c1b6bc8699d362610e4cb79..8d2de8d30158eb3ded824eccae340107ad3b0d1c 100644 (file)
@@ -1,107 +1,2 @@
-// This entry is the "full-build" that includes both the runtime
-// and the compiler, and supports on-the-fly compilation of the template option.
-import { initDev } from './dev'
-import {
-  type CompilerError,
-  type CompilerOptions,
-  compile,
-} from '@vue/compiler-dom'
-import {
-  type RenderFunction,
-  registerRuntimeCompiler,
-  warn,
-} from '@vue/runtime-dom'
-import * as runtimeDom from '@vue/runtime-dom'
-import {
-  NOOP,
-  extend,
-  genCacheKey,
-  generateCodeFrame,
-  isString,
-} from '@vue/shared'
-import type { InternalRenderFunction } from 'packages/runtime-core/src/component'
-
-if (__DEV__) {
-  initDev()
-}
-
-const compileCache: Record<string, RenderFunction> = Object.create(null)
-
-function compileToFunction(
-  template: string | HTMLElement,
-  options?: CompilerOptions,
-): RenderFunction {
-  if (!isString(template)) {
-    if (template.nodeType) {
-      template = template.innerHTML
-    } else {
-      __DEV__ && warn(`invalid template option: `, template)
-      return NOOP
-    }
-  }
-
-  const key = genCacheKey(template, options)
-  const cached = compileCache[key]
-  if (cached) {
-    return cached
-  }
-
-  if (template[0] === '#') {
-    const el = document.querySelector(template)
-    if (__DEV__ && !el) {
-      warn(`Template element not found or is empty: ${template}`)
-    }
-    // __UNSAFE__
-    // Reason: potential execution of JS expressions in in-DOM template.
-    // The user must make sure the in-DOM template is trusted. If it's rendered
-    // by the server, the template should not contain any user data.
-    template = el ? el.innerHTML : ``
-  }
-
-  const opts = extend(
-    {
-      hoistStatic: true,
-      onError: __DEV__ ? onError : undefined,
-      onWarn: __DEV__ ? e => onError(e, true) : NOOP,
-    } as CompilerOptions,
-    options,
-  )
-
-  if (!opts.isCustomElement && typeof customElements !== 'undefined') {
-    opts.isCustomElement = tag => !!customElements.get(tag)
-  }
-
-  const { code } = compile(template, opts)
-
-  function onError(err: CompilerError, asWarning = false) {
-    const message = asWarning
-      ? err.message
-      : `Template compilation error: ${err.message}`
-    const codeFrame =
-      err.loc &&
-      generateCodeFrame(
-        template as string,
-        err.loc.start.offset,
-        err.loc.end.offset,
-      )
-    warn(codeFrame ? `${message}\n${codeFrame}` : message)
-  }
-
-  // The wildcard import results in a huge object with every export
-  // with keys that cannot be mangled, and can be quite heavy size-wise.
-  // In the global build we know `Vue` is available globally so we can avoid
-  // the wildcard object.
-  const render = (
-    __GLOBAL__ ? new Function(code)() : new Function('Vue', code)(runtimeDom)
-  ) as RenderFunction
-
-  // mark the function as runtime compiled
-  ;(render as InternalRenderFunction)._rc = true
-
-  return (compileCache[key] = render)
-}
-
-registerRuntimeCompiler(compileToFunction)
-
-export { compileToFunction as compile }
-export * from '@vue/runtime-dom'
+export * from './indexBase'
+export * from './vaporAliases'
diff --git a/packages/vue/src/indexBase.ts b/packages/vue/src/indexBase.ts
new file mode 100644 (file)
index 0000000..785f3fd
--- /dev/null
@@ -0,0 +1,107 @@
+// This entry is the "full-build" that includes both the runtime
+// and the compiler, and supports on-the-fly compilation of the template option.
+import { initDev } from './dev'
+import {
+  type CompilerError,
+  type CompilerOptions,
+  compile,
+} from '@vue/compiler-dom'
+import {
+  type RenderFunction,
+  registerRuntimeCompiler,
+  warn,
+} from '@vue/runtime-dom'
+import * as runtimeDom from '@vue/runtime-dom'
+import {
+  NOOP,
+  extend,
+  genCacheKey,
+  generateCodeFrame,
+  isString,
+} from '@vue/shared'
+import type { InternalRenderFunction } from 'packages/runtime-core/src/component'
+
+if (__DEV__) {
+  initDev()
+}
+
+const compileCache: Record<string, RenderFunction> = Object.create(null)
+
+function compileToFunction(
+  template: string | HTMLElement,
+  options?: CompilerOptions,
+): RenderFunction {
+  if (!isString(template)) {
+    if (template.nodeType) {
+      template = template.innerHTML
+    } else {
+      __DEV__ && warn(`invalid template option: `, template)
+      return NOOP
+    }
+  }
+
+  const key = genCacheKey(template, options)
+  const cached = compileCache[key]
+  if (cached) {
+    return cached
+  }
+
+  if (template[0] === '#') {
+    const el = document.querySelector(template)
+    if (__DEV__ && !el) {
+      warn(`Template element not found or is empty: ${template}`)
+    }
+    // __UNSAFE__
+    // Reason: potential execution of JS expressions in in-DOM template.
+    // The user must make sure the in-DOM template is trusted. If it's rendered
+    // by the server, the template should not contain any user data.
+    template = el ? el.innerHTML : ``
+  }
+
+  const opts = extend(
+    {
+      hoistStatic: true,
+      onError: __DEV__ ? onError : undefined,
+      onWarn: __DEV__ ? e => onError(e, true) : NOOP,
+    } as CompilerOptions,
+    options,
+  )
+
+  if (!opts.isCustomElement && typeof customElements !== 'undefined') {
+    opts.isCustomElement = tag => !!customElements.get(tag)
+  }
+
+  const { code } = compile(template, opts)
+
+  function onError(err: CompilerError, asWarning = false) {
+    const message = asWarning
+      ? err.message
+      : `Template compilation error: ${err.message}`
+    const codeFrame =
+      err.loc &&
+      generateCodeFrame(
+        template as string,
+        err.loc.start.offset,
+        err.loc.end.offset,
+      )
+    warn(codeFrame ? `${message}\n${codeFrame}` : message)
+  }
+
+  // The wildcard import results in a huge object with every export
+  // with keys that cannot be mangled, and can be quite heavy size-wise.
+  // In the global build we know `Vue` is available globally so we can avoid
+  // the wildcard object.
+  const render = (
+    __GLOBAL__ ? new Function(code)() : new Function('Vue', code)(runtimeDom)
+  ) as RenderFunction
+
+  // mark the function as runtime compiled
+  ;(render as InternalRenderFunction)._rc = true
+
+  return (compileCache[key] = render)
+}
+
+registerRuntimeCompiler(compileToFunction)
+
+export { compileToFunction as compile }
+export * from '@vue/runtime-dom'
index 4f03329ede4c39cef6a3e11f5e14c0d69859941c..eee717fc130564729f3774e299c5136165223e68 100644 (file)
@@ -1,2 +1,2 @@
-export * from './runtime'
+export * from './runtimeBase'
 export * from '@vue/runtime-vapor'
index af1ffe7a12a09ade54d6a8675176ac8c20b287f6..1c81ab0badf1a1f98cd2f235b7d5986e90668537 100644 (file)
@@ -1,27 +1,2 @@
-// This entry exports the runtime only, and is built as
-// `dist/vue.esm-bundler.js` which is used by default for bundlers.
-import { NOOP } from '@vue/shared'
-import { initDev } from './dev'
-import { type RenderFunction, warn } from '@vue/runtime-dom'
-
-if (__DEV__) {
-  initDev()
-}
-
-export * from '@vue/runtime-dom'
-
-export const compile = (_template: string): RenderFunction => {
-  if (__DEV__) {
-    warn(
-      `Runtime compilation is not supported in this build of Vue.` +
-        (__ESM_BUNDLER__
-          ? ` Configure your bundler to alias "vue" to "vue/dist/vue.esm-bundler.js".`
-          : __ESM_BROWSER__
-            ? ` Use "vue.esm-browser.js" instead.`
-            : __GLOBAL__
-              ? ` Use "vue.global.js" instead.`
-              : ``) /* should not happen */,
-    )
-  }
-  return NOOP
-}
+export * from './runtimeBase'
+export * from './vaporAliases'
diff --git a/packages/vue/src/runtimeBase.ts b/packages/vue/src/runtimeBase.ts
new file mode 100644 (file)
index 0000000..af1ffe7
--- /dev/null
@@ -0,0 +1,27 @@
+// This entry exports the runtime only, and is built as
+// `dist/vue.esm-bundler.js` which is used by default for bundlers.
+import { NOOP } from '@vue/shared'
+import { initDev } from './dev'
+import { type RenderFunction, warn } from '@vue/runtime-dom'
+
+if (__DEV__) {
+  initDev()
+}
+
+export * from '@vue/runtime-dom'
+
+export const compile = (_template: string): RenderFunction => {
+  if (__DEV__) {
+    warn(
+      `Runtime compilation is not supported in this build of Vue.` +
+        (__ESM_BUNDLER__
+          ? ` Configure your bundler to alias "vue" to "vue/dist/vue.esm-bundler.js".`
+          : __ESM_BROWSER__
+            ? ` Use "vue.esm-browser.js" instead.`
+            : __GLOBAL__
+              ? ` Use "vue.global.js" instead.`
+              : ``) /* should not happen */,
+    )
+  }
+  return NOOP
+}
diff --git a/packages/vue/src/vaporAliases.ts b/packages/vue/src/vaporAliases.ts
new file mode 100644 (file)
index 0000000..f426d9d
--- /dev/null
@@ -0,0 +1,7 @@
+// Vapor-only APIs do not exist in the standard build, yet SSR executes
+// the standard entry. We alias them to the core implementations so SSR
+// keeps working without the Vapor runtime.
+export {
+  defineAsyncComponent as defineVaporAsyncComponent,
+  defineComponent as defineVaporComponent,
+} from '@vue/runtime-core'