mirror of
https://github.com/nuxt/nuxt.git
synced 2025-02-16 13:48:13 +00:00
fix(nuxt): use parser to treeshake client-only declarations (#18951)
This commit is contained in:
parent
9dc5413cbd
commit
61cd6b5c71
@ -2,22 +2,23 @@ import { pathToFileURL } from 'node:url'
|
|||||||
import { parseURL } from 'ufo'
|
import { parseURL } from 'ufo'
|
||||||
import MagicString from 'magic-string'
|
import MagicString from 'magic-string'
|
||||||
import { walk } from 'estree-walker'
|
import { walk } from 'estree-walker'
|
||||||
import type { CallExpression, Property, Identifier, ImportDeclaration, MemberExpression, Literal, ReturnStatement, VariableDeclaration, ObjectExpression, Node } from 'estree'
|
import type { CallExpression, Property, Identifier, MemberExpression, Literal, ReturnStatement, VariableDeclaration, ObjectExpression, Node, Pattern, AssignmentProperty, Program } from 'estree'
|
||||||
import { createUnplugin } from 'unplugin'
|
import { createUnplugin } from 'unplugin'
|
||||||
import escapeStringRegexp from 'escape-string-regexp'
|
import type { Component } from '@nuxt/schema'
|
||||||
import { resolve } from 'pathe'
|
import { resolve } from 'pathe'
|
||||||
import { distDir } from '../dirs'
|
import { distDir } from '../dirs'
|
||||||
import type { Component } from 'nuxt/schema'
|
|
||||||
|
|
||||||
interface TreeShakeTemplatePluginOptions {
|
interface TreeShakeTemplatePluginOptions {
|
||||||
sourcemap?: boolean
|
sourcemap?: boolean
|
||||||
getComponents (): Component[]
|
getComponents (): Component[]
|
||||||
}
|
}
|
||||||
|
|
||||||
type AcornNode<N> = N & { start: number, end: number }
|
type AcornNode<N extends Node> = N & { start: number, end: number }
|
||||||
|
|
||||||
const SSR_RENDER_RE = /ssrRenderComponent/
|
const SSR_RENDER_RE = /ssrRenderComponent/
|
||||||
const PLACEHOLDER_EXACT_RE = /^(fallback|placeholder)$/
|
const PLACEHOLDER_EXACT_RE = /^(fallback|placeholder)$/
|
||||||
|
const CLIENT_ONLY_NAME_RE = /^(?:_unref\()?(?:_component_)?(?:Lazy|lazy_)?(?:client_only|ClientOnly\)?)$/
|
||||||
|
const PARSER_OPTIONS = { sourceType: 'module', ecmaVersion: 'latest' }
|
||||||
|
|
||||||
export const TreeShakeTemplatePlugin = createUnplugin((options: TreeShakeTemplatePluginOptions) => {
|
export const TreeShakeTemplatePlugin = createUnplugin((options: TreeShakeTemplatePluginOptions) => {
|
||||||
const regexpMap = new WeakMap<Component[], [RegExp, RegExp, string[]]>()
|
const regexpMap = new WeakMap<Component[], [RegExp, RegExp, string[]]>()
|
||||||
@ -41,84 +42,47 @@ export const TreeShakeTemplatePlugin = createUnplugin((options: TreeShakeTemplat
|
|||||||
}
|
}
|
||||||
|
|
||||||
const s = new MagicString(code)
|
const s = new MagicString(code)
|
||||||
const importDeclarations: AcornNode<ImportDeclaration>[] = []
|
|
||||||
|
|
||||||
const [COMPONENTS_RE, COMPONENTS_IDENTIFIERS_RE] = regexpMap.get(components)!
|
const [COMPONENTS_RE, COMPONENTS_IDENTIFIERS_RE] = regexpMap.get(components)!
|
||||||
if (!COMPONENTS_RE.test(code)) { return }
|
if (!COMPONENTS_RE.test(code)) { return }
|
||||||
|
|
||||||
walk(this.parse(code, { sourceType: 'module', ecmaVersion: 'latest' }) as Node, {
|
const codeAst = this.parse(code, PARSER_OPTIONS) as AcornNode<Program>
|
||||||
|
|
||||||
|
const componentsToRemoveSet = new Set<string>()
|
||||||
|
|
||||||
|
// remove client only components or components called in ClientOnly default slot
|
||||||
|
walk(codeAst, {
|
||||||
enter: (_node) => {
|
enter: (_node) => {
|
||||||
const node = _node as AcornNode<CallExpression | ImportDeclaration>
|
const node = _node as AcornNode<Node>
|
||||||
if (node.type === 'ImportDeclaration') {
|
if (isSsrRender(node)) {
|
||||||
importDeclarations.push(node)
|
|
||||||
} else if (
|
|
||||||
node.type === 'CallExpression' &&
|
|
||||||
node.callee.type === 'Identifier' &&
|
|
||||||
SSR_RENDER_RE.test(node.callee.name)
|
|
||||||
) {
|
|
||||||
const [componentCall, _, children] = node.arguments
|
const [componentCall, _, children] = node.arguments
|
||||||
if (componentCall.type === 'Identifier' || componentCall.type === 'MemberExpression' || componentCall.type === 'CallExpression') {
|
if (componentCall.type === 'Identifier' || componentCall.type === 'MemberExpression' || componentCall.type === 'CallExpression') {
|
||||||
const componentName = getComponentName(node)
|
const componentName = getComponentName(node)
|
||||||
const isClientComponent = COMPONENTS_IDENTIFIERS_RE.test(componentName)
|
const isClientComponent = COMPONENTS_IDENTIFIERS_RE.test(componentName)
|
||||||
const isClientOnlyComponent = /^(?:_unref\()?(?:_component_)?(?:Lazy|lazy_)?(?:client_only|ClientOnly\)?)$/.test(componentName)
|
const isClientOnlyComponent = CLIENT_ONLY_NAME_RE.test(componentName)
|
||||||
|
|
||||||
if (isClientComponent && children?.type === 'ObjectExpression') {
|
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 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>[]
|
||||||
|
|
||||||
for (const slot of slotsToRemove) {
|
for (const slot of slotsToRemove) {
|
||||||
const componentsSet = new Set<string>()
|
|
||||||
s.remove(slot.start, slot.end + 1)
|
s.remove(slot.start, slot.end + 1)
|
||||||
const removedCode = `({${code.slice(slot.start, slot.end + 1)}})`
|
const removedCode = `({${code.slice(slot.start, slot.end + 1)}})`
|
||||||
const currentCode = s.toString()
|
const currentCodeAst = this.parse(s.toString(), PARSER_OPTIONS) as Node
|
||||||
walk(this.parse(removedCode, { sourceType: 'module', ecmaVersion: 'latest' }) as Node, {
|
|
||||||
|
walk(this.parse(removedCode, PARSER_OPTIONS) as Node, {
|
||||||
enter: (_node) => {
|
enter: (_node) => {
|
||||||
const node = _node as AcornNode<CallExpression>
|
const node = _node as AcornNode<CallExpression>
|
||||||
if (node.type === 'CallExpression' && node.callee.type === 'Identifier' && SSR_RENDER_RE.test(node.callee.name)) {
|
if (isSsrRender(node)) {
|
||||||
const componentNode = node.arguments[0]
|
const name = getComponentName(node)
|
||||||
|
|
||||||
if (componentNode.type === 'CallExpression') {
|
// detect if the component is called else where
|
||||||
const identifier = componentNode.arguments[0] as Identifier
|
const nameToRemove = isComponentNotCalledInSetup(currentCodeAst, name)
|
||||||
if (!isRenderedInCode(currentCode, removedCode.slice((componentNode as AcornNode<CallExpression>).start, (componentNode as AcornNode<CallExpression>).end))) { componentsSet.add(identifier.name) }
|
if (nameToRemove) {
|
||||||
} else if (componentNode.type === 'Identifier' && !isRenderedInCode(currentCode, componentNode.name)) {
|
componentsToRemoveSet.add(nameToRemove)
|
||||||
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
|
|
||||||
|
|
||||||
if (componentSpecifier) { s.remove(componentSpecifier.start, componentSpecifier.end + 1) }
|
|
||||||
} else {
|
|
||||||
s.remove(declaration.start, declaration.end)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -126,6 +90,18 @@ export const TreeShakeTemplatePlugin = createUnplugin((options: TreeShakeTemplat
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const componentsToRemove = [...componentsToRemoveSet]
|
||||||
|
const removedNodes = new WeakSet<AcornNode<Node>>()
|
||||||
|
|
||||||
|
for (const componentName of componentsToRemove) {
|
||||||
|
// remove import declaration if it exists
|
||||||
|
removeImportDeclaration(codeAst, componentName, s)
|
||||||
|
// remove variable declaration
|
||||||
|
removeVariableDeclarator(codeAst, componentName, s, removedNodes)
|
||||||
|
// remove from setup return statement
|
||||||
|
removeFromSetupReturn(codeAst, componentName, s)
|
||||||
|
}
|
||||||
|
|
||||||
if (s.hasChanged()) {
|
if (s.hasChanged()) {
|
||||||
return {
|
return {
|
||||||
code: s.toString(),
|
code: s.toString(),
|
||||||
@ -139,29 +115,107 @@ export const TreeShakeTemplatePlugin = createUnplugin((options: TreeShakeTemplat
|
|||||||
})
|
})
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* find and return the importDeclaration that contain the import specifier
|
* find and remove all property with the name parameter from the setup return statement and the __returned__ object
|
||||||
*
|
|
||||||
* @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 {
|
function removeFromSetupReturn (codeAst: Program, name: string, magicString: MagicString) {
|
||||||
const declaration = declarations.find((d) => {
|
let walkedInSetup = false
|
||||||
const specifier = d.specifiers.find(s => s.local.name === importName)
|
walk(codeAst, {
|
||||||
if (specifier) { return true }
|
enter (node) {
|
||||||
return false
|
if (walkedInSetup) {
|
||||||
})
|
this.skip()
|
||||||
|
} else if (node.type === 'Property' && node.key.type === 'Identifier' && node.key.name === 'setup' && (node.value.type === 'FunctionExpression' || node.value.type === 'ArrowFunctionExpression')) {
|
||||||
|
// walk into the setup function
|
||||||
|
walkedInSetup = true
|
||||||
|
if (node.value.body.type === 'BlockStatement') {
|
||||||
|
const returnStatement = node.value.body.body.find(statement => statement.type === 'ReturnStatement') as ReturnStatement
|
||||||
|
if (returnStatement && returnStatement.argument?.type === 'ObjectExpression') {
|
||||||
|
// remove from return statement
|
||||||
|
removePropertyFromObject(returnStatement.argument, name, magicString)
|
||||||
|
}
|
||||||
|
|
||||||
return declaration
|
// remove from __returned__
|
||||||
|
const variableList = node.value.body.body.filter((statement): statement is VariableDeclaration => statement.type === 'VariableDeclaration')
|
||||||
|
const returnedVariableDeclaration = variableList.find(declaration => declaration.declarations[0]?.id.type === 'Identifier' && declaration.declarations[0]?.id.name === '__returned__' && declaration.declarations[0]?.init?.type === 'ObjectExpression')
|
||||||
|
if (returnedVariableDeclaration) {
|
||||||
|
const init = returnedVariableDeclaration.declarations[0].init as ObjectExpression
|
||||||
|
removePropertyFromObject(init, name, magicString)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* test if the name argument is used to render a component in the code
|
* remove a property from an object expression
|
||||||
*
|
|
||||||
* @param code code to test
|
|
||||||
* @param name component name
|
|
||||||
*/
|
*/
|
||||||
function isRenderedInCode (code: string, name: string) {
|
function removePropertyFromObject (node: ObjectExpression, name: string, magicString: MagicString) {
|
||||||
return new RegExp(`ssrRenderComponent\\(${escapeStringRegexp(name)}`).test(code)
|
for (const property of node.properties) {
|
||||||
|
if (property.type === 'Property' && property.key.type === 'Identifier' && property.key.name === name) {
|
||||||
|
magicString.remove((property as AcornNode<Property>).start, (property as AcornNode<Property>).end + 1)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* is the node a call expression ssrRenderComponent()
|
||||||
|
*/
|
||||||
|
function isSsrRender (node: Node): node is AcornNode<CallExpression> {
|
||||||
|
return node.type === 'CallExpression' && node.callee.type === 'Identifier' && SSR_RENDER_RE.test(node.callee.name)
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeImportDeclaration (ast: Program, importName: string, magicString: MagicString): boolean {
|
||||||
|
for (const node of ast.body) {
|
||||||
|
if (node.type === 'ImportDeclaration') {
|
||||||
|
const specifier = node.specifiers.find(s => s.local.name === importName)
|
||||||
|
if (specifier) {
|
||||||
|
if (node.specifiers.length > 1) {
|
||||||
|
const specifierIndex = node.specifiers.findIndex(s => s.local.name === importName)
|
||||||
|
if (specifierIndex > -1) {
|
||||||
|
magicString.remove((node.specifiers[specifierIndex] as AcornNode<Node>).start, (node.specifiers[specifierIndex] as AcornNode<Node>).end + 1)
|
||||||
|
node.specifiers.splice(specifierIndex, 1)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
magicString.remove((node as AcornNode<Node>).start, (node as AcornNode<Node>).end)
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* detect if the component is called else where
|
||||||
|
* ImportDeclarations and VariableDeclarations are ignored
|
||||||
|
* return the name of the component if is not called
|
||||||
|
*/
|
||||||
|
function isComponentNotCalledInSetup (codeAst: Node, name: string): string|void {
|
||||||
|
if (name) {
|
||||||
|
let found = false
|
||||||
|
walk(codeAst, {
|
||||||
|
enter (node) {
|
||||||
|
if ((node.type === 'Property' && node.key.type === 'Identifier' && node.value.type === 'FunctionExpression' && node.key.name === 'setup') || (node.type === 'FunctionDeclaration' && node.id?.name === '_sfc_ssrRender')) {
|
||||||
|
// walk through the setup function node or the ssrRender function
|
||||||
|
walk(node, {
|
||||||
|
enter (node) {
|
||||||
|
if (found || node.type === 'VariableDeclaration') {
|
||||||
|
this.skip()
|
||||||
|
} else if (node.type === 'Identifier' && node.name === name) {
|
||||||
|
found = true
|
||||||
|
} else if (node.type === 'MemberExpression') {
|
||||||
|
// dev only with $setup or _ctx
|
||||||
|
found = (node.property.type === 'Literal' && node.property.value === name) || (node.property.type === 'Identifier' && node.property.name === name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
if (!found) { return name }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -181,19 +235,60 @@ function getComponentName (ssrRenderNode: AcornNode<CallExpression>): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* remove a key from the return statement of the setup function
|
* remove a variable declaration within the code
|
||||||
*/
|
*/
|
||||||
function removeFromSetupReturnStatement (s: MagicString, node: Property, name: string) {
|
function removeVariableDeclarator (codeAst: Node, name: string, magicString: MagicString, removedNodes: WeakSet<Node>): AcornNode<Node> | void {
|
||||||
if (node.type === 'Property' && node.key.type === 'Identifier' && node.key.name === 'setup' && node.value.type === 'FunctionExpression') {
|
// remove variables
|
||||||
const returnStatement = node.value.body.body.find(n => n.type === 'ReturnStatement') as ReturnStatement | undefined
|
walk(codeAst, {
|
||||||
if (returnStatement?.argument?.type === 'Identifier') {
|
enter (node) {
|
||||||
const returnIdentifier = returnStatement.argument.name
|
if (node.type === 'VariableDeclaration') {
|
||||||
const returnedDeclaration = node.value.body.body.find(n => n.type === 'VariableDeclaration' && (n.declarations[0].id as Identifier).name === returnIdentifier) as AcornNode<VariableDeclaration>
|
for (const declarator of node.declarations) {
|
||||||
const componentProperty = (returnedDeclaration?.declarations[0].init as ObjectExpression)?.properties.find(p => ((p as Property).key as Identifier).name === name) as AcornNode<Property>
|
const toRemove = findMatchingPatternToRemove(declarator.id as AcornNode<Pattern>, node as AcornNode<VariableDeclaration>, name, removedNodes)
|
||||||
if (componentProperty) { s.remove(componentProperty.start, componentProperty.end + 1) }
|
if (toRemove) {
|
||||||
} else if (returnStatement?.argument?.type === 'ObjectExpression') {
|
magicString.remove(toRemove.start, toRemove.end + 1)
|
||||||
const componentProperty = returnStatement.argument?.properties.find(p => ((p as Property).key as Identifier).name === name) as AcornNode<Property>
|
removedNodes.add(toRemove)
|
||||||
if (componentProperty) { s.remove(componentProperty.start, componentProperty.end + 1) }
|
return toRemove
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* find the Pattern to remove which the identifier is equal to the name parameter.
|
||||||
|
*/
|
||||||
|
function findMatchingPatternToRemove (node: AcornNode<Pattern>, toRemoveIfMatched: AcornNode<Node>, name: string, removedNodeSet: WeakSet<Node>): AcornNode<Node> | undefined {
|
||||||
|
if (node.type === 'Identifier') {
|
||||||
|
if (node.name === name) {
|
||||||
|
return toRemoveIfMatched
|
||||||
|
}
|
||||||
|
} else if (node.type === 'ArrayPattern') {
|
||||||
|
const elements = node.elements.filter((e): e is AcornNode<Pattern> => e !== null && !removedNodeSet.has(e))
|
||||||
|
|
||||||
|
for (const element of elements) {
|
||||||
|
const matched = findMatchingPatternToRemove(element, elements.length > 1 ? element : toRemoveIfMatched, name, removedNodeSet)
|
||||||
|
if (matched) { return matched }
|
||||||
|
}
|
||||||
|
} else if (node.type === 'ObjectPattern') {
|
||||||
|
const properties = node.properties.filter((e): e is AssignmentProperty => e.type === 'Property' && !removedNodeSet.has(e))
|
||||||
|
|
||||||
|
for (const [index, property] of properties.entries()) {
|
||||||
|
let nodeToRemove = property as AcornNode<Node>
|
||||||
|
if (properties.length < 2) {
|
||||||
|
nodeToRemove = toRemoveIfMatched
|
||||||
|
}
|
||||||
|
|
||||||
|
const matched = findMatchingPatternToRemove(property.value as AcornNode<Pattern>, nodeToRemove as AcornNode<Node>, name, removedNodeSet)
|
||||||
|
if (matched) {
|
||||||
|
if (matched === property) {
|
||||||
|
properties.splice(index, 1)
|
||||||
|
}
|
||||||
|
return matched
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (node.type === 'AssignmentPattern') {
|
||||||
|
const matched = findMatchingPatternToRemove(node.left as AcornNode<Pattern>, toRemoveIfMatched, name, removedNodeSet)
|
||||||
|
if (matched) { return matched }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Binary file not shown.
@ -0,0 +1,10 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
Awesome Component count: {{ count }}
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<script setup lang="ts">
|
||||||
|
defineProps<{
|
||||||
|
count?: number
|
||||||
|
}>()
|
||||||
|
</script>
|
@ -1,5 +1,8 @@
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
|
<NotDotClientComponent>
|
||||||
|
<ByeBye />
|
||||||
|
</NotDotClientComponent>
|
||||||
<div>
|
<div>
|
||||||
<Glob />
|
<Glob />
|
||||||
</div>
|
</div>
|
||||||
@ -7,11 +10,20 @@
|
|||||||
<div class="not-client">
|
<div class="not-client">
|
||||||
Hello
|
Hello
|
||||||
</div>
|
</div>
|
||||||
<ClientOnly>
|
<DotClientComponent>
|
||||||
<HelloWorld />
|
<HelloWorld />
|
||||||
<Glob />
|
<Glob />
|
||||||
<SomeGlob />
|
<SomeGlob />
|
||||||
</ClientOnly>
|
<SomeIsland />
|
||||||
|
<NotToBeTreeShaken />
|
||||||
|
<ObjectPattern />
|
||||||
|
<ObjectPatternDeclaration />
|
||||||
|
<AutoImportedNotTreeShakenComponent />
|
||||||
|
<AutoImportedComponent />
|
||||||
|
<Halllo />
|
||||||
|
<Both />
|
||||||
|
<AreTreeshaken />
|
||||||
|
</DotClientComponent>
|
||||||
<ClientOnly>
|
<ClientOnly>
|
||||||
<div class="should-be-treeshaken">
|
<div class="should-be-treeshaken">
|
||||||
this should not be visible
|
this should not be visible
|
||||||
@ -19,16 +31,109 @@
|
|||||||
<ClientImport />
|
<ClientImport />
|
||||||
<Treeshaken />
|
<Treeshaken />
|
||||||
<ResolvedImport />
|
<ResolvedImport />
|
||||||
|
<FromArray />
|
||||||
|
<Please />
|
||||||
|
<Doo />
|
||||||
|
<What />
|
||||||
|
<Deep />
|
||||||
|
<Pattern />
|
||||||
|
<DontRemoveThisSinceItIsUsedInSetup />
|
||||||
</ClientOnly>
|
</ClientOnly>
|
||||||
|
<ButShouldNotBeTreeShaken />
|
||||||
|
<Dont />
|
||||||
|
<That />
|
||||||
|
<NotToBeTreeShaken />
|
||||||
|
<AutoImportedNotTreeShakenComponent />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { Treeshaken } from 'somepath'
|
import { Treeshaken } from 'somepath'
|
||||||
import HelloWorld from '../HelloWorld.vue'
|
import HelloWorld from '../HelloWorld.vue'
|
||||||
|
import DontRemoveThisSinceItIsUsedInSetup from './ComponentWithProps.vue'
|
||||||
import { Glob, ClientImport } from '#components'
|
import { Glob, ClientImport } from '#components'
|
||||||
|
import { Both, AreTreeshaken } from '#imports'
|
||||||
|
|
||||||
const hello = 'world'
|
const hello = 'world'
|
||||||
|
const ByeBye = defineAsyncComponent(() => import('./../some-glob.global.vue'))
|
||||||
|
|
||||||
|
const NotDotClientComponent = defineAsyncComponent(() => import('./../some.island.vue'))
|
||||||
|
const SomeIsland = defineAsyncComponent(async () => {
|
||||||
|
if (process.client) {
|
||||||
|
return (await import('./../some.island.vue'))
|
||||||
|
}
|
||||||
|
|
||||||
|
return {}
|
||||||
|
})
|
||||||
|
|
||||||
|
const NotToBeTreeShaken = defineAsyncComponent(async () => {
|
||||||
|
if (process.client) {
|
||||||
|
return (await import('./../HelloWorld.vue'))
|
||||||
|
}
|
||||||
|
|
||||||
|
return {}
|
||||||
|
})
|
||||||
|
|
||||||
|
const { ObjectPattern } = defineAsyncComponent(async () => {
|
||||||
|
if (process.client) {
|
||||||
|
return (await import('./../HelloWorld.vue'))
|
||||||
|
}
|
||||||
|
|
||||||
|
return {}
|
||||||
|
})
|
||||||
|
|
||||||
|
const { ObjectPattern: ObjectPatternDeclaration } = defineAsyncComponent(async () => {
|
||||||
|
if (process.client) {
|
||||||
|
return (await import('./../HelloWorld.vue'))
|
||||||
|
}
|
||||||
|
|
||||||
|
return {}
|
||||||
|
})
|
||||||
|
|
||||||
|
const { ObjectPattern: Halllo, ButShouldNotBeTreeShaken } = defineAsyncComponent(async () => {
|
||||||
|
if (process.client) {
|
||||||
|
return (await import('./../HelloWorld.vue'))
|
||||||
|
}
|
||||||
|
|
||||||
|
return {}
|
||||||
|
})
|
||||||
|
const isThis = {}
|
||||||
|
|
||||||
|
const { woooooo, What = isThis } = defineAsyncComponent(async () => {
|
||||||
|
if (process.client) {
|
||||||
|
return (await import('./../HelloWorld.vue'))
|
||||||
|
}
|
||||||
|
|
||||||
|
return {}
|
||||||
|
})
|
||||||
|
|
||||||
|
console.log(woooooo)
|
||||||
|
|
||||||
|
const { Deep, assignment: { Pattern = ofComponent } } = defineAsyncComponent(async () => {
|
||||||
|
if (process.client) {
|
||||||
|
return (await import('./../HelloWorld.vue'))
|
||||||
|
}
|
||||||
|
|
||||||
|
return {}
|
||||||
|
})
|
||||||
|
|
||||||
|
const [FromArray] = defineAsyncComponent(async () => {
|
||||||
|
if (process.client) {
|
||||||
|
return (await import('./../HelloWorld.vue'))
|
||||||
|
}
|
||||||
|
|
||||||
|
return {}
|
||||||
|
})
|
||||||
|
|
||||||
|
const [Please, { Dont, Doo }, That] = defineAsyncComponent(async () => {
|
||||||
|
if (process.client) {
|
||||||
|
return (await import('./../HelloWorld.vue'))
|
||||||
|
}
|
||||||
|
|
||||||
|
return {}
|
||||||
|
})
|
||||||
|
|
||||||
|
console.log(DontRemoveThisSinceItIsUsedInSetup.props)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
@ -147,6 +147,18 @@ const expectedComponents = [
|
|||||||
prefetch: false,
|
prefetch: false,
|
||||||
preload: false
|
preload: false
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
chunkName: 'components/client-component-with-props',
|
||||||
|
export: 'default',
|
||||||
|
global: undefined,
|
||||||
|
island: undefined,
|
||||||
|
kebabName: 'client-component-with-props',
|
||||||
|
mode: 'all',
|
||||||
|
pascalName: 'ClientComponentWithProps',
|
||||||
|
prefetch: false,
|
||||||
|
preload: false,
|
||||||
|
shortPath: 'components/client/ComponentWithProps.vue'
|
||||||
|
},
|
||||||
{
|
{
|
||||||
chunkName: 'components/client-with-client-only-setup',
|
chunkName: 'components/client-with-client-only-setup',
|
||||||
export: 'default',
|
export: 'default',
|
||||||
|
@ -7,7 +7,7 @@ import { Parser } from 'acorn'
|
|||||||
import type { Options } from '@vitejs/plugin-vue'
|
import type { Options } from '@vitejs/plugin-vue'
|
||||||
import _vuePlugin from '@vitejs/plugin-vue'
|
import _vuePlugin from '@vitejs/plugin-vue'
|
||||||
import { TreeShakeTemplatePlugin } from '../src/components/tree-shake'
|
import { TreeShakeTemplatePlugin } from '../src/components/tree-shake'
|
||||||
import { fixtureDir } from './utils'
|
import { fixtureDir, normalizeLineEndings } from './utils'
|
||||||
|
|
||||||
vi.mock('node:crypto', () => ({
|
vi.mock('node:crypto', () => ({
|
||||||
update: vi.fn().mockReturnThis(),
|
update: vi.fn().mockReturnThis(),
|
||||||
@ -31,9 +31,34 @@ function vuePlugin (options: Options) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const WithClientOnly = readFileSync(path.resolve(fixtureDir, './components/client/WithClientOnlySetup.vue')).toString()
|
const WithClientOnly = normalizeLineEndings(readFileSync(path.resolve(fixtureDir, './components/client/WithClientOnlySetup.vue')).toString())
|
||||||
|
|
||||||
const treeshakeTemplatePlugin = TreeShakeTemplatePlugin.raw({ sourcemap: false, getComponents () { return [] } }, { framework: 'rollup' }) as Plugin
|
const treeshakeTemplatePlugin = TreeShakeTemplatePlugin.raw({
|
||||||
|
sourcemap: false,
|
||||||
|
getComponents () {
|
||||||
|
return [{
|
||||||
|
pascalName: 'NotDotClientComponent',
|
||||||
|
kebabName: 'not-dot-client-component',
|
||||||
|
export: 'default',
|
||||||
|
filePath: 'dummypath',
|
||||||
|
shortPath: 'dummypath',
|
||||||
|
chunkName: '123',
|
||||||
|
prefetch: false,
|
||||||
|
preload: false,
|
||||||
|
mode: 'client'
|
||||||
|
}, {
|
||||||
|
pascalName: 'DotClientComponent',
|
||||||
|
kebabName: 'dot-client-component',
|
||||||
|
export: 'default',
|
||||||
|
filePath: 'dummypath',
|
||||||
|
shortPath: 'dummypath',
|
||||||
|
chunkName: '123',
|
||||||
|
prefetch: false,
|
||||||
|
preload: false,
|
||||||
|
mode: 'client'
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
}, { framework: 'rollup' }) as Plugin
|
||||||
|
|
||||||
const treeshake = async (source: string): Promise<string> => {
|
const treeshake = async (source: string): Promise<string> => {
|
||||||
const result = await (treeshakeTemplatePlugin.transform! as Function).call({
|
const result = await (treeshakeTemplatePlugin.transform! as Function).call({
|
||||||
@ -73,7 +98,7 @@ const stateToTest: {name: string, options: Partial<Options & {devServer: {config
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'dev not inlined',
|
name: 'dev',
|
||||||
options: {
|
options: {
|
||||||
isProduction: false,
|
isProduction: false,
|
||||||
devServer: {
|
devServer: {
|
||||||
@ -115,10 +140,45 @@ describe('treeshake client only in ssr', () => {
|
|||||||
expect(treeshaken).not.toContain('const _component_ResolvedImport =')
|
expect(treeshaken).not.toContain('const _component_ResolvedImport =')
|
||||||
expect(clientResult).toContain('const _component_ResolvedImport =')
|
expect(clientResult).toContain('const _component_ResolvedImport =')
|
||||||
|
|
||||||
|
// treeshake multi line variable declaration
|
||||||
|
expect(clientResult).toContain('const SomeIsland = defineAsyncComponent(async () => {')
|
||||||
|
expect(treeshaken).not.toContain('const SomeIsland = defineAsyncComponent(async () => {')
|
||||||
|
expect(treeshaken).not.toContain("return (await import('./../some.island.vue'))")
|
||||||
|
expect(treeshaken).toContain('const NotToBeTreeShaken = defineAsyncComponent(async () => {')
|
||||||
|
|
||||||
|
// treeshake object and array declaration
|
||||||
|
expect(treeshaken).not.toContain("const { ObjectPattern } = await import('nuxt.com')")
|
||||||
|
expect(treeshaken).not.toContain("const { ObjectPattern: ObjectPatternDeclaration } = await import('nuxt.com')")
|
||||||
|
expect(treeshaken).toContain('const { ButShouldNotBeTreeShaken } = defineAsyncComponent(async () => {')
|
||||||
|
expect(treeshaken).toContain('const [ { Dont, }, That] = defineAsyncComponent(async () => {')
|
||||||
|
|
||||||
|
// treeshake object that has an assignement pattern
|
||||||
|
expect(treeshaken).toContain('const { woooooo, } = defineAsyncComponent(async () => {')
|
||||||
|
expect(treeshaken).not.toContain('const { Deep, assignment: { Pattern = ofComponent } } = defineAsyncComponent(async () => {')
|
||||||
|
|
||||||
|
// expect no empty ObjectPattern on treeshaking
|
||||||
|
expect(treeshaken).not.toContain('const { } = defineAsyncComponent')
|
||||||
|
expect(treeshaken).not.toContain('import { } from')
|
||||||
|
|
||||||
|
// expect components used in setup to not be removed
|
||||||
|
expect(treeshaken).toContain("import DontRemoveThisSinceItIsUsedInSetup from './ComponentWithProps.vue'")
|
||||||
|
|
||||||
// expect import of ClientImport to be treeshaken but not Glob since it is also used outside <ClientOnly>
|
// expect import of ClientImport to be treeshaken but not Glob since it is also used outside <ClientOnly>
|
||||||
expect(treeshaken).not.toContain('ClientImport')
|
expect(treeshaken).not.toContain('ClientImport')
|
||||||
expect(treeshaken).toContain('import { Glob, } from \'#components\'')
|
expect(treeshaken).toContain('import { Glob, } from \'#components\'')
|
||||||
|
|
||||||
|
// treeshake .client slot
|
||||||
|
expect(treeshaken).not.toContain('ByeBye')
|
||||||
|
// don't treeshake variables that has the same name as .client components
|
||||||
|
expect(treeshaken).toContain('NotDotClientComponent')
|
||||||
|
expect(treeshaken).not.toContain('(DotClientComponent')
|
||||||
|
|
||||||
|
expect(treeshaken).not.toContain('AutoImportedComponent')
|
||||||
|
expect(treeshaken).toContain('AutoImportedNotTreeShakenComponent')
|
||||||
|
|
||||||
|
expect(treeshaken).not.toContain('Both')
|
||||||
|
expect(treeshaken).not.toContain('AreTreeshaken')
|
||||||
|
|
||||||
if (state.options.isProduction === false) {
|
if (state.options.isProduction === false) {
|
||||||
// treeshake at inlined template
|
// treeshake at inlined template
|
||||||
expect(treeshaken).not.toContain('ssrRenderComponent($setup["HelloWorld"]')
|
expect(treeshaken).not.toContain('ssrRenderComponent($setup["HelloWorld"]')
|
||||||
|
@ -1,3 +1,7 @@
|
|||||||
import { resolve } from 'node:path'
|
import { resolve } from 'node:path'
|
||||||
|
|
||||||
export const fixtureDir = resolve(__dirname, 'fixture')
|
export const fixtureDir = resolve(__dirname, 'fixture')
|
||||||
|
|
||||||
|
export function normalizeLineEndings (str: string, normalized = '\n') {
|
||||||
|
return str.replace(/\r?\n/g, normalized)
|
||||||
|
}
|
||||||
|
@ -7,6 +7,7 @@
|
|||||||
foo="hello"
|
foo="hello"
|
||||||
>
|
>
|
||||||
<template #test>
|
<template #test>
|
||||||
|
<BreakServerComponent />
|
||||||
<div class="slot-test">
|
<div class="slot-test">
|
||||||
Hello
|
Hello
|
||||||
<BreaksServer />
|
<BreaksServer />
|
||||||
@ -15,6 +16,7 @@
|
|||||||
</ClientSetupScript>
|
</ClientSetupScript>
|
||||||
<ClientOnly>
|
<ClientOnly>
|
||||||
Should not be server rendered.
|
Should not be server rendered.
|
||||||
|
<BreakServerComponent />
|
||||||
<template #fallback>
|
<template #fallback>
|
||||||
<div>Fallback</div>
|
<div>Fallback</div>
|
||||||
</template>
|
</template>
|
||||||
@ -69,6 +71,9 @@ const stringStatefulComp = ref(null) as any as Comp
|
|||||||
const stringStatefulScriptComp = ref(null) as any as Comp
|
const stringStatefulScriptComp = ref(null) as any as Comp
|
||||||
const clientScript = ref(null) as any as Comp
|
const clientScript = ref(null) as any as Comp
|
||||||
const clientSetupScript = ref(null) as any as Comp
|
const clientSetupScript = ref(null) as any as Comp
|
||||||
|
const BreakServerComponent = defineAsyncComponent(() => {
|
||||||
|
return import('./../components/BreaksServer.client')
|
||||||
|
})
|
||||||
|
|
||||||
const show = ref(false)
|
const show = ref(false)
|
||||||
</script>
|
</script>
|
||||||
|
Loading…
Reference in New Issue
Block a user