= 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 @@
+
+
+ This shouldn't be visible at first with network!
+
+
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 @@
+
+
+ This should be visible at first with network!
+
+
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 @@
+
+
+ This shouldn't be visible at first with viewport!
+
+
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 @@
+
+
+ This should be visible at first with viewport!
+
+
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(/ /, "")
+ })
+})