diff --git a/packages/nuxt/src/components/runtime/client-delayed-component.ts b/packages/nuxt/src/components/runtime/client-delayed-component.ts index 56f3f16b96..ad9ed0518d 100644 --- a/packages/nuxt/src/components/runtime/client-delayed-component.ts +++ b/packages/nuxt/src/components/runtime/client-delayed-component.ts @@ -11,10 +11,12 @@ export const createLazyIOComponent = (loader: AsyncComponentLoader) => { required: false, }, }, - setup (props, { attrs }) { + 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': '' })) + return () => h(comp, mergeProps(attrs, { 'data-allow-mismatch': '', 'onVnodeMounted': hydrated })) }, }) } @@ -29,14 +31,16 @@ export const createLazyNetworkComponent = (loader: AsyncComponentLoader) => { required: false, }, }, - setup (props, { attrs }) { + 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': '' })) + 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': '' })) + return () => h(comp, mergeProps(attrs, { 'data-allow-mismatch': '', 'onVnodeMounted': hydrated })) }, }) } @@ -52,11 +56,13 @@ export const createLazyEventComponent = (loader: AsyncComponentLoader) => { default: 'mouseover', }, }, - setup (props, { attrs }) { + 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': '' })) + return () => h(comp, mergeProps(attrs, { 'data-allow-mismatch': '', 'onVnodeMounted': hydrated })) }, }) } @@ -72,10 +78,12 @@ export const createLazyMediaComponent = (loader: AsyncComponentLoader) => { default: '(min-width: 1px)', }, }, - setup (props, { attrs }) { + 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': '' })) + return () => h(comp, mergeProps(attrs, { 'data-allow-mismatch': '', 'onVnodeMounted': hydrated })) }, }) } @@ -91,18 +99,20 @@ export const createLazyIfComponent = (loader: AsyncComponentLoader) => { default: true, }, }, - setup (props, { attrs }) { + 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': '' })) + 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': '' })) + return () => h(comp, mergeProps(attrs, { 'data-allow-mismatch': '', 'onVnodeMounted': hydrated })) }, }) } @@ -118,10 +128,12 @@ export const createLazyTimeComponent = (loader: AsyncComponentLoader) => { default: 2000, }, }, - setup (props, { attrs }) { + 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': '' })) + return () => h(comp, mergeProps(attrs, { 'data-allow-mismatch': '', 'onVnodeMounted': hydrated })) } const strategy: HydrationStrategy = (hydrate) => { const id = setTimeout(hydrate, props.hydrate) @@ -129,7 +141,7 @@ export const createLazyTimeComponent = (loader: AsyncComponentLoader) => { } 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': '' })) + return () => h(comp, mergeProps(attrs, { 'data-allow-mismatch': '', 'onVnodeMounted': hydrated })) }, }) } @@ -144,11 +156,13 @@ export const createLazyPromiseComponent = (loader: AsyncComponentLoader) => { required: false, }, }, - setup (props, { attrs }) { + emits: ['hydrated'], + setup (props, { attrs, emit }) { + const hydrated = () => { emit('hydrated') } if (!props.hydrate || typeof props.hydrate.then !== 'function') { 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': '' })) + return () => h(comp, mergeProps(attrs, { 'data-allow-mismatch': '', 'onVnodeMounted': hydrated })) } const strategy: HydrationStrategy = (hydrate) => { // @ts-expect-error TS does not see hydrate as non-null @@ -156,7 +170,7 @@ export const createLazyPromiseComponent = (loader: AsyncComponentLoader) => { return () => {} } const comp = defineAsyncComponent({ loader, hydrate: strategy }) - return () => h(comp, mergeProps(attrs, { 'data-allow-mismatch': '' })) + return () => h(comp, mergeProps(attrs, { 'data-allow-mismatch': '', 'onVnodeMounted': hydrated })) }, }) } diff --git a/packages/nuxt/src/components/templates.ts b/packages/nuxt/src/components/templates.ts index d4900463e8..8a2ce659a1 100644 --- a/packages/nuxt/src/components/templates.ts +++ b/packages/nuxt/src/components/templates.ts @@ -115,21 +115,22 @@ export const componentsTypeTemplate = { c.island || c.mode === 'server' ? `IslandComponent<${type}>` : type, ] }) - const islandType = 'type IslandComponent = T & DefineComponent<{}, {refresh: () => Promise}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, SlotsType<{ fallback: { error: unknown } }>>' + const delayedType = 'type DelayedComponent = 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} & DefineComponent<{hydrate?: number}>`).join('\n')} - ${componentTypes.map(([pascalName, type]) => ` 'LazyTime${pascalName}': ${type} & DefineComponent<{hydrate?: number}>`).join('\n')} - ${componentTypes.map(([pascalName, type]) => ` 'LazyPromise${pascalName}': ${type} & DefineComponent<{hydrate?: Promise}>`).join('\n')} - ${componentTypes.map(([pascalName, type]) => ` 'LazyVisible${pascalName}': ${type} & DefineComponent<{hydrate?: Partial}>`).join('\n')} - ${componentTypes.map(([pascalName, type]) => ` 'LazyEvent${pascalName}': ${type} & DefineComponent<{hydrate?: keyof HTMLElementEventMap | Array}>`).join('\n')} - ${componentTypes.map(([pascalName, type]) => ` 'LazyMedia${pascalName}': ${type} & DefineComponent<{hydrate?: string}>`).join('\n')} - ${componentTypes.map(([pascalName, type]) => ` 'LazyIf${pascalName}': ${type} & DefineComponent<{hydrate?: unknown}>`).join('\n')} + ${componentTypes.map(([pascalName, type]) => ` 'LazyIdle${pascalName}': ${type} & DelayedComponent`).join('\n')} + ${componentTypes.map(([pascalName, type]) => ` 'LazyTime${pascalName}': ${type} & DelayedComponent`).join('\n')} + ${componentTypes.map(([pascalName, type]) => ` 'LazyPromise${pascalName}': ${type} & DelayedComponent>`).join('\n')} + ${componentTypes.map(([pascalName, type]) => ` 'LazyVisible${pascalName}': ${type} & DelayedComponent`).join('\n')} + ${componentTypes.map(([pascalName, type]) => ` 'LazyEvent${pascalName}': ${type} & DelayedComponent>`).join('\n')} + ${componentTypes.map(([pascalName, type]) => ` 'LazyMedia${pascalName}': ${type} & DelayedComponent`).join('\n')} + ${componentTypes.map(([pascalName, type]) => ` 'LazyIf${pascalName}': ${type} & DelayedComponent`).join('\n')} ${componentTypes.map(([pascalName, type]) => ` 'LazyNever${pascalName}': ${type}`).join('\n')} } @@ -139,13 +140,13 @@ 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} & DefineComponent<{hydrate?: number}>`).join('\n')} -${componentTypes.map(([pascalName, type]) => `export const LazyTime${pascalName}: ${type} & DefineComponent<{hydrate?: number}>`).join('\n')} -${componentTypes.map(([pascalName, type]) => `export const LazyPromise${pascalName}: ${type} & DefineComponent<{hydrate?: Promise}>`).join('\n')} -${componentTypes.map(([pascalName, type]) => `export const LazyVisible${pascalName}: ${type} & DefineComponent<{hydrate?: Partial}>`).join('\n')} -${componentTypes.map(([pascalName, type]) => `export const LazyEvent${pascalName}: ${type} & DefineComponent<{hydrate?: keyof HTMLElementEventMap | Array}>`).join('\n')} -${componentTypes.map(([pascalName, type]) => `export const LazyMedia${pascalName}: ${type} & DefineComponent<{hydrate?: string}>`).join('\n')} -${componentTypes.map(([pascalName, type]) => `export const LazyIf${pascalName}: ${type} & DefineComponent<{hydrate?: unknown}>`).join('\n')} +${componentTypes.map(([pascalName, type]) => `export const LazyIdle${pascalName}: ${type} & DelayedComponent`).join('\n')} +${componentTypes.map(([pascalName, type]) => `export const LazyTime${pascalName}: ${type} & DelayedComponent`).join('\n')} +${componentTypes.map(([pascalName, type]) => `export const LazyPromise${pascalName}: ${type} & DelayedComponent>`).join('\n')} +${componentTypes.map(([pascalName, type]) => `export const LazyVisible${pascalName}: ${type} & DelayedComponent`).join('\n')} +${componentTypes.map(([pascalName, type]) => `export const LazyEvent${pascalName}: ${type} & DelayedComponent>`).join('\n')} +${componentTypes.map(([pascalName, type]) => `export const LazyMedia${pascalName}: ${type} & DelayedComponent`).join('\n')} +${componentTypes.map(([pascalName, type]) => `export const LazyIf${pascalName}: ${type} & DelayedComponent`).join('\n')} ${componentTypes.map(([pascalName, type]) => `export const LazyNever${pascalName}: ${type}`).join('\n')} export const componentNames: string[] diff --git a/packages/schema/src/config/experimental.ts b/packages/schema/src/config/experimental.ts index b91b0429e2..33b89e40e9 100644 --- a/packages/schema/src/config/experimental.ts +++ b/packages/schema/src/config/experimental.ts @@ -219,7 +219,6 @@ export default defineUntypedSchema({ /** * Delayed component hydration * - * This enables components to defer hydration until necessary, improving performance by not requesting all resources at once. * @type {boolean} */ delayedHydration: false, diff --git a/test/basic.test.ts b/test/basic.test.ts index fb82ed4edd..a87774708c 100644 --- a/test/basic.test.ts +++ b/test/basic.test.ts @@ -2840,15 +2840,20 @@ describe('lazy import components', () => { 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') + const { page } = await renderPage('/lazy-import-components/model-event') expect(await page.locator('#count').textContent()).toBe('0') - await page.locator('#count').click() 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', () => { 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..ad24eb76f3 --- /dev/null +++ b/test/fixtures/basic/pages/lazy-import-components/model-event.vue @@ -0,0 +1,15 @@ + + + diff --git a/test/fixtures/basic/pages/lazy-import-components/model.vue b/test/fixtures/basic/pages/lazy-import-components/model.vue deleted file mode 100644 index 16cd3e5137..0000000000 --- a/test/fixtures/basic/pages/lazy-import-components/model.vue +++ /dev/null @@ -1,9 +0,0 @@ - - -