From e304d6c3671aa74249608c6f1ebd299094f3c709 Mon Sep 17 00:00:00 2001 From: tbitw2549 Date: Sun, 25 Aug 2024 13:53:29 +0300 Subject: [PATCH] feat: support condition, media query, and never This adds 3 more types of hydration to cover most of the use cases. --- .../2.directory-structure/1.components.md | 42 +++++++++++++++++-- packages/nuxt/src/components/loader.ts | 29 ++++++++++--- .../runtime/client-delayed-component.ts | 37 +++++++++++++++- test/basic.test.ts | 16 ++++--- .../components/DelayedCondition.client.vue | 5 +++ .../components/DelayedCondition.server.vue | 5 +++ .../basic/components/DelayedNever.client.vue | 5 +++ .../basic/components/DelayedNever.server.vue | 5 +++ .../pages/lazy-import-components/index.vue | 14 +++++++ 9 files changed, 144 insertions(+), 14 deletions(-) create mode 100644 test/fixtures/basic/components/DelayedCondition.client.vue create mode 100644 test/fixtures/basic/components/DelayedCondition.server.vue create mode 100644 test/fixtures/basic/components/DelayedNever.client.vue create mode 100644 test/fixtures/basic/components/DelayedNever.server.vue diff --git a/docs/2.guide/2.directory-structure/1.components.md b/docs/2.guide/2.directory-structure/1.components.md index 623bb6614f..d66cbdc842 100644 --- a/docs/2.guide/2.directory-structure/1.components.md +++ b/docs/2.guide/2.directory-structure/1.components.md @@ -165,6 +165,36 @@ If you would like the component to load after certain events occur, like a click ``` +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] + +``` + +For general purpose conditional hydration, you can use the `LazyIf` prefix: + +```vue [pages/index.vue] + +``` + +If you would like to never hydrate a component, use the `LazyNever` prefix: + +```vue [pages/index.vue] + +``` + ### 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. @@ -172,9 +202,11 @@ If you would like to override the default hydration triggers when dealing with d ```vue [pages/index.vue] ``` @@ -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 `` and not `` to make it a delayed hydration component, otherwise it would be treated as a regular [dynamic import](/docs/guide/directory-structure/components#dynamic-imports) :: diff --git a/packages/nuxt/src/components/loader.ts b/packages/nuxt/src/components/loader.ts index d7708af946..4fca3d1937 100644 --- a/packages/nuxt/src/components/loader.ts +++ b/packages/nuxt/src/components/loader.ts @@ -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 }])) diff --git a/packages/nuxt/src/components/runtime/client-delayed-component.ts b/packages/nuxt/src/components/runtime/client-delayed-component.ts index 293533e714..fc64a7da82 100644 --- a/packages/nuxt/src/components/runtime/client-delayed-component.ts +++ b/packages/nuxt/src/components/runtime/client-delayed-component.ts @@ -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)) + }, + }) +} \ No newline at end of file diff --git a/test/basic.test.ts b/test/basic.test.ts index 958a09cc47..17d32c9be5 100644 --- a/test/basic.test.ts +++ b/test/basic.test.ts @@ -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!') }) }) diff --git a/test/fixtures/basic/components/DelayedCondition.client.vue b/test/fixtures/basic/components/DelayedCondition.client.vue new file mode 100644 index 0000000000..94996942bb --- /dev/null +++ b/test/fixtures/basic/components/DelayedCondition.client.vue @@ -0,0 +1,5 @@ + diff --git a/test/fixtures/basic/components/DelayedCondition.server.vue b/test/fixtures/basic/components/DelayedCondition.server.vue new file mode 100644 index 0000000000..41e7cd9631 --- /dev/null +++ b/test/fixtures/basic/components/DelayedCondition.server.vue @@ -0,0 +1,5 @@ + diff --git a/test/fixtures/basic/components/DelayedNever.client.vue b/test/fixtures/basic/components/DelayedNever.client.vue new file mode 100644 index 0000000000..4cc25bdd19 --- /dev/null +++ b/test/fixtures/basic/components/DelayedNever.client.vue @@ -0,0 +1,5 @@ + diff --git a/test/fixtures/basic/components/DelayedNever.server.vue b/test/fixtures/basic/components/DelayedNever.server.vue new file mode 100644 index 0000000000..0339b1e7c4 --- /dev/null +++ b/test/fixtures/basic/components/DelayedNever.server.vue @@ -0,0 +1,5 @@ + diff --git a/test/fixtures/basic/pages/lazy-import-components/index.vue b/test/fixtures/basic/pages/lazy-import-components/index.vue index 0b49fc7fe5..1dd64147f1 100644 --- a/test/fixtures/basic/pages/lazy-import-components/index.vue +++ b/test/fixtures/basic/pages/lazy-import-components/index.vue @@ -5,10 +5,20 @@ + + +