diff --git a/docs/2.guide/2.directory-structure/1.components.md b/docs/2.guide/2.directory-structure/1.components.md index 0cdf5cb4b651..331b2547744a 100644 --- a/docs/2.guide/2.directory-structure/1.components.md +++ b/docs/2.guide/2.directory-structure/1.components.md @@ -119,6 +119,37 @@ const show = ref(false) ``` +## Delayed Hydration + +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. Nuxt has first class support for delayed hydration components and can help you reduce your boilerplate along the way. + +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] + +``` + +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] + +``` + +::important +Since Nuxt uses `LazyIdle` and `LazyVisible` to handle delayed hydration, you should avoid naming your components that, as dynamic imports will break for you. Delayed hydration would still be possible by adding the prefix, for example to a component named `IdleBar`: +`` +:: + ## Direct Imports You can also explicitly import components from `#components` if you want or need to bypass Nuxt's auto-importing functionality. diff --git a/packages/nuxt/src/app/components/nuxt-link.ts b/packages/nuxt/src/app/components/nuxt-link.ts index 16116ebcfda0..95270055dd5d 100644 --- a/packages/nuxt/src/app/components/nuxt-link.ts +++ b/packages/nuxt/src/app/components/nuxt-link.ts @@ -14,6 +14,7 @@ import { onNuxtReady } from '../composables/ready' import { navigateTo, useRouter } from '../composables/router' import { useNuxtApp, useRuntimeConfig } from '../nuxt' import { cancelIdleCallback, requestIdleCallback } from '../compat/idle-callback' +import { useObserver } from '../utils' // @ts-expect-error virtual file import { nuxtLinkDefaults } from '#build/nuxt.config.mjs' @@ -435,49 +436,6 @@ function applyTrailingSlashBehavior (to: string, trailingSlash: NuxtLinkOptions[ } // --- Prefetching utils --- -type CallbackFn = () => void -type ObserveFn = (element: Element, callback: CallbackFn) => () => void - -function useObserver (): { observe: ObserveFn } | undefined { - if (import.meta.server) { return } - - const nuxtApp = useNuxtApp() - if (nuxtApp._observer) { - return nuxtApp._observer - } - - let observer: IntersectionObserver | null = null - - const callbacks = new Map() - - const observe: ObserveFn = (element, callback) => { - if (!observer) { - observer = new IntersectionObserver((entries) => { - for (const entry of entries) { - const callback = callbacks.get(entry.target) - const isVisible = entry.isIntersecting || entry.intersectionRatio > 0 - if (isVisible && callback) { callback() } - } - }) - } - callbacks.set(element, callback) - observer.observe(element) - return () => { - callbacks.delete(element) - observer!.unobserve(element) - if (callbacks.size === 0) { - observer!.disconnect() - observer = null - } - } - } - - const _observer = nuxtApp._observer = { - observe, - } - - return _observer -} function isSlowConnection () { if (import.meta.server) { return } diff --git a/packages/nuxt/src/app/utils.ts b/packages/nuxt/src/app/utils.ts index 72b096120b49..a46715cb2109 100644 --- a/packages/nuxt/src/app/utils.ts +++ b/packages/nuxt/src/app/utils.ts @@ -1,4 +1,49 @@ -/** @since 3.9.0 */ +import { useNuxtApp } from './nuxt' + export function toArray (value: T | T[]): T[] { return Array.isArray(value) ? value : [value] } + +type CallbackFn = () => void +type ObserveFn = (element: Element, callback: CallbackFn) => () => void + +export function useObserver (): { observe: ObserveFn } | undefined { + if (import.meta.server) { return } + + const nuxtApp = useNuxtApp() + if (nuxtApp._observer) { + return nuxtApp._observer + } + + let observer: IntersectionObserver | null = null + + const callbacks = new Map() + + const observe: ObserveFn = (element, callback) => { + if (!observer) { + observer = new IntersectionObserver((entries) => { + for (const entry of entries) { + const callback = callbacks.get(entry.target) + const isVisible = entry.isIntersecting || entry.intersectionRatio > 0 + if (isVisible && callback) { callback() } + } + }) + } + callbacks.set(element, callback) + observer.observe(element) + return () => { + callbacks.delete(element) + observer!.unobserve(element) + if (callbacks.size === 0) { + observer!.disconnect() + observer = null + } + } + } + + const _observer = nuxtApp._observer = { + observe, + } + + return _observer +} diff --git a/packages/nuxt/src/components/loader.ts b/packages/nuxt/src/components/loader.ts index b56271e631c8..b65385f3e959 100644 --- a/packages/nuxt/src/components/loader.ts +++ b/packages/nuxt/src/components/loader.ts @@ -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,13 @@ export const loaderPlugin = createUnplugin((options: LoaderOptions) => { const imports = new Set() const map = new Map() const s = new MagicString(code) - + const nuxt = tryUseNuxt() // replace `_resolveComponent("...")` to direct import - s.replace(/(?<=[ (])_?resolveComponent\(\s*["'](lazy-|Lazy)?([^'"]*)["'][^)]*\)/g, (full: string, lazy: string, name: string) => { + s.replace(/(?<=[ (])_?resolveComponent\(\s*["'](lazy-|Lazy)?(Idle|Visible|idle-|visible-)?([^'"]*)["'][^)]*\)/g, (full: string, lazy: string, modifier: string, name: string) => { const component = findComponent(components, name, options.mode) 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)) } @@ -73,8 +73,25 @@ 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))' : ''})`) + if (nuxt?.options.experimental.componentLazyHydration === true && modifier) { + switch (modifier) { + case 'Visible': + case 'visible-': + imports.add(genImport(clientDelayedComponentRuntime, [{ name: 'createLazyIOClientPage' }])) + identifier += '_delayedIO' + imports.add(`const ${identifier} = createLazyIOClientPage(__defineAsyncComponent(${genDynamicImport(component.filePath, { interopDefault: false })}.then(c => c.${component.export ?? 'default'} || c)))`) + break + case 'Idle': + case 'idle-': + imports.add(genImport(clientDelayedComponentRuntime, [{ name: 'createLazyNetworkClientPage' }])) + identifier += '_delayedNetwork' + imports.add(`const ${identifier} = createLazyNetworkClientPage(__defineAsyncComponent(${genDynamicImport(component.filePath, { interopDefault: false })}.then(c => c.${component.export ?? 'default'} || c)))`) + break + } + } else { + identifier += '_lazy' + imports.add(`const ${identifier} = __defineAsyncComponent(${genDynamicImport(component.filePath, { interopDefault: false })}.then(c => c.${component.export ?? 'default'} || c)${isClientOnly ? '.then(c => createClientOnly(c))' : ''})`) + } } else { imports.add(genImport(component.filePath, [{ name: component._raw ? 'default' : component.export, as: identifier }])) diff --git a/packages/nuxt/src/components/runtime/client-delayed-component.ts b/packages/nuxt/src/components/runtime/client-delayed-component.ts new file mode 100644 index 000000000000..5386172dd238 --- /dev/null +++ b/packages/nuxt/src/components/runtime/client-delayed-component.ts @@ -0,0 +1,87 @@ +import { createStaticVNode, createVNode, defineComponent, getCurrentInstance, h, onBeforeUnmount, onMounted, ref } from 'vue' +import type { Component, Ref } from 'vue' +// import ClientOnly from '#app/components/client-only' +import { useObserver } from '#app/utils' +import { getFragmentHTML } from '#app/components/utils' +import { useNuxtApp } from '#app/nuxt' + +function elementIsVisibleInViewport (el: Element) { + const { top, left, bottom, right } = el.getBoundingClientRect() + const { innerHeight, innerWidth } = window + return ((top > 0 && top < innerHeight) || + (bottom > 0 && bottom < innerHeight)) && + ((left > 0 && left < innerWidth) || (right > 0 && right < innerWidth)) +} + +/* @__NO_SIDE_EFFECTS__ */ +export const createLazyIOClientPage = (componentLoader: Component) => { + return defineComponent({ + inheritAttrs: false, + setup (_, { attrs }) { + if (import.meta.server) { + return () => h(componentLoader, attrs) + } + + const nuxt = useNuxtApp() + const instance = getCurrentInstance()! + const isIntersecting = ref(false) + const el: Ref = ref(null) + let unobserve: (() => void) | null = null + + // todo can be refactored + if (instance.vnode.el && nuxt.isHydrating) { + isIntersecting.value = elementIsVisibleInViewport(instance.vnode.el as Element) + } + + if (!isIntersecting.value) { + onMounted(() => { + const observer = useObserver() + unobserve = observer!.observe(el.value as Element, () => { + isIntersecting.value = true + unobserve?.() + unobserve = null + }) + }) + } + onBeforeUnmount(() => { + unobserve?.() + unobserve = null + }) + return () => { + return h('div', { ref: el }, [ + isIntersecting.value ? h(componentLoader, attrs) : (instance.vnode.el && nuxt.isHydrating) ? createVNode(createStaticVNode(getFragmentHTML(instance.vnode.el ?? null, true)?.join('') || '', 1)) : null, + ]) + } + }, + }) +} + +/* @__NO_SIDE_EFFECTS__ */ +export const createLazyNetworkClientPage = (componentLoader: Component) => { + return defineComponent({ + inheritAttrs: false, + setup (_, { attrs }) { + if (import.meta.server) { + return () => h(componentLoader, attrs) + } + const nuxt = useNuxtApp() + const instance = getCurrentInstance()! + const isIdle = ref(false) + let idleHandle: number | null = null + onMounted(() => { + idleHandle = requestIdleCallback(() => { + isIdle.value = true + cancelIdleCallback(idleHandle as unknown as number) + idleHandle = null + }) + }) + onBeforeUnmount(() => { + if (idleHandle) { + cancelIdleCallback(idleHandle as unknown as number) + idleHandle = null + } + }) + return () => isIdle.value ? h(componentLoader, attrs) : (instance.vnode.el && nuxt.isHydrating) ? createVNode(createStaticVNode(getFragmentHTML(instance.vnode.el ?? null, true)?.join('') || '', 1)) : null + }, + }) +} diff --git a/packages/schema/src/config/experimental.ts b/packages/schema/src/config/experimental.ts index b80f0beca1bd..924f46e13ba6 100644 --- a/packages/schema/src/config/experimental.ts +++ b/packages/schema/src/config/experimental.ts @@ -259,6 +259,13 @@ export default defineUntypedSchema({ }, }, + /** + * Experimental built-in delayed component hydration + * + * This enables components to lazily hydrate when needed, improving performance for sites with components below-the-fold + */ + componentLazyHydration: false, + /** * Config schema support * @see [Nuxt Issue #15592](https://github.com/nuxt/nuxt/issues/15592) diff --git a/test/basic.test.ts b/test/basic.test.ts index 1e4db83c89a7..0981d3e4a84d 100644 --- a/test/basic.test.ts +++ b/test/basic.test.ts @@ -2635,6 +2635,16 @@ 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') + 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) + await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight)) + await page.waitForLoadState('networkidle') + expect(await page.locator('body').getByText('This shouldn\'t be visible at first with viewport!').all()).toHaveLength(1) + }) }) describe('defineNuxtComponent watch duplicate', () => { diff --git a/test/fixtures/basic/components/DelayedNetwork.client.vue b/test/fixtures/basic/components/DelayedNetwork.client.vue new file mode 100644 index 000000000000..c76f76b8d6d5 --- /dev/null +++ b/test/fixtures/basic/components/DelayedNetwork.client.vue @@ -0,0 +1,5 @@ + diff --git a/test/fixtures/basic/components/DelayedNetwork.server.vue b/test/fixtures/basic/components/DelayedNetwork.server.vue new file mode 100644 index 000000000000..65192055eafa --- /dev/null +++ b/test/fixtures/basic/components/DelayedNetwork.server.vue @@ -0,0 +1,5 @@ + diff --git a/test/fixtures/basic/components/DelayedVisible.client.vue b/test/fixtures/basic/components/DelayedVisible.client.vue new file mode 100644 index 000000000000..138d4fa96f0d --- /dev/null +++ b/test/fixtures/basic/components/DelayedVisible.client.vue @@ -0,0 +1,5 @@ + diff --git a/test/fixtures/basic/components/DelayedVisible.server.vue b/test/fixtures/basic/components/DelayedVisible.server.vue new file mode 100644 index 000000000000..49f2e0262a0f --- /dev/null +++ b/test/fixtures/basic/components/DelayedVisible.server.vue @@ -0,0 +1,5 @@ + diff --git a/test/fixtures/basic/nuxt.config.ts b/test/fixtures/basic/nuxt.config.ts index d312d55944b0..beda5b9fcbb7 100644 --- a/test/fixtures/basic/nuxt.config.ts +++ b/test/fixtures/basic/nuxt.config.ts @@ -238,6 +238,7 @@ export default defineNuxtConfig({ renderJsonPayloads: process.env.TEST_PAYLOAD !== 'js', headNext: true, inlineRouteRules: true, + componentLazyHydration: true, }, appConfig: { fromNuxtConfig: true, diff --git a/test/fixtures/basic/pages/lazy-import-components/index.vue b/test/fixtures/basic/pages/lazy-import-components/index.vue index 0f46fc9dfc61..797f01870049 100644 --- a/test/fixtures/basic/pages/lazy-import-components/index.vue +++ b/test/fixtures/basic/pages/lazy-import-components/index.vue @@ -3,5 +3,10 @@ + +
+ This is a very tall div +
+ diff --git a/test/fixtures/basic/server/plugins/basicRenderDelayedHydration.ts b/test/fixtures/basic/server/plugins/basicRenderDelayedHydration.ts new file mode 100644 index 000000000000..6e7c7403059a --- /dev/null +++ b/test/fixtures/basic/server/plugins/basicRenderDelayedHydration.ts @@ -0,0 +1,5 @@ +export default defineNitroPlugin((nitroApp) => { + nitroApp.hooks.hook('render:html', (html, { event }) => { + html.head = html.head.map((headSection: string) => headSection.replace(/]+\bhref="\/_nuxt\/DelayedWrapperTestComponent\.([^.]+)\.js")[^>]+>)/, '')) // .replace(//, "") + }) +})