Skip to content

Commit 3d585ee

Browse files
cezaraugustocezaraugusto
authored andcommitted
Enforce transactional optional dependency installs
1 parent b0b75cf commit 3d585ee

File tree

7 files changed

+213
-78
lines changed

7 files changed

+213
-78
lines changed

ci-scripts/run-optional-deps-smoke.mjs

Lines changed: 1 addition & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -141,17 +141,7 @@ async function terminateChildProcess(child) {
141141
}
142142

143143
function buildSmokeEnv(pm) {
144-
if (pm !== 'pnpm') return baseEnv
145-
146-
const npmCommandPath = resolveCommandPath('npm')
147-
return {
148-
...baseEnv,
149-
// Force the isolated optional-deps cache installs through npm so the
150-
// smoke test can focus on lockfile stability instead of host-specific
151-
// pnpm shim resolution in nested child processes.
152-
EXTENSION_JS_PACKAGE_MANAGER: 'npm',
153-
...(npmCommandPath ? {EXTENSION_JS_PM_EXEC_PATH: npmCommandPath} : {})
154-
}
144+
return baseEnv
155145
}
156146

157147
function commandFor(tool) {

programs/develop/webpack/optional-deps-lib/__spec__/installer-engine.spec.ts

Lines changed: 55 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,20 @@ const createSpawn = (
2727
}
2828
})
2929

