diff --git a/docs/2.guide/2.directory-structure/1.components.md b/docs/2.guide/2.directory-structure/1.components.md index 1e4a5f8b01..b705e71bf8 100644 --- a/docs/2.guide/2.directory-structure/1.components.md +++ b/docs/2.guide/2.directory-structure/1.components.md @@ -123,6 +123,172 @@ const show = ref(false) ``` +## Delayed (or Lazy) Hydration + +Lazy components are great for controlling the chunk sizes in your app, but they don't always 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. + +In order to optimize your app, you may want to delay the hydration of some components until they're visible, or until the browser is done with more important tasks. + +Nuxt supports this using lazy (or delayed) hydration, allowing you to control when components become interactive. + +### Hydration Strategies + +Nuxt provides a range of built-in hydration strategies. Only one strategy can be used per lazy component. + +::warning +Currently Nuxt's built-in lazy hydration only works in single-file components (SFCs), and requires you to define the prop in the template (rather than spreading an object of props via `v-bind`). It also does not work with direct imports from `#components`. +:: + +#### `hydrate-on-visible` + +Hydrates the component when it becomes visible in the viewport. + +```vue [pages/index.vue] + +``` + +::read-more{to="https://developer.mozilla.org/en-US/docs/Web/API/IntersectionObserver/IntersectionObserver" title="IntersectionObserver options"} +Read more about the options for `hydrate-on-visible`. +:: + +::note +Under the hood, this uses Vue's built-in [`hydrateOnVisible` strategy](https://vuejs.org/guide/components/async.html#hydrate-on-visible). +:: + +#### `hydrate-on-idle` + +Hydrates the component when the browser is idle. This is suitable if you need the component to load as soon as possible, but not block the critical rendering path. + +You can also pass a number which serves as a max timeout. + +```vue [pages/index.vue] + +``` + +::note +Under the hood, this uses Vue's built-in [`hydrateOnIdle` strategy](https://vuejs.org/guide/components/async.html#hydrate-on-idle). +:: + +#### `hydrate-on-interaction` + +Hydrates the component after a specified interaction (e.g., click, mouseover). + +```vue [pages/index.vue] + +``` + +If you do not pass an event or list of events, it defaults to hydrating on `pointerenter` and `focus`. + +::note +Under the hood, this uses Vue's built-in [`hydrateOnInteraction` strategy](https://vuejs.org/guide/components/async.html#hydrate-on-interaction). +:: + +#### `hydrate-on-media-query` + +Hydrates the component when the window matches a media query. + +```vue [pages/index.vue] + +``` + +::note +Under the hood, this uses Vue's built-in [`hydrateOnMediaQuery` strategy](https://vuejs.org/guide/components/async.html#hydrate-on-media-query). +:: + +#### `hydrate-after` + +Hydrates the component after a specified delay (in milliseconds). + +```vue [pages/index.vue] + +``` + +#### `hydrate-when` + +Hydrates the component based on a boolean condition. + +```vue [pages/index.vue] + + +``` + +#### `hydrate-never` + +Never hydrates the component. + +```vue [pages/index.vue] + +``` + +### Listening to Hydration Events + +All delayed hydration components emit a `@hydrated` event when they are hydrated. + +```vue [pages/index.vue] + + + +``` + +### Caveats and Best Practices + +Delayed hydration can offer performance benefits, but it's essential to use it correctly: + +1. **Prioritize In-Viewport Content:** Avoid delayed hydration for critical, above-the-fold content. It's best suited for content that isn't immediately needed. + +2. **Conditional Rendering:** When using `v-if="false"` on a lazy component, you might not need delayed hydration. You can just use a normal lazy component. + +3. **Shared State:** Be mindful of shared state (`v-model`) across multiple components. Updating the model in one component can trigger hydration in all components bound to that model. + +4. **Use Each Strategy's Intended Use Case:** Each strategy is optimized for a specific purpose. + * `hydrate-when` is best for components that might not always need to be hydrated. + * `hydrate-after` is for components that can wait a specific amount of time. + * `hydrate-on-idle` is for components that can be hydrated when the browser is idle. + +5. **Avoid `hydrate-never` on interactive components:** If a component requires user interaction, it should not be set to never hydrate. + ## Direct Imports You can also explicitly import components from `#components` if you want or need to bypass Nuxt's auto-importing functionality. @@ -343,10 +509,10 @@ When rendering a server-only or island component, `` makes a fetch r This means: -- A new Vue app will be created server-side to create the `NuxtIslandResponse`. -- A new 'island context' will be created while rendering the component. -- You can't access the 'island context' from the rest of your app and you can't access the context of the rest of your app from the island component. In other words, the server component or island is _isolated_ from the rest of your app. -- Your plugins will run again when rendering the island, unless they have `env: { islands: false }` set (which you can do in an object-syntax plugin). +* A new Vue app will be created server-side to create the `NuxtIslandResponse`. +* A new 'island context' will be created while rendering the component. +* You can't access the 'island context' from the rest of your app and you can't access the context of the rest of your app from the island component. In other words, the server component or island is _isolated_ from the rest of your app. +* Your plugins will run again when rendering the island, unless they have `env: { islands: false }` set (which you can do in an object-syntax plugin). Within an island component, you can access its island context through `nuxtApp.ssrContext.islandContext`. Note that while island components are still marked as experimental, the format of this context may change. diff --git a/packages/nuxt/package.json b/packages/nuxt/package.json index 68a0a02420..43b5d74dd0 100644 --- a/packages/nuxt/package.json +++ b/packages/nuxt/package.json @@ -141,6 +141,7 @@ "@parcel/watcher": "2.5.1", "@types/estree": "1.0.6", "@vitejs/plugin-vue": "5.2.1", + "@vitejs/plugin-vue-jsx": "^4.1.1", "@vue/compiler-sfc": "3.5.13", "unbuild": "latest", "vite": "6.2.0", diff --git a/packages/nuxt/src/components/module.ts b/packages/nuxt/src/components/module.ts index f08f629bad..4f4824df40 100644 --- a/packages/nuxt/src/components/module.ts +++ b/packages/nuxt/src/components/module.ts @@ -14,6 +14,7 @@ import { ComponentsChunkPlugin, IslandsTransformPlugin } from './plugins/islands import { TransformPlugin } from './plugins/transform' import { TreeShakeTemplatePlugin } from './plugins/tree-shake' import { ComponentNamePlugin } from './plugins/component-names' +import { LazyHydrationTransformPlugin } from './plugins/lazy-hydration-transform' const isPureObjectOrString = (val: any) => (!Array.isArray(val) && typeof val === 'object') || typeof val === 'string' const isDirectory = (p: string) => { try { return statSync(p).isDirectory() } catch { return false } } @@ -203,9 +204,13 @@ export default defineNuxtModule({ addBuildPlugin(TreeShakeTemplatePlugin({ sourcemap: !!nuxt.options.sourcemap.server, getComponents }), { client: false }) } + const clientDelayedComponentRuntime = await findPath(join(distDir, 'components/runtime/lazy-hydrated-component')) ?? join(distDir, 'components/runtime/lazy-hydrated-component') + const sharedLoaderOptions = { getComponents, + clientDelayedComponentRuntime, serverComponentRuntime, + srcDir: nuxt.options.srcDir, transform: typeof nuxt.options.components === 'object' && !Array.isArray(nuxt.options.components) ? nuxt.options.components.transform : undefined, experimentalComponentIslands: !!nuxt.options.experimental.componentIslands, } @@ -213,6 +218,13 @@ export default defineNuxtModule({ addBuildPlugin(LoaderPlugin({ ...sharedLoaderOptions, sourcemap: !!nuxt.options.sourcemap.client, mode: 'client' }), { server: false }) addBuildPlugin(LoaderPlugin({ ...sharedLoaderOptions, sourcemap: !!nuxt.options.sourcemap.server, mode: 'server' }), { client: false }) + if (nuxt.options.experimental.lazyHydration) { + addBuildPlugin(LazyHydrationTransformPlugin({ + ...sharedLoaderOptions, + sourcemap: !!(nuxt.options.sourcemap.server || nuxt.options.sourcemap.client), + }), { prepend: true }) + } + if (nuxt.options.experimental.componentIslands) { const selectiveClient = typeof nuxt.options.experimental.componentIslands === 'object' && nuxt.options.experimental.componentIslands.selectiveClient diff --git a/packages/nuxt/src/components/plugins/lazy-hydration-transform.ts b/packages/nuxt/src/components/plugins/lazy-hydration-transform.ts new file mode 100644 index 0000000000..8b384d2423 --- /dev/null +++ b/packages/nuxt/src/components/plugins/lazy-hydration-transform.ts @@ -0,0 +1,107 @@ +import { createUnplugin } from 'unplugin' +import MagicString from 'magic-string' +import { camelCase, pascalCase } from 'scule' +import type { Component, ComponentsOptions } from 'nuxt/schema' + +import { parse, walk } from 'ultrahtml' +import { isVue } from '../../core/utils' +import { logger } from '../../utils' + +interface LoaderOptions { + getComponents (): Component[] + sourcemap?: boolean + transform?: ComponentsOptions['transform'] +} + +const TEMPLATE_RE = / + + diff --git a/test/fixtures/basic/pages/lazy-import-components/model-event.vue b/test/fixtures/basic/pages/lazy-import-components/model-event.vue new file mode 100644 index 0000000000..902c32f03b --- /dev/null +++ b/test/fixtures/basic/pages/lazy-import-components/model-event.vue @@ -0,0 +1,16 @@ + + + diff --git a/test/fixtures/basic/pages/lazy-import-components/time.vue b/test/fixtures/basic/pages/lazy-import-components/time.vue new file mode 100644 index 0000000000..0a404c0d57 --- /dev/null +++ b/test/fixtures/basic/pages/lazy-import-components/time.vue @@ -0,0 +1,8 @@ +