feat: add hydration emit for callbacks

This commit is contained in:
tbitw2549 2024-09-14 21:31:16 +03:00
parent 373e223268
commit c0844b902e
6 changed files with 70 additions and 45 deletions

View File

@ -11,10 +11,12 @@ export const createLazyIOComponent = (loader: AsyncComponentLoader) => {
required: false, 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) }) 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. // 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, required: false,
}, },
}, },
setup (props, { attrs }) { emits: ['hydrated'],
setup (props, { attrs, emit }) {
const hydrated = () => { emit('hydrated') }
if (props.hydrate === 0) { if (props.hydrate === 0) {
const comp = defineAsyncComponent(loader) 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) }) 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. // 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', default: 'mouseover',
}, },
}, },
setup (props, { attrs }) { emits: ['hydrated'],
setup (props, { attrs, emit }) {
const hydrated = () => { emit('hydrated') }
// @ts-expect-error Cannot type HTMLElementEventMap in props // @ts-expect-error Cannot type HTMLElementEventMap in props
const comp = defineAsyncComponent({ loader, hydrate: hydrateOnInteraction(props.hydrate) }) 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. // 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)', 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) }) 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. // 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, default: true,
}, },
}, },
setup (props, { attrs }) { emits: ['hydrated'],
setup (props, { attrs, emit }) {
const hydrated = () => { emit('hydrated') }
if (props.hydrate) { if (props.hydrate) {
const comp = defineAsyncComponent(loader) 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. // 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 strategy: HydrationStrategy = (hydrate) => {
const unwatch = watch(() => props.hydrate, () => hydrate(), { once: true }) const unwatch = watch(() => props.hydrate, () => hydrate(), { once: true })
return () => unwatch() return () => unwatch()
} }
const comp = defineAsyncComponent({ loader, hydrate: strategy }) 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, default: 2000,
}, },
}, },
setup (props, { attrs }) { emits: ['hydrated'],
setup (props, { attrs, emit }) {
const hydrated = () => { emit('hydrated') }
if (props.hydrate === 0) { if (props.hydrate === 0) {
const comp = defineAsyncComponent(loader) 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 strategy: HydrationStrategy = (hydrate) => {
const id = setTimeout(hydrate, props.hydrate) const id = setTimeout(hydrate, props.hydrate)
@ -129,7 +141,7 @@ export const createLazyTimeComponent = (loader: AsyncComponentLoader) => {
} }
const comp = defineAsyncComponent({ loader, hydrate: strategy }) 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. // 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, required: false,
}, },
}, },
setup (props, { attrs }) { emits: ['hydrated'],
setup (props, { attrs, emit }) {
const hydrated = () => { emit('hydrated') }
if (!props.hydrate || typeof props.hydrate.then !== 'function') { if (!props.hydrate || typeof props.hydrate.then !== 'function') {
const comp = defineAsyncComponent(loader) 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. // 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 strategy: HydrationStrategy = (hydrate) => {
// @ts-expect-error TS does not see hydrate as non-null // @ts-expect-error TS does not see hydrate as non-null
@ -156,7 +170,7 @@ export const createLazyPromiseComponent = (loader: AsyncComponentLoader) => {
return () => {} return () => {}
} }
const comp = defineAsyncComponent({ loader, hydrate: strategy }) const comp = defineAsyncComponent({ loader, hydrate: strategy })
return () => h(comp, mergeProps(attrs, { 'data-allow-mismatch': '' })) return () => h(comp, mergeProps(attrs, { 'data-allow-mismatch': '', 'onVnodeMounted': hydrated }))
}, },
}) })
} }

View File