30+
function seedInstalledDependency(installRoot: string, dependencyId: string) {
31+
const packageDir = path.join(
32+
installRoot,
33+
'node_modules',
34+
...dependencyId.split('/')
35+
)
36+
fs.mkdirSync(packageDir, {recursive: true})
37+
fs.writeFileSync(path.join(packageDir, 'index.js'), 'module.exports = {}')
38+
fs.writeFileSync(
39+
path.join(packageDir, 'package.json'),
40+
JSON.stringify({name: dependencyId, version: '0.0.0'})
41+
)
42+
}
43+
3044
vi.mock('child_process', () => ({
3145
execFileSync: vi.fn(),
3246
spawnSync: vi.fn(() => ({status: 0})),
@@ -66,7 +80,9 @@ describe('optional-deps-lib installer engine', () => {
6680

6781
it('installs optional tooling into isolated cache root', async () => {
6882
const {spawn} = (await import('child_process')) as any
69-
const {installOptionalDependencies} = await import('../index')
83+
const {installOptionalDependencies, resolveOptionalInstallRoot} =
84+
await import('../index')
85+
seedInstalledDependency(resolveOptionalInstallRoot(), 'postcss')
7086

7187
await installOptionalDependencies('PostCSS', ['postcss'])
7288

@@ -80,6 +96,9 @@ describe('optional-deps-lib installer engine', () => {
8096
process.env.npm_config_user_agent = 'npm'
8197
const {installOptionalDependencies, resolveOptionalInstallRoot} =
8298
await import('../index')
99+
const installRoot = resolveOptionalInstallRoot()
100+
seedInstalledDependency(installRoot, 'react-refresh')
101+
seedInstalledDependency(installRoot, '@rspack/plugin-react-refresh')
83102

84103
await installOptionalDependencies('React', [
85104
'react-refresh',
@@ -97,7 +116,9 @@ describe('optional-deps-lib installer engine', () => {
97116
it('runs a single command for single-package install', async () => {
98117
process.env.npm_config_user_agent = 'npm'
99118
const {spawn} = (await import('child_process')) as any
100-
const {installOptionalDependencies} = await import('../index')
119+
const {installOptionalDependencies, resolveOptionalInstallRoot} =
120+
await import('../index')
121+
seedInstalledDependency(resolveOptionalInstallRoot(), 'postcss')
101122

102123
await installOptionalDependencies('PostCSS', ['postcss'])
103124

@@ -107,7 +128,12 @@ describe('optional-deps-lib installer engine', () => {
107128
it('runs root relink for multi-package install', async () => {
108129
process.env.npm_config_user_agent = 'npm'
109130
const {spawn} = (await import('child_process')) as any
110-
const {installOptionalDependencies} = await import('../index')
131+
const {installOptionalDependencies, resolveOptionalInstallRoot} =
132+
await import('../index')
133+
const installRoot = resolveOptionalInstallRoot()
134+
seedInstalledDependency(installRoot, 'vue-loader')
135+
seedInstalledDependency(installRoot, '@vue/compiler-sfc')
136+
seedInstalledDependency(installRoot, 'vue')
111137

112138
await installOptionalDependencies('Vue', [
113139
'vue-loader',
@@ -122,6 +148,32 @@ describe('optional-deps-lib installer engine', () => {
122148
expect(relinkArgs).toContain('install')
123149
})
124150

151+
it('recovers from partial install by topping up missing dependency', async () => {
152+
process.env.npm_config_user_agent = 'npm'
153+
const {spawn} = (await import('child_process')) as any
154+
const {installOptionalDependencies, resolveOptionalInstallRoot} =
155+
await import('../index')
156+
const installRoot = resolveOptionalInstallRoot()
157+
seedInstalledDependency(installRoot, '@rspack/plugin-react-refresh')
158+
159+
let callIndex = 0
160+
spawn.mockImplementation(() => {
161+
if (callIndex === 2) {
162+
seedInstalledDependency(installRoot, 'react-refresh')
163+
}
164+
callIndex += 1
165+
return createSpawn({exitCode: 0})
166+
})
167+
168+
const result = await installOptionalDependencies('React', [
169+
'react-refresh',
170+
'@rspack/plugin-react-refresh'
171+
])
172+
173+
expect(result).toBe(true)
174+
expect(callIndex).toBeGreaterThanOrEqual(3)
175+
})
176+
125177
it('does not fallback on windows when primary manager is npm', async () => {
126178
setPlatform('win32')
127179
process.env.npm_config_user_agent = 'npm'

programs/develop/webpack/optional-deps-lib/installer-engine.ts

Lines changed: 143 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import * as path from 'path'
2+
import {createRequire} from 'module'
13
import colors from 'pintor'
24
import * as messages from '../plugin-css/css-lib/messages'
35
import {resolveOptionalDependencySpecs} from '../webpack-lib/optional-dependencies'
@@ -34,6 +36,29 @@ export type InstallOptionalDependenciesOptions = {
3436
forceRecreateInstallRoot?: boolean
3537
}
3638

39+
function isPathInside(basePath: string, candidatePath: string) {
40+
const relative = path.relative(
41+
path.resolve(basePath),
42+
path.resolve(candidatePath)
43+
)
44+
return !relative.startsWith('..') && !path.isAbsolute(relative)
45+
}
46+
47+
function getMissingDependenciesAtInstallRoot(
48+
dependencies: string[],
49+
installBaseDir: string
50+
) {
51+
const req = createRequire(path.join(installBaseDir, 'package.json'))
52+
return dependencies.filter((dependencyId) => {
53+
try {
54+
const resolvedPath = req.resolve(dependencyId)
55+
return !isPathInside(installBaseDir, resolvedPath)
56+
} catch {
57+
return true
58+
}
59+
})
60+
}
61+
3762
function parseWslUncPath(value: string): {distro: string; path: string} | null {
3863
const match = /^\\\\wsl(?:\.localhost)?\\([^\\]+)\\(.+)$/.exec(value)
3964
if (!match) return null
@@ -226,6 +251,76 @@ function getRootInstallCommand(
226251
return buildInstallCommand(pm, ['install', '--silent', ...dirArgs])
227252
}
228253

254+
async function runInstallAttempt(input: {
255+
pm: PackageManagerResolution
256+
wslContext: WslContext
257+
installBaseDir: string
258+
dependencies: string[]
259+
isAuthor: boolean
260+
integration: string
261+
}) {
262+
const installCommand = getOptionalInstallCommand(
263+
input.pm,
264+
input.dependencies,
265+
input.wslContext.installDir || input.installBaseDir
266+
)
267+
const execCommand = wrapCommandForWsl(installCommand, input.wslContext)
268+
const fallbackNpmCommand = input.wslContext.useWsl
269+
? undefined
270+
: buildNpmCliFallback([
271+
'--silent',
272+
'install',
273+
...resolveOptionalDependencySpecs(input.dependencies),
274+
'--prefix',
275+
input.installBaseDir,
276+
'--save-optional'
277+
])
278+
await execInstallWithFallback(execCommand, {
279+
cwd: input.wslContext.useWsl ? undefined : input.installBaseDir,
280+
fallbackNpmCommand,
281+
allowFallbackOnFailure:
282+
!input.wslContext.useWsl &&
283+
input.pm.name !== 'npm' &&
284+
fallbackNpmCommand !== undefined
285+
})
286+
287+
await new Promise((resolve) => setTimeout(resolve, 500))
288+
289+
const needsRootRelink = input.isAuthor || input.dependencies.length > 1
290+
if (!needsRootRelink) return
291+
292+
if (input.isAuthor) {
293+
console.log(messages.optionalToolingRootInstall(input.integration))
294+
}
295+
296+
const rootInstall = getRootInstallCommand(
297+
input.pm,
298+
input.wslContext.useWsl ? input.wslContext.installDir : undefined
299+
)
300+
const rootCommand = wrapCommandForWsl(rootInstall, input.wslContext)
301+
const rootFallbackCommand = input.wslContext.useWsl
302+
? undefined
303+
: buildNpmCliFallback([
304+
'--silent',
305+
'install',
306+
'--prefix',
307+
input.installBaseDir
308+
])
309+
310+
await execInstallWithFallback(rootCommand, {
311+
cwd: input.wslContext.useWsl ? undefined : input.installBaseDir,
312+
fallbackNpmCommand: rootFallbackCommand,
313+
allowFallbackOnFailure:
314+
!input.wslContext.useWsl &&
315+
input.pm.name !== 'npm' &&
316+
rootFallbackCommand !== undefined
317+
})
318+
319+
if (input.isAuthor) {
320+
console.log(messages.optionalToolingReady(input.integration))
321+
}
322+
}
323+
229324
export async function installOptionalDependencies(
230325
integration: string,
231326
dependencies: string[],
@@ -268,63 +363,63 @@ export async function installOptionalDependencies(
268363
if (isAuthor) console.warn(setupMessageWithIndex)
269364
else console.log(setupMessageWithIndex)
270365

271-
const installCommand = getOptionalInstallCommand(
366+
await runInstallAttempt({
272367
pm,
368+
wslContext,
369+
installBaseDir,
273370
dependencies,
274-
wslContext.installDir || installBaseDir
275-
)
276-
const execCommand = wrapCommandForWsl(installCommand, wslContext)
277-
const fallbackNpmCommand = wslContext.useWsl
278-
? undefined
279-
: buildNpmCliFallback([
280-
'--silent',
281-
'install',
282-
...resolveOptionalDependencySpecs(dependencies),
283-
'--prefix',
284-
installBaseDir,
285-
'--save-optional'
286-
])
287-
await execInstallWithFallback(execCommand, {
288-
cwd: wslContext.useWsl ? undefined : installBaseDir,
289-
fallbackNpmCommand,
290-
allowFallbackOnFailure:
291-
!wslContext.useWsl &&
292-
pm.name !== 'npm' &&
293-
fallbackNpmCommand !== undefined
371+
isAuthor,
372+
integration
294373
})
295374

296-
await new Promise((resolve) => setTimeout(resolve, 500))
375+
let missingDependencies = getMissingDependenciesAtInstallRoot(
376+
dependencies,
377+
installBaseDir
378+
)
297379

298-
const needsRootRelink = isAuthor || dependencies.length > 1
299-
if (needsRootRelink) {
300-
if (isAuthor) {
301-
console.log(messages.optionalToolingRootInstall(integration))
302-
}
303-
const rootInstall = getRootInstallCommand(
380+
if (missingDependencies.length > 0) {
381+
await runInstallAttempt({
304382
pm,
305-
wslContext.useWsl ? wslContext.installDir : undefined
383+
wslContext,
384+
installBaseDir,
385+
dependencies: missingDependencies,
386+
isAuthor,
387+
integration
388+
})
389+
390+
missingDependencies = getMissingDependenciesAtInstallRoot(
391+
dependencies,
392+
installBaseDir
306393
)
307-
const rootCommand = wrapCommandForWsl(rootInstall, wslContext)
308-
const rootFallbackCommand = wslContext.useWsl
309-
? undefined
310-
: buildNpmCliFallback([
311-
'--silent',
312-
'install',
313-
'--prefix',
314-
installBaseDir
315-
])
316-
await execInstallWithFallback(rootCommand, {
317-
cwd: wslContext.useWsl ? undefined : installBaseDir,
318-
fallbackNpmCommand: rootFallbackCommand,
319-
allowFallbackOnFailure:
320-
!wslContext.useWsl &&
321-
pm.name !== 'npm' &&
322-
rootFallbackCommand !== undefined
394+
}
395+
396+
if (missingDependencies.length > 0) {
397+
prepareOptionalInstallState({
398+
installBaseDir,
399+
dependencies,
400+
forceRecreateInstallRoot: true
323401
})
324-
if (isAuthor) {
325-
console.log(messages.optionalToolingReady(integration))
326-
}
402+
await runInstallAttempt({
403+
pm,
404+
wslContext,
405+
installBaseDir,
406+
dependencies,
407+
isAuthor,
408+
integration
409+
})
410+
411+
missingDependencies = getMissingDependenciesAtInstallRoot(
412+
dependencies,
413+
installBaseDir
414+
)
327415
}
416+
417+
if (missingDependencies.length > 0) {
418+
throw new Error(
419+
`[${integration}] Optional dependency install reported success but packages are missing: ${missingDependencies.join(', ')}`
420+
)
421+
}
422+
328423
return true
329424
} catch (error) {
330425
console.error('[extension.js][optional-deps] debug', {

programs/develop/webpack/plugin-js-frameworks/__spec__/js-tools/react.spec.ts

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,7 @@ vi.mock('../../frameworks-lib/integrations', () => ({
88

99
vi.mock('../../../webpack-lib/optional-deps-resolver', () => ({
1010
resolveOptionalPackageWithoutInstall: vi.fn(() => '/mock/react-refresh'),
11-
loadOptionalModuleWithoutInstall: vi.fn(
12-
() => class ReactRefreshPluginMock {}
13-
)
11+
loadOptionalModuleWithoutInstall: vi.fn(() => class ReactRefreshPluginMock {})
1412
}))
1513

1614
// Ensure require.resolve('react-refresh') succeeds to avoid install+exit

programs/develop/webpack/plugin-js-frameworks/js-tools/react.ts

Lines changed: 10 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -95,17 +95,16 @@ export async function maybeUseReact(
9595
verifyPackageIds: reactDependencies
9696
})
9797

98-
const ReactRefreshPlugin = loadOptionalModuleWithoutInstall<
99-
ReactRefreshPluginCtor
100-
>({
101-
integration: 'React',
102-
projectPath,
103-
dependencyId: '@rspack/plugin-react-refresh',
104-
installDependencies: reactDependencies,
105-
verifyPackageIds: reactDependencies,
106-
moduleAdapter: (mod: any) =>
107-
((mod && mod.default) || mod) as ReactRefreshPluginCtor
108-
})
98+
const ReactRefreshPlugin =
99+
loadOptionalModuleWithoutInstall<ReactRefreshPluginCtor>({
100+
integration: 'React',
101+
projectPath,
102+
dependencyId: '@rspack/plugin-react-refresh',
103+
installDependencies: reactDependencies,
104+
verifyPackageIds: reactDependencies,
105+
moduleAdapter: (mod: any) =>
106+
((mod && mod.default) || mod) as ReactRefreshPluginCtor
107+
})
109108

110109
const reactPlugins: RspackPluginInstance[] = [
111110
new ReactRefreshPlugin({

programs/develop/webpack/webpack-lib/__spec__/preflight-optional-deps.spec.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -112,5 +112,4 @@ describe('preflight-optional-deps', () => {
112112

113113
expect(installOptionalDependenciesBatch).not.toHaveBeenCalled()
114114
})
115-
116115
})

programs/develop/webpack/webpack-lib/optional-deps-resolver.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -625,7 +625,9 @@ export async function ensureOptionalPackageResolved(
625625
)
626626
}
627627

628-
export function resolveOptionalPackageWithoutInstall(input: EnsureResolveInput) {
628+
export function resolveOptionalPackageWithoutInstall(
629+
input: EnsureResolveInput
630+
) {
629631
const installDependencies = input.installDependencies || [input.dependencyId]
630632
const verifyPackageIds = input.verifyPackageIds || installDependencies
631633
const installRoot = resolveOptionalInstallRoot()

0 commit comments

Comments
 (0)