mirror of
https://github.com/nuxt/nuxt.git
synced 2025-03-16 22:01:42 +00:00
feat(nuxt): delayed/lazy hydration support (#26468)
Co-authored-by: Daniel Roe <daniel@roe.dev>
This commit is contained in:
parent
e0f40cfc22
commit
281a931a0a
@ -123,6 +123,172 @@ const show = ref(false)
|
||||
</template>
|
||||
```
|
||||
|
||||
## Delayed (or Lazy) Hydration
|
||||
|
||||
Lazy components are great for controlling the chunk sizes in your app, but they don't always enhance runtime performance, as they still load eagerly unless conditionally rendered. In real-world applications, some pages may include a lot of content and a lot of components, and most of the time not all of them need to be interactive as soon as the page is loaded. Having them all load eagerly can negatively impact performance.
|
||||
|
||||
In order to optimize your app, you may want to delay the hydration of some components until they're visible, or until the browser is done with more important tasks.
|
||||
|
||||
Nuxt supports this using lazy (or delayed) hydration, allowing you to control when components become interactive.
|
||||
|
||||
### Hydration Strategies
|
||||
|
||||
Nuxt provides a range of built-in hydration strategies. Only one strategy can be used per lazy component.
|
||||
|
||||
::warning
|
||||
Currently Nuxt's built-in lazy hydration only works in single-file components (SFCs), and requires you to define the prop in the template (rather than spreading an object of props via `v-bind`). It also does not work with direct imports from `#components`.
|
||||
::
|
||||
|
||||
#### `hydrate-on-visible`
|
||||
|
||||
Hydrates the component when it becomes visible in the viewport.
|
||||
|
||||
```vue [pages/index.vue]
|
||||
<template>
|
||||
<div>
|
||||
<LazyMyComponent hydrate-on-visible />
|
||||
</div>
|
||||
</template>
|
||||
```
|
||||
|
||||
::read-more{to="https://developer.mozilla.org/en-US/docs/Web/API/IntersectionObserver/IntersectionObserver" title="IntersectionObserver options"}
|
||||
Read more about the options for `hydrate-on-visible`.
|
||||
::
|
||||
|
||||
::note
|
||||
Under the hood, this uses Vue's built-in [`hydrateOnVisible` strategy](https://vuejs.org/guide/components/async.html#hydrate-on-visible).
|
||||
::
|
||||
|
||||
#### `hydrate-on-idle`
|
||||
|
||||
Hydrates the component when the browser is idle. This is suitable if you need the component to load as soon as possible, but not block the critical rendering path.
|
||||
|
||||
You can also pass a number which serves as a max timeout.
|
||||
|
||||
```vue [pages/index.vue]
|
||||
<template>
|
||||
<div>
|
||||
<LazyMyComponent hydrate-on-idle />
|
||||
</div>
|
||||
</template>
|
||||
```
|
||||
|
||||
::note
|
||||
Under the hood, this uses Vue's built-in [`hydrateOnIdle` strategy](https://vuejs.org/guide/components/async.html#hydrate-on-idle).
|
||||
::
|
||||
|
||||
#### `hydrate-on-interaction`
|
||||
|
||||
Hydrates the component after a specified interaction (e.g., click, mouseover).
|
||||
|
||||
```vue [pages/index.vue]
|
||||
<template>
|
||||
<div>
|
||||
<LazyMyComponent hydrate-on-interaction="mouseover" />
|
||||
</div>
|
||||
</template>
|
||||
```
|
||||
|
||||
If you do not pass an event or list of events, it defaults to hydrating on `pointerenter` and `focus`.
|
||||
|
||||
::note
|
||||
Under the hood, this uses Vue's built-in [`hydrateOnInteraction` strategy](https://vuejs.org/guide/components/async.html#hydrate-on-interaction).
|
||||
::
|
||||
|
||||
#### `hydrate-on-media-query`
|
||||
|
||||
Hydrates the component when the window matches a media query.
|
||||
|
||||
```vue [pages/index.vue]
|
||||
<template>
|
||||
<div>
|
||||
<LazyMyComponent hydrate-on-media-query="(max-width: 768px)" />
|
||||
</div>
|
||||
</template>
|
||||
```
|
||||
|
||||
::note
|
||||
Under the hood, this uses Vue's built-in [`hydrateOnMediaQuery` strategy](https://vuejs.org/guide/components/async.html#hydrate-on-media-query).
|
||||
::
|
||||
|
||||
#### `hydrate-after`
|
||||
|
||||
Hydrates the component after a specified delay (in milliseconds).
|
||||
|
||||
```vue [pages/index.vue]
|
||||
<template>
|
||||
<div>
|
||||
<LazyMyComponent :hydrate-after="2000" />
|
||||
</div>
|
||||
</template>
|
||||
```
|
||||
|
||||
#### `hydrate-when`
|
||||
|
||||
Hydrates the component based on a boolean condition.
|
||||
|
||||
```vue [pages/index.vue]
|
||||
<template>
|
||||
<div>
|
||||
<LazyMyComponent :hydrate-when="isReady" />
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
const isReady = ref(false)
|
||||
function myFunction() {
|
||||
// trigger custom hydration strategy...
|
||||
isReady.value = true
|
||||
}
|
||||
</script>
|
||||
```
|
||||
|
||||
#### `hydrate-never`
|
||||
|
||||
Never hydrates the component.
|
||||
|
||||
```vue [pages/index.vue]
|
||||
<template>
|
||||
<div>
|
||||
<LazyMyComponent hydrate-never />
|
||||
</div>
|
||||
</template>
|
||||
```
|
||||
|
||||
### Listening to Hydration Events
|
||||
|
||||
All delayed hydration components emit a `@hydrated` event when they are hydrated.
|
||||
|
||||
```vue [pages/index.vue]
|
||||
<template>
|
||||
<div>
|
||||
<LazyMyComponent hydrate-on-visible @hydrated="onHydrated" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
function onHydrate() {
|
||||
console.log("Component has been hydrated!")
|
||||
}
|
||||
</script>
|
||||
```
|
||||
|
||||
### Caveats and Best Practices
|
||||
|
||||
Delayed hydration can offer performance benefits, but it's essential to use it correctly:
|
||||
|
||||
1. **Prioritize In-Viewport Content:** Avoid delayed hydration for critical, above-the-fold content. It's best suited for content that isn't immediately needed.
|
||||
|
||||
2. **Conditional Rendering:** When using `v-if="false"` on a lazy component, you might not need delayed hydration. You can just use a normal lazy component.
|
||||
|
||||
3. **Shared State:** Be mindful of shared state (`v-model`) across multiple components. Updating the model in one component can trigger hydration in all components bound to that model.
|
||||
|
||||
4. **Use Each Strategy's Intended Use Case:** Each strategy is optimized for a specific purpose.
|
||||
* `hydrate-when` is best for components that might not always need to be hydrated.
|
||||
* `hydrate-after` is for components that can wait a specific amount of time.
|
||||
* `hydrate-on-idle` is for components that can be hydrated when the browser is idle.
|
||||
|
||||
5. **Avoid `hydrate-never` on interactive components:** If a component requires user interaction, it should not be set to never hydrate.
|
||||
|
||||
## Direct Imports
|
||||
|
||||
You can also explicitly import components from `#components` if you want or need to bypass Nuxt's auto-importing functionality.
|
||||
@ -343,10 +509,10 @@ When rendering a server-only or island component, `<NuxtIsland>` makes a fetch r
|
||||
|
||||
This means:
|
||||
|
||||
- A new Vue app will be created server-side to create the `NuxtIslandResponse`.
|
||||
- A new 'island context' will be created while rendering the component.
|
||||
- You can't access the 'island context' from the rest of your app and you can't access the context of the rest of your app from the island component. In other words, the server component or island is _isolated_ from the rest of your app.
|
||||
- Your plugins will run again when rendering the island, unless they have `env: { islands: false }` set (which you can do in an object-syntax plugin).
|
||||
* A new Vue app will be created server-side to create the `NuxtIslandResponse`.
|
||||
* A new 'island context' will be created while rendering the component.
|
||||
* You can't access the 'island context' from the rest of your app and you can't access the context of the rest of your app from the island component. In other words, the server component or island is _isolated_ from the rest of your app.
|
||||
* Your plugins will run again when rendering the island, unless they have `env: { islands: false }` set (which you can do in an object-syntax plugin).
|
||||
|
||||
Within an island component, you can access its island context through `nuxtApp.ssrContext.islandContext`. Note that while island components are still marked as experimental, the format of this context may change.
|
||||
|
||||
|
@ -136,6 +136,7 @@
|
||||
"@parcel/watcher": "2.5.1",
|
||||
"@types/estree": "1.0.6",
|
||||
"@vitejs/plugin-vue": "5.2.1",
|
||||
"@vitejs/plugin-vue-jsx": "^4.1.1",
|
||||
"@vue/compiler-sfc": "3.5.13",
|
||||
"unbuild": "3.5.0",
|
||||
"vite": "6.2.0",
|
||||
|
@ -14,6 +14,7 @@ import { ComponentsChunkPlugin, IslandsTransformPlugin } from './plugins/islands
|
||||
import { TransformPlugin } from './plugins/transform'
|
||||
import { TreeShakeTemplatePlugin } from './plugins/tree-shake'
|
||||
import { ComponentNamePlugin } from './plugins/component-names'
|
||||
import { LazyHydrationTransformPlugin } from './plugins/lazy-hydration-transform'
|
||||
|
||||
const isPureObjectOrString = (val: any) => (!Array.isArray(val) && typeof val === 'object') || typeof val === 'string'
|
||||
const isDirectory = (p: string) => { try { return statSync(p).isDirectory() } catch { return false } }
|
||||
@ -201,9 +202,13 @@ export default defineNuxtModule<ComponentsOptions>({
|
||||
|
||||
addBuildPlugin(TreeShakeTemplatePlugin({ sourcemap: !!nuxt.options.sourcemap.server, getComponents }), { client: false })
|
||||
|
||||
const clientDelayedComponentRuntime = await findPath(join(distDir, 'components/runtime/lazy-hydrated-component')) ?? join(distDir, 'components/runtime/lazy-hydrated-component')
|
||||
|
||||
const sharedLoaderOptions = {
|
||||
getComponents,
|
||||
clientDelayedComponentRuntime,
|
||||
serverComponentRuntime,
|
||||
srcDir: nuxt.options.srcDir,
|
||||
transform: typeof nuxt.options.components === 'object' && !Array.isArray(nuxt.options.components) ? nuxt.options.components.transform : undefined,
|
||||
experimentalComponentIslands: !!nuxt.options.experimental.componentIslands,
|
||||
}
|
||||
@ -211,6 +216,13 @@ export default defineNuxtModule<ComponentsOptions>({
|
||||
addBuildPlugin(LoaderPlugin({ ...sharedLoaderOptions, sourcemap: !!nuxt.options.sourcemap.client, mode: 'client' }), { server: false })
|
||||
addBuildPlugin(LoaderPlugin({ ...sharedLoaderOptions, sourcemap: !!nuxt.options.sourcemap.server, mode: 'server' }), { client: false })
|
||||
|
||||
if (nuxt.options.experimental.lazyHydration) {
|
||||
addBuildPlugin(LazyHydrationTransformPlugin({
|
||||
...sharedLoaderOptions,
|
||||
sourcemap: !!(nuxt.options.sourcemap.server || nuxt.options.sourcemap.client),
|
||||
}), { prepend: true })
|
||||
}
|
||||
|
||||
if (nuxt.options.experimental.componentIslands) {
|
||||
const selectiveClient = typeof nuxt.options.experimental.componentIslands === 'object' && nuxt.options.experimental.componentIslands.selectiveClient
|
||||
|
||||
|
107
packages/nuxt/src/components/plugins/lazy-hydration-transform.ts
Normal file
107
packages/nuxt/src/components/plugins/lazy-hydration-transform.ts
Normal file
@ -0,0 +1,107 @@
|
||||
import { createUnplugin } from 'unplugin'
|
||||
import MagicString from 'magic-string'
|
||||
import { camelCase, pascalCase } from 'scule'
|
||||
import type { Component, ComponentsOptions } from 'nuxt/schema'
|
||||
|
||||
import { parse, walk } from 'ultrahtml'
|
||||
import { isVue } from '../../core/utils'
|
||||
import { logger } from '../../utils'
|
||||
|
||||
interface LoaderOptions {
|
||||
getComponents (): Component[]
|
||||
sourcemap?: boolean
|
||||
transform?: ComponentsOptions['transform']
|
||||
}
|
||||
|
||||
const TEMPLATE_RE = /<template>([\s\S]*)<\/template>/
|
||||
const hydrationStrategyMap = {
|
||||
hydrateOnIdle: 'Idle',
|
||||
hydrateOnVisible: 'Visible',
|
||||
hydrateOnInteraction: 'Interaction',
|
||||
hydrateOnMediaQuery: 'MediaQuery',
|
||||
hydrateAfter: 'Time',
|
||||
hydrateWhen: 'If',
|
||||
hydrateNever: 'Never',
|
||||
}
|
||||
const LAZY_HYDRATION_PROPS_RE = /\bhydrate-?on-?idle|hydrate-?on-?visible|hydrate-?on-?interaction|hydrate-?on-?media-?query|hydrate-?after|hydrate-?when\b/
|
||||
export const LazyHydrationTransformPlugin = (options: LoaderOptions) => createUnplugin(() => {
|
||||
const exclude = options.transform?.exclude || []
|
||||
const include = options.transform?.include || []
|
||||
|
||||
return {
|
||||
name: 'nuxt:components-loader-pre',
|
||||
enforce: 'pre',
|
||||
transformInclude (id) {
|
||||
if (exclude.some(pattern => pattern.test(id))) {
|
||||
return false
|
||||
}
|
||||
if (include.some(pattern => pattern.test(id))) {
|
||||
return true
|
||||
}
|
||||
return isVue(id)
|
||||
},
|
||||
async transform (code) {
|
||||
// change <LazyMyComponent hydrate-on-idle /> to <LazyIdleMyComponent hydrate-on-idle />
|
||||
const { 0: template, index: offset = 0 } = code.match(TEMPLATE_RE) || {}
|
||||
if (!template) { return }
|
||||
if (!LAZY_HYDRATION_PROPS_RE.test(template)) {
|
||||
return
|
||||
}
|
||||
const s = new MagicString(code)
|
||||
try {
|
||||
const ast = parse(template)
|
||||
const components = options.getComponents()
|
||||
await walk(ast, (node) => {
|
||||
if (node.type !== 1 /* ELEMENT_NODE */) {
|
||||
return
|
||||
}
|
||||
if (!/^(?:Lazy|lazy-)/.test(node.name)) {
|
||||
return
|
||||
}
|
||||
const pascalName = pascalCase(node.name.slice(4))
|
||||
if (!components.some(c => c.pascalName === pascalName)) {
|
||||
// not auto-imported
|
||||
return
|
||||
}
|
||||
|
||||
let strategy: string | undefined
|
||||
|
||||
for (const attr in node.attributes) {
|
||||
const isDynamic = attr.startsWith(':')
|
||||
const prop = camelCase(isDynamic ? attr.slice(1) : attr)
|
||||
if (prop in hydrationStrategyMap) {
|
||||
if (strategy) {
|
||||
logger.warn(`Multiple hydration strategies are not supported in the same component`)
|
||||
} else {
|
||||
strategy = hydrationStrategyMap[prop as keyof typeof hydrationStrategyMap]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (strategy) {
|
||||
const newName = 'Lazy' + strategy + pascalName
|
||||
const chunk = template.slice(node.loc[0].start, node.loc.at(-1)!.end)
|
||||
const chunkOffset = node.loc[0].start + offset
|
||||
const { 0: startingChunk, index: startingPoint = 0 } = chunk.match(new RegExp(`<${node.name}[^>]*>`)) || {}
|
||||
s.overwrite(startingPoint + chunkOffset, startingPoint + chunkOffset + startingChunk!.length, startingChunk!.replace(node.name, newName))
|
||||
|
||||
const { 0: endingChunk, index: endingPoint } = chunk.match(new RegExp(`<\\/${node.name}[^>]*>$`)) || {}
|
||||
if (endingChunk && endingPoint) {
|
||||
s.overwrite(endingPoint + chunkOffset, endingPoint + chunkOffset + endingChunk.length, endingChunk.replace(node.name, newName))
|
||||
}
|
||||
}
|
||||
})
|
||||
} catch {
|
||||
// ignore errors if it's not html-like
|
||||
}
|
||||
if (s.hasChanged()) {
|
||||
return {
|
||||
code: s.toString(),
|
||||
map: options.sourcemap
|
||||
? s.generateMap({ hires: true })
|
||||
: undefined,
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
})
|
@ -13,13 +13,15 @@ import { logger } from '../../utils'
|
||||
interface LoaderOptions {
|
||||
getComponents (): Component[]
|
||||
mode: 'server' | 'client'
|
||||
srcDir: string
|
||||
serverComponentRuntime: string
|
||||
clientDelayedComponentRuntime: string
|
||||
sourcemap?: boolean
|
||||
transform?: ComponentsOptions['transform']
|
||||
experimentalComponentIslands?: boolean
|
||||
}
|
||||
|
||||
const REPLACE_COMPONENT_TO_DIRECT_IMPORT_RE = /(?<=[ (])_?resolveComponent\(\s*["'](lazy-|Lazy(?=[A-Z]))?([^'"]*)["'][^)]*\)/g
|
||||
const REPLACE_COMPONENT_TO_DIRECT_IMPORT_RE = /(?<=[ (])_?resolveComponent\(\s*["'](lazy-|Lazy(?=[A-Z]))?(Idle|Visible|idle-|visible-|Interaction|interaction-|MediaQuery|media-query-|If|if-|Never|never-|Time|time-)?([^'"]*)["'][^)]*\)/g
|
||||
export const LoaderPlugin = (options: LoaderOptions) => createUnplugin(() => {
|
||||
const exclude = options.transform?.exclude || []
|
||||
const include = options.transform?.include || []
|
||||
@ -44,10 +46,12 @@ export const LoaderPlugin = (options: LoaderOptions) => createUnplugin(() => {
|
||||
const imports = new Set<string>()
|
||||
const map = new Map<Component, string>()
|
||||
const s = new MagicString(code)
|
||||
|
||||
// replace `_resolveComponent("...")` to direct import
|
||||
s.replace(REPLACE_COMPONENT_TO_DIRECT_IMPORT_RE, (full: string, lazy: string, name: string) => {
|
||||
const component = findComponent(components, name, options.mode)
|
||||
s.replace(REPLACE_COMPONENT_TO_DIRECT_IMPORT_RE, (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
|
||||
|
||||
if (component) {
|
||||
// TODO: refactor to nuxi
|
||||
const internalInstall = ((component as any)._internal_install) as string
|
||||
@ -79,9 +83,58 @@ export const LoaderPlugin = (options: LoaderOptions) => createUnplugin(() => {
|
||||
}
|
||||
|
||||
if (lazy) {
|
||||
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))' : ''})`)
|
||||
const dynamicImport = `${genDynamicImport(component.filePath, { interopDefault: false })}.then(c => c.${component.export ?? 'default'} || c)`
|
||||
if (modifier && normalComponent) {
|
||||
const relativePath = relative(options.srcDir, component.filePath)
|
||||
switch (modifier) {
|
||||
case 'Visible':
|
||||
case 'visible-':
|
||||
imports.add(genImport(options.clientDelayedComponentRuntime, [{ name: 'createLazyVisibleComponent' }]))
|
||||
identifier += '_lazy_visible'
|
||||
imports.add(`const ${identifier} = createLazyVisibleComponent(${JSON.stringify(relativePath)}, ${dynamicImport})`)
|
||||
break
|
||||
case 'Interaction':
|
||||
case 'interaction-':
|
||||
imports.add(genImport(options.clientDelayedComponentRuntime, [{ name: 'createLazyInteractionComponent' }]))
|
||||
identifier += '_lazy_event'
|
||||
imports.add(`const ${identifier} = createLazyInteractionComponent(${JSON.stringify(relativePath)}, ${dynamicImport})`)
|
||||
break
|
||||
case 'Idle':
|
||||
case 'idle-':
|
||||
imports.add(genImport(options.clientDelayedComponentRuntime, [{ name: 'createLazyIdleComponent' }]))
|
||||
identifier += '_lazy_idle'
|
||||
imports.add(`const ${identifier} = createLazyIdleComponent(${JSON.stringify(relativePath)}, ${dynamicImport})`)
|
||||
break
|
||||
case 'MediaQuery':
|
||||
case 'media-query-':
|
||||
imports.add(genImport(options.clientDelayedComponentRuntime, [{ name: 'createLazyMediaQueryComponent' }]))
|
||||
identifier += '_lazy_media'
|
||||
imports.add(`const ${identifier} = createLazyMediaQueryComponent(${JSON.stringify(relativePath)}, ${dynamicImport})`)
|
||||
break
|
||||
case 'If':
|
||||
case 'if-':
|
||||
imports.add(genImport(options.clientDelayedComponentRuntime, [{ name: 'createLazyIfComponent' }]))
|
||||
identifier += '_lazy_if'
|
||||
imports.add(`const ${identifier} = createLazyIfComponent(${JSON.stringify(relativePath)}, ${dynamicImport})`)
|
||||
break
|
||||
case 'Never':
|
||||
case 'never-':
|
||||
imports.add(genImport(options.clientDelayedComponentRuntime, [{ name: 'createLazyNeverComponent' }]))
|
||||
identifier += '_lazy_never'
|
||||
imports.add(`const ${identifier} = createLazyNeverComponent(${JSON.stringify(relativePath)}, ${dynamicImport})`)
|
||||
break
|
||||
case 'Time':
|
||||
case 'time-':
|
||||
imports.add(genImport(options.clientDelayedComponentRuntime, [{ name: 'createLazyTimeComponent' }]))
|
||||
identifier += '_lazy_time'
|
||||
imports.add(`const ${identifier} = createLazyTimeComponent(${JSON.stringify(relativePath)}, ${dynamicImport})`)
|
||||
break
|
||||
}
|
||||
} else {
|
||||
imports.add(genImport('vue', [{ name: 'defineAsyncComponent', as: '__defineAsyncComponent' }]))
|
||||
identifier += '_lazy'
|
||||
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 }]))
|
||||
|
||||
|
114
packages/nuxt/src/components/runtime/lazy-hydrated-component.ts
Normal file
114
packages/nuxt/src/components/runtime/lazy-hydrated-component.ts
Normal file
@ -0,0 +1,114 @@
|
||||
import { defineAsyncComponent, defineComponent, h, hydrateOnIdle, hydrateOnInteraction, hydrateOnMediaQuery, hydrateOnVisible, mergeProps, watch } from 'vue'
|
||||
import type { AsyncComponentLoader, ComponentObjectPropsOptions, ExtractPropTypes, HydrationStrategy } from 'vue'
|
||||
import { useNuxtApp } from '#app/nuxt'
|
||||
|
||||
function defineLazyComponent<P extends ComponentObjectPropsOptions> (props: P, defineStrategy: (props: ExtractPropTypes<P>) => HydrationStrategy | undefined) {
|
||||
return (id: string, loader: AsyncComponentLoader) => defineComponent({
|
||||
inheritAttrs: false,
|
||||
props,
|
||||
emits: ['hydrated'],
|
||||
setup (props, ctx) {
|
||||
if (import.meta.server) {
|
||||
const nuxtApp = useNuxtApp()
|
||||
nuxtApp.hook('app:rendered', ({ ssrContext }) => {
|
||||
// strip the lazy hydrated component from the ssrContext so prefetch/preload tags are not rendered for it
|
||||
ssrContext!.modules!.delete(id)
|
||||
})
|
||||
}
|
||||
// wrap the async component in a second component to avoid loading the chunk too soon
|
||||
const child = defineAsyncComponent({ loader })
|
||||
const comp = defineAsyncComponent({
|
||||
hydrate: defineStrategy(props as ExtractPropTypes<P>),
|
||||
loader: () => Promise.resolve(child),
|
||||
})
|
||||
const onVnodeMounted = () => { ctx.emit('hydrated') }
|
||||
return () => h(comp, mergeProps(ctx.attrs, { onVnodeMounted }))
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/* @__NO_SIDE_EFFECTS__ */
|
||||
export const createLazyVisibleComponent = defineLazyComponent({
|
||||
hydrateOnVisible: {
|
||||
type: [Object, Boolean] as unknown as () => true | IntersectionObserverInit,
|
||||
required: false,
|
||||
},
|
||||
},
|
||||
props => hydrateOnVisible(props.hydrateOnVisible === true ? undefined : props.hydrateOnVisible),
|
||||
)
|
||||
|
||||
/* @__NO_SIDE_EFFECTS__ */
|
||||
export const createLazyIdleComponent = defineLazyComponent({
|
||||
hydrateOnIdle: {
|
||||
type: [Number, Boolean] as unknown as () => true | number,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
props => props.hydrateOnIdle === 0
|
||||
? undefined /* hydrate immediately */
|
||||
: hydrateOnIdle(props.hydrateOnIdle === true ? undefined : props.hydrateOnIdle),
|
||||
)
|
||||
|
||||
const defaultInteractionEvents = ['pointerenter', 'click', 'focus'] satisfies Array<keyof HTMLElementEventMap>
|
||||
|
||||
/* @__NO_SIDE_EFFECTS__ */
|
||||
export const createLazyInteractionComponent = defineLazyComponent({
|
||||
hydrateOnInteraction: {
|
||||
type: [String, Array] as unknown as () => keyof HTMLElementEventMap | Array<keyof HTMLElementEventMap> | true,
|
||||
required: false,
|
||||
default: defaultInteractionEvents,
|
||||
},
|
||||
},
|
||||
props => hydrateOnInteraction(props.hydrateOnInteraction === true ? defaultInteractionEvents : (props.hydrateOnInteraction || defaultInteractionEvents)),
|
||||
)
|
||||
|
||||
/* @__NO_SIDE_EFFECTS__ */
|
||||
export const createLazyMediaQueryComponent = defineLazyComponent({
|
||||
hydrateOnMediaQuery: {
|
||||
type: String as unknown as () => string,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
props => hydrateOnMediaQuery(props.hydrateOnMediaQuery),
|
||||
)
|
||||
|
||||
/* @__NO_SIDE_EFFECTS__ */
|
||||
export const createLazyIfComponent = defineLazyComponent({
|
||||
hydrateWhen: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
},
|
||||
props => props.hydrateWhen
|
||||
? undefined /* hydrate immediately */
|
||||
: (hydrate) => {
|
||||
const unwatch = watch(() => props.hydrateWhen, () => hydrate(), { once: true })
|
||||
return () => unwatch()
|
||||
},
|
||||
)
|
||||
|
||||
/* @__NO_SIDE_EFFECTS__ */
|
||||
export const createLazyTimeComponent = defineLazyComponent({
|
||||
hydrateAfter: {
|
||||
type: Number,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
props => props.hydrateAfter === 0
|
||||
? undefined /* hydrate immediately */
|
||||
: (hydrate) => {
|
||||
const id = setTimeout(hydrate, props.hydrateAfter)
|
||||
return () => clearTimeout(id)
|
||||
},
|
||||
)
|
||||
|
||||
/* @__NO_SIDE_EFFECTS__ */
|
||||
const hydrateNever = () => {}
|
||||
export const createLazyNeverComponent = defineLazyComponent({
|
||||
hydrateNever: {
|
||||
type: Boolean as () => true,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
() => hydrateNever,
|
||||
)
|
@ -116,14 +116,23 @@ 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 } }>>'
|
||||
return `
|
||||
import type { DefineComponent, SlotsType } from 'vue'
|
||||
${nuxt.options.experimental.componentIslands ? islandType : ''}
|
||||
type HydrationStrategies = {
|
||||
hydrateOnVisible?: IntersectionObserverInit | true
|
||||
hydrateOnIdle?: number | true
|
||||
hydrateOnInteraction?: keyof HTMLElementEventMap | Array<keyof HTMLElementEventMap> | true
|
||||
hydrateOnMediaQuery?: string
|
||||
hydrateAfter?: number
|
||||
hydrateWhen?: boolean
|
||||
hydrateNever?: true
|
||||
}
|
||||
type LazyComponent<T> = (T & DefineComponent<HydrationStrategies, {}, {}, {}, {}, {}, {}, { hydrated: () => void }>)
|
||||
interface _GlobalComponents {
|
||||
${componentTypes.map(([pascalName, type]) => ` '${pascalName}': ${type}`).join('\n')}
|
||||
${componentTypes.map(([pascalName, type]) => ` 'Lazy${pascalName}': ${type}`).join('\n')}
|
||||
${componentTypes.map(([pascalName, type]) => ` 'Lazy${pascalName}': LazyComponent<${type}>`).join('\n')}
|
||||
}
|
||||
|
||||
declare module 'vue' {
|
||||
@ -131,7 +140,7 @@ 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 Lazy${pascalName}: LazyComponent<${type}>`).join('\n')}
|
||||
|
||||
export const componentNames: string[]
|
||||
`
|
||||
|
172
packages/nuxt/test/component-loader.test.ts
Normal file
172
packages/nuxt/test/component-loader.test.ts
Normal file
@ -0,0 +1,172 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { kebabCase, pascalCase } from 'scule'
|
||||
import { rollup } from 'rollup'
|
||||
import vuePlugin from '@vitejs/plugin-vue'
|
||||
import vuePluginJsx from '@vitejs/plugin-vue-jsx'
|
||||
import type { AddComponentOptions } from '@nuxt/kit'
|
||||
|
||||
import { LoaderPlugin } from '../src/components/plugins/loader'
|
||||
import { LazyHydrationTransformPlugin } from '../src/components/plugins/lazy-hydration-transform'
|
||||
|
||||
describe('components:loader', () => {
|
||||
it('should correctly resolve components', async () => {
|
||||
const sfc = `
|
||||
<template>
|
||||
<MyComponent />
|
||||
<LazyMyComponent />
|
||||
<RouterLink />
|
||||
<NamedComponent />
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
const NamedComponent = resolveComponent('MyComponent')
|
||||
</script>
|
||||
`
|
||||
const code = await transform(sfc, '/pages/index.vue')
|
||||
expect(code).toMatchInlineSnapshot(`
|
||||
"import __nuxt_component_0 from '../components/MyComponent.vue';
|
||||
import { defineAsyncComponent, resolveComponent, createElementBlock, openBlock, Fragment, createVNode, unref } from 'vue';
|
||||
|
||||
const __nuxt_component_0_lazy = defineAsyncComponent(() => import('../components/MyComponent.vue').then(c => c.default || c));
|
||||
|
||||
|
||||
const _sfc_main = {
|
||||
__name: 'index',
|
||||
setup(__props) {
|
||||
|
||||
const NamedComponent = __nuxt_component_0;
|
||||
|
||||
return (_ctx, _cache) => {
|
||||
const _component_MyComponent = __nuxt_component_0;
|
||||
const _component_LazyMyComponent = __nuxt_component_0_lazy;
|
||||
const _component_RouterLink = resolveComponent("RouterLink");
|
||||
|
||||
return (openBlock(), createElementBlock(Fragment, null, [
|
||||
createVNode(_component_MyComponent),
|
||||
createVNode(_component_LazyMyComponent),
|
||||
createVNode(_component_RouterLink),
|
||||
createVNode(unref(NamedComponent))
|
||||
], 64 /* STABLE_FRAGMENT */))
|
||||
}
|
||||
}
|
||||
|
||||
};
|
||||
|
||||
export { _sfc_main as default };"
|
||||
`)
|
||||
})
|
||||
|
||||
it('should work in jsx', async () => {
|
||||
const component = `
|
||||
import { defineComponent } from 'vue'
|
||||
export default defineComponent({
|
||||
setup () {
|
||||
const NamedComponent = resolveComponent('MyComponent')
|
||||
return () => <div>
|
||||
<MyComponent />
|
||||
<LazyMyComponent />
|
||||
<RouterLink />
|
||||
<NamedComponent />
|
||||
</div>
|
||||
}
|
||||
})
|
||||
`
|
||||
const code = await transform(component, '/pages/about.tsx')
|
||||
expect(code).toMatchInlineSnapshot(`
|
||||
"import __nuxt_component_0 from '../components/MyComponent.vue';
|
||||
import { defineAsyncComponent, defineComponent, createVNode, resolveComponent } from 'vue';
|
||||
|
||||
const __nuxt_component_0_lazy = defineAsyncComponent(() => import('../components/MyComponent.vue').then(c => c.default || c));
|
||||
var about = /* @__PURE__ */ defineComponent({
|
||||
setup() {
|
||||
const NamedComponent = __nuxt_component_0;
|
||||
return () => createVNode("div", null, [createVNode(__nuxt_component_0, null, null), createVNode(__nuxt_component_0_lazy, null, null), createVNode(resolveComponent("RouterLink"), null, null), createVNode(NamedComponent, null, null)]);
|
||||
}
|
||||
});
|
||||
|
||||
export { about as default };"
|
||||
`)
|
||||
})
|
||||
|
||||
it('should correctly resolve lazy hydration components', async () => {
|
||||
const sfc = `
|
||||
<template>
|
||||
<LazyMyComponent :hydrate-on-idle="3000" />
|
||||
<LazyMyComponent :hydrate-on-visible="{threshold: 0.2}" />
|
||||
<LazyMyComponent :hydrate-on-interaction="['click','mouseover']" />
|
||||
<LazyMyComponent hydrate-on-media-query="(max-width: 500px)" />
|
||||
<LazyMyComponent :hydrate-after="3000" />
|
||||
<LazyMyComponent :hydrateAfter="3000" />
|
||||
<LazyMyComponent :hydrate-on-idle>
|
||||
<LazyMyComponent hydrate-when="true" />
|
||||
</LazyMyComponent>
|
||||
<LazyMyComponent hydrate-on-visible />
|
||||
<LazyMyComponent hydrate-never />
|
||||
</template>
|
||||
`
|
||||
const lines = await transform(sfc, '/pages/index.vue').then(r => r.split('\n'))
|
||||
const imports = lines.filter(l => l.startsWith('import'))
|
||||
expect(imports.join('\n')).toMatchInlineSnapshot(`
|
||||
"import { createLazyIdleComponent, createLazyVisibleComponent, createLazyInteractionComponent, createLazyMediaQueryComponent, createLazyTimeComponent, createLazyIfComponent, createLazyNeverComponent } from '../client-runtime.mjs';
|
||||
import { createElementBlock, openBlock, Fragment, createVNode, withCtx } from 'vue';"
|
||||
`)
|
||||
const components = lines.filter(l => l.startsWith('const __nuxt_component'))
|
||||
expect(components.join('\n')).toMatchInlineSnapshot(`
|
||||
"const __nuxt_component_0_lazy_idle = createLazyIdleComponent("components/MyComponent.vue", () => import('../components/MyComponent.vue').then(c => c.default || c));
|
||||
const __nuxt_component_0_lazy_visible = createLazyVisibleComponent("components/MyComponent.vue", () => import('../components/MyComponent.vue').then(c => c.default || c));
|
||||
const __nuxt_component_0_lazy_event = createLazyInteractionComponent("components/MyComponent.vue", () => import('../components/MyComponent.vue').then(c => c.default || c));
|
||||
const __nuxt_component_0_lazy_media = createLazyMediaQueryComponent("components/MyComponent.vue", () => import('../components/MyComponent.vue').then(c => c.default || c));
|
||||
const __nuxt_component_0_lazy_time = createLazyTimeComponent("components/MyComponent.vue", () => import('../components/MyComponent.vue').then(c => c.default || c));
|
||||
const __nuxt_component_0_lazy_if = createLazyIfComponent("components/MyComponent.vue", () => import('../components/MyComponent.vue').then(c => c.default || c));
|
||||
const __nuxt_component_0_lazy_never = createLazyNeverComponent("components/MyComponent.vue", () => import('../components/MyComponent.vue').then(c => c.default || c));"
|
||||
`)
|
||||
})
|
||||
})
|
||||
|
||||
async function transform (code: string, filename: string) {
|
||||
const components = ([{ name: 'MyComponent', filePath: '/components/MyComponent.vue' }] as AddComponentOptions[]).map(opts => ({
|
||||
export: opts.export || 'default',
|
||||
chunkName: 'components/' + kebabCase(opts.name),
|
||||
global: opts.global ?? false,
|
||||
kebabName: kebabCase(opts.name || ''),
|
||||
pascalName: pascalCase(opts.name || ''),
|
||||
prefetch: false,
|
||||
preload: false,
|
||||
mode: 'all' as const,
|
||||
shortPath: opts.filePath,
|
||||
priority: 0,
|
||||
meta: {},
|
||||
...opts,
|
||||
}))
|
||||
|
||||
const bundle = await rollup({
|
||||
input: filename,
|
||||
plugins: [
|
||||
{
|
||||
name: 'entry',
|
||||
resolveId (id) {
|
||||
if (id === filename) {
|
||||
return id
|
||||
}
|
||||
},
|
||||
load (id) {
|
||||
if (id === filename) {
|
||||
return code
|
||||
}
|
||||
},
|
||||
},
|
||||
LazyHydrationTransformPlugin({ getComponents: () => components }).rollup(),
|
||||
vuePlugin(),
|
||||
vuePluginJsx(),
|
||||
LoaderPlugin({
|
||||
clientDelayedComponentRuntime: '/client-runtime.mjs',
|
||||
serverComponentRuntime: '/server-runtime.mjs',
|
||||
getComponents: () => components,
|
||||
srcDir: '/',
|
||||
mode: 'server',
|
||||
}).rollup(),
|
||||
],
|
||||
})
|
||||
const { output: [chunk] } = await bundle.generate({})
|
||||
return chunk.code.trim()
|
||||
}
|
@ -478,5 +478,14 @@ export default defineResolvers({
|
||||
return typeof val === 'boolean' ? val : Boolean(await get('debug'))
|
||||
},
|
||||
},
|
||||
|
||||
/**
|
||||
* Enable automatic configuration of hydration strategies for `<Lazy>` components.
|
||||
*/
|
||||
lazyHydration: {
|
||||
$resolve: (val) => {
|
||||
return typeof val === 'boolean' ? val : true
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
@ -537,6 +537,9 @@ importers:
|
||||
'@vitejs/plugin-vue':
|
||||
specifier: 5.2.1
|
||||
version: 5.2.1(vite@6.2.0(@types/node@22.13.6)(jiti@2.4.2)(terser@5.32.0)(tsx@4.19.2)(yaml@2.7.0))(vue@3.5.13(typescript@5.8.2))
|
||||
'@vitejs/plugin-vue-jsx':
|
||||
specifier: ^4.1.1
|
||||
version: 4.1.1(vite@6.2.0(@types/node@22.13.6)(jiti@2.4.2)(terser@5.32.0)(tsx@4.19.2)(yaml@2.7.0))(vue@3.5.13(typescript@5.8.2))
|
||||
'@vue/compiler-sfc':
|
||||
specifier: 3.5.13
|
||||
version: 3.5.13
|
||||
|
@ -2860,6 +2860,112 @@ describe('lazy import components', () => {
|
||||
it('lazy load named component with mode server', () => {
|
||||
expect(html).toContain('lazy-named-comp-server')
|
||||
})
|
||||
|
||||
it('lazy load delayed hydration comps at the right time', { timeout: 20_000 }, async () => {
|
||||
const { page } = await renderPage('/lazy-import-components')
|
||||
|
||||
const hydratedText = 'This is mounted.'
|
||||
const unhydratedText = 'This is not mounted.'
|
||||
|
||||
expect.soft(html).toContain(unhydratedText)
|
||||
expect.soft(html).not.toContain(hydratedText)
|
||||
|
||||
await page.locator('data-testid=hydrate-on-visible', { hasText: hydratedText }).waitFor()
|
||||
expect.soft(await page.locator('data-testid=hydrate-on-visible-bottom').textContent().then(r => r?.trim())).toBe(unhydratedText)
|
||||
|
||||
await page.locator('data-testid=hydrate-on-interaction-default', { hasText: unhydratedText }).waitFor()
|
||||
await page.locator('data-testid=hydrate-on-interaction-click', { hasText: unhydratedText }).waitFor()
|
||||
|
||||
await page.locator('data-testid=hydrate-when-always', { hasText: hydratedText }).waitFor()
|
||||
await page.locator('data-testid=hydrate-when-state', { hasText: unhydratedText }).waitFor()
|
||||
|
||||
const component = page.getByTestId('hydrate-on-interaction-default')
|
||||
await component.hover()
|
||||
await page.locator('data-testid=hydrate-on-interaction-default', { hasText: hydratedText }).waitFor()
|
||||
|
||||
await page.getByTestId('button-increase-state').click()
|
||||
await page.locator('data-testid=hydrate-when-state', { hasText: hydratedText }).waitFor()
|
||||
|
||||
await page.getByTestId('hydrate-on-visible-bottom').scrollIntoViewIfNeeded()
|
||||
await page.locator('data-testid=hydrate-on-visible-bottom', { hasText: hydratedText }).waitFor()
|
||||
|
||||
await page.locator('data-testid=hydrate-never', { hasText: unhydratedText }).waitFor()
|
||||
|
||||
await page.close()
|
||||
})
|
||||
it('respects custom delayed hydration triggers and overrides defaults', async () => {
|
||||
const { page } = await renderPage('/lazy-import-components')
|
||||
|
||||
const unhydratedText = 'This is not mounted.'
|
||||
const hydratedText = 'This is mounted.'
|
||||
|
||||
await page.locator('data-testid=hydrate-on-interaction-click', { hasText: unhydratedText }).waitFor({ state: 'visible' })
|
||||
|
||||
await page.getByTestId('hydrate-on-interaction-click').hover()
|
||||
await page.locator('data-testid=hydrate-on-interaction-click', { hasText: unhydratedText }).waitFor({ state: 'visible' })
|
||||
|
||||
await page.getByTestId('hydrate-on-interaction-click').click()
|
||||
await page.locator('data-testid=hydrate-on-interaction-click', { hasText: hydratedText }).waitFor({ state: 'visible' })
|
||||
await page.locator('data-testid=hydrate-on-interaction-click', { hasText: unhydratedText }).waitFor({ state: 'hidden' })
|
||||
|
||||
await page.close()
|
||||
})
|
||||
|
||||
it('does not delay hydration of components named after modifiers', async () => {
|
||||
const { page } = await renderPage('/lazy-import-components')
|
||||
|
||||
await page.locator('data-testid=event-view-normal-component', { hasText: 'This is mounted.' }).waitFor()
|
||||
await page.locator('data-testid=event-view-normal-component', { hasText: 'This is not mounted.' }).waitFor({ state: 'hidden' })
|
||||
|
||||
await page.close()
|
||||
})
|
||||
|
||||
it('handles time-based hydration correctly', async () => {
|
||||
const { page } = await renderPage('/lazy-import-components/time')
|
||||
|
||||
const unhydratedText = 'This is not mounted.'
|
||||
const hydratedText = 'This is mounted.'
|
||||
|
||||
await page.locator('[data-testid=hydrate-after]', { hasText: unhydratedText }).waitFor({ state: 'visible' })
|
||||
await page.locator('[data-testid=hydrate-after]', { hasText: hydratedText }).waitFor({ state: 'hidden' })
|
||||
|
||||
await page.close()
|
||||
})
|
||||
|
||||
it('keeps reactivity with models', async () => {
|
||||
const { page } = await renderPage('/lazy-import-components/model-event')
|
||||
|
||||
const countLocator = page.getByTestId('count')
|
||||
const incrementButton = page.getByTestId('increment')
|
||||
|
||||
await countLocator.waitFor()
|
||||
|
||||
for (let i = 0; i < 10; i++) {
|
||||
expect(await countLocator.textContent()).toBe(`${i}`)
|
||||
await incrementButton.hover()
|
||||
await incrementButton.click()
|
||||
}
|
||||
|
||||
expect(await countLocator.textContent()).toBe('10')
|
||||
|
||||
await page.close()
|
||||
})
|
||||
|
||||
it('emits hydration events', async () => {
|
||||
const { page, consoleLogs } = await renderPage('/lazy-import-components/model-event')
|
||||
|
||||
const initialLogs = consoleLogs.filter(log => log.type === 'log' && log.text === 'Component hydrated')
|
||||
expect(initialLogs.length).toBe(0)
|
||||
|
||||
await page.getByTestId('count').click()
|
||||
|
||||
// Wait for all pending micro ticks to be cleared in case hydration hasn't finished yet.
|
||||
await page.evaluate(() => new Promise(resolve => setTimeout(resolve, 10)))
|
||||
const hydrationLogs = consoleLogs.filter(log => log.type === 'log' && log.text === 'Component hydrated')
|
||||
expect(hydrationLogs.length).toBeGreaterThan(0)
|
||||
|
||||
await page.close()
|
||||
})
|
||||
})
|
||||
|
||||
describe('defineNuxtComponent', () => {
|
||||
|
10
test/fixtures/basic-types/types.ts
vendored
10
test/fixtures/basic-types/types.ts
vendored
@ -12,7 +12,7 @@ import { defineNuxtConfig } from 'nuxt/config'
|
||||
import { callWithNuxt, isVue3 } from '#app'
|
||||
import type { NuxtError } from '#app'
|
||||
import type { NavigateToOptions } from '#app/composables/router'
|
||||
import { NuxtLayout, NuxtLink, NuxtPage, ServerComponent, WithTypes } from '#components'
|
||||
import { LazyWithTypes, NuxtLayout, NuxtLink, NuxtPage, ServerComponent, WithTypes } from '#components'
|
||||
import { useRouter } from '#imports'
|
||||
|
||||
type DefaultAsyncDataErrorValue = undefined
|
||||
@ -447,6 +447,14 @@ describe('components', () => {
|
||||
|
||||
// TODO: assert typed slots, exposed, generics, etc.
|
||||
})
|
||||
it('includes types for lazy hydration', () => {
|
||||
h(LazyWithTypes)
|
||||
h(LazyWithTypes, { hydrateAfter: 300 })
|
||||
h(LazyWithTypes, { hydrateOnIdle: true })
|
||||
|
||||
// @ts-expect-error wrong prop type for this hydration strategy
|
||||
h(LazyWithTypes, { hydrateAfter: '' })
|
||||
})
|
||||
it('include fallback slot in server components', () => {
|
||||
expectTypeOf(ServerComponent.slots).toEqualTypeOf<SlotsType<{ fallback: { error: unknown } }> | undefined>()
|
||||
})
|
||||
|
16
test/fixtures/basic/components/DelayedComponent.vue
vendored
Normal file
16
test/fixtures/basic/components/DelayedComponent.vue
vendored
Normal file
@ -0,0 +1,16 @@
|
||||
<template>
|
||||
<div>
|
||||
This {{ mounted ? 'is' : 'is not' }} mounted.
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const mounted = ref(false)
|
||||
onMounted(() => { mounted.value = true })
|
||||
|
||||
const props = defineProps<{ logHydration?: true }>()
|
||||
|
||||
if (props.logHydration) {
|
||||
console.log('hydrated')
|
||||
}
|
||||
</script>
|
15
test/fixtures/basic/components/DelayedModel.vue
vendored
Normal file
15
test/fixtures/basic/components/DelayedModel.vue
vendored
Normal file
@ -0,0 +1,15 @@
|
||||
<template>
|
||||
<div>
|
||||
<span data-testid="count">{{ model }}</span>
|
||||
<button
|
||||
data-testid="increment"
|
||||
@click="model++"
|
||||
>
|
||||
Increment
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const model = defineModel<number>()
|
||||
</script>
|
10
test/fixtures/basic/components/EventView.vue
vendored
Normal file
10
test/fixtures/basic/components/EventView.vue
vendored
Normal file
@ -0,0 +1,10 @@
|
||||
<template>
|
||||
<div>
|
||||
This {{ mounted ? 'is' : 'is not' }} mounted.
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const mounted = ref(false)
|
||||
onMounted(() => { mounted.value = true })
|
||||
</script>
|
@ -3,5 +3,61 @@
|
||||
<LazyNCompAll message="lazy-named-comp-all" />
|
||||
<LazyNCompClient message="lazy-named-comp-client" />
|
||||
<LazyNCompServer message="lazy-named-comp-server" />
|
||||
hydrate-on-interaction-default:
|
||||
<LazyDelayedComponent
|
||||
data-testid="hydrate-on-interaction-default"
|
||||
hydrate-on-interaction
|
||||
/>
|
||||
event-view-normal-component:
|
||||
<LazyEventView data-testid="event-view-normal-component" />
|
||||
hydrate-on-visible:
|
||||
<LazyDelayedComponent
|
||||
data-testid="hydrate-on-visible"
|
||||
hydrate-on-visible
|
||||
/>
|
||||
hydrate-never:
|
||||
<LazyDelayedComponent
|
||||
data-testid="hydrate-never"
|
||||
hydrate-never
|
||||
/>
|
||||
hydrate-on-interaction-click:
|
||||
<LazyDelayedComponent
|
||||
data-testid="hydrate-on-interaction-click"
|
||||
:hydrate-on-interaction="['click']"
|
||||
/>
|
||||
hydrate-when-always:
|
||||
<LazyDelayedComponent
|
||||
data-testid="hydrate-when-always"
|
||||
:hydrate-when="true"
|
||||
/>
|
||||
button-increase-state:
|
||||
<button
|
||||
data-testid="button-increase-state"
|
||||
@click="state++"
|
||||
>
|
||||
Increase state
|
||||
</button>
|
||||
hydrate-when-state:
|
||||
<LazyDelayedComponent
|
||||
data-testid="hydrate-when-state"
|
||||
:hydrate-when="state > 1"
|
||||
/>
|
||||
hydrate-on-idle:
|
||||
<LazyDelayedComponent
|
||||
data-testid="hydrate-on-idle"
|
||||
:hydrate-on-idle="3"
|
||||
/>
|
||||
<div style="height:3000px">
|
||||
This is a very tall div
|
||||
</div>
|
||||
hydrate-on-visible-bottom:
|
||||
<LazyDelayedComponent
|
||||
data-testid="hydrate-on-visible-bottom"
|
||||
hydrate-on-visible
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const state = useState('delayedHydrationCondition', () => 1)
|
||||
</script>
|
||||
|
16
test/fixtures/basic/pages/lazy-import-components/model-event.vue
vendored
Normal file
16
test/fixtures/basic/pages/lazy-import-components/model-event.vue
vendored
Normal file
@ -0,0 +1,16 @@
|
||||
<template>
|
||||
<div>
|
||||
<LazyDelayedModel
|
||||
v-model="model"
|
||||
hydrate-on-interaction
|
||||
@hydrated="log"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const model = ref(0)
|
||||
function log () {
|
||||
console.log('Component hydrated')
|
||||
}
|
||||
</script>
|
8
test/fixtures/basic/pages/lazy-import-components/time.vue
vendored
Normal file
8
test/fixtures/basic/pages/lazy-import-components/time.vue
vendored
Normal file
@ -0,0 +1,8 @@
|
||||
<template>
|
||||
<div>
|
||||
<LazyDelayedComponent
|
||||
data-testid="hydrate-after"
|
||||
:hydrate-after="50"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
Loading…
Reference in New Issue
Block a user