feat: time and promise based delayed hydration

This commit is contained in:
tbitw2549 2024-09-14 14:01:50 +03:00
parent 86384794e8
commit fe9b2b96f5
11 changed files with 162 additions and 14 deletions

View File

@ -175,16 +175,6 @@ If you would like to load the component when the window matches a media query, y
</template> </template>
``` ```
For general purpose conditional hydration, you can use the `LazyIf` prefix:
```vue [pages/index.vue]
<template>
<div>
<LazyIfMyComponent />
</div>
</template>
```
If you would like to never hydrate a component, use the `LazyNever` prefix: If you would like to never hydrate a component, use the `LazyNever` prefix:
```vue [pages/index.vue] ```vue [pages/index.vue]
@ -195,6 +185,36 @@ If you would like to never hydrate a component, use the `LazyNever` prefix:
</template> </template>
``` ```
If you would like to hydrate a component after a certain amount of time, use the `LazyTime` prefix:
```vue [pages/index.vue]
<template>
<div>
<LazyTimeMyComponent />
</div>
</template>
```
If you would like to hydrate a component once a promise is fulfilled, use the `LazyPromise` prefix:
```vue [pages/index.vue]
<template>
<div>
<LazyPromiseMyComponent />
</div>
</template>
```
Nuxt also provides a way to extend this feature by each developer. If you have highly specific hydration triggers that aren't covered by the default strategies, you can use the general purpose conditional hydration, via the `LazyIf` prefix:
```vue [pages/index.vue]
<template>
<div>
<LazyIfMyComponent />
</div>
</template>
```
### Custom hydration triggers ### Custom hydration triggers
If you would like to override the default hydration triggers when dealing with delayed hydration, like changing the timeout, the options for the intersection observer, or the events to trigger the hydration, you can do so by supplying a `hydrate` prop to your lazy components. If you would like to override the default hydration triggers when dealing with delayed hydration, like changing the timeout, the options for the intersection observer, or the events to trigger the hydration, you can do so by supplying a `hydrate` prop to your lazy components.
@ -207,11 +227,14 @@ If you would like to override the default hydration triggers when dealing with d
<LazyEventMyComponent :hydrate="createEventLoader(['click','mouseover'])" /> <LazyEventMyComponent :hydrate="createEventLoader(['click','mouseover'])" />
<LazyMediaMyComponent hydrate="(max-width: 500px)" /> <LazyMediaMyComponent hydrate="(max-width: 500px)" />
<LazyIfMyComponent :hydrate="someCondition" /> <LazyIfMyComponent :hydrate="someCondition" />
<LazyTimeMyComponent :hydrate="3000" />
<LazyPromiseMyComponent :hydrate="promise" />
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
const someCondition = ref(true) const someCondition = ref(true)
const promise = Promise.resolve(42)
</script> </script>
``` ```
@ -240,9 +263,17 @@ For example, if you have a component named `IdleBar` and you'd like it to be del
Delayed hydration has many performance benefits, but in order to gain the most out of it, it's important to use it correctly: Delayed hydration has many performance benefits, but in order to gain the most out of it, it's important to use it correctly:
1. Do not use `LazyVisible` for in-viewport content - `LazyVisible` is best for content that is not immediately visible and requires scrolling to get to. If it is present on screen immediately, using it as a normal component would provide better performance and loading times. 1. Avoid delayed hydration components as much as possible for in-viewport content - delayed hydration is best for content that is not immediately available and requires some interaction to get to. If it is present on screen and is meant ot be available for use immediately, using it as a normal component would provide better performance and loading times. Use this feature sparingly to avoid hurting the user experience, as there are only very specific use cases that warrant delayed hydration for on-screen content.
2. Do not use `LazyIf` for components that are always/never going to be hydrated - you can use a regular component/`LazyNever` respectively, which would provide better performance for each use case. Keep `LazyIf` for components that could get hydrated, but might not get hydrated immediately (for example, following user interaction). 2. Use each hydration strategy for its intended use case - each hydration strategy has built-in optimizations specifically designed for that strategy's purpose. Using them incorrectly could hurt performance and user experience. Examples include:
- Using `LazyIf` for always/never hydrated components (via `:hydrate="true"`/`:hydrate="false"`) - you can use a regular component/`LazyNever` respectively, which would provide better performance for each use case. Keep `LazyIf` for components that could get hydrated, but might not get hydrated immediately (for example, following user interaction).
- Using `LazyTime` as an alternative to `LazyIdle` - while both of these strategies share similarities, they are meant for different purposes. `LazyTime` is specifically designed to hydrate a component immediately after a certain amount of time has passed. `LazyIdle`, on the other side, is meant to provide a limit for the browser to handle the hydration whenever it's idle. If you use `LazyTime` for idle-based hydration, the component might get hydrated while other critical components are hydrated simultaneously, which would be different in behavior as the browser won't be idle, potentially slowing down the hydration of other important components in your page.
- \[ADVANCED\] Using only `LazyIf` to manually implement existing delayed hydration strategies - while a viable option, using only `LazyIf` in stead of relying on the built-in supported strategies could impact the overall memory consumption and performance.
For example, in stead of handling promises manually and setting a boolean indicator for when the promise was fulfilled, which would then get passed to `LazyIf`, using `LazyPromise` would handle the promise without requiring another ref, reducing the complexity and the amount of load Vue's reactivity system would need to handle and track.
Always remember that while `LazyIf` allows for implementation of custom, highly-tailored hydration strategies, it should mainly be used when no built-in strategy matches your specific use case, due to the internal optimizations each existing hydration strategy has.
## Direct Imports ## Direct Imports

