mirror of
https://github.com/nuxt/nuxt.git
synced 2024-11-22 21:55:11 +00:00
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:
parent
7fc29e1a56
commit
e304d6c367
@ -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)
|
||||
::
|
||||
|
@ -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 }]))
|
||||
|
@ -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))
|
||||
},
|
||||
})
|
||||
}
|
@ -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!')
|
||||
})
|
||||
})
|
||||
|
||||
|
5
test/fixtures/basic/components/DelayedCondition.client.vue
vendored
Normal file
5
test/fixtures/basic/components/DelayedCondition.client.vue
vendored
Normal file
@ -0,0 +1,5 @@
|
||||
<template>
|
||||
<div>
|
||||
This shouldn't be visible at first with conditions!
|
||||
</div>
|
||||
</template>
|
5
test/fixtures/basic/components/DelayedCondition.server.vue
vendored
Normal file
5
test/fixtures/basic/components/DelayedCondition.server.vue
vendored
Normal file
@ -0,0 +1,5 @@
|
||||
<template>
|
||||
<div>
|
||||
This should be visible at first with conditions!
|
||||
</div>
|
||||
</template>
|
5
test/fixtures/basic/components/DelayedNever.client.vue
vendored
Normal file
5
test/fixtures/basic/components/DelayedNever.client.vue
vendored
Normal file
@ -0,0 +1,5 @@
|
||||
<template>
|
||||
<div>
|
||||
This should never be visible!
|
||||
</div>
|
||||
</template>
|
5
test/fixtures/basic/components/DelayedNever.server.vue
vendored
Normal file
5
test/fixtures/basic/components/DelayedNever.server.vue
vendored
Normal file
@ -0,0 +1,5 @@
|
||||
<template>
|
||||
<div>
|
||||
This should always be visible!
|
||||
</div>
|
||||
</template>
|
@ -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>
|
||||
|
Loading…
Reference in New Issue
Block a user