From df04a665ce175dcff5f849de2d3ebdb004199231 Mon Sep 17 00:00:00 2001 From: Daniel Roe Date: Thu, 14 Jul 2022 18:46:12 +0100 Subject: [PATCH] perf(nuxt): tree-shake client-only components from ssr bundle (#5750) Co-authored-by: Pooya Parsa --- .../3.directory-structure/4.components.md | 4 ++ packages/nuxt/src/components/module.ts | 3 ++ packages/nuxt/src/components/tree-shake.ts | 48 +++++++++++++++++++ test/fixtures/basic/pages/client.vue | 12 +++++ 4 files changed, 67 insertions(+) create mode 100644 packages/nuxt/src/components/tree-shake.ts diff --git a/docs/content/2.guide/3.directory-structure/4.components.md b/docs/content/2.guide/3.directory-structure/4.components.md index 7c825a10d9..41b7686e82 100644 --- a/docs/content/2.guide/3.directory-structure/4.components.md +++ b/docs/content/2.guide/3.directory-structure/4.components.md @@ -174,6 +174,10 @@ Use a slot as fallback until `` is mounted on client side. ``` +::alert{type=warning} +Make sure not to _nest_ `` components or other client-only components. Nuxt performs an optimization to remove the contents of these components from the server-side render, which can break in this case. +:: + ## Library Authors Making Vue component libraries with automatic tree-shaking and component registration is super easy ✨ diff --git a/packages/nuxt/src/components/module.ts b/packages/nuxt/src/components/module.ts index e592d37e7e..a601af92bb 100644 --- a/packages/nuxt/src/components/module.ts +++ b/packages/nuxt/src/components/module.ts @@ -5,6 +5,7 @@ import type { Component, ComponentsDir, ComponentsOptions } from '@nuxt/schema' import { componentsPluginTemplate, componentsTemplate, componentsTypeTemplate } from './templates' import { scanComponents } from './scan' import { loaderPlugin } from './loader' +import { TreeShakeTemplatePlugin } from './tree-shake' const isPureObjectOrString = (val: any) => (!Array.isArray(val) && typeof val === 'object') || typeof val === 'string' const isDirectory = (p: string) => { try { return statSync(p).isDirectory() } catch (_e) { return false } } @@ -136,6 +137,7 @@ export default defineNuxtModule({ getComponents, mode: isClient ? 'client' : 'server' })) + config.plugins.push(TreeShakeTemplatePlugin.vite({ sourcemap: nuxt.options.sourcemap, getComponents })) }) nuxt.hook('webpack:config', (configs) => { configs.forEach((config) => { @@ -145,6 +147,7 @@ export default defineNuxtModule({ getComponents, mode: config.name === 'client' ? 'client' : 'server' })) + config.plugins.push(TreeShakeTemplatePlugin.webpack({ sourcemap: nuxt.options.sourcemap, getComponents })) }) }) } diff --git a/packages/nuxt/src/components/tree-shake.ts b/packages/nuxt/src/components/tree-shake.ts new file mode 100644 index 0000000000..434e41b831 --- /dev/null +++ b/packages/nuxt/src/components/tree-shake.ts @@ -0,0 +1,48 @@ +import { pathToFileURL } from 'node:url' +import { parseURL } from 'ufo' +import MagicString from 'magic-string' +import { createUnplugin } from 'unplugin' +import type { Component } from '@nuxt/schema' + +interface TreeShakeTemplatePluginOptions { + sourcemap?: boolean + getComponents(): Component[] +} + +export const TreeShakeTemplatePlugin = createUnplugin((options: TreeShakeTemplatePluginOptions) => { + const regexpMap = new WeakMap() + return { + name: 'nuxt:tree-shake-template', + enforce: 'pre', + transformInclude (id) { + const { pathname } = parseURL(decodeURIComponent(pathToFileURL(id).href)) + return pathname.endsWith('.vue') + }, + transform (code, id) { + const components = options.getComponents() + + if (!regexpMap.has(components)) { + const clientOnlyComponents = components + .filter(c => c.mode === 'client' && !components.some(other => other.mode !== 'client' && other.pascalName === c.pascalName)) + .map(c => `${c.pascalName}|${c.kebabName}`) + .concat('ClientOnly|client-only') + .map(component => `<(${component})[^>]*>[\\s\\S]*?<\\/(${component})>`) + + regexpMap.set(components, new RegExp(`(${clientOnlyComponents.join('|')})`, 'g')) + } + + const COMPONENTS_RE = regexpMap.get(components) + const s = new MagicString(code) + + // Do not render client-only slots on SSR, but preserve attributes + s.replace(COMPONENTS_RE, r => r.replace(/<([^ >]*)[ >][\s\S]*$/, '<$1 />')) + + if (s.hasChanged()) { + return { + code: s.toString(), + map: options.sourcemap && s.generateMap({ source: id, includeContent: true }) + } + } + } + } +}) diff --git a/test/fixtures/basic/pages/client.vue b/test/fixtures/basic/pages/client.vue index 457e6c0e7d..95da86f78d 100644 --- a/test/fixtures/basic/pages/client.vue +++ b/test/fixtures/basic/pages/client.vue @@ -12,5 +12,17 @@ onBeforeUnmount(() => import('~/components/BreaksServer'))