refactor: avoid conflicting renders

This refactors SSR handling with the existing approach, given the SSR limitation of establishing rendering separation.

This also simplifies SSR renders using the same data and adds early returns wherever possible to internally minimize delayed hydration dependency
This commit is contained in:
tbitw2549 2024-09-13 12:04:49 +03:00
parent 74231878ea
commit fb6d4a518a
4 changed files with 57 additions and 60 deletions

View File

@ -209,6 +209,10 @@ If you would like to override the default hydration triggers when dealing with d
<LazyIfMyComponent :hydrate="someCondition" /> <LazyIfMyComponent :hydrate="someCondition" />
</div> </div>
</template> </template>
<script setup lang="ts">
const someCondition = ref(true)
</script>
``` ```
::read-more{to="/docs/api/utils/create-idle-loader"} ::read-more{to="/docs/api/utils/create-idle-loader"}
@ -225,9 +229,21 @@ Read more about using `LazyMedia` components and the accepted values.
:: ::
::important ::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) If your components begin with a reserved delayed hydration prefix like Visible/Idle/Event, they will not have delayed hydration by default. 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 if you'd like for them to have delayed hydration.
For example, if you have a component named `IdleBar` and you'd like it to be delayed based on network idle time, 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)
:: ::
### Caveats and best practices
Delayed hydration has many performance benefits, but in order to gain the most out of it, it's important to use it correctly:
1. Do not use `LazyVisible` for in-viewport content - `LazyVisible` is best for content that is not immediately visible and requires scrolling to get to. If it is present on screen immediately, using it as a normal component would provide better performance and loading times.
2. Do not use `LazyIf` for components that are always/never going to be hydrated - you can use a regular component/`LazyNever` respectively, which would provide better performance for each use case. Keep `LazyIf` for components that could get hydrated, but might not get hydrated immediately (for example, following user interaction).
## Direct Imports ## Direct Imports
You can also explicitly import components from `#components` if you want or need to bypass Nuxt's auto-importing functionality. You can also explicitly import components from `#components` if you want or need to bypass Nuxt's auto-importing functionality.

View File

@ -37,7 +37,7 @@ export const createIdleLoader = (timeout: number) => timeout
export const createVisibleLoader = (opts: Partial<IntersectionObserverInit>) => opts export const createVisibleLoader = (opts: Partial<IntersectionObserverInit>) => opts
/** /**
* A utility used to determine which events should trigger hydration in components with event-based delayed hydration. * A utility used to determine which event/events should trigger hydration in components with event-based delayed hydration.
* @param events an array of events that will be used to trigger the hydration * @param events an event or array of events that will be used to trigger the hydration
*/ */
export const createEventLoader = (events: Array<keyof HTMLElementEventMap>) => events export const createEventLoader = (events: keyof HTMLElementEventMap | Array<keyof HTMLElementEventMap>) => events

View File

