Skip to content
This repository has been archived by the owner on Apr 6, 2023. It is now read-only.

Commit

Permalink
feat(nuxt): wrap #components client exports with createClientOnly (#…
Browse files Browse the repository at this point in the history
…7412)

Co-authored-by: jhuang@hsk-partners.com <jhuang@hsk-partners.com>
  • Loading branch information
huang-julien and jhuang@hsk-partners.com committed Oct 11, 2022
1 parent 2aa0973 commit ee41bb6
Show file tree
Hide file tree
Showing 7 changed files with 71 additions and 20 deletions.
2 changes: 1 addition & 1 deletion docs/content/2.guide/2.directory-structure/1.components.md
Expand Up @@ -201,7 +201,7 @@ If a component is meant to be rendered only client-side, you can add the `.clien
```

::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
Expand Down
12 changes: 12 additions & 0 deletions packages/nuxt/src/components/module.ts
Expand Up @@ -2,6 +2,7 @@ import { statSync } from 'node:fs'
import { relative, resolve } from 'pathe'
import { defineNuxtModule, resolveAlias, addTemplate, addPluginTemplate } from '@nuxt/kit'
import type { Component, ComponentsDir, ComponentsOptions } from '@nuxt/schema'
import { distDir } from '../dirs'
import { componentsPluginTemplate, componentsTemplate, componentsTypeTemplate } from './templates'
import { scanComponents } from './scan'
import { loaderPlugin } from './loader'
Expand Down Expand Up @@ -146,6 +147,17 @@ export default defineNuxtModule<ComponentsOptions>({
nuxt.hook('app:templates', async () => {
const newComponents = await scanComponents(componentDirs, nuxt.options.srcDir!)
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
})

Expand Down
36 changes: 25 additions & 11 deletions packages/nuxt/src/components/templates.ts
@@ -1,6 +1,6 @@
import { isAbsolute, relative } from 'pathe'
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 {
nuxt: Nuxt
Expand Down Expand Up @@ -53,17 +53,31 @@ export default defineNuxtPlugin(nuxtApp => {
export const componentsTemplate: NuxtTemplate<ComponentsTemplateContext> = {
// components.[server|client].mjs'
getContents ({ options }) {
return [
'import { defineAsyncComponent } from \'vue\'',
...options.getComponents(options.mode).flatMap((c) => {
const exp = c.export === 'default' ? 'c.default || c' : `c['${c.export}']`
const comment = createImportMagicComments(c)
const imports = new Set<string>()
imports.add('import { defineAsyncComponent } from \'vue\'')

let num = 0
const components = options.getComponents(options.mode).flatMap((c) => {
const exp = c.export === 'default' ? 'c.default || c' : `c['${c.export}']`
const comment = createImportMagicComments(c)

return [
genExport(c.filePath, [{ name: c.export, as: c.pascalName }]),
`export const Lazy${c.pascalName} = defineAsyncComponent(${genDynamicImport(c.filePath, { comment })}.then(c => ${exp}))`
]
}),
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 [
...imports,
...components,
`export const componentNames = ${JSON.stringify(options.getComponents().map(c => c.pascalName))}`
].join('\n')
}
Expand Down
11 changes: 11 additions & 0 deletions test/basic.test.ts
Expand Up @@ -219,6 +219,17 @@ describe('pages', () => {
await Promise.all(hiddenSelectors.map(selector => page.locator(selector).isVisible()))
.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', () => {
Expand Down
11 changes: 11 additions & 0 deletions test/fixtures/basic/pages/client-only-explicit-import.vue
@@ -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>
19 changes: 11 additions & 8 deletions test/fixtures/basic/pages/client.vue
@@ -1,12 +1,15 @@
<script setup lang="ts">
onMounted(() => import('~/components/BreaksServer'))
onBeforeMount(() => import('~/components/BreaksServer'))
onBeforeUpdate(() => import('~/components/BreaksServer'))
onRenderTracked(() => import('~/components/BreaksServer'))
onRenderTriggered(() => import('~/components/BreaksServer'))
onActivated(() => import('~/components/BreaksServer'))
onDeactivated(() => import('~/components/BreaksServer'))
onBeforeUnmount(() => import('~/components/BreaksServer'))
// explicit import to bypass client import protection
import BreaksServer from '../components/BreaksServer.client'
onMounted(() => import('~/components/BreaksServer.client'))
onBeforeMount(() => import('~/components/BreaksServer.client'))
onBeforeUpdate(() => import('~/components/BreaksServer.client'))
onRenderTracked(() => import('~/components/BreaksServer.client'))
onRenderTriggered(() => import('~/components/BreaksServer.client'))
onActivated(() => import('~/components/BreaksServer.client'))
onDeactivated(() => import('~/components/BreaksServer.client'))
onBeforeUnmount(() => import('~/components/BreaksServer.client'))
</script>

<template>
Expand Down

0 comments on commit ee41bb6

Please sign in to comment.