This commit is contained in:
Michael Brevard 2024-09-21 16:21:32 +03:00 committed by GitHub
commit 8bfaf87543
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
34 changed files with 805 additions and 13 deletions

View File

@ -119,6 +119,193 @@ const show = ref(false)
</template>
```
## Delayed Hydration
Lazy components are great for controlling the chunk sizes in your app, but they don't enhance runtime performance, as they still load eagerly unless conditionally rendered. In real world applications, some pages may include a lot of content and a lot of components, and most of the time not all of them need to be interactive as soon as the page is loaded. Having them all load eagerly can negatively impact performance and increase bundle size.
In order to optimize the page, you may want to delay the hydration of some components until they're visible, or until the browser is done with more important tasks for example. Delaying the hydration of components will ensure it is only loaded when necessary, which is great for making a good user experience and a performant app. Nuxt has first class support for delayed hydration to help with that, without requiring you to write all the related boilerplate.
In order to use delayed hydration, you first need to enable it in your experimental config in `nuxt.config`
```ts [nuxt.config.{ts,js}]
export default defineNuxtConfig({
experimental: {
delayedHydration: true
}
})
```
Nuxt has reserved component prefixes that will handle this delayed hydration for you, that extend dynamic imports. By prefixing your component with `LazyVisible`, Nuxt will automatically handle your component and delay its hydration until it will be on screen.
```vue [pages/index.vue]
<template>
<div>
<LazyVisibleMyComponent />
</div>
</template>
```
If you need the component to load as soon as possible, but not block the critical rendering path, you can use the `LazyIdle` prefix, which would handle your component's hydration whenever the browser goes idle.
```vue [pages/index.vue]
<template>
<div>
<LazyIdleMyComponent />
</div>
</template>
```
If you would like the component to load after certain events occur, like a click or a mouse over, you can use the `LazyEvent` prefix, which would only trigger the hydration when those events occur.
```vue [pages/index.vue]
<template>
<div>
<LazyEventMyComponent />
</div>
</template>
```
If you would like to load the component when the window matches a media query, you can use the `LazyMedia` prefix:
```vue [pages/index.vue]
<template>
<div>
<LazyMediaMyComponent />
</div>
</template>
```
If you would like to never hydrate a component, use the `LazyNever` prefix:
```vue [pages/index.vue]
<template>
<div>
<LazyNeverMyComponent />
</div>
</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's delayed hydration system is highly flexible, allowing each developer to build upon it and implement their own hydration strategy.
If you have highly specific hydration triggers that aren't covered by the default strategies, or you want to have conditional hydraion, you can use the general purpose `LazyIf` prefix:
```vue [pages/index.vue]
<template>
<div>
<button @click="myFunction">Click me to start the custom hydration strategy</button>
<LazyIfMyComponent :hydrate="myCondition" />
</div>
</template>
<script setup lang="ts">
const myCondition = ref(false)
function myFunction() {
// trigger custom hydration strategy...
myCondition.value = true
}
</script>
```
### 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.
```vue [pages/index.vue]
<template>
<div>
<LazyIdleMyComponent :hydrate="createIdleLoader(3000)" />
<LazyVisibleMyComponent :hydrate="createVisibleLoader({threshold: 0.2})" />
<LazyEventMyComponent :hydrate="createEventLoader(['click','mouseover'])" />
<LazyMediaMyComponent hydrate="(max-width: 500px)" />
<LazyIfMyComponent :hydrate="someCondition" />
<LazyTimeMyComponent :hydrate="3000" />
<LazyPromiseMyComponent :hydrate="promise" />
</div>
</template>
<script setup lang="ts">
const someCondition = ref(true)
const promise = Promise.resolve(42)
</script>
```
::read-more{to="/docs/api/utils/create-idle-loader"}
::
::read-more{to="/docs/api/utils/create-visible-loader"}
::
::read-more{to="/docs/api/utils/create-event-loader"}
::
::read-more{to="https://developer.mozilla.org/en-US/docs/Web/API/Window/matchMedia"}
Read more about using `LazyMedia` components and the accepted values.
::
::important
If your components begin with a reserved delayed hydration prefix like Visible/Idle/Event, they will not have delayed hydration by default. This is made to ensure you have full control over all your components and prevent breaking dynamic imports for those components.
This also means you would need to explicitly add the prefix to those components for if you'd like for them to have delayed hydration.
For example, if you have a component named `IdleBar` and you'd like it to be delayed based on network idle time, you would need to use it like `<LazyIdleIdleBar>` and not `<LazyIdleBar>` to make it a delayed hydration component. Otherwise, it would be treated as a regular [dynamic import](/docs/guide/directory-structure/components#dynamic-imports)
::
### Listening to hydration events
All delayed hydration components have a `@hydrated` event that is fired whenever they are hydrated. You can listen to this event to trigger some action that depends on the component:
```vue [pages/index.vue]
<template>
<div>
<LazyVisibleMyComponent @hydrated="onHydrate" />
</div>
</template>
<script setup lang="ts">
function onHydrate() {
console.log("Component has been hydrated!")
}
</script>
```
### Caveats and best practices
Delayed hydration has many performance benefits, but in order to gain the most out of it, it's important to use it correctly:
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 to 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 a few cases that warrant delayed hydration for on-screen content.
2. Delayed hydration with conditional rendering - when using `v-if` with delayed hydration components, note that `v-if` takes precedence. That means, the component will be hydrated when the `v-if` is truthy, as that will render exclusively on the client. If you need to render the component only when the condition is true, use a regular async component (`<LazyMyComponent />`) with a `v-if`. If you need it to hydrate when the condition is fulfilled, use a delayed hydration prefix with the `hydrate` prop.
3. Delayed hydration with a shared state - when using multiple components (for example, in a `v-for`) with the same `v-model`, where some components might get hydrated before others (for example, progressively increasing media queries), if one of the components updates the model, note that it will trigger hydration for all components with that same model. That is because Vue's reactivity system triggers an update for all the dependencies that rely on that state, forcing hydration in the process. Props are unaffected by this. Try to avoid multiple components with the same model if that is not an intended side effect.
4. 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 (`: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.
- Using `LazyTime` as an alternative to `LazyIdle` - while 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 hand, 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 browser might handle the component's hydration while handling other, potentially more important components at the same time. This could slow down the hydration for all components being handled.
- \[ADVANCED\] Using only `LazyIf` to manually implement existing hydration strategies - while an 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` directly would handle it without requiring another ref, reducing the complexity and the amount of work 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 either pure conditional hydration is required (for example, hydration of a component when a separate button is clicked), or when no built-in strategy matches your specific use case, due to the internal optimizations each existing hydration strategy has.
## Direct Imports
You can also explicitly import components from `#components` if you want or need to bypass Nuxt's auto-importing functionality.

View File

@ -0,0 +1,33 @@
---
title: 'createEventLoader'
description: A utility function to select the events used for event-based delayed hydration.
links:
- label: Source
icon: i-simple-icons-github
to: https://github.com/nuxt/nuxt/blob/main/packages/nuxt/src/app/composables/hydrate.ts
size: xs
---
You can use this utility to set specific events that would trigger hydration in event-based delayed hydration components.
## Parameters
- `options`: An array of valid HTML events.
## Example
If you would like to trigger hydration when the element is either clicked or has the mouse over it:
```vue [pages/index.vue]
<template>
<div>
<LazyEventMyComponent :hydrate="createEventLoader(['click','mouseover'])"/>
</div>
<template>
```
::read-more{to="/docs/guide/directory-structure/components#delayed-hydration"}
::
::read-more{to="https://developer.mozilla.org/en-US/docs/Web/API/Element#events"}
Read more on the possible events that can be used.
::

View File

@ -0,0 +1,33 @@
---
title: 'createIdleLoader'
description: A utility function to customize delayed hydration based on network idle time.
links:
- label: Source
icon: i-simple-icons-github
to: https://github.com/nuxt/nuxt/blob/main/packages/nuxt/src/app/composables/hydrate.ts
size: xs
---
You can use this utility to customize the timeout of delayed hydration components based on network idle time.
## Parameters
- `timeout` : `number`
## Example
If you would like to give a timeout of 5 seconds for the components:
```vue [pages/index.vue]
<template>
<div>
<LazyIdleMyComponent :hydrate="createIdleLoader(5000)"/>
</div>
<template>
```
::read-more{to="/docs/guide/directory-structure/components#delayed-hydration"}
::
::read-more{to="https://developer.mozilla.org/en-US/docs/Web/API/Window/requestIdleCallback"}
This is based on the `requestIdleCallback` web API, and therefore only accepts the time in milliseconds for the max idle callback duration, which should be a number.
::

View File

@ -0,0 +1,33 @@
---
title: 'createVisibleLoader'
description: A utility function to customize delayed hydration based on visibility properties.
links:
- label: Source
icon: i-simple-icons-github
to: https://github.com/nuxt/nuxt/blob/main/packages/nuxt/src/app/composables/hydrate.ts
size: xs
---
You can use this utility to customize the conditions through which delayed hydration components would hydrate, based on their visiblity status and properties.
## Parameters
- `options`: `{ root, rootMargin, threshold }`
## Example
If you would like to change the threshold of the element:
```vue [pages/index.vue]
<template>
<div>
<LazyVisibleMyComponent :hydrate="createVisibleLoader({threshold: 0.2})"/>
</div>
<template>
```
::read-more{to="/docs/guide/directory-structure/components#delayed-hydration"}
::
::read-more{to="https://developer.mozilla.org/en-US/docs/Web/API/Intersection_Observer_API"}
This is based on the `IntersectionObserver` web API, and therefore only accepts the API's properties. You can specify only part of the properties, while the rest will default to the web API's defaults.
::

View File

@ -480,7 +480,7 @@ type CallbackFn = () => void
type ObserveFn = (element: Element, callback: CallbackFn) => () => void
function useObserver (): { observe: ObserveFn } | undefined {
if (import.meta.server) { return }
if (import.meta.server) { return { observe: () => () => {} } }
const nuxtApp = useNuxtApp()
if (nuxtApp._observer) {
@ -488,7 +488,6 @@ function useObserver (): { observe: ObserveFn } | undefined {
}
let observer: IntersectionObserver | null = null
const callbacks = new Map<Element, CallbackFn>()
const observe: ObserveFn = (element, callback) => {
@ -519,7 +518,6 @@ function useObserver (): { observe: ObserveFn } | undefined {
return _observer
}
function isSlowConnection () {
if (import.meta.server) { return }

View File

@ -23,3 +23,21 @@ export const useHydration = <K extends keyof NuxtPayload, T = NuxtPayload[K]> (k
})
}
}
/**
* A `requestIdleCallback` options utility, used to determine custom timeout for idle-callback based delayed hydration.
* @param timeout the max timeout for the idle callback, in milliseconds
*/
export const createIdleLoader = (timeout: number) => timeout
/**
* An `IntersectionObserver` options utility, used to determine custom viewport-based delayed hydration behavior.
* @param opts the options object, containing the wanted viewport options
*/
export const createVisibleLoader = (opts: Partial<IntersectionObserverInit>) => opts
/**
* A utility used to determine which event/events should trigger hydration in components with event-based delayed hydration.
* @param events an event or array of events that will be used to trigger the hydration
*/
export const createEventLoader = (events: keyof HTMLElementEventMap | Array<keyof HTMLElementEventMap>) => events

View File

@ -21,7 +21,7 @@ export const loaderPlugin = createUnplugin((options: LoaderOptions) => {
const exclude = options.transform?.exclude || []
const include = options.transform?.include || []
const serverComponentRuntime = resolve(distDir, 'components/runtime/server-component')
const clientDelayedComponentRuntime = resolve(distDir, 'components/runtime/client-delayed-component')
return {
name: 'nuxt:components-loader',
enforce: 'post',
@ -41,13 +41,16 @@ export const loaderPlugin = createUnplugin((options: LoaderOptions) => {
const imports = new Set<string>()
const map = new Map<Component, string>()
const s = new MagicString(code)
const nuxt = tryUseNuxt()
// replace `_resolveComponent("...")` to direct import
s.replace(/(?<=[ (])_?resolveComponent\(\s*["'](lazy-|Lazy(?=[A-Z]))?([^'"]*)["'][^)]*\)/g, (full: string, lazy: string, name: string) => {
const component = findComponent(components, name, options.mode)
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 modifierComponent = !normalComponent && modifier ? findComponent(components, modifier + name, options.mode) : null
const component = normalComponent || modifierComponent
if (component) {
// @ts-expect-error TODO: refactor to nuxi
if (component._internal_install && tryUseNuxt()?.options.test === false) {
if (component._internal_install && nuxt?.options.test === false) {
// @ts-expect-error TODO: refactor to nuxi
import('../core/features').then(({ installNuxtModule }) => installNuxtModule(component._internal_install))
}
@ -72,9 +75,63 @@ export const loaderPlugin = createUnplugin((options: LoaderOptions) => {
}
if (lazy) {
imports.add(genImport('vue', [{ name: 'defineAsyncComponent', as: '__defineAsyncComponent' }]))
identifier += '_lazy'
imports.add(`const ${identifier} = __defineAsyncComponent(${genDynamicImport(component.filePath, { interopDefault: false })}.then(c => c.${component.export ?? 'default'} || c)${isClientOnly ? '.then(c => createClientOnly(c))' : ''})`)
const dynamicImport = `${genDynamicImport(component.filePath, { interopDefault: false })}.then(c => c.${component.export ?? 'default'} || c)`
if (modifier && normalComponent && nuxt?.options.experimental.delayedHydration === true) {
switch (modifier) {
case 'Visible':
case 'visible-':
imports.add(genImport(clientDelayedComponentRuntime, [{ name: 'createLazyIOComponent' }]))
identifier += '_delayedIO'
imports.add(`const ${identifier} = createLazyIOComponent(${dynamicImport})`)
break
case 'Event':
case 'event-':
imports.add(genImport(clientDelayedComponentRuntime, [{ name: 'createLazyEventComponent' }]))
identifier += '_delayedEvent'
imports.add(`const ${identifier} = createLazyEventComponent(${dynamicImport})`)
break
case 'Idle':
case 'idle-':
imports.add(genImport(clientDelayedComponentRuntime, [{ name: 'createLazyNetworkComponent' }]))
identifier += '_delayedNetwork'
imports.add(`const ${identifier} = createLazyNetworkComponent(${dynamicImport})`)
break
case 'Media':
case 'media-':
imports.add(genImport(clientDelayedComponentRuntime, [{ name: 'createLazyMediaComponent' }]))
identifier += '_delayedMedia'
imports.add(`const ${identifier} = createLazyMediaComponent(${dynamicImport})`)
break
case 'If':
case 'if-':
imports.add(genImport(clientDelayedComponentRuntime, [{ name: 'createLazyIfComponent' }]))
identifier += '_delayedIf'
imports.add(`const ${identifier} = createLazyIfComponent(${dynamicImport})`)
break
case 'Never':
case 'never-':
imports.add(genImport('vue', [{ name: 'defineAsyncComponent', as: '__defineAsyncComponent' }]))
identifier += '_delayedNever'
imports.add(`const ${identifier} = __defineAsyncComponent({loader: ${dynamicImport}, hydrate: () => {}})`)
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 {
imports.add(genImport('vue', [{ name: 'defineAsyncComponent', as: '__defineAsyncComponent' }]))
identifier += '_lazy'
imports.add(`const ${identifier} = __defineAsyncComponent(${dynamicImport}${isClientOnly ? '.then(c => createClientOnly(c))' : ''})`)
}
} else {
imports.add(genImport(component.filePath, [{ name: component._raw ? 'default' : component.export, as: identifier }]))

View File

@ -0,0 +1,175 @@
import { defineAsyncComponent, defineComponent, h, hydrateOnIdle, hydrateOnInteraction, hydrateOnMediaQuery, hydrateOnVisible, mergeProps, watch } from 'vue'
import type { AsyncComponentLoader, HydrationStrategy } from 'vue'
/* @__NO_SIDE_EFFECTS__ */
export const createLazyIOComponent = (loader: AsyncComponentLoader) => {
return defineComponent({
inheritAttrs: false,
props: {
hydrate: {
type: Object,
required: false,
},
},
emits: ['hydrated'],
setup (props, { attrs, emit }) {
const hydrated = () => { emit('hydrated') }
const comp = defineAsyncComponent({ loader, hydrate: hydrateOnVisible(props.hydrate as IntersectionObserverInit | undefined) })
// 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, mergeProps(attrs, { 'data-allow-mismatch': '', 'onVnodeMounted': hydrated }))
},
})
}
/* @__NO_SIDE_EFFECTS__ */
export const createLazyNetworkComponent = (loader: AsyncComponentLoader) => {
return defineComponent({
inheritAttrs: false,
props: {
hydrate: {
type: Number,
required: false,
},
},
emits: ['hydrated'],
setup (props, { attrs, emit }) {
const hydrated = () => { emit('hydrated') }
if (props.hydrate === 0) {
const comp = defineAsyncComponent(loader)
return () => h(comp, mergeProps(attrs, { 'data-allow-mismatch': '', 'onVnodeMounted': hydrated }))
}
const comp = defineAsyncComponent({ loader, hydrate: hydrateOnIdle(props.hydrate) })
// 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, mergeProps(attrs, { 'data-allow-mismatch': '', 'onVnodeMounted': hydrated }))
},
})
}
/* @__NO_SIDE_EFFECTS__ */
export const createLazyEventComponent = (loader: AsyncComponentLoader) => {
return defineComponent({
inheritAttrs: false,
props: {
hydrate: {
type: [String, Array],
required: false,
default: 'mouseover',
},
},
emits: ['hydrated'],
setup (props, { attrs, emit }) {
const hydrated = () => { emit('hydrated') }
// @ts-expect-error Cannot type HTMLElementEventMap in props
const comp = defineAsyncComponent({ loader, hydrate: hydrateOnInteraction(props.hydrate) })
// 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, mergeProps(attrs, { 'data-allow-mismatch': '', 'onVnodeMounted': hydrated }))
},
})
}
/* @__NO_SIDE_EFFECTS__ */
export const createLazyMediaComponent = (loader: AsyncComponentLoader) => {
return defineComponent({
inheritAttrs: false,
props: {
hydrate: {
type: String,
required: false,
default: '(min-width: 1px)',
},
},
emits: ['hydrated'],
setup (props, { attrs, emit }) {
const hydrated = () => { emit('hydrated') }
const comp = defineAsyncComponent({ loader, hydrate: hydrateOnMediaQuery(props.hydrate) })
// 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, mergeProps(attrs, { 'data-allow-mismatch': '', 'onVnodeMounted': hydrated }))
},
})
}
/* @__NO_SIDE_EFFECTS__ */
export const createLazyIfComponent = (loader: AsyncComponentLoader) => {
return defineComponent({
inheritAttrs: false,
props: {
hydrate: {
type: Boolean,
required: false,
default: true,
},
},
emits: ['hydrated'],
setup (props, { attrs, emit }) {
const hydrated = () => { emit('hydrated') }
if (props.hydrate) {
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, mergeProps(attrs, { 'data-allow-mismatch': '', 'onVnodeMounted': hydrated }))
}
const strategy: HydrationStrategy = (hydrate) => {
const unwatch = watch(() => props.hydrate, () => hydrate(), { once: true })
return () => unwatch()
}
const comp = defineAsyncComponent({ loader, hydrate: strategy })
return () => h(comp, mergeProps(attrs, { 'data-allow-mismatch': '', 'onVnodeMounted': hydrated }))
},
})
}
/* @__NO_SIDE_EFFECTS__ */
export const createLazyTimeComponent = (loader: AsyncComponentLoader) => {
return defineComponent({
inheritAttrs: false,
props: {
hydrate: {
type: Number,
required: false,
default: 2000,
},
},
emits: ['hydrated'],
setup (props, { attrs, emit }) {
const hydrated = () => { emit('hydrated') }
if (props.hydrate === 0) {
const comp = defineAsyncComponent(loader)
return () => h(comp, mergeProps(attrs, { 'data-allow-mismatch': '', 'onVnodeMounted': hydrated }))
}
const strategy: HydrationStrategy = (hydrate) => {
const id = setTimeout(hydrate, props.hydrate)
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, mergeProps(attrs, { 'data-allow-mismatch': '', 'onVnodeMounted': hydrated }))
},
})
}
/* @__NO_SIDE_EFFECTS__ */
export const createLazyPromiseComponent = (loader: AsyncComponentLoader) => {
return defineComponent({
inheritAttrs: false,
props: {
hydrate: {
type: Promise,
required: false,
},
},
emits: ['hydrated'],
setup (props, { attrs, emit }) {
const hydrated = () => { emit('hydrated') }
if (!props.hydrate) {
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, mergeProps(attrs, { 'data-allow-mismatch': '', 'onVnodeMounted': hydrated }))
}
const strategy: HydrationStrategy = (hydrate) => {
props.hydrate!.then(hydrate)
return () => {}
}
const comp = defineAsyncComponent({ loader, hydrate: strategy })
return () => h(comp, mergeProps(attrs, { 'data-allow-mismatch': '', 'onVnodeMounted': hydrated }))
},
})
}

View File

@ -115,14 +115,23 @@ export const componentsTypeTemplate = {
c.island || c.mode === 'server' ? `IslandComponent<${type}>` : type,
]
})
const islandType = 'type IslandComponent<T extends DefineComponent> = T & DefineComponent<{}, {refresh: () => Promise<void>}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, SlotsType<{ fallback: { error: unknown } }>>'
const delayedType = 'type DelayedComponent<T> = DefineComponent<{hydrate?: T},{},{},{},{},{},{},{hydrated: void}>'
return `
import type { DefineComponent, SlotsType } from 'vue'
${nuxt.options.experimental.componentIslands ? islandType : ''}
${nuxt.options.experimental.delayedHydration ? delayedType : ''}
interface _GlobalComponents {
${componentTypes.map(([pascalName, type]) => ` '${pascalName}': ${type}`).join('\n')}
${componentTypes.map(([pascalName, type]) => ` 'Lazy${pascalName}': ${type}`).join('\n')}
${componentTypes.map(([pascalName, type]) => ` 'LazyIdle${pascalName}': ${type} & DelayedComponent<number>`).join('\n')}
${componentTypes.map(([pascalName, type]) => ` 'LazyTime${pascalName}': ${type} & DelayedComponent<number>`).join('\n')}
${componentTypes.map(([pascalName, type]) => ` 'LazyPromise${pascalName}': ${type} & DelayedComponent<Promise<unknown>>`).join('\n')}
${componentTypes.map(([pascalName, type]) => ` 'LazyVisible${pascalName}': ${type} & DelayedComponent<IntersectionObserverInit>`).join('\n')}
${componentTypes.map(([pascalName, type]) => ` 'LazyEvent${pascalName}': ${type} & DelayedComponent<keyof HTMLElementEventMap | Array<keyof HTMLElementEventMap>>`).join('\n')}
${componentTypes.map(([pascalName, type]) => ` 'LazyMedia${pascalName}': ${type} & DelayedComponent<string>`).join('\n')}
${componentTypes.map(([pascalName, type]) => ` 'LazyIf${pascalName}': ${type} & DelayedComponent<unknown>`).join('\n')}
${componentTypes.map(([pascalName, type]) => ` 'LazyNever${pascalName}': ${type}`).join('\n')}
}
declare module 'vue' {
@ -131,6 +140,14 @@ declare module 'vue' {
${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 LazyIdle${pascalName}: ${type} & DelayedComponent<number>`).join('\n')}
${componentTypes.map(([pascalName, type]) => `export const LazyTime${pascalName}: ${type} & DelayedComponent<number>`).join('\n')}
${componentTypes.map(([pascalName, type]) => `export const LazyPromise${pascalName}: ${type} & DelayedComponent<Promise<unknown>>`).join('\n')}
${componentTypes.map(([pascalName, type]) => `export const LazyVisible${pascalName}: ${type} & DelayedComponent<IntersectionObserverInit>`).join('\n')}
${componentTypes.map(([pascalName, type]) => `export const LazyEvent${pascalName}: ${type} & DelayedComponent<keyof HTMLElementEventMap | Array<keyof HTMLElementEventMap>>`).join('\n')}
${componentTypes.map(([pascalName, type]) => `export const LazyMedia${pascalName}: ${type} & DelayedComponent<string>`).join('\n')}
${componentTypes.map(([pascalName, type]) => `export const LazyIf${pascalName}: ${type} & DelayedComponent<unknown>`).join('\n')}
${componentTypes.map(([pascalName, type]) => `export const LazyNever${pascalName}: ${type}`).join('\n')}
export const componentNames: string[]
`

View File

@ -42,7 +42,7 @@ const granularAppPresets: InlinePreset[] = [
from: '#app/composables/asyncData',
},
{
imports: ['useHydration'],
imports: ['useHydration', 'createEventLoader', 'createIdleLoader', 'createVisibleLoader'],
from: '#app/composables/hydrate',
},
{

View File

@ -216,6 +216,13 @@ export default defineUntypedSchema({
},
},
/**
* Delayed component hydration
*
* @type {boolean}
*/
delayedHydration: false,
/** Resolve `~`, `~~`, `@` and `@@` aliases located within layers with respect to their layer source and root directories. */
localLayerAliases: true,

View File

@ -2780,6 +2780,80 @@ describe('lazy import components', () => {
it('lazy load named component with mode server', () => {
expect(html).toContain('lazy-named-comp-server')
})
it('lazy load delayed hydration comps at the right time', async () => {
expect(html).toContain('This should be visible at first with network!')
const { page } = await renderPage('/lazy-import-components')
await page.waitForLoadState('networkidle')
expect(await page.locator('body').getByText('This shouldn\'t be visible at first with network!').all()).toHaveLength(1)
expect(await page.locator('body').getByText('This should be visible at first with viewport!').all()).toHaveLength(1)
expect(await page.locator('body').getByText('This should be visible at first with events!').all()).toHaveLength(2)
// The default value is immediately truthy, however, there is a hydration mismatch without the hack
expect(await page.locator('body').getByText('This should be visible at first with conditions!').all()).toHaveLength(1)
const component = page.locator('#lazyevent')
const rect = (await component.boundingBox())!
await page.mouse.move(rect.x + rect.width / 2, rect.y + rect.height / 2)
await page.waitForLoadState('networkidle')
expect(await page.locator('body').getByText('This shouldn\'t be visible at first with events!').all()).toHaveLength(1)
await page.locator('#conditionbutton').click()
await page.waitForLoadState('networkidle')
expect(await page.locator('body').getByText('This shouldn\'t be visible at first with conditions!').all()).toHaveLength(2)
await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight))
await page.waitForTimeout(300) // Wait for the intersection observer to fire the callback
expect(await page.locator('body').getByText('This shouldn\'t be visible at first with viewport!').all()).toHaveLength(2)
expect(await page.locator('body').getByText('This should always be visible!').all()).toHaveLength(1)
await page.close()
})
it('respects custom delayed hydration triggers and overrides defaults', async () => {
const { page } = await renderPage('/lazy-import-components')
await page.waitForLoadState('networkidle')
const component = page.locator('#lazyevent2')
const rect = (await component.boundingBox())!
await page.mouse.move(rect.x + rect.width / 2, rect.y + rect.height / 2)
await page.waitForTimeout(500)
await page.waitForLoadState('networkidle')
expect(await page.locator('body').getByText('This should be visible at first with events!').all()).toHaveLength(2)
await page.locator('#lazyevent2').click()
await page.waitForLoadState('networkidle')
expect(await page.locator('body').getByText('This should be visible at first with events!').all()).toHaveLength(1)
expect(await page.locator('body').getByText('This shouldn\'t be visible at first with events!').all()).toHaveLength(1)
await page.close()
})
it('does not delay hydration of components named after modifiers', async () => {
const { page } = await renderPage('/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 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)
})
it('keeps reactivity with models', async () => {
const { page } = await renderPage('/lazy-import-components/model-event')
expect(await page.locator('#count').textContent()).toBe('0')
for (let i = 0; i < 10; i++) {
expect(await page.locator('#count').textContent()).toBe(`${i}`)
await page.locator('#inc').click()
}
expect(await page.locator('#count').textContent()).toBe('10')
})
it('emits hydration events', async () => {
const { page, consoleLogs } = await renderPage('/lazy-import-components/model-event')
expect(consoleLogs.some(log => log.type === 'log' && log.text === 'Component hydrated')).toBeFalsy()
await page.locator('#count').click()
expect(consoleLogs.some(log => log.type === 'log' && log.text === 'Component hydrated')).toBeTruthy()
})
})
describe('defineNuxtComponent watch duplicate', () => {

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,15 @@
<template>
<div>
<span id="count">{{ model }}</span>
<button
id="inc"
@click="model++"
>
Increment
</button>
</div>
</template>
<script setup lang="ts">
const model = defineModel()
</script>

View File

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

View File

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

View File

@ -0,0 +1,5 @@
<template>
<div>
This should never be visible!
</div>
</template>

View File

@ -0,0 +1,5 @@
<template>
<div>
This should always be visible!
</div>
</template>

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,5 @@
<template>
<div>
This shouldn't be visible at first with viewport!
</div>
</template>

View File

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

View File

@ -0,0 +1,5 @@
<template>
<div>
This fake lazy event should be visible!
</div>
</template>

View File

@ -0,0 +1,5 @@
<template>
<div>
This fake lazy event shouldn't be visible!
</div>
</template>

View File

@ -254,6 +254,7 @@ export default defineNuxtConfig({
renderJsonPayloads: process.env.TEST_PAYLOAD !== 'js',
headNext: true,
inlineRouteRules: true,
delayedHydration: true,
},
appConfig: {
fromNuxtConfig: true,

View File

@ -3,5 +3,31 @@
<LazyNCompAll message="lazy-named-comp-all" />
<LazyNCompClient message="lazy-named-comp-client" />
<LazyNCompServer message="lazy-named-comp-server" />
<LazyEventDelayedEvent id="lazyevent" />
<LazyEventView />
<LazyVisibleDelayedVisible />
<LazyNeverDelayedNever />
<LazyEventDelayedEvent
id="lazyevent2"
:hydrate="createEventLoader(['click'])"
/>
<LazyIfDelayedCondition id="lazycondition" />
<button
id="conditionbutton"
@click="state++"
/>
<LazyIfDelayedCondition
id="lazycondition2"
:hydrate="state > 1"
/>
<LazyIdleDelayedNetwork />
<div style="height:3000px">
This is a very tall div
</div>
<LazyVisibleDelayedVisible />
</div>
</template>
<script setup lang="ts">
const state = useState('delayedHydrationCondition', () => 1)
</script>

View File

@ -0,0 +1,15 @@
<template>
<div>
<LazyEventDelayedModel
v-model="model"
@hydrated="log"
/>
</div>
</template>
<script setup lang="ts">
const model = ref(0)
function log () {
console.log('Component hydrated')
}
</script>

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>