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,
},
},
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 }))
},
})
}

View File

@ -115,21 +115,22 @@ export const componentsTypeTemplate = {
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 delayedType = 'type DelayedComponent<T> = 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<unknown>}>`).join('\n')}
${componentTypes.map(([pascalName, type]) => ` 'LazyVisible${pascalName}': ${type} & DefineComponent<{hydrate?: Partial<IntersectionObserverInit>}>`).join('\n')}
${componentTypes.map(([pascalName, type]) => ` 'LazyEvent${pascalName}': ${type} & DefineComponent<{hydrate?: keyof HTMLElementEventMap | Array<keyof HTMLElementEventMap>}>`).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<number>`).join('\n')}
${componentTypes.map(([pascalName, type]) => ` 'LazyTime${pascalName}': ${type} & DelayedComponent<number>`).join('\n')}
${componentTypes.map(([pascalName, type]) => ` 'LazyPromise${pascalName}': ${type} & DelayedComponent<Promise<unknown>>`).join('\n')}
${componentTypes.map(([pascalName, type]) => ` 'LazyVisible${pascalName}': ${type} & DelayedComponent<IntersectionObserverInit>`).join('\n')}
${componentTypes.map(([pascalName, type]) => ` 'LazyEvent${pascalName}': ${type} & DelayedComponent<keyof HTMLElementEventMap | Array<keyof HTMLElementEventMap>>`).join('\n')}
${componentTypes.map(([pascalName, type]) => ` 'LazyMedia${pascalName}': ${type} & DelayedComponent<string>`).join('\n')}
${componentTypes.map(([pascalName, type]) => ` 'LazyIf${pascalName}': ${type} & DelayedComponent<unknown>`).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<unknown>}>`).join('\n')}
${componentTypes.map(([pascalName, type]) => `export const LazyVisible${pascalName}: ${type} & DefineComponent<{hydrate?: Partial<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 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<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} & DelayedComponent<Promise<unknown>>`).join('\n')}
${componentTypes.map(([pascalName, type]) => `export const LazyVisible${pascalName}: ${type} & DelayedComponent<IntersectionObserverInit>`).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} & DelayedComponent<string>`).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')}
export const componentNames: string[]

View File

@ -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,

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)
})
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', () => {

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>