Uh oh!
There was an error while loading. Please reload this page.
This repository was archived by the owner on Apr 6, 2023. It is now read-only.
Uh oh!
There was an error while loading. Please reload this page.
- Notifications
You must be signed in to change notification settings - Fork 970
fix(nuxt): use parser to treeshake <ClientOnly>#8713
Merged
danielroe
merged 39 commits into
nuxt:main
from
huang-julien:fix/treeshakeCLientOnlyStyleFeb 8, 2023
Uh oh!
There was an error while loading. Please reload this page.
Merged
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 a62d688
test(nuxt): ensure scoped style does not break
huang-julien ee02bed
style: lint
huang-julien 27c9205
refactor(nuxt): improve regex and change jsdoc
huang-julien fecea00
fix(nuxt): treeshake client only components in setup
huang-julien 6357b28
fix(nuxt): fix refactor + change component to components
huang-julien d8397c1
chore: remove `ultrahtml` dependency
danielroe 13aa7f8
Update packages/nuxt/src/components/tree-shake.ts
huang-julien 3bb188b
Update packages/nuxt/src/components/tree-shake.ts
huang-julien 34107a2
refactor(nuxt): use escapeStringRegexp()
huang-julien 3cb007a
fix(nuxt): set back treeshake for all clientonly components
huang-julien 4ec2fa9
fix(nuxt): avoid removing placeholder slot
huang-julien 334d68f
fix(nuxt): fix regexpMap
huang-julien 210da41
test(nuxt): ensure .client components are treeshaken
huang-julien 9dd27d5
style: lint
huang-julien 24f85fe
fix(nuxt): add new regex to match component exact var name using ^$
huang-julien ddc0e70
fix: treeshake direct import of .client components
huang-julien ac4cdb2
fix: treeshake lazy client
huang-julien f27f94d
fix(nuxt): treeshake all slots on .client components
huang-julien 9ee2e49
Apply suggestions from code review
huang-julien 1a86be5
fix(nuxt): treeshake call expressions with ssrRenderComponent
huang-julien 8923aec
Merge branch 'fix/treeshakeCLientOnlyStyle' of https://github.com/huaβ¦
huang-julien 3ac6cd1
Merge branch 'main' into fix/treeshakeCLientOnlyStyle
huang-julien cac7cc2
chore: fix lock
huang-julien 121e1f3
Merge branch 'main' into fix/treeshakeCLientOnlyStyle
danielroe 205293e
Merge remote-tracking branch 'origin/main' into fix/treeshakeCLientOnβ¦
danielroe b08125f
test(nuxt): unit test treeshake client plugin
huang-julien 0fe07f6
fix(nuxt): remove whole line of component declaration instead of remoβ¦
huang-julien 521f883
refactor(nuxt): move some logic into a function
huang-julien c40c649
chore: remove unecessary comment
huang-julien 646e69f
test(nuxt): add snapshots for treeshake client only
huang-julien 939bc3d
test: fix snapshot
huang-julien 139c615
fix: update snapshot
huang-julien 392b401
test: directly mock node:crypto
huang-julien eabbff9
Merge remote-tracking branch 'origin/main' into fix/treeshakeCLientOnβ¦
danielroe 73da1fe
test(nuxt): mock path relative due to os differences
huang-julien cbfe602
test: set correct mock
huang-julien 7fc8681
test: fix max callstack
huang-julien e5271a7
lint + fix
huang-julien File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Uh oh!
There was an error while loading. Please reload this page.
Jump to
Jump to file
Failed to load files.
Loading
Uh oh!
There was an error while loading. Please reload this page.
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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' | ||
| @@ -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() | ||
huang-julien marked this conversation as resolved.
Uh oh!There was an error while loading. Please reload this page. | ||
| 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) | ||
| } | ||
| } | ||
| } | ||
| } | ||
| } | ||
| } | ||
| } | ||
| } | ||
| } | ||
| }) | ||
| @@ -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.
38 changes: 38 additions & 0 deletions
38 packages/nuxt/test/fixture/components/client/WithClientOnlySetup.vue
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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> |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Uh oh!
There was an error while loading. Please reload this page.
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.