From 113ce71c3443fdf6ba59f2c5819b459b9ac82b09 Mon Sep 17 00:00:00 2001 From: Julien Huang Date: Wed, 8 Feb 2023 09:59:57 +0100 Subject: [PATCH] fix(nuxt): use parser to treeshake `` (#8713) --- packages/nuxt/package.json | 1 - packages/nuxt/src/components/module.ts | 24 +-- packages/nuxt/src/components/tree-shake.ts | 183 +++++++++++++++--- .../treeshake-client.test.ts.snap | Bin 0 -> 3709 bytes .../components/client/WithClientOnlySetup.vue | 38 ++++ packages/nuxt/test/scan-components.test.ts | 12 ++ packages/nuxt/test/treeshake-client.test.ts | 134 +++++++++++++ packages/nuxt/test/utils.ts | 3 + pnpm-lock.yaml | 6 - test/basic.test.ts | 10 + .../basic/pages/client-only-components.vue | 4 + test/fixtures/basic/pages/client.vue | 48 ++++- vitest.config.ts | 1 + 13 files changed, 411 insertions(+), 53 deletions(-) create mode 100644 packages/nuxt/test/__snapshots__/treeshake-client.test.ts.snap create mode 100644 packages/nuxt/test/fixture/components/client/WithClientOnlySetup.vue create mode 100644 packages/nuxt/test/treeshake-client.test.ts create mode 100644 packages/nuxt/test/utils.ts diff --git a/packages/nuxt/package.json b/packages/nuxt/package.json index 5f90d2fad4..6ff6052ef8 100644 --- a/packages/nuxt/package.json +++ b/packages/nuxt/package.json @@ -70,7 +70,6 @@ "scule": "^1.0.0", "strip-literal": "^1.0.1", "ufo": "^1.0.1", - "ultrahtml": "^1.2.0", "unctx": "^2.1.1", "unenv": "^1.1.0", "unhead": "^1.0.21", diff --git a/packages/nuxt/src/components/module.ts b/packages/nuxt/src/components/module.ts index a340646d7d..41d7a5a834 100644 --- a/packages/nuxt/src/components/module.ts +++ b/packages/nuxt/src/components/module.ts @@ -192,35 +192,35 @@ export default defineNuxtModule({ const mode = isClient ? 'client' : 'server' config.plugins = config.plugins || [] - config.plugins.push(loaderPlugin.vite({ - sourcemap: nuxt.options.sourcemap[mode], - getComponents, - mode, - experimentalComponentIslands: nuxt.options.experimental.componentIslands - })) if (nuxt.options.experimental.treeshakeClientOnly && isServer) { config.plugins.push(TreeShakeTemplatePlugin.vite({ sourcemap: nuxt.options.sourcemap[mode], getComponents })) } + config.plugins.push(loaderPlugin.vite({ + sourcemap: nuxt.options.sourcemap[mode], + getComponents, + mode, + experimentalComponentIslands: nuxt.options.experimental.componentIslands + })) }) nuxt.hook('webpack:config', (configs) => { configs.forEach((config) => { const mode = config.name === 'client' ? 'client' : 'server' config.plugins = config.plugins || [] - config.plugins.push(loaderPlugin.webpack({ - sourcemap: nuxt.options.sourcemap[mode], - getComponents, - mode, - experimentalComponentIslands: nuxt.options.experimental.componentIslands - })) if (nuxt.options.experimental.treeshakeClientOnly && mode === 'server') { config.plugins.push(TreeShakeTemplatePlugin.webpack({ sourcemap: nuxt.options.sourcemap[mode], getComponents })) } + config.plugins.push(loaderPlugin.webpack({ + sourcemap: nuxt.options.sourcemap[mode], + getComponents, + mode, + experimentalComponentIslands: nuxt.options.experimental.componentIslands + })) }) }) } diff --git a/packages/nuxt/src/components/tree-shake.ts b/packages/nuxt/src/components/tree-shake.ts index 7e1b49d527..874003d87d 100644 --- a/packages/nuxt/src/components/tree-shake.ts +++ b/packages/nuxt/src/components/tree-shake.ts @@ -1,9 +1,10 @@ import { pathToFileURL } from 'node:url' import { parseURL } from 'ufo' import MagicString from 'magic-string' -import type { Node } from 'ultrahtml' -import { parse, walk, ELEMENT_NODE } from 'ultrahtml' +import { walk } from 'estree-walker' +import type { CallExpression, Property, Identifier, ImportDeclaration, MemberExpression, Literal, ReturnStatement, VariableDeclaration, ObjectExpression, Node } from 'estree' import { createUnplugin } from 'unplugin' +import escapeStringRegexp from 'escape-string-regexp' import type { Component } from '@nuxt/schema' import { resolve } from 'pathe' import { distDir } from '../dirs' @@ -13,57 +14,115 @@ interface TreeShakeTemplatePluginOptions { getComponents (): Component[] } -const PLACEHOLDER_RE = /^(v-slot|#)(fallback|placeholder)/ +type AcornNode = N & { start: number, end: number } + +const SSR_RENDER_RE = /ssrRenderComponent/ +const PLACEHOLDER_EXACT_RE = /^(fallback|placeholder)$/ export const TreeShakeTemplatePlugin = createUnplugin((options: TreeShakeTemplatePluginOptions) => { - const regexpMap = new WeakMap() + const regexpMap = new WeakMap() return { name: 'nuxt:tree-shake-template', - enforce: 'pre', + enforce: 'post', transformInclude (id) { const { pathname } = parseURL(decodeURIComponent(pathToFileURL(id).href)) return pathname.endsWith('.vue') }, - async transform (code, id) { - const template = code.match(/ + + + + diff --git a/packages/nuxt/test/scan-components.test.ts b/packages/nuxt/test/scan-components.test.ts index 99ce7447b6..d9fa5fbdf6 100644 --- a/packages/nuxt/test/scan-components.test.ts +++ b/packages/nuxt/test/scan-components.test.ts @@ -147,6 +147,18 @@ const expectedComponents = [ prefetch: false, preload: false }, + { + chunkName: 'components/client-with-client-only-setup', + export: 'default', + global: undefined, + island: undefined, + kebabName: 'client-with-client-only-setup', + mode: 'all', + pascalName: 'ClientWithClientOnlySetup', + prefetch: false, + preload: false, + shortPath: 'components/client/WithClientOnlySetup.vue' + }, { mode: 'server', pascalName: 'ParentFolder', diff --git a/packages/nuxt/test/treeshake-client.test.ts b/packages/nuxt/test/treeshake-client.test.ts new file mode 100644 index 0000000000..e2e903e62c --- /dev/null +++ b/packages/nuxt/test/treeshake-client.test.ts @@ -0,0 +1,134 @@ +import { readFileSync } from 'node:fs' +import path from 'node:path' +import { describe, it, expect, vi } from 'vitest' +import * as VueCompilerSFC from 'vue/compiler-sfc' +import type { Plugin } from 'vite' +import { Parser } from 'acorn' +import type { Options } from '@vitejs/plugin-vue' +import _vuePlugin from '@vitejs/plugin-vue' +import { TreeShakeTemplatePlugin } from '../src/components/tree-shake' +import { fixtureDir } from './utils' + +vi.mock('node:crypto', () => ({ + update: vi.fn().mockReturnThis(), + digest: vi.fn().mockReturnValue('one-hash-to-rule-them-all'), + createHash: vi.fn().mockReturnThis() +})) + +// mock due to differences of results between windows and linux +vi.spyOn(path, 'relative').mockImplementation((from: string, to: string) => { + if (to.includes('SomeComponent')) { + return to + } + return path.resolve(from, to) +}) + +function vuePlugin (options: Options) { + return { + ..._vuePlugin(options), + handleHotUpdate () {}, + configureDevServer () {} + } +} + +const WithClientOnly = readFileSync(path.resolve(fixtureDir, './components/client/WithClientOnlySetup.vue')).toString() + +const treeshakeTemplatePlugin = TreeShakeTemplatePlugin.raw({ sourcemap: false, getComponents () { return [] } }, { framework: 'rollup' }) as Plugin + +const treeshake = async (source: string): Promise => { + const result = await (treeshakeTemplatePlugin.transform! as Function).call({ + parse: (code: string, opts: any = {}) => Parser.parse(code, { + sourceType: 'module', + ecmaVersion: 'latest', + locations: true, + ...opts + }) + }, source) + return typeof result === 'string' ? result : result?.code +} + +async function SFCCompile (name: string, source: string, options: Options, ssr = false): Promise { + const result = await (vuePlugin({ + compiler: VueCompilerSFC, + ...options + }).transform! as Function).call({ + parse: (code: string, opts: any = {}) => Parser.parse(code, { + sourceType: 'module', + ecmaVersion: 'latest', + locations: true, + ...opts + }) + }, source, name, { + ssr + }) + + return typeof result === 'string' ? result : result?.code +} + +const stateToTest: {name: string, options: Partial }[] = [ + { + name: 'prod', + options: { + isProduction: true + } + }, + { + name: 'dev not inlined', + options: { + isProduction: false, + devServer: { + config: { + // trigger dev behavior + server: false + } + } + } + } +] + +describe('treeshake client only in ssr', () => { + vi.spyOn(process, 'cwd').mockImplementation(() => '') + for (const [index, state] of stateToTest.entries()) { + it(`should treeshake ClientOnly correctly in ${state.name}`, async () => { + // add index to avoid using vite vue plugin cache + const clientResult = await SFCCompile(`SomeComponent${index}.vue`, WithClientOnly, state.options) + + const ssrResult = await SFCCompile(`SomeComponent${index}.vue`, WithClientOnly, state.options, true) + + const treeshaked = await treeshake(ssrResult) + const [_, scopeId] = clientResult.match(/_pushScopeId\("(.*)"\)/)! + + // ensure the id is correctly passed between server and client + expect(clientResult).toContain(`pushScopeId("${scopeId}")`) + expect(treeshaked).toContain(`
`) + + expect(clientResult).toContain('should-be-treeshaken') + expect(treeshaked).not.toContain('should-be-treeshaken') + + expect(treeshaked).not.toContain("import HelloWorld from '../HelloWorld.vue'") + expect(clientResult).toContain("import HelloWorld from '../HelloWorld.vue'") + + expect(treeshaked).not.toContain("import { Treeshaken } from 'somepath'") + expect(clientResult).toContain("import { Treeshaken } from 'somepath'") + + // remove resolved import + expect(treeshaked).not.toContain('const _component_ResolvedImport =') + expect(clientResult).toContain('const _component_ResolvedImport =') + + // expect import of ClientImport to be treeshaken but not Glob since it is also used outside + expect(treeshaked).not.toContain('ClientImport') + expect(treeshaked).toContain('import { Glob, } from \'#components\'') + + if (state.options.isProduction === false) { + // treeshake at inlined template + expect(treeshaked).not.toContain('ssrRenderComponent($setup["HelloWorld"]') + expect(treeshaked).toContain('ssrRenderComponent($setup["Glob"]') + } else { + // treeshake unref + expect(treeshaked).not.toContain('ssrRenderComponent(_unref(HelloWorld') + expect(treeshaked).toContain('ssrRenderComponent(_unref(Glob') + } + expect(treeshaked).toMatchSnapshot() + }) + } +}) diff --git a/packages/nuxt/test/utils.ts b/packages/nuxt/test/utils.ts new file mode 100644 index 0000000000..8d791a8b51 --- /dev/null +++ b/packages/nuxt/test/utils.ts @@ -0,0 +1,3 @@ +import { resolve } from 'node:path' + +export const fixtureDir = resolve(__dirname, 'fixture') diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a118715a9f..fbf445c193 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -445,7 +445,6 @@ importers: scule: ^1.0.0 strip-literal: ^1.0.1 ufo: ^1.0.1 - ultrahtml: ^1.2.0 unbuild: ^1.1.1 unctx: ^2.1.1 unenv: ^1.1.0 @@ -492,7 +491,6 @@ importers: scule: 1.0.0 strip-literal: 1.0.1 ufo: 1.0.1 - ultrahtml: 1.2.0 unctx: 2.1.1 unenv: 1.1.0 unhead: 1.0.21 @@ -8151,10 +8149,6 @@ packages: /ufo/1.0.1: resolution: {integrity: sha512-boAm74ubXHY7KJQZLlXrtMz52qFvpsbOxDcZOnw/Wf+LS4Mmyu7JxmzD4tDLtUQtmZECypJ0FrCz4QIe6dvKRA==} - /ultrahtml/1.2.0: - resolution: {integrity: sha512-vxZM2yNvajRmCj/SknRYGNXk2tqiy6kRNvZjJLaleG3zJbSh/aNkOqD1/CVzypw8tyHyhpzYuwQgMMhUB4ZVNQ==} - dev: false - /unbox-primitive/1.0.2: resolution: {integrity: sha512-61pPlCD9h51VoreyJ0BReideM3MDKMKnh6+V9L08331ipq6Q8OFXZYiqP6n/tbHx4s5I9uRhcye6BrbkizkBDw==} dependencies: diff --git a/test/basic.test.ts b/test/basic.test.ts index 3cab371591..a49028e5f3 100644 --- a/test/basic.test.ts +++ b/test/basic.test.ts @@ -444,6 +444,16 @@ describe('server tree shaking', () => { const html = await $fetch('/client') expect(html).toContain('This page should not crash when rendered') + expect(html).toContain('fallback for ClientOnly') + expect(html).not.toContain('rendered client-side') + expect(html).not.toContain('id="client-side"') + + const page = await createPage('/client') + await page.waitForLoadState('networkidle') + // ensure scoped classes are correctly assigned between client and server + expect(await page.$eval('.red', e => getComputedStyle(e).color)).toBe('rgb(255, 0, 0)') + expect(await page.$eval('.blue', e => getComputedStyle(e).color)).toBe('rgb(0, 0, 255)') + expect(await page.locator('#client-side').textContent()).toContain('This should be rendered client-side') }) }) diff --git a/test/fixtures/basic/pages/client-only-components.vue b/test/fixtures/basic/pages/client-only-components.vue index 270d62f385..1581b479f0 100644 --- a/test/fixtures/basic/pages/client-only-components.vue +++ b/test/fixtures/basic/pages/client-only-components.vue @@ -9,6 +9,7 @@ @@ -59,6 +60,9 @@