]> git.ipfire.org Git - thirdparty/vuejs/core.git/commitdiff
feat: lazy hydration strategies for async components (#11458)
authorEvan You <evan@vuejs.org>
Wed, 31 Jul 2024 04:14:51 +0000 (12:14 +0800)
committerGitHub <noreply@github.com>
Wed, 31 Jul 2024 04:14:51 +0000 (12:14 +0800)
13 files changed:
packages/runtime-core/src/apiAsyncComponent.ts
packages/runtime-core/src/componentOptions.ts
packages/runtime-core/src/hydration.ts
packages/runtime-core/src/hydrationStrategies.ts [new file with mode: 0644]
packages/runtime-core/src/index.ts
packages/runtime-core/src/renderer.ts
packages/vue/__tests__/e2e/e2eUtils.ts
packages/vue/__tests__/e2e/hydration-strat-custom.html [new file with mode: 0644]
packages/vue/__tests__/e2e/hydration-strat-idle.html [new file with mode: 0644]
packages/vue/__tests__/e2e/hydration-strat-interaction.html [new file with mode: 0644]
packages/vue/__tests__/e2e/hydration-strat-media.html [new file with mode: 0644]
packages/vue/__tests__/e2e/hydration-strat-visible.html [new file with mode: 0644]
packages/vue/__tests__/e2e/hydrationStrategies.spec.ts [new file with mode: 0644]

index dc1d3ae1141843436f292093a51cdab4b07fbe9f..e1c9a0ce06f45ece4625dab1d4924a8b54c96879 100644 (file)
@@ -16,6 +16,7 @@ import { ErrorCodes, handleError } from './errorHandling'
 import { isKeepAlive } from './components/KeepAlive'
 import { queueJob } from './scheduler'
 import { markAsyncBoundary } from './helpers/useId'
+import { type HydrationStrategy, forEachElement } from './hydrationStrategies'
 
 export type AsyncComponentResolveResult<T = Component> = T | { default: T } // es modules
 
@@ -30,6 +31,7 @@ export interface AsyncComponentOptions<T = any> {
   delay?: number
   timeout?: number
   suspensible?: boolean
+  hydrate?: HydrationStrategy
   onError?: (
     error: Error,
     retry: () => void,
@@ -54,6 +56,7 @@ export function defineAsyncComponent<
     loadingComponent,
     errorComponent,
     delay = 200,
+    hydrate: hydrateStrategy,
     timeout, // undefined = never times out
     suspensible = true,
     onError: userOnError,
@@ -118,6 +121,24 @@ export function defineAsyncComponent<
 
     __asyncLoader: load,
 
+    __asyncHydrate(el, instance, hydrate) {
+      const doHydrate = hydrateStrategy
+        ? () => {
+            const teardown = hydrateStrategy(hydrate, cb =>
+              forEachElement(el, cb),
+            )
+            if (teardown) {
+              ;(instance.bum || (instance.bum = [])).push(teardown)
+            }
+          }
+        : hydrate
+      if (resolvedComp) {
+        doHydrate()
+      } else {
+        load().then(() => !instance.isUnmounted && doHydrate())
+      }
+    },
+
     get __asyncResolved() {
       return resolvedComp
     },
index 888024a270383967b58dc3cf7e1260171b05671f..f426429f2ebbfcab2304a734f08e80a5f3084135 100644 (file)
@@ -199,6 +199,15 @@ export interface ComponentOptionsBase<
    * @internal
    */
   __asyncResolved?: ConcreteComponent
+  /**
+   * Exposed for lazy hydration
+   * @internal
+   */
+  __asyncHydrate?: (
+    el: Element,
+    instance: ComponentInternalInstance,
+    hydrate: () => void,
+  ) => void
 
   // Type differentiators ------------------------------------------------------
 
index e79a9cede3d8b4835d393e5242dfcf30d2cd6f62..27a9a7d58a17f0e603e9aa14a247b401fbb4cfc2 100644 (file)
@@ -46,7 +46,7 @@ export type RootHydrateFunction = (
   container: (Element | ShadowRoot) & { _vnode?: VNode },
 ) => void
 
-enum DOMNodeTypes {
+export enum DOMNodeTypes {
   ELEMENT = 1,
   TEXT = 3,
   COMMENT = 8,
@@ -75,7 +75,7 @@ const getContainerType = (container: Element): 'svg' | 'mathml' | undefined => {
   return undefined
 }
 
-const isComment = (node: Node): node is Comment =>
+export const isComment = (node: Node): node is Comment =>
   node.nodeType === DOMNodeTypes.COMMENT
 
 // Note: hydration is DOM-specific
diff --git a/packages/runtime-core/src/hydrationStrategies.ts b/packages/runtime-core/src/hydrationStrategies.ts
new file mode 100644 (file)
index 0000000..4f0a2d2
--- /dev/null
@@ -0,0 +1,111 @@
+import { isString } from '@vue/shared'
+import { DOMNodeTypes, isComment } from './hydration'
+
+/**
+ * A lazy hydration strategy for async components.
+ * @param hydrate - call this to perform the actual hydration.
+ * @param forEachElement - iterate through the root elements of the component's
+ *                         non-hydrated DOM, accounting for possible fragments.
+ * @returns a teardown function to be called if the async component is unmounted
+ *          before it is hydrated. This can be used to e.g. remove DOM event
+ *          listeners.
+ */
+export type HydrationStrategy = (
+  hydrate: () => void,
+  forEachElement: (cb: (el: Element) => any) => void,
+) => (() => void) | void
+
+export type HydrationStrategyFactory<Options = any> = (
+  options?: Options,
+) => HydrationStrategy
+
+export const hydrateOnIdle: HydrationStrategyFactory = () => hydrate => {
+  const id = requestIdleCallback(hydrate)
+  return () => cancelIdleCallback(id)
+}
+
+export const hydrateOnVisible: HydrationStrategyFactory<string | number> =
+  (margin = 0) =>
+  (hydrate, forEach) => {
+    const ob = new IntersectionObserver(
+      entries => {
+        for (const e of entries) {
+          if (!e.isIntersecting) continue
+          ob.disconnect()
+          hydrate()
+          break
+        }
+      },
+      {
+        rootMargin: isString(margin) ? margin : margin + 'px',
+      },
+    )
+    forEach(el => ob.observe(el))
+    return () => ob.disconnect()
+  }
+
+export const hydrateOnMediaQuery: HydrationStrategyFactory<string> =
+  query => hydrate => {
+    if (query) {
+      const mql = matchMedia(query)
+      if (mql.matches) {
+        hydrate()
+      } else {
+        mql.addEventListener('change', hydrate, { once: true })
+        return () => mql.removeEventListener('change', hydrate)
+      }
+    }
+  }
+
+export const hydrateOnInteraction: HydrationStrategyFactory<
+  string | string[]
+> =
+  (interactions = []) =>
+  (hydrate, forEach) => {
+    if (isString(interactions)) interactions = [interactions]
+    let hasHydrated = false
+    const doHydrate = (e: Event) => {
+      if (!hasHydrated) {
+        hasHydrated = true
+        teardown()
+        hydrate()
+        // replay event
+        e.target!.dispatchEvent(new (e.constructor as any)(e.type, e))
+      }
+    }
+    const teardown = () => {
+      forEach(el => {
+        for (const i of interactions) {
+          el.removeEventListener(i, doHydrate)
+        }
+      })
+    }
+    forEach(el => {
+      for (const i of interactions) {
+        el.addEventListener(i, doHydrate, { once: true })
+      }
+    })
+    return teardown
+  }
+
+export function forEachElement(node: Node, cb: (el: Element) => void) {
+  // fragment
+  if (isComment(node) && node.data === '[') {
+    let depth = 1
+    let next = node.nextSibling
+    while (next) {
+      if (next.nodeType === DOMNodeTypes.ELEMENT) {
+        cb(next as Element)
+      } else if (isComment(next)) {
+        if (next.data === ']') {
+          if (--depth === 0) break
+        } else if (next.data === '[') {
+          depth++
+        }
+      }
+      next = next.nextSibling
+    }
+  } else {
+    cb(node as Element)
+  }
+}
index e4b1c55200ca8a418f7946d62a355ecc3ebafd4c..b8dc513689e13be1ce53879cb61640120879cadb 100644 (file)
@@ -64,6 +64,12 @@ export { useAttrs, useSlots } from './apiSetupHelpers'
 export { useModel } from './helpers/useModel'
 export { useTemplateRef } from './helpers/useTemplateRef'
 export { useId } from './helpers/useId'
+export {
+  hydrateOnIdle,
+  hydrateOnVisible,
+  hydrateOnMediaQuery,
+  hydrateOnInteraction,
+} from './hydrationStrategies'
 
 // <script setup> API ----------------------------------------------------------
 
@@ -327,6 +333,10 @@ export type {
   AsyncComponentOptions,
   AsyncComponentLoader,
 } from './apiAsyncComponent'
+export type {
+  HydrationStrategy,
+  HydrationStrategyFactory,
+} from './hydrationStrategies'
 export type { HMRRuntime } from './hmr'
 
 // Internal API ----------------------------------------------------------------
index ddc46c70049e242ccfe0e1ebf493cf680414eb9e..466a21a7e516b9ac7f0687d948e549fd2733a9b5 100644 (file)
@@ -1325,16 +1325,11 @@ function baseCreateRenderer(
             }
           }
 
-          if (
-            isAsyncWrapperVNode &&
-            !(type as ComponentOptions).__asyncResolved
-          ) {
-            ;(type as ComponentOptions).__asyncLoader!().then(
-              // note: we are moving the render call into an async callback,
-              // which means it won't track dependencies - but it's ok because
-              // a server-rendered async wrapper is already in resolved state
-              // and it will never need to change.
-              () => !instance.isUnmounted && hydrateSubTree(),
+          if (isAsyncWrapperVNode) {
+            ;(type as ComponentOptions).__asyncHydrate!(
+              el as Element,
+              instance,
+              hydrateSubTree,
             )
           } else {
             hydrateSubTree()
index 6e98ffc1de323979c49ba91e19628b1cb6690a8a..fd4abc56e73ee1928378689f74fd35aad06042c3 100644 (file)
@@ -30,12 +30,19 @@ export async function expectByPolling(
   }
 }
 
-export function setupPuppeteer() {
+export function setupPuppeteer(args?: string[]) {
   let browser: Browser
   let page: Page
 
+  const resolvedOptions = args
+    ? {
+        ...puppeteerOptions,
+        args: [...puppeteerOptions.args!, ...args],
+      }
+    : puppeteerOptions
+
   beforeAll(async () => {
-    browser = await puppeteer.launch(puppeteerOptions)
+    browser = await puppeteer.launch(resolvedOptions)
   }, 20000)
 
   beforeEach(async () => {
diff --git a/packages/vue/__tests__/e2e/hydration-strat-custom.html b/packages/vue/__tests__/e2e/hydration-strat-custom.html
new file mode 100644 (file)
index 0000000..c0b9c5c
--- /dev/null
@@ -0,0 +1,44 @@
+<script src="../../dist/vue.global.js"></script>
+
+<div><span id="custom-trigger">click here to hydrate</span></div>
+<div id="app"><button>0</button></div>
+
+<script>
+  window.isHydrated = false
+  const { createSSRApp, defineAsyncComponent, h, ref, onMounted } = Vue
+
+  const Comp = {
+    setup() {
+      const count = ref(0)
+      onMounted(() => {
+        console.log('hydrated')
+        window.isHydrated = true
+      })
+      return () => {
+        return h('button', { onClick: () => count.value++ }, count.value)
+      }
+    },
+  }
+
+  const AsyncComp = defineAsyncComponent({
+    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)
+  createSSRApp({
+    setup() {
+      onMounted(() => {
+        window.isRootMounted = true
+      })
+      return () => show.value ? h(AsyncComp) : 'off'
+    }
+  }).mount('#app')
+</script>
diff --git a/packages/vue/__tests__/e2e/hydration-strat-idle.html b/packages/vue/__tests__/e2e/hydration-strat-idle.html
new file mode 100644 (file)
index 0000000..5601713
--- /dev/null
@@ -0,0 +1,36 @@
+<script src="../../dist/vue.global.js"></script>
+
+<div id="app"><button>0</button></div>
+
+<script>
+  window.isHydrated = false
+  const { createSSRApp, defineAsyncComponent, h, ref, onMounted, hydrateOnIdle } = Vue
+
+  const Comp = {
+    setup() {
+      const count = ref(0)
+      onMounted(() => {
+        console.log('hydrated')
+        window.isHydrated = true
+      })
+      return () => h('button', { onClick: () => count.value++ }, count.value)
+    },
+  }
+
+  const AsyncComp = defineAsyncComponent({
+    loader: () => new Promise(resolve => {
+      setTimeout(() => {
+        console.log('resolve')
+        resolve(Comp)
+        requestIdleCallback(() => {
+          console.log('busy')
+        })
+      }, 10)
+    }),
+    hydrate: hydrateOnIdle()
+  })
+
+  createSSRApp({
+    render: () => h(AsyncComp)
+  }).mount('#app')
+</script>
diff --git a/packages/vue/__tests__/e2e/hydration-strat-interaction.html b/packages/vue/__tests__/e2e/hydration-strat-interaction.html
new file mode 100644 (file)
index 0000000..9f4f44d
--- /dev/null
@@ -0,0 +1,48 @@
+<script src="../../dist/vue.global.js"></script>
+
+<div>click to hydrate</div>
+<div id="app"><button>0</button></div>
+<style>body { margin: 0 }</style>
+
+<script>
+  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 { createSSRApp, defineAsyncComponent, h, ref, onMounted, hydrateOnInteraction } = Vue
+
+  const Comp = {
+    setup() {
+      const count = ref(0)
+      onMounted(() => {
+        console.log('hydrated')
+        window.isHydrated = true
+      })
+      return () => {
+        const button = h('button', { onClick: () => count.value++ }, count.value)
+        if (isFragment) {
+          return [[h('span', 'one')], button, h('span', 'two')]
+        } else {
+          return button
+        }
+      }
+    },
+  }
+
+  const AsyncComp = defineAsyncComponent({
+    loader: () => Promise.resolve(Comp),
+    hydrate: hydrateOnInteraction(['click', 'wheel'])
+  })
+
+  createSSRApp({
+    setup() {
+      onMounted(() => {
+        window.isRootMounted = true
+      })
+      return () => h(AsyncComp)
+    }
+  }).mount('#app')
+</script>
diff --git a/packages/vue/__tests__/e2e/hydration-strat-media.html b/packages/vue/__tests__/e2e/hydration-strat-media.html
new file mode 100644 (file)
index 0000000..f8d30a0
--- /dev/null
@@ -0,0 +1,36 @@
+<script src="../../dist/vue.global.js"></script>
+
+<div>resize the window width to < 500px to hydrate</div>
+<div id="app"><button>0</button></div>
+
+<script>
+  window.isHydrated = false
+  const { createSSRApp, defineAsyncComponent, h, ref, onMounted, hydrateOnMediaQuery } = Vue
+
+  const Comp = {
+    setup() {
+      const count = ref(0)
+      onMounted(() => {
+        console.log('hydrated')
+        window.isHydrated = true
+      })
+      return () => {
+        return h('button', { onClick: () => count.value++ }, count.value)
+      }
+    },
+  }
+
+  const AsyncComp = defineAsyncComponent({
+    loader: () => Promise.resolve(Comp),
+    hydrate: hydrateOnMediaQuery('(max-width:500px)')
+  })
+
+  createSSRApp({
+    setup() {
+      onMounted(() => {
+        window.isRootMounted = true
+      })
+      return () => h(AsyncComp)
+    }
+  }).mount('#app')
+</script>
diff --git a/packages/vue/__tests__/e2e/hydration-strat-visible.html b/packages/vue/__tests__/e2e/hydration-strat-visible.html
new file mode 100644 (file)
index 0000000..863455c
--- /dev/null
@@ -0,0 +1,49 @@
+<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>
+  const rootMargin = location.search.match(/rootMargin=(\d+)/)?.[1] ?? 0
+  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 { createSSRApp, defineAsyncComponent, h, ref, onMounted, hydrateOnVisible } = Vue
+
+  const Comp = {
+    setup() {
+      const count = ref(0)
+      onMounted(() => {
+        console.log('hydrated')
+        window.isHydrated = true
+      })
+      return () => {
+        const button = h('button', { onClick: () => count.value++ }, count.value)
+        if (isFragment) {
+          return [[h('span', 'one')], button, h('span', 'two')]
+        } else {
+          return button
+        }
+      }
+    },
+  }
+
+  const AsyncComp = defineAsyncComponent({
+    loader: () => Promise.resolve(Comp),
+    hydrate: hydrateOnVisible(rootMargin + 'px')
+  })
+
+  createSSRApp({
+    setup() {
+      onMounted(() => {
+        window.isRootMounted = true
+      })
+      return () => h(AsyncComp)
+    }
+  }).mount('#app')
+</script>
diff --git a/packages/vue/__tests__/e2e/hydrationStrategies.spec.ts b/packages/vue/__tests__/e2e/hydrationStrategies.spec.ts
new file mode 100644 (file)
index 0000000..58e3784
--- /dev/null
@@ -0,0 +1,118 @@
+import path from 'node:path'
+import { setupPuppeteer } from './e2eUtils'
+import type { Ref } from '../../src/runtime'
+
+declare const window: Window & {
+  isHydrated: boolean
+  isRootMounted: boolean
+  teardownCalled?: boolean
+  show: Ref<boolean>
+}
+
+describe('async component hydration strategies', () => {
+  const { page, click, text, count } = setupPuppeteer(['--window-size=800,600'])
+
+  async function goToCase(name: string, query = '') {
+    const file = `file://${path.resolve(__dirname, `./hydration-strat-${name}.html${query}`)}`
+    await page().goto(file)
+  }
+
+  async function assertHydrationSuccess(n = '1') {
+    await click('button')
+    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()
+  })
+
+  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()
+  })
+
+  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('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()
+  })
+
+  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)
+  })
+})