Skip to content

feat(hydration): defer loading of async components until actual hydrate #12975

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

Open
wants to merge 3 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
102 changes: 64 additions & 38 deletions packages/runtime-core/src/apiAsyncComponent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,26 +115,40 @@ export function defineAsyncComponent<
)
}

let performLoad: () => Promise<ConcreteComponent | void>

return defineComponent({
name: 'AsyncComponentWrapper',

__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)
}
if (hydrateStrategy) {
const hydrateWithCallback = (postHydrate?: () => void) => {
if (resolvedComp) {
hydrate()
postHydrate && postHydrate()
} else {
performLoad().then(() => {
if (!instance.isUnmounted) {
hydrate()
postHydrate && postHydrate()
}
})
}
: hydrate
if (resolvedComp) {
doHydrate()
}
let teardown = hydrateStrategy(hydrateWithCallback, cb =>
forEachElement(el, cb),
)
if (teardown) {
;(instance.bum || (instance.bum = [])).push(teardown)
}
} else {
load().then(() => !instance.isUnmounted && doHydrate())
if (resolvedComp) {
hydrate()
} else {
load().then(() => !instance.isUnmounted && hydrate())
}
}
},

Expand Down Expand Up @@ -166,19 +180,25 @@ 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
})
performLoad = () =>
load()
.then(comp => {
return () => createInnerComp(comp, instance)
})
.catch(err => {
onError(err)
return () =>
errorComponent
? createVNode(errorComponent as ConcreteComponent, {
error: err,
})
: null
})

if (!hydrateStrategy) {
return performLoad()
}
return
}

const loaded = ref(false)
Expand All @@ -203,19 +223,25 @@ export function defineAsyncComponent<
}, timeout)
}

load()
.then(() => {
loaded.value = true
if (instance.parent && isKeepAlive(instance.parent.vnode)) {
// parent is keep-alive, force update so the loaded component's
// name is taken into account
instance.parent.update()
}
})
.catch(err => {
onError(err)
error.value = err
})
performLoad = () =>
load()
.then(() => {
loaded.value = true
if (instance.parent && isKeepAlive(instance.parent.vnode)) {
// parent is keep-alive, force update so the loaded component's
// name is taken into account
instance.parent.update()
}
})
.catch(err => {
onError(err)
error.value = err
})

// lazy perform load if hydrate strategy is present
if (!hydrateStrategy) {
performLoad()
}

return () => {
if (loaded.value && resolvedComp) {
Expand Down
16 changes: 9 additions & 7 deletions packages/runtime-core/src/hydrationStrategies.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ const cancelIdleCallback: Window['cancelIdleCallback'] =
* listeners.
*/
export type HydrationStrategy = (
hydrate: () => void,
hydrate: (postHydrate?: () => void) => void,
forEachElement: (cb: (el: Element) => any) => void,
) => (() => void) | void

Expand All @@ -29,7 +29,7 @@ export type HydrationStrategyFactory<Options> = (
export const hydrateOnIdle: HydrationStrategyFactory<number> =
(timeout = 10000) =>
hydrate => {
const id = requestIdleCallback(hydrate, { timeout })
const id = requestIdleCallback(() => hydrate(), { timeout })
return () => cancelIdleCallback(id)
}

Expand Down Expand Up @@ -73,8 +73,9 @@ export const hydrateOnMediaQuery: HydrationStrategyFactory<string> =
if (mql.matches) {
hydrate()
} else {
mql.addEventListener('change', hydrate, { once: true })
return () => mql.removeEventListener('change', hydrate)
const doHydrate = () => hydrate()
mql.addEventListener('change', doHydrate, { once: true })
return () => mql.removeEventListener('change', doHydrate)
}
}
}
Expand All @@ -90,9 +91,10 @@ export const hydrateOnInteraction: HydrationStrategyFactory<
if (!hasHydrated) {
hasHydrated = true
teardown()
hydrate()
// replay event
e.target!.dispatchEvent(new (e.constructor as any)(e.type, e))
hydrate(() => {
// replay event
e.target!.dispatchEvent(new (e.constructor as any)(e.type, e))
})
}
}
const teardown = () => {
Expand Down
3 changes: 0 additions & 3 deletions packages/vue/__tests__/e2e/hydration-strat-idle.html
Original file line number Diff line number Diff line change
Expand Up @@ -30,9 +30,6 @@
setTimeout(() => {
console.log('resolve')
resolve(Comp)
requestIdleCallback(() => {
Copy link
Member Author

Choose a reason for hiding this comment

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

It’s no longer needed because the loader will also execute in requestIdleCallback.

console.log('busy')
})
}, 10)
}),
hydrate: hydrateOnIdle(),
Expand Down
4 changes: 2 additions & 2 deletions packages/vue/__tests__/e2e/hydrationStrategies.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,8 @@ describe('async component hydration strategies', () => {
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'])
// assert message order
expect(messages.slice(1)).toMatchObject(['resolve', 'hydrated'])
await assertHydrationSuccess()
})

Expand Down