@ -1,21 +1,15 @@
import { defineAsyncComponent, defineComponent, getCurrentInstance, h, hydrateOnIdle, hydrateOnInteraction, hydrateOnMediaQuery, hydrateOnVisible, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue' import { defineAsyncComponent, defineComponent, h, hydrateOnIdle, hydrateOnInteraction, hydrateOnMediaQuery, hydrateOnVisible, mergeProps, ref, watch } from 'vue'
import type { AsyncComponentLoader, HydrationStrategy } from 'vue' import type { AsyncComponentLoader, HydrationStrategy } from 'vue'
import { onNuxtReady, useNuxtApp } from '#app'
/* @__NO_SIDE_EFFECTS__ */ /* @__NO_SIDE_EFFECTS__ */
export const createLazyIOComponent = (loader: AsyncComponentLoader) => { export const createLazyIOComponent = (loader: AsyncComponentLoader) => {
return defineComponent({ return defineComponent({
inheritAttrs: false, inheritAttrs: false,
setup (_, { attrs }) { setup (_, { attrs }) {
if (import.meta.server) { const comp = defineAsyncComponent({ loader, hydrate: hydrateOnVisible(attrs.hydrate as IntersectionObserverInit | undefined) })
return () => h(defineAsyncComponent(loader), attrs) const merged = mergeProps(attrs, { 'data-allow-mismatch': '' })
} // 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.
const ready = ref(false) return () => h(comp, merged)
const nuxt = useNuxtApp()
const instance = getCurrentInstance()!
onNuxtReady(() => ready.value = true)
// This is a hack to prevent hydration mismatches for all hydration strategies
return () => ready.value ? h(defineAsyncComponent({ loader, hydrate: hydrateOnVisible(attrs.hydrate as IntersectionObserverInit | undefined) })) : nuxt.isHydrating && instance.vnode.el ? h('div', attrs) : null
}, },
}) })
} }
@ -25,33 +19,29 @@ export const createLazyNetworkComponent = (loader: AsyncComponentLoader) => {
return defineComponent({ return defineComponent({
inheritAttrs: false, inheritAttrs: false,
setup (_, { attrs }) { setup (_, { attrs }) {
if (import.meta.server) { const merged = mergeProps(attrs, { 'data-allow-mismatch': '' })
return () => h(defineAsyncComponent(loader), attrs) if (attrs.hydrate === 0) {
const comp = defineAsyncComponent(loader)
return () => h(comp, merged)
} }
const ready = ref(false) const comp = defineAsyncComponent({ loader, hydrate: hydrateOnIdle(attrs.hydrate as number | undefined) })
const nuxt = useNuxtApp() // 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.
const instance = getCurrentInstance()! return () => h(comp, merged)
onNuxtReady(() => ready.value = true)
// This one seems to work fine due to the intended use case
return () => ready.value ? h(defineAsyncComponent({ loader, hydrate: hydrateOnIdle(attrs.hydrate as number | undefined) })) : nuxt.isHydrating && instance.vnode.el ? h('div', attrs) : null
}, },
}) })
} }
type HTMLEvent = keyof HTMLElementEventMap | Array<keyof HTMLElementEventMap>
/* @__NO_SIDE_EFFECTS__ */ /* @__NO_SIDE_EFFECTS__ */
export const createLazyEventComponent = (loader: AsyncComponentLoader) => { export const createLazyEventComponent = (loader: AsyncComponentLoader) => {
return defineComponent({ return defineComponent({
inheritAttrs: false, inheritAttrs: false,
setup (_, { attrs }) { setup (_, { attrs }) {
if (import.meta.server) { const events: HTMLEvent = attrs.hydrate as HTMLEvent ?? 'mouseover'
return () => h(defineAsyncComponent(loader), attrs) const comp = defineAsyncComponent({ loader, hydrate: hydrateOnInteraction(events) })
} const merged = mergeProps(attrs, { 'data-allow-mismatch': '' })
const ready = ref(false) // 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.
const nuxt = useNuxtApp() return () => h(comp, merged)
const instance = getCurrentInstance()!
onNuxtReady(() => ready.value = true)
const events: Array<keyof HTMLElementEventMap> = attrs.hydrate as Array<keyof HTMLElementEventMap> ?? ['mouseover']
return () => ready.value ? h(defineAsyncComponent({ loader, hydrate: hydrateOnInteraction(events) })) : nuxt.isHydrating && instance.vnode.el ? h('div', attrs) : null
}, },
}) })
} }
@ -61,15 +51,11 @@ export const createLazyMediaComponent = (loader: AsyncComponentLoader) => {
return defineComponent({ return defineComponent({
inheritAttrs: false, inheritAttrs: false,
setup (_, { attrs }) { setup (_, { attrs }) {
if (import.meta.server) { const mediaQuery = attrs.hydrate as string ?? '(min-width: 1px)'
return () => h(defineAsyncComponent(loader), attrs) const comp = defineAsyncComponent({ loader, hydrate: hydrateOnMediaQuery(mediaQuery) })
} const merged = mergeProps(attrs, { 'data-allow-mismatch': '' })
const ready = ref(false) // 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.
const nuxt = useNuxtApp() return () => h(comp, merged)
const instance = getCurrentInstance()!
onNuxtReady(() => ready.value = true)
// This one, unlike others, can cause a hydration mismatch even a whole minute after the page loads. Given a query of min-width: 1200px, with a small window, the moment the window expands to at least 1200 it hydrates and causes a hydration mismatch.
return () => ready.value ? h(defineAsyncComponent({ loader, hydrate: hydrateOnMediaQuery(attrs.hydrate ?? '(min-width: 1px)') })) : nuxt.isHydrating && instance.vnode.el ? h('div', attrs) : null
}, },
}) })
} }
@ -79,24 +65,19 @@ export const createLazyIfComponent = (loader: AsyncComponentLoader) => {
return defineComponent({ return defineComponent({
inheritAttrs: false, inheritAttrs: false,
setup (_, { attrs }) { setup (_, { attrs }) {
if (import.meta.server) {
return () => h(defineAsyncComponent(loader), attrs)
}
const ready = ref(false)
const nuxt = useNuxtApp()
const instance = getCurrentInstance()!
onNuxtReady(() => ready.value = true)
const shouldHydrate = ref(!!(attrs.hydrate ?? true)) const shouldHydrate = ref(!!(attrs.hydrate ?? true))
if (shouldHydrate.value) {
const comp = defineAsyncComponent(loader)
const merged = mergeProps(attrs, { 'data-allow-mismatch': '' })
return () => h(comp, merged)
}
const strategy: HydrationStrategy = (hydrate) => { const strategy: HydrationStrategy = (hydrate) => {
if (!shouldHydrate.value) {
const unwatch = watch(shouldHydrate, () => hydrate(), { once: true }) const unwatch = watch(shouldHydrate, () => hydrate(), { once: true })
return () => unwatch() return () => unwatch()
} }
hydrate() const comp = defineAsyncComponent({ loader, hydrate: strategy })
return () => {}
}
// This one seems to work fine whenever the hydration condition is achieved at client side. For example, a hydration condition of a ref greater than 2 with a button to increment causes no hydration mismatch after 3 presses of the button. // This one seems to work fine whenever the hydration condition is achieved at client side. For example, a hydration condition of a ref greater than 2 with a button to increment causes no hydration mismatch after 3 presses of the button.
return () => ready.value ? h(defineAsyncComponent({ loader, hydrate: strategy })) : nuxt.isHydrating && instance.vnode.el ? h('div', attrs) : null return () => h(comp, attrs)
}, },
}) })
} }

