()
const s = new MagicString(code)
-
// replace `_resolveComponent("...")` to direct import
- s.replace(REPLACE_COMPONENT_TO_DIRECT_IMPORT_RE, (full: string, lazy: string, name: string) => {
- const component = findComponent(components, name, options.mode)
+ s.replace(REPLACE_COMPONENT_TO_DIRECT_IMPORT_RE, (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) {
// TODO: refactor to nuxi
const internalInstall = ((component as any)._internal_install) as string
@@ -79,9 +83,58 @@ export const LoaderPlugin = (options: LoaderOptions) => createUnplugin(() => {
}
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) {
+ const relativePath = relative(options.srcDir, component.filePath)
+ switch (modifier) {
+ case 'Visible':
+ case 'visible-':
+ imports.add(genImport(options.clientDelayedComponentRuntime, [{ name: 'createLazyVisibleComponent' }]))
+ identifier += '_lazy_visible'
+ imports.add(`const ${identifier} = createLazyVisibleComponent(${JSON.stringify(relativePath)}, ${dynamicImport})`)
+ break
+ case 'Interaction':
+ case 'interaction-':
+ imports.add(genImport(options.clientDelayedComponentRuntime, [{ name: 'createLazyInteractionComponent' }]))
+ identifier += '_lazy_event'
+ imports.add(`const ${identifier} = createLazyInteractionComponent(${JSON.stringify(relativePath)}, ${dynamicImport})`)
+ break
+ case 'Idle':
+ case 'idle-':
+ imports.add(genImport(options.clientDelayedComponentRuntime, [{ name: 'createLazyIdleComponent' }]))
+ identifier += '_lazy_idle'
+ imports.add(`const ${identifier} = createLazyIdleComponent(${JSON.stringify(relativePath)}, ${dynamicImport})`)
+ break
+ case 'MediaQuery':
+ case 'media-query-':
+ imports.add(genImport(options.clientDelayedComponentRuntime, [{ name: 'createLazyMediaQueryComponent' }]))
+ identifier += '_lazy_media'
+ imports.add(`const ${identifier} = createLazyMediaQueryComponent(${JSON.stringify(relativePath)}, ${dynamicImport})`)
+ break
+ case 'If':
+ case 'if-':
+ imports.add(genImport(options.clientDelayedComponentRuntime, [{ name: 'createLazyIfComponent' }]))
+ identifier += '_lazy_if'
+ imports.add(`const ${identifier} = createLazyIfComponent(${JSON.stringify(relativePath)}, ${dynamicImport})`)
+ break
+ case 'Never':
+ case 'never-':
+ imports.add(genImport(options.clientDelayedComponentRuntime, [{ name: 'createLazyNeverComponent' }]))
+ identifier += '_lazy_never'
+ imports.add(`const ${identifier} = createLazyNeverComponent(${JSON.stringify(relativePath)}, ${dynamicImport})`)
+ break
+ case 'Time':
+ case 'time-':
+ imports.add(genImport(options.clientDelayedComponentRuntime, [{ name: 'createLazyTimeComponent' }]))
+ identifier += '_lazy_time'
+ imports.add(`const ${identifier} = createLazyTimeComponent(${JSON.stringify(relativePath)}, ${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 }]))
diff --git a/packages/nuxt/src/components/runtime/lazy-hydrated-component.ts b/packages/nuxt/src/components/runtime/lazy-hydrated-component.ts
new file mode 100644
index 0000000000..693e7baeca
--- /dev/null
+++ b/packages/nuxt/src/components/runtime/lazy-hydrated-component.ts
@@ -0,0 +1,114 @@
+import { defineAsyncComponent, defineComponent, h, hydrateOnIdle, hydrateOnInteraction, hydrateOnMediaQuery, hydrateOnVisible, mergeProps, watch } from 'vue'
+import type { AsyncComponentLoader, ComponentObjectPropsOptions, ExtractPropTypes, HydrationStrategy } from 'vue'
+import { useNuxtApp } from '#app/nuxt'
+
+function defineLazyComponent (props: P, defineStrategy: (props: ExtractPropTypes
) => HydrationStrategy | undefined) {
+ return (id: string, loader: AsyncComponentLoader) => defineComponent({
+ inheritAttrs: false,
+ props,
+ emits: ['hydrated'],
+ setup (props, ctx) {
+ if (import.meta.server) {
+ const nuxtApp = useNuxtApp()
+ nuxtApp.hook('app:rendered', ({ ssrContext }) => {
+ // strip the lazy hydrated component from the ssrContext so prefetch/preload tags are not rendered for it
+ ssrContext!.modules!.delete(id)
+ })
+ }
+ // wrap the async component in a second component to avoid loading the chunk too soon
+ const child = defineAsyncComponent({ loader })
+ const comp = defineAsyncComponent({
+ hydrate: defineStrategy(props as ExtractPropTypes
),
+ loader: () => Promise.resolve(child),
+ })
+ const onVnodeMounted = () => { ctx.emit('hydrated') }
+ return () => h(comp, mergeProps(ctx.attrs, { onVnodeMounted }))
+ },
+ })
+}
+
+/* @__NO_SIDE_EFFECTS__ */
+export const createLazyVisibleComponent = defineLazyComponent({
+ hydrateOnVisible: {
+ type: [Object, Boolean] as unknown as () => true | IntersectionObserverInit,
+ required: false,
+ },
+},
+props => hydrateOnVisible(props.hydrateOnVisible === true ? undefined : props.hydrateOnVisible),
+)
+
+/* @__NO_SIDE_EFFECTS__ */
+export const createLazyIdleComponent = defineLazyComponent({
+ hydrateOnIdle: {
+ type: [Number, Boolean] as unknown as () => true | number,
+ required: true,
+ },
+},
+props => props.hydrateOnIdle === 0
+ ? undefined /* hydrate immediately */
+ : hydrateOnIdle(props.hydrateOnIdle === true ? undefined : props.hydrateOnIdle),
+)
+
+const defaultInteractionEvents = ['pointerenter', 'click', 'focus'] satisfies Array
+
+/* @__NO_SIDE_EFFECTS__ */
+export const createLazyInteractionComponent = defineLazyComponent({
+ hydrateOnInteraction: {
+ type: [String, Array] as unknown as () => keyof HTMLElementEventMap | Array | true,
+ required: false,
+ default: defaultInteractionEvents,
+ },
+},
+props => hydrateOnInteraction(props.hydrateOnInteraction === true ? defaultInteractionEvents : (props.hydrateOnInteraction || defaultInteractionEvents)),
+)
+
+/* @__NO_SIDE_EFFECTS__ */
+export const createLazyMediaQueryComponent = defineLazyComponent({
+ hydrateOnMediaQuery: {
+ type: String as unknown as () => string,
+ required: true,
+ },
+},
+props => hydrateOnMediaQuery(props.hydrateOnMediaQuery),
+)
+
+/* @__NO_SIDE_EFFECTS__ */
+export const createLazyIfComponent = defineLazyComponent({
+ hydrateWhen: {
+ type: Boolean,
+ default: true,
+ },
+},
+props => props.hydrateWhen
+ ? undefined /* hydrate immediately */
+ : (hydrate) => {
+ const unwatch = watch(() => props.hydrateWhen, () => hydrate(), { once: true })
+ return () => unwatch()
+ },
+)
+
+/* @__NO_SIDE_EFFECTS__ */
+export const createLazyTimeComponent = defineLazyComponent({
+ hydrateAfter: {
+ type: Number,
+ required: true,
+ },
+},
+props => props.hydrateAfter === 0
+ ? undefined /* hydrate immediately */
+ : (hydrate) => {
+ const id = setTimeout(hydrate, props.hydrateAfter)
+ return () => clearTimeout(id)
+ },
+)
+
+/* @__NO_SIDE_EFFECTS__ */
+const hydrateNever = () => {}
+export const createLazyNeverComponent = defineLazyComponent({
+ hydrateNever: {
+ type: Boolean as () => true,
+ required: true,
+ },
+},
+() => hydrateNever,
+)
diff --git a/packages/nuxt/src/components/templates.ts b/packages/nuxt/src/components/templates.ts
index 2ae13f57f3..3f020ef4f5 100644
--- a/packages/nuxt/src/components/templates.ts
+++ b/packages/nuxt/src/components/templates.ts
@@ -116,14 +116,23 @@ export const componentsTypeTemplate = {
c.island || c.mode === 'server' ? `IslandComponent<${type}>` : type,
]
})
-
const islandType = 'type IslandComponent = T & DefineComponent<{}, {refresh: () => Promise}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, SlotsType<{ fallback: { error: unknown } }>>'
return `
import type { DefineComponent, SlotsType } from 'vue'
${nuxt.options.experimental.componentIslands ? islandType : ''}
+type HydrationStrategies = {
+ hydrateOnVisible?: IntersectionObserverInit | true
+ hydrateOnIdle?: number | true
+ hydrateOnInteraction?: keyof HTMLElementEventMap | Array | true
+ hydrateOnMediaQuery?: string
+ hydrateAfter?: number
+ hydrateWhen?: boolean
+ hydrateNever?: true
+}
+type LazyComponent = (T & DefineComponent void }>)
interface _GlobalComponents {
${componentTypes.map(([pascalName, type]) => ` '${pascalName}': ${type}`).join('\n')}
- ${componentTypes.map(([pascalName, type]) => ` 'Lazy${pascalName}': ${type}`).join('\n')}
+ ${componentTypes.map(([pascalName, type]) => ` 'Lazy${pascalName}': LazyComponent<${type}>`).join('\n')}
}
declare module 'vue' {
@@ -131,7 +140,7 @@ 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 Lazy${pascalName}: LazyComponent<${type}>`).join('\n')}
export const componentNames: string[]
`
diff --git a/packages/nuxt/test/component-loader.test.ts b/packages/nuxt/test/component-loader.test.ts
new file mode 100644
index 0000000000..c69525395e
--- /dev/null
+++ b/packages/nuxt/test/component-loader.test.ts
@@ -0,0 +1,172 @@
+import { describe, expect, it } from 'vitest'
+import { kebabCase, pascalCase } from 'scule'
+import { rollup } from 'rollup'
+import vuePlugin from '@vitejs/plugin-vue'
+import vuePluginJsx from '@vitejs/plugin-vue-jsx'
+import type { AddComponentOptions } from '@nuxt/kit'
+
+import { LoaderPlugin } from '../src/components/plugins/loader'
+import { LazyHydrationTransformPlugin } from '../src/components/plugins/lazy-hydration-transform'
+
+describe('components:loader', () => {
+ it('should correctly resolve components', async () => {
+ const sfc = `
+
+
+
+
+
+
+
+
+ `
+ const code = await transform(sfc, '/pages/index.vue')
+ expect(code).toMatchInlineSnapshot(`
+ "import __nuxt_component_0 from '../components/MyComponent.vue';
+ import { defineAsyncComponent, resolveComponent, createElementBlock, openBlock, Fragment, createVNode, unref } from 'vue';
+
+ const __nuxt_component_0_lazy = defineAsyncComponent(() => import('../components/MyComponent.vue').then(c => c.default || c));
+
+
+ const _sfc_main = {
+ __name: 'index',
+ setup(__props) {
+
+ const NamedComponent = __nuxt_component_0;
+
+ return (_ctx, _cache) => {
+ const _component_MyComponent = __nuxt_component_0;
+ const _component_LazyMyComponent = __nuxt_component_0_lazy;
+ const _component_RouterLink = resolveComponent("RouterLink");
+
+ return (openBlock(), createElementBlock(Fragment, null, [
+ createVNode(_component_MyComponent),
+ createVNode(_component_LazyMyComponent),
+ createVNode(_component_RouterLink),
+ createVNode(unref(NamedComponent))
+ ], 64 /* STABLE_FRAGMENT */))
+ }
+ }
+
+ };
+
+ export { _sfc_main as default };"
+ `)
+ })
+
+ it('should work in jsx', async () => {
+ const component = `
+ import { defineComponent } from 'vue'
+ export default defineComponent({
+ setup () {
+ const NamedComponent = resolveComponent('MyComponent')
+ return () =>
+
+
+
+
+
+ }
+ })
+ `
+ const code = await transform(component, '/pages/about.tsx')
+ expect(code).toMatchInlineSnapshot(`
+ "import __nuxt_component_0 from '../components/MyComponent.vue';
+ import { defineAsyncComponent, defineComponent, createVNode, resolveComponent } from 'vue';
+
+ const __nuxt_component_0_lazy = defineAsyncComponent(() => import('../components/MyComponent.vue').then(c => c.default || c));
+ var about = /* @__PURE__ */ defineComponent({
+ setup() {
+ const NamedComponent = __nuxt_component_0;
+ return () => createVNode("div", null, [createVNode(__nuxt_component_0, null, null), createVNode(__nuxt_component_0_lazy, null, null), createVNode(resolveComponent("RouterLink"), null, null), createVNode(NamedComponent, null, null)]);
+ }
+ });
+
+ export { about as default };"
+ `)
+ })
+
+ it('should correctly resolve lazy hydration components', async () => {
+ const sfc = `
+
+
+
+
+
+
+
+
+
+
+
+
+
+ `
+ const lines = await transform(sfc, '/pages/index.vue').then(r => r.split('\n'))
+ const imports = lines.filter(l => l.startsWith('import'))
+ expect(imports.join('\n')).toMatchInlineSnapshot(`
+ "import { createLazyIdleComponent, createLazyVisibleComponent, createLazyInteractionComponent, createLazyMediaQueryComponent, createLazyTimeComponent, createLazyIfComponent, createLazyNeverComponent } from '../client-runtime.mjs';
+ import { createElementBlock, openBlock, Fragment, createVNode, withCtx } from 'vue';"
+ `)
+ const components = lines.filter(l => l.startsWith('const __nuxt_component'))
+ expect(components.join('\n')).toMatchInlineSnapshot(`
+ "const __nuxt_component_0_lazy_idle = createLazyIdleComponent("components/MyComponent.vue", () => import('../components/MyComponent.vue').then(c => c.default || c));
+ const __nuxt_component_0_lazy_visible = createLazyVisibleComponent("components/MyComponent.vue", () => import('../components/MyComponent.vue').then(c => c.default || c));
+ const __nuxt_component_0_lazy_event = createLazyInteractionComponent("components/MyComponent.vue", () => import('../components/MyComponent.vue').then(c => c.default || c));
+ const __nuxt_component_0_lazy_media = createLazyMediaQueryComponent("components/MyComponent.vue", () => import('../components/MyComponent.vue').then(c => c.default || c));
+ const __nuxt_component_0_lazy_time = createLazyTimeComponent("components/MyComponent.vue", () => import('../components/MyComponent.vue').then(c => c.default || c));
+ const __nuxt_component_0_lazy_if = createLazyIfComponent("components/MyComponent.vue", () => import('../components/MyComponent.vue').then(c => c.default || c));
+ const __nuxt_component_0_lazy_never = createLazyNeverComponent("components/MyComponent.vue", () => import('../components/MyComponent.vue').then(c => c.default || c));"
+ `)
+ })
+})
+
+async function transform (code: string, filename: string) {
+ const components = ([{ name: 'MyComponent', filePath: '/components/MyComponent.vue' }] as AddComponentOptions[]).map(opts => ({
+ export: opts.export || 'default',
+ chunkName: 'components/' + kebabCase(opts.name),
+ global: opts.global ?? false,
+ kebabName: kebabCase(opts.name || ''),
+ pascalName: pascalCase(opts.name || ''),
+ prefetch: false,
+ preload: false,
+ mode: 'all' as const,
+ shortPath: opts.filePath,
+ priority: 0,
+ meta: {},
+ ...opts,
+ }))
+
+ const bundle = await rollup({
+ input: filename,
+ plugins: [
+ {
+ name: 'entry',
+ resolveId (id) {
+ if (id === filename) {
+ return id
+ }
+ },
+ load (id) {
+ if (id === filename) {
+ return code
+ }
+ },
+ },
+ LazyHydrationTransformPlugin({ getComponents: () => components }).rollup(),
+ vuePlugin(),
+ vuePluginJsx(),
+ LoaderPlugin({
+ clientDelayedComponentRuntime: '/client-runtime.mjs',
+ serverComponentRuntime: '/server-runtime.mjs',
+ getComponents: () => components,
+ srcDir: '/',
+ mode: 'server',
+ }).rollup(),
+ ],
+ })
+ const { output: [chunk] } = await bundle.generate({})
+ return chunk.code.trim()
+}
diff --git a/packages/schema/src/config/experimental.ts b/packages/schema/src/config/experimental.ts
index d7fa131968..ce3238a89d 100644
--- a/packages/schema/src/config/experimental.ts
+++ b/packages/schema/src/config/experimental.ts
@@ -478,5 +478,14 @@ export default defineResolvers({
return typeof val === 'boolean' ? val : Boolean(await get('debug'))
},
},
+
+ /**
+ * Enable automatic configuration of hydration strategies for `` components.
+ */
+ lazyHydration: {
+ $resolve: (val) => {
+ return typeof val === 'boolean' ? val : true
+ },
+ },
},
})
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index eb8ad607c2..b6349f4cda 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -537,6 +537,9 @@ importers:
'@vitejs/plugin-vue':
specifier: 5.2.1
version: 5.2.1(vite@6.2.0(@types/node@22.13.6)(jiti@2.4.2)(terser@5.32.0)(tsx@4.19.2)(yaml@2.7.0))(vue@3.5.13(typescript@5.8.2))
+ '@vitejs/plugin-vue-jsx':
+ specifier: ^4.1.1
+ version: 4.1.1(vite@6.2.0(@types/node@22.13.6)(jiti@2.4.2)(terser@5.32.0)(tsx@4.19.2)(yaml@2.7.0))(vue@3.5.13(typescript@5.8.2))
'@vue/compiler-sfc':
specifier: 3.5.13
version: 3.5.13
diff --git a/test/basic.test.ts b/test/basic.test.ts
index 5ac880798d..f75d4ac704 100644
--- a/test/basic.test.ts
+++ b/test/basic.test.ts
@@ -2860,6 +2860,112 @@ 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', { timeout: 20_000 }, async () => {
+ const { page } = await renderPage('/lazy-import-components')
+
+ const hydratedText = 'This is mounted.'
+ const unhydratedText = 'This is not mounted.'
+
+ expect.soft(html).toContain(unhydratedText)
+ expect.soft(html).not.toContain(hydratedText)
+
+ await page.locator('data-testid=hydrate-on-visible', { hasText: hydratedText }).waitFor()
+ expect.soft(await page.locator('data-testid=hydrate-on-visible-bottom').textContent().then(r => r?.trim())).toBe(unhydratedText)
+
+ await page.locator('data-testid=hydrate-on-interaction-default', { hasText: unhydratedText }).waitFor()
+ await page.locator('data-testid=hydrate-on-interaction-click', { hasText: unhydratedText }).waitFor()
+
+ await page.locator('data-testid=hydrate-when-always', { hasText: hydratedText }).waitFor()
+ await page.locator('data-testid=hydrate-when-state', { hasText: unhydratedText }).waitFor()
+
+ const component = page.getByTestId('hydrate-on-interaction-default')
+ await component.hover()
+ await page.locator('data-testid=hydrate-on-interaction-default', { hasText: hydratedText }).waitFor()
+
+ await page.getByTestId('button-increase-state').click()
+ await page.locator('data-testid=hydrate-when-state', { hasText: hydratedText }).waitFor()
+
+ await page.getByTestId('hydrate-on-visible-bottom').scrollIntoViewIfNeeded()
+ await page.locator('data-testid=hydrate-on-visible-bottom', { hasText: hydratedText }).waitFor()
+
+ await page.locator('data-testid=hydrate-never', { hasText: unhydratedText }).waitFor()
+
+ await page.close()
+ })
+ it('respects custom delayed hydration triggers and overrides defaults', async () => {
+ const { page } = await renderPage('/lazy-import-components')
+
+ const unhydratedText = 'This is not mounted.'
+ const hydratedText = 'This is mounted.'
+
+ await page.locator('data-testid=hydrate-on-interaction-click', { hasText: unhydratedText }).waitFor({ state: 'visible' })
+
+ await page.getByTestId('hydrate-on-interaction-click').hover()
+ await page.locator('data-testid=hydrate-on-interaction-click', { hasText: unhydratedText }).waitFor({ state: 'visible' })
+
+ await page.getByTestId('hydrate-on-interaction-click').click()
+ await page.locator('data-testid=hydrate-on-interaction-click', { hasText: hydratedText }).waitFor({ state: 'visible' })
+ await page.locator('data-testid=hydrate-on-interaction-click', { hasText: unhydratedText }).waitFor({ state: 'hidden' })
+
+ await page.close()
+ })
+
+ it('does not delay hydration of components named after modifiers', async () => {
+ const { page } = await renderPage('/lazy-import-components')
+
+ await page.locator('data-testid=event-view-normal-component', { hasText: 'This is mounted.' }).waitFor()
+ await page.locator('data-testid=event-view-normal-component', { hasText: 'This is not mounted.' }).waitFor({ state: 'hidden' })
+
+ await page.close()
+ })
+
+ it('handles time-based hydration correctly', async () => {
+ const { page } = await renderPage('/lazy-import-components/time')
+
+ const unhydratedText = 'This is not mounted.'
+ const hydratedText = 'This is mounted.'
+
+ await page.locator('[data-testid=hydrate-after]', { hasText: unhydratedText }).waitFor({ state: 'visible' })
+ await page.locator('[data-testid=hydrate-after]', { hasText: hydratedText }).waitFor({ state: 'hidden' })
+
+ await page.close()
+ })
+
+ it('keeps reactivity with models', async () => {
+ const { page } = await renderPage('/lazy-import-components/model-event')
+
+ const countLocator = page.getByTestId('count')
+ const incrementButton = page.getByTestId('increment')
+
+ await countLocator.waitFor()
+
+ for (let i = 0; i < 10; i++) {
+ expect(await countLocator.textContent()).toBe(`${i}`)
+ await incrementButton.hover()
+ await incrementButton.click()
+ }
+
+ expect(await countLocator.textContent()).toBe('10')
+
+ await page.close()
+ })
+
+ it('emits hydration events', async () => {
+ const { page, consoleLogs } = await renderPage('/lazy-import-components/model-event')
+
+ const initialLogs = consoleLogs.filter(log => log.type === 'log' && log.text === 'Component hydrated')
+ expect(initialLogs.length).toBe(0)
+
+ await page.getByTestId('count').click()
+
+ // Wait for all pending micro ticks to be cleared in case hydration hasn't finished yet.
+ await page.evaluate(() => new Promise(resolve => setTimeout(resolve, 10)))
+ const hydrationLogs = consoleLogs.filter(log => log.type === 'log' && log.text === 'Component hydrated')
+ expect(hydrationLogs.length).toBeGreaterThan(0)
+
+ await page.close()
+ })
})
describe('defineNuxtComponent', () => {
diff --git a/test/fixtures/basic-types/types.ts b/test/fixtures/basic-types/types.ts
index 4ed752a877..d5e22f82ff 100644
--- a/test/fixtures/basic-types/types.ts
+++ b/test/fixtures/basic-types/types.ts
@@ -12,7 +12,7 @@ import { defineNuxtConfig } from 'nuxt/config'
import { callWithNuxt, isVue3 } from '#app'
import type { NuxtError } from '#app'
import type { NavigateToOptions } from '#app/composables/router'
-import { NuxtLayout, NuxtLink, NuxtPage, ServerComponent, WithTypes } from '#components'
+import { LazyWithTypes, NuxtLayout, NuxtLink, NuxtPage, ServerComponent, WithTypes } from '#components'
import { useRouter } from '#imports'
type DefaultAsyncDataErrorValue = undefined
@@ -447,6 +447,14 @@ describe('components', () => {
// TODO: assert typed slots, exposed, generics, etc.
})
+ it('includes types for lazy hydration', () => {
+ h(LazyWithTypes)
+ h(LazyWithTypes, { hydrateAfter: 300 })
+ h(LazyWithTypes, { hydrateOnIdle: true })
+
+ // @ts-expect-error wrong prop type for this hydration strategy
+ h(LazyWithTypes, { hydrateAfter: '' })
+ })
it('include fallback slot in server components', () => {
expectTypeOf(ServerComponent.slots).toEqualTypeOf | undefined>()
})
diff --git a/test/fixtures/basic/components/DelayedComponent.vue b/test/fixtures/basic/components/DelayedComponent.vue
new file mode 100644
index 0000000000..1f18388f21
--- /dev/null
+++ b/test/fixtures/basic/components/DelayedComponent.vue
@@ -0,0 +1,16 @@
+
+
+ This {{ mounted ? 'is' : 'is not' }} mounted.
+
+
+
+
diff --git a/test/fixtures/basic/components/DelayedModel.vue b/test/fixtures/basic/components/DelayedModel.vue
new file mode 100644
index 0000000000..baf642c109
--- /dev/null
+++ b/test/fixtures/basic/components/DelayedModel.vue
@@ -0,0 +1,15 @@
+
+
+ {{ model }}
+
+ Increment
+
+
+
+
+
diff --git a/test/fixtures/basic/components/EventView.vue b/test/fixtures/basic/components/EventView.vue
new file mode 100644
index 0000000000..c3bd807298
--- /dev/null
+++ b/test/fixtures/basic/components/EventView.vue
@@ -0,0 +1,10 @@
+
+
+ This {{ mounted ? 'is' : 'is not' }} mounted.
+
+
+
+
diff --git a/test/fixtures/basic/pages/lazy-import-components/index.vue b/test/fixtures/basic/pages/lazy-import-components/index.vue
index 0f46fc9dfc..7f2aaaf16e 100644
--- a/test/fixtures/basic/pages/lazy-import-components/index.vue
+++ b/test/fixtures/basic/pages/lazy-import-components/index.vue
@@ -3,5 +3,61 @@
+ hydrate-on-interaction-default:
+
+ event-view-normal-component:
+
+ hydrate-on-visible:
+
+ hydrate-never:
+
+ hydrate-on-interaction-click:
+
+ hydrate-when-always:
+
+ button-increase-state:
+
+ Increase state
+
+ hydrate-when-state:
+
+ hydrate-on-idle:
+
+
+ This is a very tall div
+
+ hydrate-on-visible-bottom:
+