feat: support condition, media query, and never

This adds 3 more types of hydration to cover most of the use cases.
This commit is contained in:
tbitw2549 2024-08-25 13:53:29 +03:00
parent 7fc29e1a56
commit e304d6c367
9 changed files with 144 additions and 14 deletions

View File

@ -165,6 +165,36 @@ If you would like the component to load after certain events occur, like a click
</template>
```
If you would like to load the component when the window matches a media query, you can use the `LazyMedia` prefix:
```vue [pages/index.vue]
<template>
<div>
<LazyMediaMyComponent />
</div>
</template>
```
For general purpose conditional hydration, you can use the `LazyIf` prefix:
```vue [pages/index.vue]
<template>
<div>
<LazyIfMyComponent />
</div>
</template>
```
If you would like to never hydrate a component, use the `LazyNever` prefix:
```vue [pages/index.vue]
<template>
<div>
<LazyNeverMyComponent />
</div>
</template>
```
### Custom hydration triggers
If you would like to override the default hydration triggers when dealing with delayed hydration, like changing the timeout, the options for the intersection observer, or the events to trigger the hydration, you can do so by supplying a `hydrate` prop to your lazy components.
@ -175,6 +205,8 @@ If you would like to override the default hydration triggers when dealing with d
<LazyIdleMyComponent :hydrate="createIdleLoader(3000)" />
<LazyVisibleMyComponent :hydrate="createVisibleLoader({threshold: 0.2})" />
<LazyEventMyComponent :hydrate="createEventLoader(['click','mouseover'])" />
<LazyMediaMyComponent hydrate="(max-width: 500px)" />
<LazyIfMyComponent :hydrate="someCondition" />
</div>
</template>
```
@ -188,6 +220,10 @@ If you would like to override the default hydration triggers when dealing with d
::read-more{to="/docs/api/utils/create-event-loader"}
::
::read-more{to="https://developer.mozilla.org/en-US/docs/Web/API/Window/matchMedia"}
Read more about using `LazyMedia` components and the accepted values.
::
::important
Nuxt will respect your component names, which means even if your components begin with a reserved prefix like Visible/Idle/Event they will not have delayed hydration. This is made to ensure you have full control over all your components and prevent breaking dynamic imports for those components. This also means you would need to explicitly add the prefix to those components. For example, if you have a component named `IdleBar`, you would need to use it like `<LazyIdleIdleBar>` and not `<LazyIdleBar>` to make it a delayed hydration component, otherwise it would be treated as a regular [dynamic import](/docs/guide/directory-structure/components#dynamic-imports)
::

View File

@ -43,7 +43,7 @@ export const loaderPlugin = createUnplugin((options: LoaderOptions) => {
const s = new MagicString(code)
const nuxt = tryUseNuxt()
// replace `_resolveComponent("...")` to direct import
s.replace(/(?<=[ (])_?resolveComponent\(\s*["'](lazy-|Lazy(?=[A-Z]))?(Idle|Visible|idle-|visible-|Event|event-)?([^'"]*)["'][^)]*\)/g, (full: string, lazy: string, modifier: string, name: string) => {
s.replace(/(?<=[ (])_?resolveComponent\(\s*["'](lazy-|Lazy(?=[A-Z]))?(Idle|Visible|idle-|visible-|Event|event-|Media|media-|If|if-|Never|never-)?([^'"]*)["'][^)]*\)/g, (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
@ -75,31 +75,50 @@ export const loaderPlugin = createUnplugin((options: LoaderOptions) => {
}
if (lazy) {
const dynamicImport = `${genDynamicImport(component.filePath, { interopDefault: false })}.then(c => c.${component.export ?? 'default'} || c)`
if (modifier && normalComponent && nuxt?.options.experimental.delayedHydration === true) {
switch (modifier) {
case 'Visible':
case 'visible-':
imports.add(genImport(clientDelayedComponentRuntime, [{ name: 'createLazyIOComponent' }]))
identifier += '_delayedIO'
imports.add(`const ${identifier} = createLazyIOComponent(${genDynamicImport(component.filePath, { interopDefault: false })}.then(c => c.${component.export ?? 'default'} || c))`)
imports.add(`const ${identifier} = createLazyIOComponent(${dynamicImport})`)
break
case 'Event':
case 'event-':
imports.add(genImport(clientDelayedComponentRuntime, [{ name: 'createLazyEventComponent' }]))
identifier += '_delayedEvent'
imports.add(`const ${identifier} = createLazyEventComponent(${genDynamicImport(component.filePath, { interopDefault: false })}.then(c => c.${component.export ?? 'default'} || c))`)
imports.add(`const ${identifier} = createLazyEventComponent(${dynamicImport})`)
break
case 'Idle':
case 'idle-':
imports.add(genImport(clientDelayedComponentRuntime, [{ name: 'createLazyNetworkComponent' }]))
identifier += '_delayedNetwork'
imports.add(`const ${identifier} = createLazyNetworkComponent(${genDynamicImport(component.filePath, { interopDefault: false })}.then(c => c.${component.export ?? 'default'} || c))`)
imports.add(`const ${identifier} = createLazyNetworkComponent(${dynamicImport})`)
break
case 'Media':
case 'media-':
imports.add(genImport(clientDelayedComponentRuntime, [{ name: 'createLazyMediaComponent' }]))
identifier += '_delayedMedia'
imports.add(`const ${identifier} = createLazyMediaComponent(${dynamicImport})`)
break
case 'If':
case 'if-':
imports.add(genImport(clientDelayedComponentRuntime, [{ name: 'createLazyIfComponent' }]))
identifier += '_delayedIf'
imports.add(`const ${identifier} = createLazyIfComponent(${dynamicImport})`)
break
case 'Never':
case 'never-':
imports.add(genImport('vue', [{ name: 'defineAsyncComponent', as: '__defineAsyncComponent' }]))
identifier += '_delayedNever'
imports.add(`const ${identifier} = __defineAsyncComponent({loader: ${dynamicImport}, hydrate: () => {})`)
break
}
} else {
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))' : ''})`)
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 }]))

View File

@ -1,4 +1,4 @@
import { defineAsyncComponent, defineComponent, getCurrentInstance, h, hydrateOnIdle, hydrateOnInteraction, hydrateOnVisible } from 'vue'
import { defineAsyncComponent, defineComponent, getCurrentInstance, h, hydrateOnIdle, hydrateOnInteraction, hydrateOnMediaQuery, hydrateOnVisible, ref, watch } from 'vue'
import type { AsyncComponentLoader, HydrationStrategy } from 'vue'
import { useNuxtApp } from '#app/nuxt'
@ -64,3 +64,38 @@ export const createLazyEventComponent = (componentLoader: AsyncComponentLoader)
},
})
}
/* @__NO_SIDE_EFFECTS__ */
export const createLazyMediaComponent = (componentLoader: AsyncComponentLoader) => {
return defineComponent({
inheritAttrs: false,
setup (_, { attrs }) {
if (import.meta.server) {
return () => h(defineAsyncComponent(componentLoader), attrs)
}
return () => h(delayedHydrationComponent(componentLoader, hydrateOnMediaQuery(attrs.hydrate as string | undefined)))
},
})
}
/* @__NO_SIDE_EFFECTS__ */
export const createLazyIfComponent = (componentLoader: AsyncComponentLoader) => {
return defineComponent({
inheritAttrs: false,
setup (_, { attrs }) {
if (import.meta.server) {
return () => h(defineAsyncComponent(componentLoader), attrs)
}
const shouldHydrate = ref(!!(attrs.hydrate ?? true))
if (shouldHydrate.value) {
return () => h(defineAsyncComponent(componentLoader), attrs)
}
const strategy: HydrationStrategy = (hydrate) => {
const unwatch = watch(shouldHydrate, () => hydrate(), { once: true })
return () => unwatch()
}
return () => h(delayedHydrationComponent(componentLoader, strategy))
},
})
}

View File

@ -2697,21 +2697,27 @@ describe('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)
expect(await page.locator('body').getByText('This should be visible at first with events!').all()).toHaveLength(2)
const component = await page.locator('#lazyevent')
// The default value is immediately truthy
expect(await page.locator('body').getByText('This shouldn\'t be visible at first with conditions!').all()).toHaveLength(1)
const component = page.locator('#lazyevent')
const rect = (await component.boundingBox())!
await page.mouse.move(rect.x + rect.width / 2, rect.y + rect.height / 2)
await page.waitForLoadState('networkidle')
expect(await page.locator('body').getByText('This shouldn\'t be visible at first with events!').all()).toHaveLength(1)
await page.locator('#conditionbutton').click()
await page.waitForLoadState('networkidle')
expect(await page.locator('body').getByText('This shouldn\'t be visible at first with conditions!').all()).toHaveLength(2)
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)
expect(await page.locator('body').getByText('This should always be visible!').all()).toHaveLength(1)
await page.close()
})
it('respects custom delayed hydration triggers and overrides defaults', async () => {
const { page } = await renderPage('/lazy-import-components')
await page.waitForLoadState('networkidle')
const component = await page.locator('#lazyevent2')
const component = page.locator('#lazyevent2')
const rect = (await component.boundingBox())!
await page.mouse.move(rect.x + rect.width / 2, rect.y + rect.height / 2)
await page.waitForTimeout(500)
@ -2723,9 +2729,9 @@ describe('lazy import components', () => {
expect(await page.locator('body').getByText('This shouldn\'t be visible at first with events!').all()).toHaveLength(1)
await page.close()
})
it('does not delay hydration of components named after modifiers', async () => {
const { page } = await renderPage('/lazy-import-components')
await page.waitForFunction(() => window.useNuxtApp?.() && !window.useNuxtApp?.().isHydrating)
it('does not delay hydration of components named after modifiers', () => {
expect(html).toContain('This fake lazy event should be visible!')
expect(html).not.toContain('This fake lazy event shouldn\'t be visible!')
})
})

View File

@ -0,0 +1,5 @@
<template>
<div>
This shouldn't be visible at first with conditions!
</div>
</template>

View File

@ -0,0 +1,5 @@
<template>
<div>
This should be visible at first with conditions!
</div>
</template>

View File

@ -0,0 +1,5 @@
<template>
<div>
This should never be visible!
</div>
</template>

View File

@ -0,0 +1,5 @@
<template>
<div>
This should always be visible!
</div>
</template>

View File

@ -5,10 +5,20 @@
<LazyNCompServer message="lazy-named-comp-server" />
<LazyEventDelayedEvent id="lazyevent" />
<LazyEventView />
<LazyNeverDelayedNever />
<LazyEventDelayedEvent
id="lazyevent2"
:hydrate="createEventLoader(['click'])"
/>
<LazyIfDelayedCondition id="lazycondition" />
<button
id="conditionbutton"
@click="state++"
/>
<LazyIfDelayedCondition
id="lazycondition2"
:hydrate="state > 1"
/>
<LazyIdleDelayedNetwork />
<div style="height:3000px">
This is a very tall div
@ -16,3 +26,7 @@
<LazyVisibleDelayedVisible />
</div>
</template>
<script setup lang="ts">
const state = useState('delayedHydrationCondition', () => 1)
</script>