diff --git a/packages/nuxt/package.json b/packages/nuxt/package.json index 8fd4adbaa9..7b02e28786 100644 --- a/packages/nuxt/package.json +++ b/packages/nuxt/package.json @@ -59,6 +59,7 @@ "pathe": "^0.3.2", "perfect-debounce": "^0.1.3", "scule": "^0.2.1", + "strip-literal": "^0.4.0", "ufo": "^0.8.4", "unctx": "^1.1.4", "unenv": "^0.5.2", diff --git a/packages/nuxt/src/core/nuxt.ts b/packages/nuxt/src/core/nuxt.ts index a8b4dffb29..55b308230c 100644 --- a/packages/nuxt/src/core/nuxt.ts +++ b/packages/nuxt/src/core/nuxt.ts @@ -14,6 +14,7 @@ import { distDir, pkgDir } from '../dirs' import { version } from '../../package.json' import { ImportProtectionPlugin, vueAppPatterns } from './plugins/import-protection' import { UnctxTransformPlugin } from './plugins/unctx' +import { TreeShakePlugin } from './plugins/tree-shake' import { addModuleTranspiles } from './modules' import { initNitro } from './nitro' @@ -67,6 +68,17 @@ async function initNuxt (nuxt: Nuxt) { addVitePlugin(UnctxTransformPlugin(nuxt).vite({ sourcemap: nuxt.options.sourcemap })) addWebpackPlugin(UnctxTransformPlugin(nuxt).webpack({ sourcemap: nuxt.options.sourcemap })) + if (!nuxt.options.dev) { + const removeFromServer = ['onBeforeMount', 'onMounted', 'onBeforeUpdate', 'onRenderTracked', 'onRenderTriggered', 'onActivated', 'onDeactivated', 'onBeforeUnmount'] + const removeFromClient = ['onServerPrefetch', 'onRenderTracked', 'onRenderTriggered'] + + // Add tree-shaking optimisations for SSR - build time only + addVitePlugin(TreeShakePlugin.vite({ sourcemap: nuxt.options.sourcemap, treeShake: removeFromServer }), { client: false }) + addVitePlugin(TreeShakePlugin.vite({ sourcemap: nuxt.options.sourcemap, treeShake: removeFromClient }), { server: false }) + addWebpackPlugin(TreeShakePlugin.webpack({ sourcemap: nuxt.options.sourcemap, treeShake: removeFromServer }), { client: false }) + addWebpackPlugin(TreeShakePlugin.webpack({ sourcemap: nuxt.options.sourcemap, treeShake: removeFromClient }), { server: false }) + } + // Transpile layers within node_modules nuxt.options.build.transpile.push( ...nuxt.options._layers.filter(i => i.cwd && i.cwd.includes('node_modules')).map(i => i.cwd) diff --git a/packages/nuxt/src/core/plugins/tree-shake.ts b/packages/nuxt/src/core/plugins/tree-shake.ts new file mode 100644 index 0000000000..88080d3246 --- /dev/null +++ b/packages/nuxt/src/core/plugins/tree-shake.ts @@ -0,0 +1,49 @@ +import { pathToFileURL } from 'node:url' +import { stripLiteral } from 'strip-literal' +import { parseQuery, parseURL } from 'ufo' +import MagicString from 'magic-string' +import { createUnplugin } from 'unplugin' + +interface TreeShakePluginOptions { + sourcemap?: boolean + treeShake: string[] +} + +export const TreeShakePlugin = createUnplugin((options: TreeShakePluginOptions) => { + const COMPOSABLE_RE = new RegExp(`($|\\s*)(${options.treeShake.join('|')})(?=\\()`, 'g') + + return { + name: 'nuxt:server-treeshake:transfrom', + enforce: 'post', + transformInclude (id) { + const { pathname, search } = parseURL(decodeURIComponent(pathToFileURL(id).href)) + const { type, macro } = parseQuery(search) + + // vue files + if (pathname.endsWith('.vue') && (type === 'script' || macro || !search)) { + return true + } + + // js files + if (pathname.match(/\.((c|m)?j|t)sx?$/g)) { + return true + } + }, + transform (code, id) { + if (!code.match(COMPOSABLE_RE)) { return } + + const s = new MagicString(code) + const strippedCode = stripLiteral(code) + for (const match of strippedCode.matchAll(COMPOSABLE_RE) || []) { + s.overwrite(match.index, match.index + match[0].length, `(() => {}) || /*#__PURE__*/ false && ${match[0]}`) + } + + if (s.hasChanged()) { + return { + code: s.toString(), + map: options.sourcemap && s.generateMap({ source: id, includeContent: true }) + } + } + } + } +}) diff --git a/test/basic.test.ts b/test/basic.test.ts index ea4ce3a0ca..24213c4c75 100644 --- a/test/basic.test.ts +++ b/test/basic.test.ts @@ -244,6 +244,14 @@ describe('reactivity transform', () => { }) }) +describe('server tree shaking', () => { + it('should work', async () => { + const html = await $fetch('/client') + + expect(html).toContain('This page should not crash when rendered') + }) +}) + describe('extends support', () => { describe('layouts & pages', () => { it('extends foo/layouts/default & foo/pages/index', async () => { diff --git a/test/fixtures/basic/components/BreaksServer.ts b/test/fixtures/basic/components/BreaksServer.ts new file mode 100644 index 0000000000..b3c50e01d6 --- /dev/null +++ b/test/fixtures/basic/components/BreaksServer.ts @@ -0,0 +1,6 @@ +// @ts-ignore +window.test = true + +export default () => ({ + render: () => 'hi' +}) diff --git a/test/fixtures/basic/pages/client.vue b/test/fixtures/basic/pages/client.vue new file mode 100644 index 0000000000..457e6c0e7d --- /dev/null +++ b/test/fixtures/basic/pages/client.vue @@ -0,0 +1,16 @@ + + + diff --git a/yarn.lock b/yarn.lock index 671783e5cb..6b6eb9da54 100644 --- a/yarn.lock +++ b/yarn.lock @@ -9900,6 +9900,7 @@ __metadata: pathe: ^0.3.2 perfect-debounce: ^0.1.3 scule: ^0.2.1 + strip-literal: ^0.4.0 ufo: ^0.8.4 unbuild: latest unctx: ^1.1.4