-
-
Notifications
You must be signed in to change notification settings - Fork 8.4k
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
Changes from all commits
Commits
Show all changes
12 commits
Select commit
Hold shift + click to select a range
87c7707
wip: move async component hydration logic to __asyncHydrate
yyx990803 669c417
wip: layout hydration strategies
yyx990803 3e254df
wip: basic strategy implementations
yyx990803 0f481f6
wip: edits
yyx990803 2d54278
wip: test
yyx990803 8d29174
wip: hydration tests
yyx990803 345f721
wip: more hydration strat tests
yyx990803 707cf32
wip: finish hydration strat tests
yyx990803 807bfc6
test: custom hydration strategy test case
yyx990803 2da6959
wip: simplify custom strategy dom interaction
yyx990803 37f8ef1
fix: handle nested fragments in forEachElement
yyx990803 6baa5a6
feat: teardown handling for async component unmounted before hydration
yyx990803 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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> |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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
48
packages/vue/__tests__/e2e/hydration-strat-interaction.html
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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> |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
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 tohydrate
.There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.