Skip to content

Commit 8bd1a10

Browse files
authored
fix: remove implicit dependencies handling (#9010)
fix #9000 and #9006 **Root Cause** It directly copies `node_modules` into the app directory and removes `devDependencies` from `package.json`. This way, `npm list` can retrieve all dependencies. However, during the lookup process, if there are duplicate dependency packages, some `dependencies` in `npm` may be empty. A previous fix(#8864 ) introduced the concept of `implicit dependencies` to address this issue, but it only handled the first level of `implicit dependencies`. If `implicit dependencies` themselves contain further `implicit dependencies`, the problem described in #9000 will occur. **How to fix** The current approach directly searches the `parsedTree` and updates the global `this.productionGraph`. If there are cases where `dependencies` are empty, it retrieves the same dependency from `allDependencies` and performs a lookup for sub-dependencies. If the dependency already exists in `this.productionGraph`, no further lookup is performed. Additionally, to prevent infinite loops, an empty array is set before searching for sub-dependencies. Now that two tree lookup has been eliminated, there will be a certain improvement in performance.
1 parent 1397775 commit 8bd1a10

File tree

7 files changed

+5249
-188
lines changed

7 files changed

+5249
-188
lines changed

.changeset/lovely-shrimps-buy.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"app-builder-lib": patch
3+
---
4+
5+
fix: remove implicit dependencies handling

packages/app-builder-lib/src/node-module-collector/nodeModulesCollector.ts

Lines changed: 9 additions & 95 deletions
Original file line numberDiff line numberDiff line change
@@ -1,28 +1,24 @@
11
import { hoist, type HoisterTree, type HoisterResult } from "./hoist"
22
import * as path from "path"
33
import * as fs from "fs"
4-
import type { NodeModuleInfo, DependencyTree, DependencyGraph, Dependency } from "./types"
4+
import type { NodeModuleInfo, DependencyGraph, Dependency } from "./types"
55
import { exec, log } from "builder-util"
66
import { Lazy } from "lazy-val"
77

88
export abstract class NodeModulesCollector<T extends Dependency<T, OptionalsType>, OptionalsType> {
99
private nodeModules: NodeModuleInfo[] = []
10-
protected dependencyPathMap: Map<string, string> = new Map()
1110
protected allDependencies: Map<string, T> = new Map()
11+
protected productionGraph: DependencyGraph = {}
1212

1313
constructor(private readonly rootDir: string) {}
1414

1515
public async getNodeModules(): Promise<NodeModuleInfo[]> {
1616
const tree: T = await this.getDependenciesTree()
1717
const realTree: T = this.getTreeFromWorkspaces(tree)
18-
const parsedTree: Dependency<T, OptionalsType> = this.extractRelevantData(realTree)
18+
this.collectAllDependencies(realTree)
19+
this.extractProductionDependencyGraph(realTree, "." /*root project name*/)
1920

20-
this.collectAllDependencies(parsedTree)
21-
22-
const productionTree: DependencyTree = this.extractProductionDependencyTree(parsedTree)
23-
const dependencyGraph: DependencyGraph = this.convertToDependencyGraph(productionTree)
24-
25-
const hoisterResult: HoisterResult = hoist(this.transToHoisterTree(dependencyGraph), { check: true })
21+
const hoisterResult: HoisterResult = hoist(this.transToHoisterTree(this.productionGraph), { check: true })
2622
this._getNodeModules(hoisterResult.dependencies, this.nodeModules)
2723

2824
return this.nodeModules
@@ -36,7 +32,8 @@ export abstract class NodeModulesCollector<T extends Dependency<T, OptionalsType
3632
protected abstract readonly pmCommand: Lazy<string>
3733
protected abstract getArgs(): string[]
3834
protected abstract parseDependenciesTree(jsonBlob: string): T
39-
protected abstract extractProductionDependencyTree(tree: Dependency<T, OptionalsType>): DependencyTree
35+
protected abstract extractProductionDependencyGraph(tree: Dependency<T, OptionalsType>, dependencyId: string): void
36+
protected abstract collectAllDependencies(tree: Dependency<T, OptionalsType>): void
4037

4138
protected async getDependenciesTree(): Promise<T> {
4239
const command = await this.pmCommand.value
@@ -48,35 +45,6 @@ export abstract class NodeModulesCollector<T extends Dependency<T, OptionalsType
4845
return this.parseDependenciesTree(dependencies)
4946
}
5047

51-
protected extractRelevantData(npmTree: T): Dependency<T, OptionalsType> {
52-
// Do not use `...npmTree` as we are explicitly extracting the data we need
53-
const { name, version, path, workspaces, dependencies } = npmTree
54-
const tree: Dependency<T, OptionalsType> = {
55-
name,
56-
version,
57-
path,
58-
workspaces,
59-
// DFS extract subtree
60-
dependencies: this.extractInternal(dependencies),
61-
}
62-
63-
return tree
64-
}
65-
66-
protected extractInternal(deps: T["dependencies"]): T["dependencies"] {
67-
return deps && Object.keys(deps).length > 0
68-
? Object.entries(deps).reduce((accum, [packageName, depObjectOrVersionString]) => {
69-
return {
70-
...accum,
71-
[packageName]:
72-
typeof depObjectOrVersionString === "object" && Object.keys(depObjectOrVersionString).length > 0
73-
? this.extractRelevantData(depObjectOrVersionString)
74-
: depObjectOrVersionString,
75-
}
76-
}, {})
77-
: undefined
78-
}
79-
8048
protected resolvePath(filePath: string): string {
8149
try {
8250
const stats = fs.lstatSync(filePath)
@@ -91,60 +59,6 @@ export abstract class NodeModulesCollector<T extends Dependency<T, OptionalsType
9159
}
9260
}
9361

94-
private convertToDependencyGraph(tree: DependencyTree, parentKey = "."): DependencyGraph {
95-
return Object.entries(tree.dependencies || {}).reduce<DependencyGraph>((acc, curr) => {
96-
const [packageName, dependencies] = curr
97-
// Skip empty dependencies (like some optionalDependencies)
98-
if (Object.keys(dependencies).length === 0) {
99-
return acc
100-
}
101-
const version = dependencies.version || ""
102-
const newKey = `${packageName}@${version}`
103-
if (!dependencies.path) {
104-
log.error(
105-
{
106-
packageName,
107-
data: dependencies,
108-
parentModule: tree.name,
109-
parentVersion: tree.version,
110-
},
111-
"dependency path is undefined"
112-
)
113-
throw new Error("unable to parse `path` during `tree.dependencies` reduce")
114-
}
115-
// Map dependency details: name, version and path to the dependency tree
116-
this.dependencyPathMap.set(newKey, path.normalize(this.resolvePath(dependencies.path)))
117-
if (!acc[parentKey]) {
118-
acc[parentKey] = { dependencies: [] }
119-
}
120-
acc[parentKey].dependencies.push(newKey)
121-
if (tree.implicitDependenciesInjected) {
122-
log.debug(
123-
{
124-
dependency: packageName,
125-
version,
126-
path: dependencies.path,
127-
parentModule: tree.name,
128-
parentVersion: tree.version,
129-
},
130-
"converted implicit dependency"
131-
)
132-
return acc
133-
}
134-
135-
return { ...acc, ...this.convertToDependencyGraph(dependencies, newKey) }
136-
}, {})
137-
}
138-
139-
private collectAllDependencies(tree: Dependency<T, OptionalsType>) {
140-
for (const [key, value] of Object.entries(tree.dependencies || {})) {
141-
if (Object.keys(value.dependencies ?? {}).length > 0) {
142-
this.allDependencies.set(`${key}@${value.version}`, value)
143-
this.collectAllDependencies(value)
144-
}
145-
}
146-
}
147-
14862
private getTreeFromWorkspaces(tree: T): T {
14963
if (tree.workspaces && tree.dependencies) {
15064
const packageJson: Dependency<string, string> = require(path.join(this.rootDir, "package.json"))
@@ -186,15 +100,15 @@ export abstract class NodeModulesCollector<T extends Dependency<T, OptionalsType
186100

187101
for (const d of dependencies.values()) {
188102
const reference = [...d.references][0]
189-
const p = this.dependencyPathMap.get(`${d.name}@${reference}`)
103+
const p = this.allDependencies.get(`${d.name}@${reference}`)?.path
190104
if (p === undefined) {
191105
log.debug({ name: d.name, reference }, "cannot find path for dependency")
192106
continue
193107
}
194108
const node: NodeModuleInfo = {
195109
name: d.name,
196110
version: reference,
197-
dir: p,
111+
dir: this.resolvePath(p),
198112
}
199113
result.push(node)
200114
if (d.dependencies.size > 0) {

packages/app-builder-lib/src/node-module-collector/npmNodeModulesCollector.ts

Lines changed: 29 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
import { Lazy } from "lazy-val"
22
import { NodeModulesCollector } from "./nodeModulesCollector"
3-
import { DependencyTree, NpmDependency, ParsedDependencyTree } from "./types"
4-
import { log } from "builder-util"
3+
import { NpmDependency } from "./types"
54

65
export class NpmNodeModulesCollector extends NodeModulesCollector<NpmDependency, string> {
76
constructor(rootDir: string) {
@@ -15,53 +14,38 @@ export class NpmNodeModulesCollector extends NodeModulesCollector<NpmDependency,
1514
return ["list", "-a", "--include", "prod", "--include", "optional", "--omit", "dev", "--json", "--long", "--silent"]
1615
}
1716

18-
protected extractRelevantData(npmTree: NpmDependency): NpmDependency {
19-
const tree = super.extractRelevantData(npmTree)
20-
const { optionalDependencies, _dependencies } = npmTree
21-
return { ...tree, optionalDependencies, _dependencies }
17+
protected collectAllDependencies(tree: NpmDependency) {
18+
for (const [key, value] of Object.entries(tree.dependencies || {})) {
19+
const { _dependencies = {}, dependencies = {} } = value
20+
const isDuplicateDep = Object.keys(_dependencies).length > 0 && Object.keys(dependencies).length === 0
21+
if (isDuplicateDep) {
22+
continue
23+
}
24+
this.allDependencies.set(`${key}@${value.version}`, value)
25+
this.collectAllDependencies(value)
26+
}
2227
}
2328

24-
protected extractProductionDependencyTree(tree: NpmDependency): DependencyTree {
25-
const _deps = tree._dependencies ?? {}
26-
27-
let deps = tree.dependencies ?? {}
28-
let implicitDependenciesInjected = false
29-
30-
if (Object.keys(_deps).length > 0 && Object.keys(deps).length === 0) {
31-
log.debug({ name: tree.name, version: tree.version }, "injecting implicit _dependencies")
32-
deps = this.allDependencies.get(`${tree.name}@${tree.version}`)?.dependencies ?? {}
33-
implicitDependenciesInjected = true
29+
protected extractProductionDependencyGraph(tree: NpmDependency, dependencyId: string): void {
30+
if (this.productionGraph[dependencyId]) {
31+
return
3432
}
3533

36-
const dependencies = Object.entries(deps).reduce<DependencyTree["dependencies"]>((acc, curr) => {
37-
const [packageName, dependency] = curr
38-
if (!_deps[packageName] || Object.keys(dependency).length === 0) {
39-
return acc
40-
}
41-
if (implicitDependenciesInjected) {
42-
const { name, version, path, workspaces } = dependency
43-
const simplifiedTree: ParsedDependencyTree = { name, version, path, workspaces }
44-
return {
45-
...acc,
46-
[packageName]: { ...simplifiedTree, implicitDependenciesInjected },
47-
}
48-
}
49-
return {
50-
...acc,
51-
[packageName]: this.extractProductionDependencyTree(dependency),
52-
}
53-
}, {})
54-
55-
const { name, version, path: packagePath, workspaces } = tree
56-
const depTree: DependencyTree = {
57-
name,
58-
version,
59-
path: packagePath,
60-
workspaces,
61-
dependencies,
62-
implicitDependenciesInjected,
63-
}
64-
return depTree
34+
const { _dependencies: prodDependencies = {}, dependencies = {} } = tree
35+
const isDuplicateDep = Object.keys(prodDependencies).length > 0 && Object.keys(dependencies).length === 0
36+
const resolvedDeps = isDuplicateDep ? (this.allDependencies.get(dependencyId)?.dependencies ?? {}) : dependencies
37+
// Initialize with empty dependencies array first to mark this dependency as "in progress"
38+
// After initialization, if there are libraries with the same name+version later, they will not be searched recursively again
39+
// This will prevents infinite loops when circular dependencies are encountered.
40+
this.productionGraph[dependencyId] = { dependencies: [] }
41+
const productionDeps = Object.entries(resolvedDeps)
42+
.filter(([packageName]) => prodDependencies[packageName])
43+
.map(([packageName, dependency]) => {
44+
const childDependencyId = `${packageName}@${dependency.version}`
45+
this.extractProductionDependencyGraph(dependency, childDependencyId)
46+
return childDependencyId
47+
})
48+
this.productionGraph[dependencyId] = { dependencies: productionDeps }
6549
}
6650

6751
protected parseDependenciesTree(jsonBlob: string): NpmDependency {

packages/app-builder-lib/src/node-module-collector/pnpmNodeModulesCollector.ts

Lines changed: 40 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
import { Lazy } from "lazy-val"
22
import { NodeModulesCollector } from "./nodeModulesCollector"
3-
import { Dependency, DependencyTree, PnpmDependency } from "./types"
4-
import * as path from "path"
3+
import { PnpmDependency, Dependency } from "./types"
54
import { exec, log } from "builder-util"
5+
import * as path from "path"
6+
import * as fs from "fs"
67

78
export class PnpmNodeModulesCollector extends NodeModulesCollector<PnpmDependency, PnpmDependency> {
89
constructor(rootDir: string) {
@@ -28,54 +29,53 @@ export class PnpmNodeModulesCollector extends NodeModulesCollector<PnpmDependenc
2829
return ["list", "--prod", "--json", "--depth", "Infinity"]
2930
}
3031

31-
protected extractRelevantData(npmTree: PnpmDependency): PnpmDependency {
32-
const tree = super.extractRelevantData(npmTree)
33-
return {
34-
...tree,
35-
optionalDependencies: this.extractInternal(npmTree.optionalDependencies),
32+
extractProductionDependencyGraph(tree: PnpmDependency, dependencyId: string): void {
33+
if (this.productionGraph[dependencyId]) {
34+
return
3635
}
37-
}
3836

39-
extractProductionDependencyTree(tree: PnpmDependency): DependencyTree {
4037
const p = path.normalize(this.resolvePath(tree.path))
4138
const packageJson: Dependency<string, string> = require(path.join(p, "package.json"))
39+
const prodDependencies = { ...packageJson.dependencies, ...packageJson.optionalDependencies }
4240

4341
const deps = { ...(tree.dependencies || {}), ...(tree.optionalDependencies || {}) }
44-
const dependencies = Object.entries(deps).reduce<DependencyTree["dependencies"]>((acc, curr) => {
45-
const [packageName, dependency] = curr
46-
47-
let isOptional: boolean
48-
if (packageJson.dependencies?.[packageName]) {
49-
isOptional = false
50-
} else if (packageJson.optionalDependencies?.[packageName]) {
51-
isOptional = true
52-
} else {
53-
return acc
54-
}
55-
56-
try {
57-
return {
58-
...acc,
59-
[packageName]: this.extractProductionDependencyTree(dependency),
42+
this.productionGraph[dependencyId] = { dependencies: [] }
43+
const dependencies = Object.entries(deps)
44+
.filter(([packageName, dependency]) => {
45+
// First check if it's in production dependencies
46+
if (!prodDependencies[packageName]) {
47+
return false
6048
}
61-
} catch (error) {
62-
if (isOptional) {
63-
return acc
49+
50+
// Then check if optional dependency path exists
51+
if (packageJson.optionalDependencies && packageJson.optionalDependencies[packageName] && !fs.existsSync(dependency.path)) {
52+
log.debug(null, `Optional dependency ${packageName}@${dependency.version} path doesn't exist: ${dependency.path}`)
53+
return false
6454
}
65-
throw error
66-
}
67-
}, {})
6855

69-
const { name, version, path: packagePath, workspaces } = tree
70-
const depTree: DependencyTree = {
71-
name,
72-
version,
73-
path: packagePath,
74-
workspaces,
75-
dependencies,
76-
implicitDependenciesInjected: false,
56+
return true
57+
})
58+
.map(([packageName, dependency]) => {
59+
const childDependencyId = `${packageName}@${dependency.version}`
60+
this.extractProductionDependencyGraph(dependency, childDependencyId)
61+
return childDependencyId
62+
})
63+
64+
this.productionGraph[dependencyId] = { dependencies }
65+
}
66+
67+
protected collectAllDependencies(tree: PnpmDependency) {
68+
// Collect regular dependencies
69+
for (const [key, value] of Object.entries(tree.dependencies || {})) {
70+
this.allDependencies.set(`${key}@${value.version}`, value)
71+
this.collectAllDependencies(value)
72+
}
73+
74+
// Collect optional dependencies if they exist
75+
for (const [key, value] of Object.entries(tree.optionalDependencies || {})) {
76+
this.allDependencies.set(`${key}@${value.version}`, value)
77+
this.collectAllDependencies(value)
7778
}
78-
return depTree
7979
}
8080

8181
protected parseDependenciesTree(jsonBlob: string): PnpmDependency {

packages/app-builder-lib/src/node-module-collector/types.ts

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -12,15 +12,13 @@ export type ParsedDependencyTree = {
1212
readonly workspaces?: string[] // we only use this at root level
1313
}
1414

15-
export interface DependencyTree extends Omit<Dependency<DependencyTree, DependencyTree>, "optionalDependencies"> {
16-
readonly implicitDependenciesInjected: boolean
17-
}
18-
1915
// Note: `PnpmDependency` and `NpmDependency` include the output of `JSON.parse(...)` of `pnpm list` and `npm list` respectively
2016
// This object has a TON of info - a majority, if not all, of each dependency's package.json
2117
// We extract only what we need when constructing DependencyTree in `extractProductionDependencyTree`
22-
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
23-
export interface PnpmDependency extends Dependency<PnpmDependency, PnpmDependency> {}
18+
export interface PnpmDependency extends Dependency<PnpmDependency, PnpmDependency> {
19+
readonly from: string
20+
}
21+
2422
export interface NpmDependency extends Dependency<NpmDependency, string> {
2523
// implicit dependencies
2624
readonly _dependencies?: {

0 commit comments

Comments
 (0)