mirror of
https://github.com/nuxt/nuxt.git
synced 2024-11-26 23:52:06 +00:00
feat: time and promise based delayed hydration
This commit is contained in:
parent
86384794e8
commit
fe9b2b96f5
@ -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
|
||||||
|
|
||||||
|
@ -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' }]))
|
||||||
|
@ -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)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
@ -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')}
|
||||||
|
@ -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', () => {
|
||||||
|
5
test/fixtures/basic/components/DelayedPromise.client.vue
vendored
Normal file
5
test/fixtures/basic/components/DelayedPromise.client.vue
vendored
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
This shouldn't be visible at first with promise!
|
||||||
|
</div>
|
||||||
|
</template>
|
5
test/fixtures/basic/components/DelayedPromise.server.vue
vendored
Normal file
5
test/fixtures/basic/components/DelayedPromise.server.vue
vendored
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
This should be visible at first with promise!
|
||||||
|
</div>
|
||||||
|
</template>
|
5
test/fixtures/basic/components/DelayedTime.client.vue
vendored
Normal file
5
test/fixtures/basic/components/DelayedTime.client.vue
vendored
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
This shouldn't be visible at first with time!
|
||||||
|
</div>
|
||||||
|
</template>
|
5
test/fixtures/basic/components/DelayedTime.server.vue
vendored
Normal file
5
test/fixtures/basic/components/DelayedTime.server.vue
vendored
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
This should be visible at first with time!
|
||||||
|
</div>
|
||||||
|
</template>
|
15
test/fixtures/basic/pages/lazy-import-components/promise.vue
vendored
Normal file
15
test/fixtures/basic/pages/lazy-import-components/promise.vue
vendored
Normal 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>
|
8
test/fixtures/basic/pages/lazy-import-components/time.vue
vendored
Normal file
8
test/fixtures/basic/pages/lazy-import-components/time.vue
vendored
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<LazyTimeDelayedTime />
|
||||||
|
<LazyTimeDelayedTime
|
||||||
|
:hydrate="500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
Loading…
Reference in New Issue
Block a user