Skip to content
This repository was archived by the owner on Apr 6, 2023. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
39 commits
Select commit Hold shift + click to select a range
ec8967b
fix(nuxt): use parser for treeshake module
huang-julien Nov 15, 2022
a62d688
test(nuxt): ensure scoped style does not break
huang-julien Nov 15, 2022
ee02bed
style: lint
huang-julien Nov 15, 2022
27c9205
refactor(nuxt): improve regex and change jsdoc
huang-julien Nov 15, 2022
fecea00
fix(nuxt): treeshake client only components in setup
huang-julien Nov 15, 2022
6357b28
fix(nuxt): fix refactor + change component to components
huang-julien Nov 15, 2022
d8397c1
chore: remove `ultrahtml` dependency
danielroe Nov 6, 2022
13aa7f8
Update packages/nuxt/src/components/tree-shake.ts
huang-julien Nov 6, 2022
3bb188b
Update packages/nuxt/src/components/tree-shake.ts
huang-julien Nov 6, 2022
34107a2
refactor(nuxt): use escapeStringRegexp()
huang-julien Nov 6, 2022
3cb007a
fix(nuxt): set back treeshake for all clientonly components
huang-julien Nov 6, 2022
4ec2fa9
fix(nuxt): avoid removing placeholder slot
huang-julien Nov 6, 2022
334d68f
fix(nuxt): fix regexpMap
huang-julien Nov 7, 2022
210da41
test(nuxt): ensure .client components are treeshaken
huang-julien Nov 7, 2022
9dd27d5
style: lint
huang-julien Nov 8, 2022
24f85fe
fix(nuxt): add new regex to match component exact var name using ^$
huang-julien Nov 8, 2022
ddc0e70
fix: treeshake direct import of .client components
huang-julien Nov 9, 2022
ac4cdb2
fix: treeshake lazy client
huang-julien Nov 11, 2022
f27f94d
fix(nuxt): treeshake all slots on .client components
huang-julien Nov 15, 2022
9ee2e49
Apply suggestions from code review
huang-julien Nov 15, 2022
1a86be5
fix(nuxt): treeshake call expressions with ssrRenderComponent
huang-julien Nov 16, 2022
8923aec
Merge branch 'fix/treeshakeCLientOnlyStyle' of https://github.com/hua…
huang-julien Nov 16, 2022
3ac6cd1
Merge branch 'main' into fix/treeshakeCLientOnlyStyle
huang-julien Nov 16, 2022
cac7cc2
chore: fix lock
huang-julien Nov 16, 2022
121e1f3
Merge branch 'main' into fix/treeshakeCLientOnlyStyle
danielroe Dec 19, 2022
205293e
Merge remote-tracking branch 'origin/main' into fix/treeshakeCLientOn…
danielroe Jan 14, 2023
b08125f
test(nuxt): unit test treeshake client plugin
huang-julien Jan 22, 2023
0fe07f6
fix(nuxt): remove whole line of component declaration instead of remo…
huang-julien Jan 22, 2023
521f883
refactor(nuxt): move some logic into a function
huang-julien Jan 22, 2023
c40c649
chore: remove unecessary comment
huang-julien Jan 22, 2023
646e69f
test(nuxt): add snapshots for treeshake client only
huang-julien Jan 22, 2023
939bc3d
test: fix snapshot
huang-julien Jan 22, 2023
139c615
fix: update snapshot
huang-julien Jan 22, 2023
392b401
test: directly mock node:crypto
huang-julien Jan 22, 2023
eabbff9
Merge remote-tracking branch 'origin/main' into fix/treeshakeCLientOn…
danielroe Feb 7, 2023
73da1fe
test(nuxt): mock path relative due to os differences
huang-julien Feb 7, 2023
cbfe602
test: set correct mock
huang-julien Feb 7, 2023
7fc8681
test: fix max callstack
huang-julien Feb 7, 2023
e5271a7
lint + fix
huang-julien Feb 7, 2023
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion packages/nuxt/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
24 changes: 12 additions & 12 deletions packages/nuxt/src/components/module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -192,35 +192,35 @@ export default defineNuxtModule<ComponentsOptions>({
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
}))
})
})
}
Expand Down
185 changes: 152 additions & 33 deletions packages/nuxt/src/components/tree-shake.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -13,57 +14,115 @@ interface TreeShakeTemplatePluginOptions {
getComponents (): Component[]
}

