]> git.ipfire.org Git - thirdparty/vuejs/core.git/commitdiff
wip: hydrate vapor async component (#13976)
authoredison <daiwei521@126.com>
Mon, 13 Oct 2025 01:42:26 +0000 (09:42 +0800)
committerGitHub <noreply@github.com>
Mon, 13 Oct 2025 01:42:26 +0000 (09:42 +0800)
17 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/runtime-vapor/src/dom/hydration.ts
packages/runtime-vapor/src/fragment.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

index e94789865e9be4f15a509cf303ead4bb7fefa9ee..dbac818bcd1a0ef39788a8a4492da1e358afead9 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 ac579f6fb8ebd32d6265cda9040644fca1c93fc6..53d0e4ad3fbdd80b1fa0cee9827eb835466e96fb 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() {
@@ -130,19 +108,7 @@ export function defineAsyncComponent<
         (__FEATURE_SUSPENSE__ && suspensible && instance.suspense) ||
         (__SSR__ && isInSSRComponentSetup)
       ) {
-        return load()
-          .then(comp => {
-            return () => createInnerComp(comp, instance)
-          })
-          .catch(err => {
-            onError(err)
-            return () =>
-              errorComponent
-                ? createVNode(errorComponent as ConcreteComponent, {
-                    error: err,
-                  })
-                : null
-          })
+        return loadInnerComponent(instance, load, onError, errorComponent)
       }
 
       const { loaded, error, delayed } = useAsyncComponentState(
@@ -185,10 +151,10 @@ export function defineAsyncComponent<
   }) as T
 }
 
