feat(nuxt): wrap #components client exports with createClientOnly (#7412)

Co-authored-by: jhuang@hsk-partners.com <jhuang@hsk-partners.com>
This commit is contained in:
Julien Huang 2022-10-11 17:26:03 +02:00 committed by GitHub
parent 2aa097310c
commit ee41bb6d5d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 71 additions and 20 deletions

View File

@ -201,7 +201,7 @@ If a component is meant to be rendered only client-side, you can add the `.clien
``` ```
::alert{type=warning} ::alert{type=warning}
This feature only works with Nuxt auto-imports. Explicitly importing these components does not convert them into client-only components. This feature only works with Nuxt auto-imports and `#components` imports. Explicitly importing these components from their real paths does not convert them into client-only components.
:: ::
## .server Components ## .server Components

View File

@ -2,6 +2,7 @@ import { statSync } from 'node:fs'
import { relative, resolve } from 'pathe' import { relative, resolve } from 'pathe'
import { defineNuxtModule, resolveAlias, addTemplate, addPluginTemplate } from '@nuxt/kit' import { defineNuxtModule, resolveAlias, addTemplate, addPluginTemplate } from '@nuxt/kit'
import type { Component, ComponentsDir, ComponentsOptions } from '@nuxt/schema' import type { Component, ComponentsDir, ComponentsOptions } from '@nuxt/schema'
import { distDir } from '../dirs'
import { componentsPluginTemplate, componentsTemplate, componentsTypeTemplate } from './templates' import { componentsPluginTemplate, componentsTemplate, componentsTypeTemplate } from './templates'
import { scanComponents } from './scan' import { scanComponents } from './scan'
import { loaderPlugin } from './loader' import { loaderPlugin } from './loader'
@ -146,6 +147,17 @@ export default defineNuxtModule<ComponentsOptions>({
nuxt.hook('app:templates', async () => { nuxt.hook('app:templates', async () => {
const newComponents = await scanComponents(componentDirs, nuxt.options.srcDir!) const newComponents = await scanComponents(componentDirs, nuxt.options.srcDir!)
await nuxt.callHook('components:extend', newComponents) await nuxt.callHook('components:extend', newComponents)
// add server placeholder for .client components server side. issue: #7085
for (const component of newComponents) {
if (component.mode === 'client' && !newComponents.some(c => c.pascalName === component.pascalName && c.mode === 'server')) {
newComponents.push({
...component,
mode: 'server',
filePath: resolve(distDir, 'app/components/server-placeholder'),
chunkName: 'components/' + component.kebabName
})
}
}
context.components = newComponents context.components = newComponents
}) })

View File

@ -1,6 +1,6 @@
import { isAbsolute, relative } from 'pathe' import { isAbsolute, relative } from 'pathe'
import type { Component, Nuxt, NuxtPluginTemplate, NuxtTemplate } from '@nuxt/schema' import type { Component, Nuxt, NuxtPluginTemplate, NuxtTemplate } from '@nuxt/schema'
import { genDynamicImport, genExport, genObjectFromRawEntries } from 'knitwork' import { genDynamicImport, genExport, genImport, genObjectFromRawEntries } from 'knitwork'
export interface ComponentsTemplateContext { export interface ComponentsTemplateContext {
nuxt: Nuxt nuxt: Nuxt
@ -53,17 +53,31 @@ export default defineNuxtPlugin(nuxtApp => {
export const componentsTemplate: NuxtTemplate<ComponentsTemplateContext> = { export const componentsTemplate: NuxtTemplate<ComponentsTemplateContext> = {
// components.[server|client].mjs' // components.[server|client].mjs'
getContents ({ options }) { getContents ({ options }) {
return [ const imports = new Set<string>()
'import { defineAsyncComponent } from \'vue\'', imports.add('import { defineAsyncComponent } from \'vue\'')
...options.getComponents(options.mode).flatMap((c) => {
let num = 0
const components = options.getComponents(options.mode).flatMap((c) => {
const exp = c.export === 'default' ? 'c.default || c' : `c['${c.export}']` const exp = c.export === 'default' ? 'c.default || c' : `c['${c.export}']`
const comment = createImportMagicComments(c) const comment = createImportMagicComments(c)
const isClient = c.mode === 'client'
const definitions = []
if (isClient) {
num++
const identifier = `__nuxt_component_${num}`
imports.add(genImport('#app/components/client-only', [{ name: 'createClientOnly' }]))
imports.add(genImport(c.filePath, [{ name: c.export, as: identifier }]))
definitions.push(`export const ${c.pascalName} = /*#__PURE__*/ createClientOnly(${identifier})`)
} else {
definitions.push(genExport(c.filePath, [{ name: c.export, as: c.pascalName }]))
}
definitions.push(`export const Lazy${c.pascalName} = defineAsyncComponent(${genDynamicImport(c.filePath, { comment })}.then(c => ${isClient ? `createClientOnly(${exp})` : exp}))`)
return definitions
})
return [ return [
genExport(c.filePath, [{ name: c.export, as: c.pascalName }]), ...imports,
`export const Lazy${c.pascalName} = defineAsyncComponent(${genDynamicImport(c.filePath, { comment })}.then(c => ${exp}))` ...components,
]
}),
`export const componentNames = ${JSON.stringify(options.getComponents().map(c => c.pascalName))}` `export const componentNames = ${JSON.stringify(options.getComponents().map(c => c.pascalName))}`
].join('\n') ].join('\n')
} }

View File

@ -219,6 +219,17 @@ describe('pages', () => {
await Promise.all(hiddenSelectors.map(selector => page.locator(selector).isVisible())) await Promise.all(hiddenSelectors.map(selector => page.locator(selector).isVisible()))
.then(results => results.forEach(isVisible => expect(isVisible).toBeTruthy())) .then(results => results.forEach(isVisible => expect(isVisible).toBeTruthy()))
}) })
it('/client-only-explicit-import', async () => {
const html = await $fetch('/client-only-explicit-import')
// ensure fallbacks with classes and arbitrary attributes are rendered
expect(html).toContain('<div class="client-only-script" foo="bar">')
expect(html).toContain('<div class="lazy-client-only-script-setup" foo="hello">')
// ensure components are not rendered server-side
expect(html).not.toContain('client only script')
await expectNoClientErrors('/client-only-components')
})
}) })
describe('head tags', () => { describe('head tags', () => {

View File

@ -0,0 +1,11 @@
<template>
<div>
<ClientOnlyScript class="client-only-script" foo="bar" />
<LazyClientOnlySetupScript class="lazy-client-only-script-setup" foo="hello" />
</div>
</template>
<script setup lang="ts">
import { ClientOnlyScript, LazyClientOnlySetupScript } from '#components'
</script>

View File

@ -1,12 +1,15 @@
<script setup lang="ts"> <script setup lang="ts">
onMounted(() => import('~/components/BreaksServer')) // explicit import to bypass client import protection
onBeforeMount(() => import('~/components/BreaksServer')) import BreaksServer from '../components/BreaksServer.client'
onBeforeUpdate(() => import('~/components/BreaksServer'))
onRenderTracked(() => import('~/components/BreaksServer')) onMounted(() => import('~/components/BreaksServer.client'))
onRenderTriggered(() => import('~/components/BreaksServer')) onBeforeMount(() => import('~/components/BreaksServer.client'))
onActivated(() => import('~/components/BreaksServer')) onBeforeUpdate(() => import('~/components/BreaksServer.client'))
onDeactivated(() => import('~/components/BreaksServer')) onRenderTracked(() => import('~/components/BreaksServer.client'))
onBeforeUnmount(() => import('~/components/BreaksServer')) onRenderTriggered(() => import('~/components/BreaksServer.client'))
onActivated(() => import('~/components/BreaksServer.client'))
onDeactivated(() => import('~/components/BreaksServer.client'))
onBeforeUnmount(() => import('~/components/BreaksServer.client'))
</script> </script>
<template> <template>