Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: lazy hydration #26468

Draft
wants to merge 86 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
86 commits
Select commit Hold shift + click to select a range
9f629ad
feat: lazy hydration
GalacticHypernova Mar 24, 2024
d64d78f
[autofix.ci] apply automated fixes
autofix-ci[bot] Mar 24, 2024
9ac1261
feat: client-io-component.ts barebones functionality
GalacticHypernova Mar 24, 2024
84b0a71
feat: provide an emit
GalacticHypernova Mar 24, 2024
ae2bb27
fix: provide ref
GalacticHypernova Mar 24, 2024
6a32dc1
fix: client-io-component.ts
GalacticHypernova Mar 24, 2024
932d143
[autofix.ci] apply automated fixes
autofix-ci[bot] Mar 24, 2024
cb221ed
types: client-io-component.ts
GalacticHypernova Mar 24, 2024
2ac2a97
fix: proper intersection callback type
GalacticHypernova Mar 24, 2024
d94436b
fix: import type Ref and provide emit
GalacticHypernova Mar 24, 2024
85b4d69
[autofix.ci] apply automated fixes
autofix-ci[bot] Mar 24, 2024
624188a
feat: extract useObserver
GalacticHypernova Mar 25, 2024
f039dfd
feat: provide exported function
GalacticHypernova Mar 25, 2024
7d2f345
fix: remove unnecessary imports
GalacticHypernova Mar 25, 2024
f51c936
fix: export function as opposed to type
GalacticHypernova Mar 25, 2024
b3b1676
[autofix.ci] apply automated fixes
autofix-ci[bot] Mar 25, 2024
caf7352
Merge branch 'main' into patch-21
GalacticHypernova Mar 25, 2024
966c17b
fix: provide a proper wrapper for IO with the comp
GalacticHypernova Mar 25, 2024
f6ba225
fix: ensure observer is not undefined
GalacticHypernova Mar 25, 2024
1e16b4a
[autofix.ci] apply automated fixes
autofix-ci[bot] Mar 25, 2024
0caf815
Merge branch 'main' into patch-21
GalacticHypernova Mar 26, 2024
600f55d
wip: rename client-io-component.ts to client-delayed-component.ts for…
GalacticHypernova Mar 26, 2024
36f2c75
Merge branch 'main' into patch-21
GalacticHypernova Mar 27, 2024
586cfa5
Merge branch 'main' into patch-21
GalacticHypernova Mar 30, 2024
9713e32
Merge branch 'nuxt:main' into patch-21
GalacticHypernova Mar 31, 2024
8d435a1
Merge branch 'main' into patch-21
GalacticHypernova Apr 2, 2024
444e5be
Merge branch 'main' into patch-21
GalacticHypernova Apr 5, 2024
7588244
Merge branch 'main' into patch-21
GalacticHypernova Apr 8, 2024
66938bc
[autofix.ci] apply automated fixes
autofix-ci[bot] Apr 8, 2024
20a32b2
wip: provide hardcoded check to test delayed hydration runtime comp
GalacticHypernova Apr 8, 2024
816ba11
[autofix.ci] apply automated fixes
autofix-ci[bot] Apr 8, 2024
6c683e0
wip: add comp to lazy-import-components
GalacticHypernova Apr 8, 2024
998c1ba
[autofix.ci] apply automated fixes
autofix-ci[bot] Apr 8, 2024
4cc9efe
wip: add initial lazy load check
GalacticHypernova Apr 8, 2024
d209a02
[autofix.ci] apply automated fixes
autofix-ci[bot] Apr 8, 2024
88bae15
wip: testing page text getting
GalacticHypernova Apr 8, 2024
00a68bd
fix: async
GalacticHypernova Apr 8, 2024
10f7f22
fix: check for count
GalacticHypernova Apr 8, 2024
84b2333
fix: await
GalacticHypernova Apr 8, 2024
d89e70e
Create DelayedWrapperTestComponent.vue
GalacticHypernova Apr 8, 2024
4b0d88c
refactor: use a component to test transform
GalacticHypernova Apr 8, 2024
2236f78
[autofix.ci] apply automated fixes
autofix-ci[bot] Apr 8, 2024
36dc731
fix: append identifier before import
GalacticHypernova Apr 8, 2024
dd36a18
wip: add scrolling to test for delayed hydration
GalacticHypernova Apr 8, 2024
bd7ca85
[autofix.ci] apply automated fixes
autofix-ci[bot] Apr 8, 2024
5653d9e
wip: retrying through mouse.wheel
GalacticHypernova Apr 8, 2024
26f2e4e
[autofix.ci] apply automated fixes
autofix-ci[bot] Apr 8, 2024
988a99b
fix: don't use the default that is used for slots
GalacticHypernova Apr 9, 2024
2fa63cc
Merge branch 'main' into patch-21
GalacticHypernova Apr 9, 2024
669fcee
[autofix.ci] apply automated fixes
autofix-ci[bot] Apr 9, 2024
9b94d10
chore: retrying without ClientOnly
GalacticHypernova Apr 9, 2024
3a8b1f3
chore: rerunning tests
GalacticHypernova Apr 9, 2024
59edd16
chore: mark no side effects
GalacticHypernova Apr 9, 2024
d4bca6c
refactor: use page.evaluate
GalacticHypernova Apr 9, 2024
b3250e4
fix(nuxt): send the component loader and not the name
huang-julien Apr 9, 2024
8c1c23a
chore: trying to resolve default
GalacticHypernova Apr 9, 2024
45e49c5
test: wait for network idle
huang-julien Apr 9, 2024
717a91c
Merge branch 'nuxt:patch-21' into patch-21
GalacticHypernova Apr 9, 2024
382bc93
test: remove only
huang-julien Apr 9, 2024
817c3ac
Merge branch 'nuxt:patch-21' into patch-21
GalacticHypernova Apr 9, 2024
503b560
wip: network idle based delayed component
GalacticHypernova Apr 10, 2024
2df20ac
fix: remove comma
GalacticHypernova Apr 10, 2024
effebbb
fix: strict types
GalacticHypernova Apr 10, 2024
b4c4d47
Merge branch 'main' into patch-21
GalacticHypernova Apr 10, 2024
2375650
feat: support lazy hydration on SSR
GalacticHypernova Apr 10, 2024
31002ec
[autofix.ci] apply automated fixes
autofix-ci[bot] Apr 10, 2024
580d9bd
fix: import useNuxtApp and provide types
GalacticHypernova Apr 10, 2024
e2d0350
fix: use VNode type
GalacticHypernova Apr 10, 2024
dc6d922
fix: join the fragments
GalacticHypernova Apr 10, 2024
2ee3cf5
fix: verify none null
GalacticHypernova Apr 10, 2024
8555cec
fix: ensure el itself isn't null
GalacticHypernova Apr 10, 2024
cb7fc3f
chore: rerunning tests
GalacticHypernova Apr 10, 2024
cfb6660
fix: missing question marks
GalacticHypernova Apr 10, 2024
e4c9940
fix: ssr improvement + static vnode rendering
huang-julien Apr 13, 2024
5c300a4
[autofix.ci] apply automated fixes
autofix-ci[bot] Apr 13, 2024
1f196a5
Merge branch 'main' into patch-21
GalacticHypernova Apr 13, 2024
7821e7f
feat(schema): add experimental lazy hydration option
GalacticHypernova Apr 13, 2024
162908a
[autofix.ci] apply automated fixes
autofix-ci[bot] Apr 13, 2024
fd0adea
fix: return render function and not vnode directly
huang-julien Apr 13, 2024
13949ed
wip: wait for response with the component
GalacticHypernova Apr 13, 2024
9b8d33b
fix: remove unused var
GalacticHypernova Apr 13, 2024
8eaf057
[autofix.ci] apply automated fixes
autofix-ci[bot] Apr 13, 2024
0cb15c6
chore: remove wait for load state
GalacticHypernova Apr 14, 2024
850287b
fix: remove the preload for SSR render of delayed hydration
GalacticHypernova Apr 14, 2024
b45c3c9
[autofix.ci] apply automated fixes
autofix-ci[bot] Apr 14, 2024
ad6bef5
Merge branch 'main' into patch-21
GalacticHypernova May 3, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
44 changes: 1 addition & 43 deletions packages/nuxt/src/app/components/nuxt-link.ts
Expand Up @@ -14,6 +14,7 @@ import { onNuxtReady } from '../composables/ready'
import { navigateTo, useRouter } from '../composables/router'
import { useNuxtApp, useRuntimeConfig } from '../nuxt'
import { cancelIdleCallback, requestIdleCallback } from '../compat/idle-callback'
import { useObserver } from '../utils'

