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
delay?: number
timeout?: number
suspensible?: boolean
+ hydrate?: HydrationStrategy
onError?: (
error: Error,
retry: () => void,
loadingComponent,
errorComponent,
delay = 200,
+ hydrate: hydrateStrategy,
timeout, // undefined = never times out
suspensible = true,
onError: userOnError,
__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
},
* @internal
*/
__asyncResolved?: ConcreteComponent
+ /**
+ * Exposed for lazy hydration
+ * @internal
+ */
+ __asyncHydrate?: (
+ el: Element,
+ instance: ComponentInternalInstance,
+ hydrate: () => void,
+ ) => void
// Type differentiators ------------------------------------------------------
container: (Element | ShadowRoot) & { _vnode?: VNode },
) => void
-enum DOMNodeTypes {
+export enum DOMNodeTypes {
ELEMENT = 1,
TEXT = 3,
COMMENT = 8,
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
--- /dev/null
+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)
+ }
+}
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 ----------------------------------------------------------
AsyncComponentOptions,
AsyncComponentLoader,
} from './apiAsyncComponent'
+export type {
+ HydrationStrategy,
+ HydrationStrategyFactory,
+} from './hydrationStrategies'
export type { HMRRuntime } from './hmr'
// Internal API ----------------------------------------------------------------
}
}
- 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()
}
}
-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 () => {
--- /dev/null
+<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>
--- /dev/null
+<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>
--- /dev/null
+<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>
--- /dev/null
+<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>
--- /dev/null
+<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>
--- /dev/null
+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)
+ })
+})