-function createInnerComp(
+export function createInnerComp(
   comp: ConcreteComponent,
   parent: ComponentInternalInstance,
-) {
+): VNode {
   const { ref, props, children, ce } = parent.vnode
   const vnode = createVNode(comp, props, children)
   // ensure inner component inherits the async wrapper's ref owner
@@ -311,3 +277,73 @@ export const useAsyncComponentState = (
 
   return { loaded, error, delayed }
 }
+
+/**
+ * shared between core and vapor
+ * @internal
+ */
+export function loadInnerComponent(
+  instance: ComponentInternalInstance,
+  load: () => Promise<any>,
+  onError: (err: Error) => void,
+  errorComponent: ConcreteComponent | undefined,
+): Promise<() => VNode | null> {
+  return load()
+    .then(comp => {
+      return () => createInnerComp(comp, instance)
+    })
+    .catch(err => {
+      onError(err)
+      return () =>
+        errorComponent
+          ? createVNode(errorComponent as ConcreteComponent, {
+              error: err,
+            })
+          : null
+    })
+}
+
+/**
+ * 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 67d5a1b8720b9420bc0a5a865deaff3ac4ebfdb9..1a2e5879d131551074cdca50369afc3245805bef 100644 (file)
@@ -223,6 +223,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 b7d811710b796f0a3b7e1b42136296b52719dafa..c6033815d77b3186f5b4a6b5d97c31bb591f18c7 100644 (file)
@@ -271,6 +271,7 @@ export type {
   GlobalDirectives,
   ComponentInstance,
   ComponentCustomElementInterface,
+  AsyncComponentInternalOptions,
 } from './component'
 export type {
   DefineComponent,
@@ -535,7 +536,12 @@ export { queueJob, flushOnAppMount } from './scheduler'
 /**
  * @internal
  */
-export { expose, nextUid, validateComponentName } from './component'
+export {
+  expose,
+  nextUid,
+  validateComponentName,
+  isInSSRComponentSetup,
+} from './component'
 /**
  * @internal
  */
@@ -595,6 +601,9 @@ export {
   createAsyncComponentContext,
   useAsyncComponentState,
   isAsyncWrapper,
+  performAsyncHydrate,
+  loadInnerComponent,
+  createInnerComp,
 } from './apiAsyncComponent'
 /**
  * @internal
index eeda18af3062f6a9a916ae2106a3bcd63db6fd2a..a20c6f3f55a7baab62f655218b224c5b8d80f194 100644 (file)
@@ -1,4 +1,8 @@
-import { createVaporSSRApp, delegateEvents } from '../src'
+import {
+  createVaporSSRApp,
+  defineVaporAsyncComponent,
+  delegateEvents,
+} from '../src'
 import { nextTick, reactive, ref } from '@vue/runtime-dom'
 import { compileScript, parse } from '@vue/compiler-sfc'
 import * as runtimeVapor from '../src'
@@ -85,7 +89,10 @@ function compileVaporComponent(
   components?: Record<string, any>,
   ssr = false,
 ) {
-  return compile(`<template>${code}</template>`, data, components, {
+  if (!code.includes(`<script`)) {
+    code = `<template>${code}</template>`
+  }
+  return compile(code, data, components, {
     vapor: true,
     ssr,
   })
@@ -2952,18 +2959,410 @@ describe('Vapor Mode hydration', () => {
     })
   })
 
-  describe.todo('async component', async () => {
-    test('async component', async () => {})
+  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
+      let AsyncComp = defineVaporAsyncComponent(
+        () =>
+          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
+          }),
+      )
+
+      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
+      let AsyncComp = defineVaporAsyncComponent(
+        () =>
+          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
+          }),
+      )
+
+      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
+      let AsyncComp = defineVaporAsyncComponent(
+        () =>
+          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
+          }),
+      )
+
+      const Comp = compileVaporComponent(compCode)
+      const App = compileVaporComponent(appCode, data, { AsyncComp })
 
-    test('update async wrapper before resolve', async () => {})
+      const container = document.createElement('div')
+      container.innerHTML = html
+      document.body.appendChild(container)
+      createVaporSSRApp(App).mount(container)
 
-    test('hydrate safely when property used by async setup changed before render', async () => {})
+      // 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--><!--]-->"`,
+      )
+    })
 
-    test('unmount async wrapper before load', async () => {})
+    // required vapor Suspense
+    test.todo(
+      'hydrate safely when property used by async setup changed before render',
+      async () => {},
+    )
 
-    test('nested async wrapper', 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 (fragment)', 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('force hydrate prop', async () => {
index e609dfa795d13af982bc3fe3a2d6f5fe9ed4671b..5072340af5dd7f9972d51a327968eb08527fb7d9 100644 (file)
@@ -3,10 +3,15 @@ import {
   type AsyncComponentOptions,
   ErrorCodes,
   createAsyncComponentContext,
+  createInnerComp as createSSRInnerComp,
   currentInstance,
   handleError,
+  isInSSRComponentSetup,
+  loadInnerComponent as loadSSRInnerComponent,
   markAsyncBoundary,
+  performAsyncHydrate,
   useAsyncComponentState,
+  watch,
 } from '@vue/runtime-dom'
 import { defineVaporComponent } from './apiDefineComponent'
 import {
@@ -16,8 +21,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 +44,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 +55,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 +115,20 @@ 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))
+        // SSR
+        if (__SSR__ && isInSSRComponentSetup) {
+          return () => createSSRInnerComp(resolvedComp! as any, instance as any)
+        }
+
+        frag!.update(() => createInnerComp(resolvedComp!, instance))
         return frag
       }
 
@@ -73,7 +142,19 @@ export function defineVaporAsyncComponent<T extends VaporComponent>(
         )
       }
 
-      // TODO suspense-controlled or SSR.
+      // TODO suspense-controlled
+      if (__FEATURE_SUSPENSE__ && suspensible && instance.suspense) {
+      }
+
+      // SSR
+      if (__SSR__ && isInSSRComponentSetup) {
+        return loadSSRInnerComponent(
+          instance as any,
+          load,
+          onError,
+          errorComponent,
+        )
+      }
 
       const { loaded, error, delayed } = useAsyncComponentState(
         delay,
@@ -103,7 +184,7 @@ export function defineVaporAsyncComponent<T extends VaporComponent>(
         } else if (loadingComponent && !delayed.value) {
           render = () => createComponent(loadingComponent)
         }
-        frag.update(render)
+        frag!.update(render)
       })
 
       return frag
index 3828d7119c71176711e1aab0423a13a32931a1c6..0139d9cf1c63f0dea56d5cfdfcf641af7a8c65df 100644 (file)
@@ -1,4 +1,5 @@
 import {
+  type AsyncComponentInternalOptions,
   type ComponentInternalOptions,
   type ComponentPropsOptions,
   EffectScope,
@@ -15,6 +16,7 @@ import {
   currentInstance,
   endMeasure,
   expose,
+  isAsyncWrapper,
   nextUid,
   popWarningContext,
   pushWarningContext,
@@ -41,13 +43,7 @@ import {
   setActiveSub,
   unref,
 } from '@vue/reactivity'
-import {
-  EMPTY_OBJ,
-  invokeArrayFns,
-  isArray,
-  isFunction,
-  isString,
-} from '@vue/shared'
+import { EMPTY_OBJ, invokeArrayFns, isFunction, isString } from '@vue/shared'
 import {
   type DynamicPropsSource,
   type RawProps,
@@ -70,12 +66,14 @@ import {
   getSlot,
 } from './componentSlots'
 import { hmrReload, hmrRerender } from './hmr'
-import { createElement } from './dom/node'
+import { _next, createElement } from './dom/node'
 import {
   adoptTemplate,
   advanceHydrationNode,
   currentHydrationNode,
+  isComment,
   isHydrating,
+  locateEndAnchor,
   locateHydrationNode,
   setCurrentHydrationNode,
 } from './dom/hydration'
@@ -103,6 +101,7 @@ export type FunctionalVaporComponent = VaporSetupFn &
 
 export interface ObjectVaporComponent
   extends ComponentInternalOptions,
+    AsyncComponentInternalOptions<ObjectVaporComponent, VaporComponentInstance>,
     SharedInternalOptions {
   setup?: VaporSetupFn
   inheritAttrs?: boolean
@@ -118,8 +117,6 @@ export interface ObjectVaporComponent
 
   name?: string
   vapor?: boolean
-  __asyncLoader?: () => Promise<VaporComponent>
-  __asyncResolved?: VaporComponent
 }
 
 interface SharedInternalOptions {
@@ -254,6 +251,64 @@ 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, scopeId),
+    )
+  } else {
+    setupComponent(instance, component, scopeId)
+  }
+
+  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,
+  scopeId: string | undefined,
+): void {
   const prevInstance = setCurrentInstance(instance)
   const prevSub = setActiveSub()
 
@@ -311,6 +366,8 @@ export function createComponent(
     }
   }
 
+  if (scopeId) setScopeId(instance.block, scopeId)
+
   setActiveSub(prevSub)
   setCurrentInstance(...prevInstance)
 
@@ -318,19 +375,6 @@ export function createComponent(
     popWarningContext()
     endMeasure(instance, 'init')
   }
-
-  onScopeDispose(() => unmountComponent(instance), true)
-
-  if (scopeId) setScopeId(instance.block, scopeId)
-
-  if (_insertionParent) {
-    mountComponent(instance, _insertionParent, _insertionAnchor)
-  }
-
-  if (isHydrating && _insertionAnchor !== undefined) {
-    advanceHydrationNode(_insertionParent!)
-  }
-  return instance
 }
 
 export let isApplyingFallthroughProps = false
@@ -623,19 +667,10 @@ export function mountComponent(
     startMeasure(instance, `mount`)
   }
   if (instance.bm) invokeArrayFns(instance.bm)
-  const block = instance.block
-  if (isHydrating) {
-    if (
-      !(block instanceof Node) ||
-      (isArray(block) && block.some(b => !(b instanceof Node)))
-    ) {
-      insert(block, parent, anchor)
-    }
-  } else {
-    insert(block, parent, anchor)
+  if (!isHydrating) {
+    insert(instance.block, parent, anchor)
     setComponentScopeId(instance)
   }
-
   if (instance.m) queuePostFlushCb(() => invokeArrayFns(instance.m!))
   instance.isMounted = true
   if (__DEV__) {
index 3f5eafbc2e4f363ccec9175fbf17cd7d171f0935..a542b360cb69cd56fbfe3c59d99b4aa8c6060b9b 100644 (file)
@@ -22,12 +22,21 @@ const isHydratingStack = [] as boolean[]
 export let isHydrating = false
 export let currentHydrationNode: Node | null = null
 
+function pushIsHydrating(value: boolean): void {
+  isHydratingStack.push((isHydrating = value))
+}
+
+function popIsHydrating(): void {
+  isHydratingStack.pop()
+  isHydrating = isHydratingStack[isHydratingStack.length - 1] || false
+}
+
 export function runWithoutHydration(fn: () => any): any {
   try {
-    isHydrating = false
+    pushIsHydrating(false)
     return fn()
   } finally {
-    isHydrating = true
+    popIsHydrating()
   }
 }
 
@@ -53,13 +62,12 @@ function performHydration<T>(
     isOptimized = true
   }
   enableHydrationNodeLookup()
-  isHydratingStack.push((isHydrating = true))
+  pushIsHydrating(true)
   setup()
   const res = fn()
   cleanup()
   currentHydrationNode = null
-  isHydratingStack.pop()
-  isHydrating = isHydratingStack[isHydratingStack.length - 1] || false
+  popIsHydrating()
   if (!isHydrating) disableHydrationNodeLookup()
   return res
 }
@@ -239,15 +247,7 @@ function handleMismatch(node: Node, template: string): Node {
 
   // fragment
   if (isComment(node, '[')) {
-    const end = locateEndAnchor(node as Anchor)
-    while (true) {
-      const next = _next(node)
-      if (next && next !== end) {
-        remove(next, parentNode(node)!)
-      } else {
-        break
-      }
-    }
+    removeFragmentNodes(node)
   }
 
   const next = _next(node)
@@ -280,3 +280,15 @@ export const logMismatchError = (): void => {
   console.error('Hydration completed but contains mismatches.')
   hasLoggedMismatchError = true
 }
+
+export function removeFragmentNodes(node: Node, endAnchor?: Node): void {
+  const end = endAnchor || locateEndAnchor(node as Anchor)
+  while (true) {
+    const next = _next(node)
+    if (next && next !== end) {
+      remove(next, parentNode(node)!)
+    } else {
+      break
+    }
+  }
+}
index 65eb1af494cee96a8a802f5b65624ad56c700690..6f98e026b7c531aa9c789f2653624c3a2a429fd7 100644 (file)
@@ -144,6 +144,7 @@ export class DynamicFragment extends VaporFragment {
     if (this.anchor) return
 
     // reuse the empty comment node as the anchor for empty if
+    // e.g. `<div v-if="false"></div>` -> `<!---->`
     if (this.anchorLabel === 'if' && isEmpty) {
       this.anchor = currentHydrationNode!
       if (!this.anchor) {
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)
+    })
+  }
 })