Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: lazy hydration strategies for async components #11458

Merged
merged 12 commits into from
Jul 31, 2024
21 changes: 21 additions & 0 deletions packages/runtime-core/src/apiAsyncComponent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -30,6 +31,7 @@ export interface AsyncComponentOptions<T = any> {
delay?: number
timeout?: number
suspensible?: boolean
hydrate?: HydrationStrategy
onError?: (
error: Error,
retry: () => void,
Expand All @@ -54,6 +56,7 @@ export function defineAsyncComponent<
loadingComponent,
errorComponent,
delay = 200,
hydrate: hydrateStrategy,
timeout, // undefined = never times out
suspensible = true,
onError: userOnError,
Expand Down Expand Up @@ -118,6 +121,24 @@ export function defineAsyncComponent<

__asyncLoader: load,

__asyncHydrate(el, instance, hydrate) {
const doHydrate = hydrateStrategy
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we need to handle exceptions since user can pass a custom strategy? If an exception occurs in hydrateStrategy, fall back to hydrate.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think the behavior of falling back to hydrate should be optional, because if users want to use a custom hydration strategy, it often means that they have to implement a complete hydrate function themselves, so exception handling should also be one of the things that users need to consider.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good point. Although it should be noted that even if sync errors are handled, we can't catch all possible async errors in custom strategies, so there will still be a possibility for the hydration to never happen. In other words, error handling here can only mitigate errors to a certain extent - it's ultimately the user's responsibility to provide a working strategy.

? () => {
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
},
Expand Down
9 changes: 9 additions & 0 deletions packages/runtime-core/src/componentOptions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ------------------------------------------------------

Expand Down
4 changes: 2 additions & 2 deletions packages/runtime-core/src/hydration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ export type RootHydrateFunction = (
container: (Element | ShadowRoot) & { _vnode?: VNode },
) => void

enum DOMNodeTypes {
export enum DOMNodeTypes {
ELEMENT = 1,
TEXT = 3,
COMMENT = 8,
Expand Down Expand Up @@ -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
Expand Down
111 changes: 111 additions & 0 deletions packages/runtime-core/src/hydrationStrategies.ts
Original file line number Diff line number Diff line change
@@ -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)
}
}
10 changes: 10 additions & 0 deletions packages/runtime-core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ----------------------------------------------------------

Expand Down Expand Up @@ -327,6 +333,10 @@ export type {
AsyncComponentOptions,
AsyncComponentLoader,
} from './apiAsyncComponent'
export type {
HydrationStrategy,
HydrationStrategyFactory,
} from './hydrationStrategies'
export type { HMRRuntime } from './hmr'

// Internal API ----------------------------------------------------------------
Expand Down
15 changes: 5 additions & 10 deletions packages/runtime-core/src/renderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
11 changes: 9 additions & 2 deletions packages/vue/__tests__/e2e/e2eUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
44 changes: 44 additions & 0 deletions packages/vue/__tests__/e2e/hydration-strat-custom.html
Original file line number Diff line number Diff line change
@@ -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>
36 changes: 36 additions & 0 deletions packages/vue/__tests__/e2e/hydration-strat-idle.html
Original file line number Diff line number Diff line change
@@ -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>
48 changes: 48 additions & 0 deletions packages/vue/__tests__/e2e/hydration-strat-interaction.html
Original file line number Diff line number Diff line change
@@ -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>
Loading