View File

@ -43,7 +43,7 @@ export const loaderPlugin = createUnplugin((options: LoaderOptions) => {
const s = new MagicString(code) const s = new MagicString(code)
const nuxt = tryUseNuxt() const nuxt = tryUseNuxt()
// replace `_resolveComponent("...")` to direct import // replace `_resolveComponent("...")` to direct import
s.replace(/(?<=[ (])_?resolveComponent\(\s*["'](lazy-|Lazy(?=[A-Z]))?(Idle|Visible|idle-|visible-|Event|event-|Media|media-|If|if-|Never|never-)?([^'"]*)["'][^)]*\)/g, (full: string, lazy: string, modifier: string, name: string) => { s.replace(/(?<=[ (])_?resolveComponent\(\s*["'](lazy-|Lazy(?=[A-Z]))?(Idle|Visible|idle-|visible-|Event|event-|Media|media-|If|if-|Never|never-|Time|time-|Promise|promise-)?([^'"]*)["'][^)]*\)/g, (full: string, lazy: string, modifier: string, name: string) => {
const normalComponent = findComponent(components, name, options.mode) const normalComponent = findComponent(components, name, options.mode)
const modifierComponent = !normalComponent && modifier ? findComponent(components, modifier + name, options.mode) : null const modifierComponent = !normalComponent && modifier ? findComponent(components, modifier + name, options.mode) : null
const component = normalComponent || modifierComponent const component = normalComponent || modifierComponent
@ -114,6 +114,18 @@ export const loaderPlugin = createUnplugin((options: LoaderOptions) => {
identifier += '_delayedNever' identifier += '_delayedNever'
imports.add(`const ${identifier} = __defineAsyncComponent({loader: ${dynamicImport}, hydrate: () => {}})`) imports.add(`const ${identifier} = __defineAsyncComponent({loader: ${dynamicImport}, hydrate: () => {}})`)
break break
case 'Time':
case 'time-':
imports.add(genImport(clientDelayedComponentRuntime, [{ name: 'createLazyTimeComponent' }]))
identifier += '_delayedTime'
imports.add(`const ${identifier} = createLazyTimeComponent(${dynamicImport})`)
break
case 'Promise':
case 'promise-':
imports.add(genImport(clientDelayedComponentRuntime, [{ name: 'createLazyPromiseComponent' }]))
identifier += '_delayedPromise'
imports.add(`const ${identifier} = createLazyPromiseComponent(${dynamicImport})`)
break
} }
} else { } else {
imports.add(genImport('vue', [{ name: 'defineAsyncComponent', as: '__defineAsyncComponent' }])) imports.add(genImport('vue', [{ name: 'defineAsyncComponent', as: '__defineAsyncComponent' }]))

View File

@ -69,6 +69,7 @@ export const createLazyIfComponent = (loader: AsyncComponentLoader) => {
if (shouldHydrate.value) { if (shouldHydrate.value) {
const comp = defineAsyncComponent(loader) const comp = defineAsyncComponent(loader)
const merged = mergeProps(attrs, { 'data-allow-mismatch': '' }) const merged = mergeProps(attrs, { 'data-allow-mismatch': '' })
// TODO: fix hydration mismatches on Vue's side. The data-allow-mismatch is ideally a temporary solution due to Vue's SSR limitation with hydrated content.
return () => h(comp, merged) return () => h(comp, merged)
} }
const strategy: HydrationStrategy = (hydrate) => { const strategy: HydrationStrategy = (hydrate) => {
@ -76,8 +77,51 @@ export const createLazyIfComponent = (loader: AsyncComponentLoader) => {
return () => unwatch() return () => unwatch()
} }
const comp = defineAsyncComponent({ loader, hydrate: strategy }) const comp = defineAsyncComponent({ loader, hydrate: strategy })
// This one seems to work fine whenever the hydration condition is achieved at client side. For example, a hydration condition of a ref greater than 2 with a button to increment causes no hydration mismatch after 3 presses of the button.
return () => h(comp, attrs) return () => h(comp, attrs)
}, },
}) })
} }
/* @__NO_SIDE_EFFECTS__ */
export const createLazyTimeComponent = (loader: AsyncComponentLoader) => {
return defineComponent({
inheritAttrs: false,
setup (_, { attrs }) {
const merged = mergeProps(attrs, { 'data-allow-mismatch': '' })
if (attrs.hydrate === 0) {
const comp = defineAsyncComponent(loader)
return () => h(comp, merged)
}
const strategy: HydrationStrategy = (hydrate) => {
const id = setTimeout(hydrate, attrs.hydrate as number | undefined ?? 2000)
return () => clearTimeout(id)
}
const comp = defineAsyncComponent({ loader, hydrate: strategy })
// TODO: fix hydration mismatches on Vue's side. The data-allow-mismatch is ideally a temporary solution due to Vue's SSR limitation with hydrated content.
return () => h(comp, merged)
},
})
}
/* @__NO_SIDE_EFFECTS__ */
export const createLazyPromiseComponent = (loader: AsyncComponentLoader) => {
return defineComponent({
inheritAttrs: false,
setup (_, { attrs }) {
const merged = mergeProps(attrs, { 'data-allow-mismatch': '' })
// @ts-expect-error Attributes cannot be typed
if (!attrs.hydrate || typeof attrs.hydrate.then !== 'function') {
const comp = defineAsyncComponent(loader)
// TODO: fix hydration mismatches on Vue's side. The data-allow-mismatch is ideally a temporary solution due to Vue's SSR limitation with hydrated content.
return () => h(comp, merged)
}
const strategy: HydrationStrategy = (hydrate) => {
// @ts-expect-error Attributes cannot be typed
attrs.hydrate.then(hydrate)
return () => {}
}
const comp = defineAsyncComponent({ loader, hydrate: strategy })
return () => h(comp, merged)
},
})
}