View File

@ -123,9 +123,9 @@ ${nuxt.options.experimental.componentIslands ? islandType : ''}
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?: IdleRequestOptions}>`).join('\n')} ${componentTypes.map(([pascalName, type]) => ` 'LazyIdle${pascalName}': ${type} & DefineComponent<{hydrate?: number}>`).join('\n')}
${componentTypes.map(([pascalName, type]) => ` 'LazyVisible${pascalName}': ${type} & DefineComponent<{hydrate?: Partial<IntersectionObserverInit>}>`).join('\n')} ${componentTypes.map(([pascalName, type]) => ` 'LazyVisible${pascalName}': ${type} & DefineComponent<{hydrate?: Partial<IntersectionObserverInit>}>`).join('\n')}
${componentTypes.map(([pascalName, type]) => ` 'LazyEvent${pascalName}': ${type} & DefineComponent<{hydrate?: Array<keyof HTMLElementEventMap>}>`).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]) => ` 'LazyMedia${pascalName}': ${type} & DefineComponent<{hydrate?: string}>`).join('\n')}
${componentTypes.map(([pascalName, type]) => ` 'LazyIf${pascalName}': ${type} & DefineComponent<{hydrate?: unknown}>`).join('\n')} ${componentTypes.map(([pascalName, type]) => ` 'LazyIf${pascalName}': ${type} & DefineComponent<{hydrate?: unknown}>`).join('\n')}
${componentTypes.map(([pascalName, type]) => ` 'LazyNever${pascalName}': ${type}`).join('\n')} ${componentTypes.map(([pascalName, type]) => ` 'LazyNever${pascalName}': ${type}`).join('\n')}
@ -137,9 +137,9 @@ 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?: IdleRequestOptions}>`).join('\n')} ${componentTypes.map(([pascalName, type]) => `export const LazyIdle${pascalName}: ${type} & DefineComponent<{hydrate?: number}>`).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} & DefineComponent<{hydrate?: Partial<IntersectionObserverInit>}>`).join('\n')}
${componentTypes.map(([pascalName, type]) => `export const LazyEvent${pascalName}: ${type} & DefineComponent<{hydrate?: Array<keyof HTMLElementEventMap>}>`).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 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 LazyIf${pascalName}: ${type} & DefineComponent<{hydrate?: 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')}