@ -115,21 +115,22 @@ export const componentsTypeTemplate = {
c.island || c.mode === 'server' ? `IslandComponent<${type}>` : type, c.island || c.mode === 'server' ? `IslandComponent<${type}>` : type,
] ]
}) })
const islandType = 'type IslandComponent<T extends DefineComponent> = T & DefineComponent<{}, {refresh: () => Promise<void>}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, SlotsType<{ fallback: { error: unknown } }>>' const islandType = 'type IslandComponent<T extends DefineComponent> = T & DefineComponent<{}, {refresh: () => Promise<void>}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, SlotsType<{ fallback: { error: unknown } }>>'
const delayedType = 'type DelayedComponent<T> = DefineComponent<{hydrate?: T},{},{},{},{},{},{},{hydrated: void}>'
return ` return `
import type { DefineComponent, SlotsType } from 'vue' import type { DefineComponent, SlotsType } from 'vue'
${nuxt.options.experimental.componentIslands ? islandType : ''} ${nuxt.options.experimental.componentIslands ? islandType : ''}
${nuxt.options.experimental.delayedHydration ? delayedType : ''}
interface _GlobalComponents { interface _GlobalComponents {
${componentTypes.map(([pascalName, type]) => ` '${pascalName}': ${type}`).join('\n')} ${componentTypes.map(([pascalName, type]) => ` '${pascalName}': ${type}`).join('\n')}
${componentTypes.map(([pascalName, type]) => ` 'Lazy${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]) => ` 'LazyIdle${pascalName}': ${type} & DelayedComponent<number>`).join('\n')}
${componentTypes.map(([pascalName, type]) => ` 'LazyTime${pascalName}': ${type} & DefineComponent<{hydrate?: number}>`).join('\n')} ${componentTypes.map(([pascalName, type]) => ` 'LazyTime${pascalName}': ${type} & DelayedComponent<number>`).join('\n')}
${componentTypes.map(([pascalName, type]) => ` 'LazyPromise${pascalName}': ${type} & DefineComponent<{hydrate?: Promise<unknown>}>`).join('\n')} ${componentTypes.map(([pascalName, type]) => ` 'LazyPromise${pascalName}': ${type} & DelayedComponent<Promise<unknown>>`).join('\n')}
${componentTypes.map(([pascalName, type]) => ` 'LazyVisible${pascalName}': ${type} & DefineComponent<{hydrate?: Partial<IntersectionObserverInit>}>`).join('\n')} ${componentTypes.map(([pascalName, type]) => ` 'LazyVisible${pascalName}': ${type} & DelayedComponent<IntersectionObserverInit>`).join('\n')}
${componentTypes.map(([pascalName, type]) => ` 'LazyEvent${pascalName}': ${type} & DefineComponent<{hydrate?: keyof HTMLElementEventMap | Array<keyof HTMLElementEventMap>}>`).join('\n')} ${componentTypes.map(([pascalName, type]) => ` 'LazyEvent${pascalName}': ${type} & DelayedComponent<keyof HTMLElementEventMap | Array<keyof HTMLElementEventMap>>`).join('\n')}
${componentTypes.map(([pascalName, type]) => ` 'LazyMedia${pascalName}': ${type} & DefineComponent<{hydrate?: string}>`).join('\n')} ${componentTypes.map(([pascalName, type]) => ` 'LazyMedia${pascalName}': ${type} & DelayedComponent<string>`).join('\n')}
${componentTypes.map(([pascalName, type]) => ` 'LazyIf${pascalName}': ${type} & DefineComponent<{hydrate?: unknown}>`).join('\n')} ${componentTypes.map(([pascalName, type]) => ` 'LazyIf${pascalName}': ${type} & DelayedComponent<unknown>`).join('\n')}
${componentTypes.map(([pascalName, type]) => ` 'LazyNever${pascalName}': ${type}`).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 ${pascalName}: ${type}`).join('\n')}
${componentTypes.map(([pascalName, type]) => `export const Lazy${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 LazyIdle${pascalName}: ${type} & DelayedComponent<number>`).join('\n')}
${componentTypes.map(([pascalName, type]) => `export const LazyTime${pascalName}: ${type} & DefineComponent<{hydrate?: number}>`).join('\n')} ${componentTypes.map(([pascalName, type]) => `export const LazyTime${pascalName}: ${type} & DelayedComponent<number>`).join('\n')}
${componentTypes.map(([pascalName, type]) => `export const LazyPromise${pascalName}: ${type} & DefineComponent<{hydrate?: Promise<unknown>}>`).join('\n')} ${componentTypes.map(([pascalName, type]) => `export const LazyPromise${pascalName}: ${type} & DelayedComponent<Promise<unknown>>`).join('\n')}
${componentTypes.map(([pascalName, type]) => `export const LazyVisible${pascalName}: ${type} & DefineComponent<{hydrate?: Partial<IntersectionObserverInit>}>`).join('\n')} ${componentTypes.map(([pascalName, type]) => `export const LazyVisible${pascalName}: ${type} & DelayedComponent<IntersectionObserverInit>`).join('\n')}
${componentTypes.map(([pascalName, type]) => `export const LazyEvent${pascalName}: ${type} & DefineComponent<{hydrate?: keyof HTMLElementEventMap | Array<keyof HTMLElementEventMap>}>`).join('\n')} ${componentTypes.map(([pascalName, type]) => `export const LazyEvent${pascalName}: ${type} & DelayedComponent<keyof HTMLElementEventMap | Array<keyof HTMLElementEventMap>>`).join('\n')}
${componentTypes.map(([pascalName, type]) => `export const LazyMedia${pascalName}: ${type} & DefineComponent<{hydrate?: string}>`).join('\n')} ${componentTypes.map(([pascalName, type]) => `export const LazyMedia${pascalName}: ${type} & DelayedComponent<string>`).join('\n')}
${componentTypes.map(([pascalName, type]) => `export const LazyIf${pascalName}: ${type} & DefineComponent<{hydrate?: unknown}>`).join('\n')} ${componentTypes.map(([pascalName, type]) => `export const LazyIf${pascalName}: ${type} & DelayedComponent<unknown>`).join('\n')}
${componentTypes.map(([pascalName, type]) => `export const LazyNever${pascalName}: ${type}`).join('\n')} ${componentTypes.map(([pascalName, type]) => `export const LazyNever${pascalName}: ${type}`).join('\n')}
export const componentNames: string[] export const componentNames: string[]

View File

@ -219,7 +219,6 @@ export default defineUntypedSchema({
/** /**
* Delayed component hydration * Delayed component hydration
* *
* This enables components to defer hydration until necessary, improving performance by not requesting all resources at once.
* @type {boolean} * @type {boolean}
*/ */
delayedHydration: false, delayedHydration: false,

View File

@ -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) expect(await page.locator('body').getByText('This should be visible at first with promise!').all()).toHaveLength(0)
}) })
it('keeps reactivity with models', async () => { 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') expect(await page.locator('#count').textContent()).toBe('0')
await page.locator('#count').click()
for (let i = 0; i < 10; i++) { for (let i = 0; i < 10; i++) {
expect(await page.locator('#count').textContent()).toBe(`${i}`) expect(await page.locator('#count').textContent()).toBe(`${i}`)
await page.locator('#inc').click() await page.locator('#inc').click()
} }
expect(await page.locator('#count').textContent()).toBe('10') 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', () => { describe('defineNuxtComponent watch duplicate', () => {

View File

@ -0,0 +1,15 @@
<template>
<div>
<LazyEventDelayedModel
v-model="model"
@hydrated="log"
/>
</div>
</template>
<script setup lang="ts">
const model = ref(0)
function log () {
console.log('Component hydrated')
}
</script>

View File

@ -1,9 +0,0 @@
<template>
<div>
<LazyEventDelayedModel v-model="model" />
</div>
</template>
<script setup lang="ts">
const model = ref(0)
</script>