View File

@ -124,6 +124,8 @@ interface _GlobalComponents {
${componentTypes.map(([pascalName, type]) => ` '${pascalName}': ${type}`).join('\n')} ${componentTypes.map(([pascalName, type]) => ` '${pascalName}': ${type}`).join('\n')}
${componentTypes.map(([pascalName, type]) => ` 'Lazy${pascalName}': ${type}`).join('\n')} ${componentTypes.map(([pascalName, type]) => ` 'Lazy${pascalName}': ${type}`).join('\n')}
${componentTypes.map(([pascalName, type]) => ` 'LazyIdle${pascalName}': ${type} & DefineComponent<{hydrate?: number}>`).join('\n')} ${componentTypes.map(([pascalName, type]) => ` 'LazyIdle${pascalName}': ${type} & DefineComponent<{hydrate?: number}>`).join('\n')}
${componentTypes.map(([pascalName, type]) => ` 'LazyTime${pascalName}': ${type} & DefineComponent<{hydrate?: number}>`).join('\n')}
${componentTypes.map(([pascalName, type]) => ` 'LazyPromise${pascalName}': ${type} & DefineComponent<{hydrate?: Promise<unknown>}>`).join('\n')}
${componentTypes.map(([pascalName, type]) => ` 'LazyVisible${pascalName}': ${type} & DefineComponent<{hydrate?: Partial<IntersectionObserverInit>}>`).join('\n')} ${componentTypes.map(([pascalName, type]) => ` 'LazyVisible${pascalName}': ${type} & DefineComponent<{hydrate?: Partial<IntersectionObserverInit>}>`).join('\n')}
${componentTypes.map(([pascalName, type]) => ` 'LazyEvent${pascalName}': ${type} & DefineComponent<{hydrate?: keyof HTMLElementEventMap | Array<keyof HTMLElementEventMap>}>`).join('\n')} ${componentTypes.map(([pascalName, type]) => ` 'LazyEvent${pascalName}': ${type} & DefineComponent<{hydrate?: keyof HTMLElementEventMap | Array<keyof HTMLElementEventMap>}>`).join('\n')}
${componentTypes.map(([pascalName, type]) => ` 'LazyMedia${pascalName}': ${type} & DefineComponent<{hydrate?: string}>`).join('\n')} ${componentTypes.map(([pascalName, type]) => ` 'LazyMedia${pascalName}': ${type} & DefineComponent<{hydrate?: string}>`).join('\n')}
@ -138,6 +140,8 @@ declare module 'vue' {
${componentTypes.map(([pascalName, type]) => `export const ${pascalName}: ${type}`).join('\n')} ${componentTypes.map(([pascalName, type]) => `export const ${pascalName}: ${type}`).join('\n')}
${componentTypes.map(([pascalName, type]) => `export const Lazy${pascalName}: ${type}`).join('\n')} ${componentTypes.map(([pascalName, type]) => `export const Lazy${pascalName}: ${type}`).join('\n')}
${componentTypes.map(([pascalName, type]) => `export const LazyIdle${pascalName}: ${type} & DefineComponent<{hydrate?: number}>`).join('\n')} ${componentTypes.map(([pascalName, type]) => `export const LazyIdle${pascalName}: ${type} & DefineComponent<{hydrate?: number}>`).join('\n')}
${componentTypes.map(([pascalName, type]) => `export const LazyTime${pascalName}: ${type} & DefineComponent<{hydrate?: number}>`).join('\n')}
${componentTypes.map(([pascalName, type]) => `export const LazyPromise${pascalName}: ${type} & DefineComponent<{hydrate?: Promise<unknown>}>`).join('\n')}
${componentTypes.map(([pascalName, type]) => `export const LazyVisible${pascalName}: ${type} & DefineComponent<{hydrate?: Partial<IntersectionObserverInit>}>`).join('\n')} ${componentTypes.map(([pascalName, type]) => `export const LazyVisible${pascalName}: ${type} & DefineComponent<{hydrate?: Partial<IntersectionObserverInit>}>`).join('\n')}
${componentTypes.map(([pascalName, type]) => `export const LazyEvent${pascalName}: ${type} & DefineComponent<{hydrate?: keyof HTMLElementEventMap | Array<keyof HTMLElementEventMap>}>`).join('\n')} ${componentTypes.map(([pascalName, type]) => `export const LazyEvent${pascalName}: ${type} & DefineComponent<{hydrate?: keyof HTMLElementEventMap | Array<keyof HTMLElementEventMap>}>`).join('\n')}
${componentTypes.map(([pascalName, type]) => `export const LazyMedia${pascalName}: ${type} & DefineComponent<{hydrate?: string}>`).join('\n')} ${componentTypes.map(([pascalName, type]) => `export const LazyMedia${pascalName}: ${type} & DefineComponent<{hydrate?: string}>`).join('\n')}

View File

@ -2825,6 +2825,20 @@ describe('lazy import components', () => {
expect(await page.locator('body').getByText('This fake lazy event should be visible!').all()).toHaveLength(1) expect(await page.locator('body').getByText('This fake lazy event should be visible!').all()).toHaveLength(1)
expect(await page.locator('body').getByText('This fake lazy event shouldn\'t be visible!').all()).toHaveLength(0) expect(await page.locator('body').getByText('This fake lazy event shouldn\'t be visible!').all()).toHaveLength(0)
}) })
it('handles time-based hydration correctly', async () => {
const { page } = await renderPage('/lazy-import-components/time')
expect(await page.locator('body').getByText('This should be visible at first with time!').all()).toHaveLength(2)
await page.waitForTimeout(500)
expect(await page.locator('body').getByText('This should be visible at first with time!').all()).toHaveLength(1)
await page.waitForTimeout(1600) // Some room for falkiness and intermittent lag
expect(await page.locator('body').getByText('This should be visible at first with time!').all()).toHaveLength(0)
})
it('handles promise-based hydration correctly', async () => {
const { page } = await renderPage('/lazy-import-components/promise')
expect(await page.locator('body').getByText('This should be visible at first with promise!').all()).toHaveLength(1)
await page.waitForTimeout(2100) // Some room for falkiness and intermittent lag
expect(await page.locator('body').getByText('This should be visible at first with promise!').all()).toHaveLength(0)
})
}) })
describe('defineNuxtComponent watch duplicate', () => { describe('defineNuxtComponent watch duplicate', () => {

View File

@ -0,0 +1,5 @@
<template>
<div>
This shouldn't be visible at first with promise!
</div>
</template>

View File

@ -0,0 +1,5 @@
<template>
<div>
This should be visible at first with promise!
</div>
</template>

View File

@ -0,0 +1,5 @@
<template>
<div>
This shouldn't be visible at first with time!
</div>
</template>

View File

@ -0,0 +1,5 @@
<template>
<div>
This should be visible at first with time!
</div>
</template>

View File

@ -0,0 +1,15 @@
<script setup lang="ts">
const pr = new Promise((resolve) => { setTimeout(resolve, 500) })
const prImmediate = Promise.resolve(42)
</script>
<template>
<div>
<LazyPromiseDelayedPromise
:hydrate="pr"
/>
<LazyPromiseDelayedPromise
:hydrate="prImmediate"
/>
</div>
</template>

View File

@ -0,0 +1,8 @@
<template>
<div>
<LazyTimeDelayedTime />
<LazyTimeDelayedTime
:hydrate="500"
/>
</div>
</template>