const PLACEHOLDER_RE = /^(v-slot|#)(fallback|placeholder)/
type AcornNode<N> = 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<Component[], [RegExp, string[]]>()
const regexpMap = new WeakMap<Component[], [RegExp, RegExp, string[]]>()
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(/<template>([\s\S]*)<\/template>/)
if (!template) { return }

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 && other.filePath !== resolve(distDir, 'app/components/server-placeholder')))
.flatMap(c => [c.pascalName, c.kebabName])
.concat(['ClientOnly', 'client-only'])
const tags = clientOnlyComponents
.map(component => `<(${component})[^>]*>[\\s\\S]*?<\\/(${component})>`)
.flatMap(c => [c.pascalName, c.kebabName.replaceAll('-', '_')])
.concat(['ClientOnly', 'client_only'])

regexpMap.set(components, [new RegExp(`(${tags.join('|')})`, 'g'), clientOnlyComponents])
regexpMap.set(components, [new RegExp(`(${clientOnlyComponents.join('|')})`), new RegExp(`^(${clientOnlyComponents.map(c => `(?:(?:_unref\\()?(?:_component_)?(?:Lazy|lazy_)?${c}\\)?)`).join('|')})$`), clientOnlyComponents])
}

const [COMPONENTS_RE, clientOnlyComponents] = regexpMap.get(components)!
const s = new MagicString(code)
const importDeclarations: AcornNode<ImportDeclaration>[] = []

const [COMPONENTS_RE, COMPONENTS_IDENTIFIERS_RE] = regexpMap.get(components)!
if (!COMPONENTS_RE.test(code)) { return }