// @ts-expect-error virtual file
import { nuxtLinkDefaults } from '#build/nuxt.config.mjs'
Expand Down Expand Up @@ -435,49 +436,6 @@ function applyTrailingSlashBehavior (to: string, trailingSlash: NuxtLinkOptions[
}

// --- Prefetching utils ---
type CallbackFn = () => void
type ObserveFn = (element: Element, callback: CallbackFn) => () => void

function useObserver (): { observe: ObserveFn } | undefined {
if (import.meta.server) { return }

const nuxtApp = useNuxtApp()
if (nuxtApp._observer) {
return nuxtApp._observer
}

let observer: IntersectionObserver | null = null

const callbacks = new Map<Element, CallbackFn>()

const observe: ObserveFn = (element, callback) => {
if (!observer) {
observer = new IntersectionObserver((entries) => {
for (const entry of entries) {
const callback = callbacks.get(entry.target)
const isVisible = entry.isIntersecting || entry.intersectionRatio > 0
if (isVisible && callback) { callback() }
}
})
}
callbacks.set(element, callback)
observer.observe(element)
return () => {
callbacks.delete(element)
observer!.unobserve(element)
if (callbacks.size === 0) {
observer!.disconnect()
observer = null
}
}
}

const _observer = nuxtApp._observer = {
observe,
}

return _observer
}

function isSlowConnection () {
if (import.meta.server) { return }
Expand Down
47 changes: 46 additions & 1 deletion packages/nuxt/src/app/utils.ts
@@ -1,4 +1,49 @@
/** @since 3.9.0 */
import { useNuxtApp } from './nuxt'

export function toArray<T> (value: T | T[]): T[] {
return Array.isArray(value) ? value : [value]
}

type CallbackFn = () => void
type ObserveFn = (element: Element, callback: CallbackFn) => () => void

export function useObserver (): { observe: ObserveFn } | undefined {
if (import.meta.server) { return }

const nuxtApp = useNuxtApp()
if (nuxtApp._observer) {
return nuxtApp._observer
}

let observer: IntersectionObserver | null = null

const callbacks = new Map<Element, CallbackFn>()

const observe: ObserveFn = (element, callback) => {
if (!observer) {
observer = new IntersectionObserver((entries) => {
for (const entry of entries) {
const callback = callbacks.get(entry.target)
const isVisible = entry.isIntersecting || entry.intersectionRatio > 0
if (isVisible && callback) { callback() }
}
})
}
callbacks.set(element, callback)
observer.observe(element)
return () => {
callbacks.delete(element)
observer!.unobserve(element)
if (callbacks.size === 0) {
observer!.disconnect()
observer = null
}
}
}

const _observer = nuxtApp._observer = {
observe,
}

return _observer
}
16 changes: 12 additions & 4 deletions packages/nuxt/src/components/loader.ts
Expand Up @@ -21,7 +21,7 @@ export const loaderPlugin = createUnplugin((options: LoaderOptions) => {
const exclude = options.transform?.exclude || []
const include = options.transform?.include || []
const serverComponentRuntime = resolve(distDir, 'components/runtime/server-component')

const clientDelayedComponentRuntime = resolve(distDir, 'components/runtime/client-delayed-component')
return {
name: 'nuxt:components-loader',
enforce: 'post',
Expand Down Expand Up @@ -72,9 +72,17 @@ export const loaderPlugin = createUnplugin((options: LoaderOptions) => {
}

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))' : ''})`)
// Temporary hardcoded check to verify runtime functionality
if (name === 'DelayedWrapperTestComponent') {
imports.add(genImport('vue', [{ name: 'defineAsyncComponent', as: '__defineAsyncComponent' }]))
imports.add(genImport(clientDelayedComponentRuntime, [{ name: 'createLazyIOClientPage' }]))
identifier += '_delayedIO'
imports.add(`const ${identifier} = createLazyIOClientPage(__defineAsyncComponent(${genDynamicImport(component.filePath, { interopDefault: false })}.then(c => c.${component.export ?? 'default'} || c)))`)
} 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))' : ''})`)
}
} else {
imports.add(genImport(component.filePath, [{ name: component._raw ? 'default' : component.export, as: identifier }]))

Expand Down
91 changes: 91 additions & 0 deletions packages/nuxt/src/components/runtime/client-delayed-component.ts
@@ -0,0 +1,91 @@
import { createStaticVNode, createVNode, defineComponent, getCurrentInstance, h, onBeforeUnmount, onMounted, ref } from 'vue'
import type { Component, Ref, VNode } from 'vue'
// import ClientOnly from '#app/components/client-only'
import { useObserver } from '#app/utils'
import { getFragmentHTML } from '#app/components/utils'
import { useNuxtApp } from '#app/nuxt'

// todo find a better way to do it ?
function elementIsVisibleInViewport (el: Element) {
const { top, left, bottom, right } = el.getBoundingClientRect()
const { innerHeight, innerWidth } = window
return ((top > 0 && top < innerHeight) ||
(bottom > 0 && bottom < innerHeight)) &&
((left > 0 && left < innerWidth) || (right > 0 && right < innerWidth))
}

/* @__NO_SIDE_EFFECTS__ */
export const createLazyIOClientPage = (componentLoader: Component) => {
return defineComponent({
inheritAttrs: false,
setup (_, { attrs }) {
if (import.meta.server) {
return () => h('div', {}, [
h(componentLoader, attrs),
])
}

const nuxt = useNuxtApp()
const instance = getCurrentInstance()!
const isIntersecting = ref(false)
const el: Ref<Element | null> = ref(null)
let unobserve: (() => void) | null = null

// todo can be refactored
if (instance.vnode.el && nuxt.isHydrating) {
isIntersecting.value = elementIsVisibleInViewport(instance.vnode.el as Element)
}

if (!isIntersecting.value) {
onMounted(() => {
const observer = useObserver()
unobserve = observer!.observe(el.value as Element, () => {
isIntersecting.value = true
unobserve?.()
unobserve = null
})
})
}
onBeforeUnmount(() => {
unobserve?.()
unobserve = null
})
return () => {
return h('div', { ref: el }, [
isIntersecting.value ? h(componentLoader, attrs) : (instance.vnode.el && nuxt.isHydrating) ? createVNode(createStaticVNode(getFragmentHTML(instance.vnode.el ?? null, true)?.join('') || '', 1)) : null,
])
}
},
})
}

/* @__NO_SIDE_EFFECTS__ */
export const createLazyNetworkClientPage = (componentLoader: Component) => {
return defineComponent({
inheritAttrs: false,
setup (_, { attrs }) {
const nuxt = useNuxtApp()
const instance = getCurrentInstance()!
let vnode: VNode | null = null
if (import.meta.client && nuxt.isHydrating && instance.vnode?.el) {
vnode = createStaticVNode(getFragmentHTML(instance.vnode.el ?? null, true)?.join('') || '', 1)
}
const isIdle = ref(false)
let idleHandle: number | null = null
onMounted(() => {
idleHandle = requestIdleCallback(() => {
isIdle.value = true
cancelIdleCallback(idleHandle as unknown as number)
idleHandle = null
})
})
onBeforeUnmount(() => {
if (idleHandle) {
cancelIdleCallback(idleHandle as unknown as number)
idleHandle = null
}
})
return () => isIdle.value ? h(componentLoader, attrs) : vnode
},
})
}
7 changes: 7 additions & 0 deletions packages/schema/src/config/experimental.ts
Expand Up @@ -238,6 +238,13 @@ export default defineUntypedSchema({
},
},

/**
* Experimental built-in delayed component hydration
*
* This enables components to lazily hydrate when needed, improving performance for sites with components below-the-fold
*/
componentLazyHydration: false,

/**
* Config schema support
* @see [Nuxt Issue #15592](https://github.com/nuxt/nuxt/issues/15592)
Expand Down
9 changes: 9 additions & 0 deletions test/basic.test.ts
Expand Up @@ -2604,4 +2604,13 @@
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', async () => {
expect(html).not.toContain('This shouldn\'t be visible at first!')
const { page } = await renderPage('/lazy-import-components')
expect(await page.locator('body').getByText('This shouldn\'t be visible at first!').all()).toHaveLength(1)
const response = page.waitForResponse(response => response.url().includes('DelayedWrapperTestComponent') && response.status() === 200)

Check failure on line 2612 in test/basic.test.ts

View workflow job for this annotation

GitHub Actions / test-fixtures (windows-latest, built, vite, async, manifest-on, v4, 18)

test/basic.test.ts > lazy import components > lazy load delayed hydration comps at the right time

TimeoutError: page.waitForResponse: Timeout 30000ms exceeded while waiting for event "response" ❯ test/basic.test.ts:2612:27

Check failure on line 2612 in test/basic.test.ts

View workflow job for this annotation

GitHub Actions / test-fixtures (windows-latest, built, vite, async, manifest-on, v3, 18)

test/basic.test.ts > lazy import components > lazy load delayed hydration comps at the right time

TimeoutError: page.waitForResponse: Timeout 30000ms exceeded while waiting for event "response" ❯ test/basic.test.ts:2612:27

Check failure on line 2612 in test/basic.test.ts

View workflow job for this annotation

GitHub Actions / test-fixtures (windows-latest, built, vite, async, manifest-off, v4, 18)

test/basic.test.ts > lazy import components > lazy load delayed hydration comps at the right time

TimeoutError: page.waitForResponse: Timeout 30000ms exceeded while waiting for event "response" ❯ test/basic.test.ts:2612:27

Check failure on line 2612 in test/basic.test.ts

View workflow job for this annotation

GitHub Actions / test-fixtures (windows-latest, built, vite, async, manifest-off, v3, 18)

test/basic.test.ts > lazy import components > lazy load delayed hydration comps at the right time

TimeoutError: page.waitForResponse: Timeout 30000ms exceeded while waiting for event "response" ❯ test/basic.test.ts:2612:27

Check failure on line 2612 in test/basic.test.ts

View workflow job for this annotation

GitHub Actions / test-fixtures (windows-latest, built, vite, default, manifest-on, v4, 18)

test/basic.test.ts > lazy import components > lazy load delayed hydration comps at the right time

TimeoutError: page.waitForResponse: Timeout 30000ms exceeded while waiting for event "response" ❯ test/basic.test.ts:2612:27

Check failure on line 2612 in test/basic.test.ts

View workflow job for this annotation

GitHub Actions / test-fixtures (windows-latest, built, vite, default, manifest-on, v3, 18)

test/basic.test.ts > lazy import components > lazy load delayed hydration comps at the right time

TimeoutError: page.waitForResponse: Timeout 30000ms exceeded while waiting for event "response" ❯ test/basic.test.ts:2612:27

Check failure on line 2612 in test/basic.test.ts

View workflow job for this annotation

GitHub Actions / test-fixtures (windows-latest, built, vite, default, manifest-off, v4, 18)

test/basic.test.ts > lazy import components > lazy load delayed hydration comps at the right time

TimeoutError: page.waitForResponse: Timeout 30000ms exceeded while waiting for event "response" ❯ test/basic.test.ts:2612:27

Check failure on line 2612 in test/basic.test.ts

View workflow job for this annotation

GitHub Actions / test-fixtures (windows-latest, built, vite, default, manifest-off, v3, 18)

test/basic.test.ts > lazy import components > lazy load delayed hydration comps at the right time

TimeoutError: page.waitForResponse: Timeout 30000ms exceeded while waiting for event "response" ❯ test/basic.test.ts:2612:27

Check failure on line 2612 in test/basic.test.ts

View workflow job for this annotation

GitHub Actions / test-fixtures (windows-latest, built, webpack, async, manifest-on, v4, 18)

test/basic.test.ts > lazy import components > lazy load delayed hydration comps at the right time

TimeoutError: page.waitForResponse: Timeout 30000ms exceeded while waiting for event "response" ❯ test/basic.test.ts:2612:27

Check failure on line 2612 in test/basic.test.ts

View workflow job for this annotation

GitHub Actions / test-fixtures (windows-latest, built, webpack, async, manifest-on, v3, 18)

test/basic.test.ts > lazy import components > lazy load delayed hydration comps at the right time

TimeoutError: page.waitForResponse: Timeout 30000ms exceeded while waiting for event "response" ❯ test/basic.test.ts:2612:27

Check failure on line 2612 in test/basic.test.ts

View workflow job for this annotation

GitHub Actions / test-fixtures (windows-latest, built, webpack, default, manifest-on, v4, 18)

test/basic.test.ts > lazy import components > lazy load delayed hydration comps at the right time

TimeoutError: page.waitForResponse: Timeout 30000ms exceeded while waiting for event "response" ❯ test/basic.test.ts:2612:27

Check failure on line 2612 in test/basic.test.ts

View workflow job for this annotation

GitHub Actions / test-fixtures (windows-latest, built, webpack, default, manifest-on, v3, 18)

test/basic.test.ts > lazy import components > lazy load delayed hydration comps at the right time

TimeoutError: page.waitForResponse: Timeout 30000ms exceeded while waiting for event "response" ❯ test/basic.test.ts:2612:27
await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight))
await response
})
})
@@ -0,0 +1,5 @@
<template>
<div>
This shouldn't be visible at first!
</div>
</template>
4 changes: 4 additions & 0 deletions test/fixtures/basic/pages/lazy-import-components/index.vue
Expand Up @@ -3,5 +3,9 @@
<LazyNCompAll message="lazy-named-comp-all" />
<LazyNCompClient message="lazy-named-comp-client" />
<LazyNCompServer message="lazy-named-comp-server" />
<div style="height:3000px">
This is a very tall div
</div>
<LazyDelayedWrapperTestComponent />
</div>
</template>
@@ -0,0 +1,5 @@
export default defineNitroPlugin((nitroApp) => {
nitroApp.hooks.hook('render:html', (html, { event }) => {
html.head = html.head.map((headSection: string) => headSection.replace(/<link((?=[^>]+\bhref="\/_nuxt\/DelayedWrapperTestComponent\.([^.]+?)\.js")[^>]+>)/, '')) // .replace(/<link rel="preload" href="\/_nuxt\/DelayedWrapperTestComponent\.js" as="script">/, "")
})
})