Skip to content

Commit d14a11c

Browse files
authored
feat: lazy hydration strategies for async components (#11458)
1 parent e28c581 commit d14a11c

13 files changed

+498
-14
lines changed

Diff for: packages/runtime-core/src/apiAsyncComponent.ts

+21
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import { ErrorCodes, handleError } from './errorHandling'
1616
import { isKeepAlive } from './components/KeepAlive'
1717
import { queueJob } from './scheduler'
1818
import { markAsyncBoundary } from './helpers/useId'
19+
import { type HydrationStrategy, forEachElement } from './hydrationStrategies'
1920

2021
export type AsyncComponentResolveResult<T = Component> = T | { default: T } // es modules
2122

@@ -30,6 +31,7 @@ export interface AsyncComponentOptions<T = any> {
3031
delay?: number
3132
timeout?: number
3233
suspensible?: boolean
34+
hydrate?: HydrationStrategy
3335
onError?: (
3436
error: Error,
3537
retry: () => void,
@@ -54,6 +56,7 @@ export function defineAsyncComponent<
5456
loadingComponent,
5557
errorComponent,
5658
delay = 200,
59+
hydrate: hydrateStrategy,
5760
timeout, // undefined = never times out
5861
suspensible = true,
5962
onError: userOnError,
@@ -118,6 +121,24 @@ export function defineAsyncComponent<
118121

119122
__asyncLoader: load,
120123

124+
__asyncHydrate(el, instance, hydrate) {
125+
const doHydrate = hydrateStrategy
126+
? () => {
127+
const teardown = hydrateStrategy(hydrate, cb =>
128+
forEachElement(el, cb),
129+
)
130+
if (teardown) {
131+
;(instance.bum || (instance.bum = [])).push(teardown)
132+
}
133+
}
134+
: hydrate
135+
if (resolvedComp) {
136+
doHydrate()
137+
} else {
138+
load().then(() => !instance.isUnmounted && doHydrate())
139+
}
140+
},
141+
121142
get __asyncResolved() {
122143
return resolvedComp
123144
},

Diff for: packages/runtime-core/src/componentOptions.ts

+9
Original file line numberDiff line numberDiff line change
@@ -199,6 +199,15 @@ export interface ComponentOptionsBase<
199199
* @internal
200200
*/
201201
__asyncResolved?: ConcreteComponent
202+
/**
203+
* Exposed for lazy hydration
204+
* @internal
205+
*/
206+
__asyncHydrate?: (
207+
el: Element,
208+
instance: ComponentInternalInstance,
209+
hydrate: () => void,
210+
) => void
202211

203212
// Type differentiators ------------------------------------------------------
204213

Diff for: packages/runtime-core/src/hydration.ts

+2-2
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ export type RootHydrateFunction = (
4646
container: (Element | ShadowRoot) & { _vnode?: VNode },
4747
) => void
4848

49-
enum DOMNodeTypes {
49+
export enum DOMNodeTypes {
5050
ELEMENT = 1,
5151
TEXT = 3,
5252
COMMENT = 8,
@@ -75,7 +75,7 @@ const getContainerType = (container: Element): 'svg' | 'mathml' | undefined => {
7575
return undefined
7676
}
7777

78-
const isComment = (node: Node): node is Comment =>
78+
export const isComment = (node: Node): node is Comment =>
7979
node.nodeType === DOMNodeTypes.COMMENT
8080

8181
// Note: hydration is DOM-specific

Diff for: packages/runtime-core/src/hydrationStrategies.ts

+111
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
import { isString } from '@vue/shared'
2+
import { DOMNodeTypes, isComment } from './hydration'
3+
4+
/**
5+
* A lazy hydration strategy for async components.
6+
* @param hydrate - call this to perform the actual hydration.
7+
* @param forEachElement - iterate through the root elements of the component's
8+
* non-hydrated DOM, accounting for possible fragments.
9+
* @returns a teardown function to be called if the async component is unmounted
10+
* before it is hydrated. This can be used to e.g. remove DOM event
11+
* listeners.
12+
*/
13+
export type HydrationStrategy = (
14+
hydrate: () => void,
15+
forEachElement: (cb: (el: Element) => any) => void,
16+
) => (() => void) | void
17+
18+
export type HydrationStrategyFactory<Options = any> = (
19+
options?: Options,
20+
) => HydrationStrategy
21+
22+
export const hydrateOnIdle: HydrationStrategyFactory = () => hydrate => {
23+
const id = requestIdleCallback(hydrate)
24+
return () => cancelIdleCallback(id)
25+
}
26+
27+
export const hydrateOnVisible: HydrationStrategyFactory<string | number> =
28+
(margin = 0) =>
29+
(hydrate, forEach) => {
30+
const ob = new IntersectionObserver(
31+
entries => {
32+
for (const e of entries) {
33+
if (!e.isIntersecting) continue
34+
ob.disconnect()
35+
hydrate()
36+
break
37+
}
38+
},
39+
{
40+
rootMargin: isString(margin) ? margin : margin + 'px',
41+
},
42+
)
43+
forEach(el => ob.observe(el))
44+
return () => ob.disconnect()
45+
}
46+
47+
export const hydrateOnMediaQuery: HydrationStrategyFactory<string> =
48+
query => hydrate => {
49+
if (query) {
50+
const mql = matchMedia(query)
51+
if (mql.matches) {
52+
hydrate()
53+
} else {
54+
mql.addEventListener('change', hydrate, { once: true })
55+
return () => mql.removeEventListener('change', hydrate)
56+
}
57+
}
58+
}
59+
60+
export const hydrateOnInteraction: HydrationStrategyFactory<
61+
string | string[]
62+
> =
63+
(interactions = []) =>
64+
(hydrate, forEach) => {
65+
if (isString(interactions)) interactions = [interactions]
66+
let hasHydrated = false
67+
const doHydrate = (e: Event) => {
68+
if (!hasHydrated) {
69+
hasHydrated = true
70+
teardown()
71+
hydrate()
72+
// replay event
73+
e.target!.dispatchEvent(new (e.constructor as any)(e.type, e))
74+
}
75+
}
76+
const teardown = () => {
77+
forEach(el => {
78+
for (const i of interactions) {
79+
el.removeEventListener(i, doHydrate)
80+
}
81+
})
82+
}
83+
forEach(el => {
84+
for (const i of interactions) {
85+
el.addEventListener(i, doHydrate, { once: true })
86+
}
87+
})
88+
return teardown
89+
}
90+
91+
export function forEachElement(node: Node, cb: (el: Element) => void) {
92+
// fragment
93+
if (isComment(node) && node.data === '[') {
94+
let depth = 1
95+
let next = node.nextSibling
96+
while (next) {
97+
if (next.nodeType === DOMNodeTypes.ELEMENT) {
98+
cb(next as Element)
99+
} else if (isComment(next)) {
100+
if (next.data === ']') {
101+
if (--depth === 0) break
102+
} else if (next.data === '[') {
103+
depth++
104+
}
105+
}
106+
next = next.nextSibling
107+
}
108+
} else {
109+
cb(node as Element)
110+
}
111+
}

Diff for: packages/runtime-core/src/index.ts

+10
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,12 @@ export { useAttrs, useSlots } from './apiSetupHelpers'
6464
export { useModel } from './helpers/useModel'
6565
export { useTemplateRef } from './helpers/useTemplateRef'
6666
export { useId } from './helpers/useId'
67+
export {
68+
hydrateOnIdle,
69+
hydrateOnVisible,
70+
hydrateOnMediaQuery,
71+
hydrateOnInteraction,
72+
} from './hydrationStrategies'
6773

6874
// <script setup> API ----------------------------------------------------------
6975

@@ -327,6 +333,10 @@ export type {
327333
AsyncComponentOptions,
328334
AsyncComponentLoader,
329335
} from './apiAsyncComponent'
336+
export type {
337+
HydrationStrategy,
338+
HydrationStrategyFactory,
339+
} from './hydrationStrategies'
330340
export type { HMRRuntime } from './hmr'
331341

332342
// Internal API ----------------------------------------------------------------

Diff for: packages/runtime-core/src/renderer.ts

+5-10
Original file line numberDiff line numberDiff line change
@@ -1325,16 +1325,11 @@ function baseCreateRenderer(
13251325
}
13261326
}
13271327

1328-
if (
1329-
isAsyncWrapperVNode &&
1330-
!(type as ComponentOptions).__asyncResolved
1331-
) {
1332-
;(type as ComponentOptions).__asyncLoader!().then(
1333-
// note: we are moving the render call into an async callback,
1334-
// which means it won't track dependencies - but it's ok because
1335-
// a server-rendered async wrapper is already in resolved state
1336-
// and it will never need to change.
1337-
() => !instance.isUnmounted && hydrateSubTree(),
1328+
if (isAsyncWrapperVNode) {
1329+
;(type as ComponentOptions).__asyncHydrate!(
1330+
el as Element,
1331+
instance,
1332+
hydrateSubTree,
13381333
)
13391334
} else {
13401335
hydrateSubTree()

Diff for: packages/vue/__tests__/e2e/e2eUtils.ts

+9-2
Original file line numberDiff line numberDiff line change
@@ -30,12 +30,19 @@ export async function expectByPolling(
3030
}
3131
}
3232

33-
export function setupPuppeteer() {
33+
export function setupPuppeteer(args?: string[]) {
3434
let browser: Browser
3535
let page: Page
3636

37+
const resolvedOptions = args
38+
? {
39+
...puppeteerOptions,
40+
args: [...puppeteerOptions.args!, ...args],
41+
}
42+
: puppeteerOptions
43+
3744
beforeAll(async () => {
38-
browser = await puppeteer.launch(puppeteerOptions)
45+
browser = await puppeteer.launch(resolvedOptions)
3946
}, 20000)
4047

4148
beforeEach(async () => {
+44
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
<script src="../../dist/vue.global.js"></script>
2+
3+
<div><span id="custom-trigger">click here to hydrate</span></div>
4+
<div id="app"><button>0</button></div>
5+
6+
<script>
7+
window.isHydrated = false
8+
const { createSSRApp, defineAsyncComponent, h, ref, onMounted } = Vue
9+
10+
const Comp = {
11+
setup() {
12+
const count = ref(0)
13+
onMounted(() => {
14+
console.log('hydrated')
15+
window.isHydrated = true
16+
})
17+
return () => {
18+
return h('button', { onClick: () => count.value++ }, count.value)
19+
}
20+
},
21+
}
22+
23+
const AsyncComp = defineAsyncComponent({
24+
loader: () => Promise.resolve(Comp),
25+
hydrate: (hydrate, el) => {
26+
const triggerEl = document.getElementById('custom-trigger')
27+
triggerEl.addEventListener('click', hydrate, { once: true })
28+
return () => {
29+
window.teardownCalled = true
30+
triggerEl.removeEventListener('click', hydrate)
31+
}
32+
}
33+
})
34+
35+
const show = window.show = ref(true)
36+
createSSRApp({
37+
setup() {
38+
onMounted(() => {
39+
window.isRootMounted = true
40+
})
41+
return () => show.value ? h(AsyncComp) : 'off'
42+
}
43+
}).mount('#app')
44+
</script>

Diff for: packages/vue/__tests__/e2e/hydration-strat-idle.html

+36
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
<script src="../../dist/vue.global.js"></script>
2+
3+
<div id="app"><button>0</button></div>
4+
5+
<script>
6+
window.isHydrated = false
7+
const { createSSRApp, defineAsyncComponent, h, ref, onMounted, hydrateOnIdle } = Vue
8+
9+
const Comp = {
10+
setup() {
11+
const count = ref(0)
12+
onMounted(() => {
13+
console.log('hydrated')
14+
window.isHydrated = true
15+
})
16+
return () => h('button', { onClick: () => count.value++ }, count.value)
17+
},
18+
}
19+
20+
const AsyncComp = defineAsyncComponent({
21+
loader: () => new Promise(resolve => {
22+
setTimeout(() => {
23+
console.log('resolve')
24+
resolve(Comp)
25+
requestIdleCallback(() => {
26+
console.log('busy')
27+
})
28+
}, 10)
29+
}),
30+
hydrate: hydrateOnIdle()
31+
})
32+
33+
createSSRApp({
34+
render: () => h(AsyncComp)
35+
}).mount('#app')
36+
</script>
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
<script src="../../dist/vue.global.js"></script>
2+
3+
<div>click to hydrate</div>
4+
<div id="app"><button>0</button></div>
5+
<style>body { margin: 0 }</style>
6+
7+
<script>
8+
const isFragment = location.search.includes('?fragment')
9+
if (isFragment) {
10+
document.getElementById('app').innerHTML =
11+
`<!--[--><!--[--><span>one</span><!--]--><button>0</button><span>two</span><!--]-->`
12+
}
13+
14+
window.isHydrated = false
15+
const { createSSRApp, defineAsyncComponent, h, ref, onMounted, hydrateOnInteraction } = Vue
16+
17+
const Comp = {
18+
setup() {
19+
const count = ref(0)
20+
onMounted(() => {
21+
console.log('hydrated')
22+
window.isHydrated = true
23+
})
24+
return () => {
25+
const button = h('button', { onClick: () => count.value++ }, count.value)
26+
if (isFragment) {
27+
return [[h('span', 'one')], button, h('span', 'two')]
28+
} else {
29+
return button
30+
}
31+
}
32+
},
33+
}
34+
35+
const AsyncComp = defineAsyncComponent({
36+
loader: () => Promise.resolve(Comp),
37+
hydrate: hydrateOnInteraction(['click', 'wheel'])
38+
})
39+
40+
createSSRApp({
41+
setup() {
42+
onMounted(() => {
43+
window.isRootMounted = true
44+
})
45+
return () => h(AsyncComp)
46+
}
47+
}).mount('#app')
48+
</script>

0 commit comments

Comments
 (0)