const s = new MagicString(code)
walk(this.parse(code, { sourceType: 'module', ecmaVersion: 'latest' }) as Node, {
enter: (_node) => {
const node = _node as AcornNode<CallExpression | ImportDeclaration>
if (node.type === 'ImportDeclaration') {
importDeclarations.push(node)
} else if (
node.type === 'CallExpression' &&
node.callee.type === 'Identifier' &&
SSR_RENDER_RE.test(node.callee.name)
) {
const [componentCall, _, children] = node.arguments
if (componentCall.type === 'Identifier' || componentCall.type === 'MemberExpression' || componentCall.type === 'CallExpression') {
const componentName = getComponentName(node)
const isClientComponent = COMPONENTS_IDENTIFIERS_RE.test(componentName)
const isClientOnlyComponent = /^(?:_unref\()?(?:_component_)?(?:Lazy|lazy_)?(?:client_only|ClientOnly\)?)$/.test(componentName)
if (isClientComponent && children?.type === 'ObjectExpression') {
const slotsToRemove = isClientOnlyComponent ? children.properties.filter(prop => prop.type === 'Property' && prop.key.type === 'Identifier' && !PLACEHOLDER_EXACT_RE.test(prop.key.name)) as AcornNode<Property>[] : children.properties as AcornNode<Property>[]

const ast = parse(template[0])
await walk(ast, (node) => {
if (node.type !== ELEMENT_NODE || !clientOnlyComponents.includes(node.name) || !node.children?.length) {
return
}
for (const slot of slotsToRemove) {
const componentsSet = new Set<string>()
s.remove(slot.start, slot.end + 1)
const removedCode = `({${code.slice(slot.start, slot.end + 1)}})`
const currentCode = s.toString()
walk(this.parse(removedCode, { sourceType: 'module', ecmaVersion: 'latest' }) as Node, {
enter: (_node) => {
const node = _node as AcornNode<CallExpression>
if (node.type === 'CallExpression' && node.callee.type === 'Identifier' && SSR_RENDER_RE.test(node.callee.name)) {
const componentNode = node.arguments[0]

if (componentNode.type === 'CallExpression') {
const identifier = componentNode.arguments[0] as Identifier
if (!isRenderedInCode(currentCode, removedCode.slice((componentNode as AcornNode<CallExpression>).start, (componentNode as AcornNode<CallExpression>).end))) { componentsSet.add(identifier.name) }
} else if (componentNode.type === 'Identifier' && !isRenderedInCode(currentCode, componentNode.name)) {
componentsSet.add(componentNode.name)
} else if (componentNode.type === 'MemberExpression') {
// expect componentNode to be a memberExpression (mostly used in dev with $setup[])
const { start, end } = componentNode as AcornNode<MemberExpression>
if (!isRenderedInCode(currentCode, removedCode.slice(start, end))) {
componentsSet.add(((componentNode as MemberExpression).property as Literal).value as string)
// remove the component from the return statement of `setup()`
walk(this.parse(code, { sourceType: 'module', ecmaVersion: 'latest' }) as Node, {
enter: (node) => {
removeFromSetupReturnStatement(s, node as Property, ((componentNode as MemberExpression).property as Literal).value as string)
}
})
}
}
}
}
})
const componentsToRemove = [...componentsSet]
for (const componentName of componentsToRemove) {
let removed = false
// remove const _component_ = resolveComponent...
const VAR_RE = new RegExp(`(?:const|let|var) ${componentName} = ([^;\\n]*);?`)
s.replace(VAR_RE, () => {
removed = true
return ''
})
if (!removed) {
// remove direct import
const declaration = findImportDeclaration(importDeclarations, componentName)
if (declaration) {
if (declaration.specifiers.length > 1) {
const componentSpecifier = declaration.specifiers.find(s => s.local.name === componentName) as AcornNode<Identifier> | undefined

const fallback = node.children.find(
(n: Node) => n.name === 'template' &&
Object.entries(n.attributes as Record<string, string>)?.flat().some(attr => PLACEHOLDER_RE.test(attr))
)

try {
// Replace node content
const text = fallback ? code.slice(template.index! + fallback.loc[0].start, template.index! + fallback.loc[fallback.loc.length - 1].end) : ''
s.overwrite(template.index! + node.loc[0].end, template.index! + node.loc[node.loc.length - 1].start, text)
} catch (err) {
// This may fail if we have a nested client-only component and are trying
// to replace some text that has already been replaced
if (componentSpecifier) { s.remove(componentSpecifier.start, componentSpecifier.end + 1) }
} else {
s.remove(declaration.start, declaration.end)
}
}
}
}
}
}
}
}
}
})

Expand All @@ -78,3 +137,63 @@ export const TreeShakeTemplatePlugin = createUnplugin((options: TreeShakeTemplat
}
}
})

/**
* find and return the importDeclaration that contain the import specifier
*
* @param {AcornNode<ImportDeclaration>[]} declarations - list of import declarations
* @param {string} importName - name of the import
*/
function findImportDeclaration (declarations: AcornNode<ImportDeclaration>[], importName: string): AcornNode<ImportDeclaration> | undefined {
const declaration = declarations.find((d) => {
const specifier = d.specifiers.find(s => s.local.name === importName)
if (specifier) { return true }
return false
})

return declaration
}

/**
* test if the name argument is used to render a component in the code
*
* @param code code to test
* @param name component name
*/
function isRenderedInCode (code: string, name: string) {
return new RegExp(`ssrRenderComponent\\(${escapeStringRegexp(name)}`).test(code)
}

/**
* retrieve the component identifier being used on ssrRender callExpression
*
* @param {CallExpression} ssrRenderNode - ssrRender callExpression
*/
function getComponentName (ssrRenderNode: AcornNode<CallExpression>): string {
const componentCall = ssrRenderNode.arguments[0] as Identifier | MemberExpression | CallExpression

if (componentCall.type === 'Identifier') {
return componentCall.name
} else if (componentCall.type === 'MemberExpression') {
return (componentCall.property as Literal).value as string
}
return (componentCall.arguments[0] as Identifier).name
}

/**
* remove a key from the return statement of the setup function
*/
function removeFromSetupReturnStatement (s: MagicString, node: Property, name: string) {
if (node.type === 'Property' && node.key.type === 'Identifier' && node.key.name === 'setup' && node.value.type === 'FunctionExpression') {
const returnStatement = node.value.body.body.find(n => n.type === 'ReturnStatement') as ReturnStatement | undefined
if (returnStatement?.argument?.type === 'Identifier') {
const returnIdentifier = returnStatement.argument.name
const returnedDeclaration = node.value.body.body.find(n => n.type === 'VariableDeclaration' && (n.declarations[0].id as Identifier).name === returnIdentifier) as AcornNode<VariableDeclaration>
const componentProperty = (returnedDeclaration?.declarations[0].init as ObjectExpression)?.properties.find(p => ((p as Property).key as Identifier).name === name) as AcornNode<Property>
if (componentProperty) { s.remove(componentProperty.start, componentProperty.end + 1) }
} else if (returnStatement?.argument?.type === 'ObjectExpression') {
const componentProperty = returnStatement.argument?.properties.find(p => ((p as Property).key as Identifier).name === name) as AcornNode<Property>
if (componentProperty) { s.remove(componentProperty.start, componentProperty.end + 1) }
}
}
}
Binary file not shown.
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
<template>
<div>
<div>
<Glob />
</div>
{{ hello }}
<div class="not-client">
Hello
</div>
<ClientOnly>
<HelloWorld />
<Glob />
<SomeGlob />
</ClientOnly>
<ClientOnly>
<div class="should-be-treeshaken">
this should not be visible
</div>
<ClientImport />
<Treeshaken />
<ResolvedImport />
</ClientOnly>
</div>
</template>

<script setup>
import { Treeshaken } from 'somepath'
import HelloWorld from '../HelloWorld.vue'
import { Glob, ClientImport } from '#components'

const hello = 'world'
</script>

<style scoped>
.not-client {
color: "red";
}
</style>
12 changes: 12 additions & 0 deletions packages/nuxt/test/scan-components.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
Loading