Skip to content

Commit f4ddefb

Browse files
fix(side-effects): read the inherited flag from the resolved file (#21826)
* fix(side-effects): read the inherited flag from the resolved file Follow-up to #21686. The flag was read from the description-file root, so a symlinked workspace package inherited for the request coming through node_modules but not for the relative imports its own files make. Report the ancestor package.json files the walk consults as dependencies of the resolve, and let an unreadable one decline the inheritance rather than fail the module. * fix(side-effects): stop inheriting past an unreadable ancestor manifest A manifest that is there but cannot be read declares something unknown, so letting an outer sideEffects: false speak for it can drop code that runs. Only an absent one keeps the walk going. Also makes the symlink probe idempotent, so a probe left behind by a failed cleanup no longer skips the case for good. * test(side-effects): fail the symlink filter on anything but a missing privilege * fix(test): keep the symlink filter on the Node 10 baseline * test(side-effects): shorten the symlink helper annotation
1 parent fc9dc8c commit f4ddefb

42 files changed

Lines changed: 451 additions & 79 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎.changeset/047-inherit-nested-sideEffects.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,4 @@
22
"webpack": patch
33
---
44

5-
Inherit sideEffects past type-only nested package.json under node_modules.
5+
Inherit sideEffects past a type-only nested package.json for files under node_modules.

‎lib/dependencies/ImportMetaGlobHelpers.js‎

Lines changed: 0 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -38,17 +38,6 @@ const getUnsupportedFeatureWarning = memoize(() =>
3838
* Property
3939
* } from "estree"
4040
*/
41-
/** @typedef {import("../ContextModule").ContextModuleOptions} ContextModuleOptions */
42-
/** @typedef {import("../javascript/JavascriptParser")} JavascriptParser */
43-
/** @typedef {import("../javascript/JavascriptParser").Range} Range */
44-
/** @typedef {import("../Dependency").RawReferencedExports} RawReferencedExports */
45-
/** @typedef {import("../Dependency").DependencyLocation} DependencyLocation */
46-
/** @typedef {import("estree").CallExpression} CallExpression */
47-
/** @typedef {import("estree").Expression} Expression */
48-
/** @typedef {import("estree").ObjectExpression} ObjectExpression */
49-
/** @typedef {import("estree").Property} Property */
50-
/** @typedef {import("./ImportMetaGlobDependency").ImportMetaGlobDependencyOptions} ImportMetaGlobDependencyOptions */
51-
5241
/**
5342
* @typedef {object} ParsedImportMetaGlobOptions
5443
* @property {string[]} patterns patterns

‎lib/optimize/SideEffectsFlagPlugin.js‎

Lines changed: 87 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ const formatLocation = require("../util/formatLocation");
1919
const { dirname, join, readJson, relative } = require("../util/fs");
2020
const { getGlobToRegExpSource } = require("../util/globUtils");
2121
const {
22+
NODE_MODULES_REGEXP,
2223
WINDOWS_PATH_SEPARATOR_REGEXP,
2324
relativePathToRequest
2425
} = require("../util/identifier");
@@ -43,6 +44,8 @@ const { CompilerHintNotationRegExp } = require("../util/magicComment");
4344
/** @import ModuleGraphConnection from "../ModuleGraphConnection" */
4445
/** @import { ExportInfo, TargetItemWithConnection } from "../ExportsInfo" */
4546
/** @import JavascriptParser, { Range } from "../javascript/JavascriptParser" */
47+
/** @import { InputFileSystem } from "../util/fs" */
48+
/** @import { CreateData, ResolveData } from "../NormalModuleFactory" */
4649
/**
4750
* @import {
4851
* JavascriptParserOptions
@@ -57,9 +60,7 @@ const { CompilerHintNotationRegExp } = require("../util/magicComment");
5760
* @property {boolean} checked if the export is conditional
5861
*/
5962

60-
/** @typedef {import("../util/fs").InputFileSystem} InputFileSystem */
61-
/** @typedef {import("../util/fs").JsonObject} JsonObject */
62-
/** @typedef {import("../NormalModuleFactory").CreateData} CreateData */
63+
/** @typedef {NonNullable<CreateData["resourceResolveData"]>} ResourceResolveData */
6364

6465
/** @typedef {string | boolean | string[] | undefined} SideEffectsFlagValue */
6566

@@ -72,14 +73,21 @@ const { CompilerHintNotationRegExp } = require("../util/magicComment");
7273
*/
7374

7475
/**
75-
* Owning-package sideEffects resolution cached per descriptionFileRoot.
76-
* `null` means the walk finished with no applicable flag.
7776
* @typedef {object} OwningSideEffectsFlag
7877
* @property {SideEffectsFlagValue} sideEffects package.json sideEffects value
7978
* @property {string} packageRoot directory of the declaring package.json
8079
*/
8180

82-
/** @typedef {Map<string, OwningSideEffectsFlag | null>} OwningSideEffectsFlagCache */
81+
/**
82+
* A finished ancestor walk, kept per descriptionFileRoot. The package.json
83+
* paths it consulted are replayed as dependencies for every module reusing it.
84+
* @typedef {object} OwningSideEffectsFlagWalk
85+
* @property {OwningSideEffectsFlag | null} owning declaring package, `null` when the walk found none
86+
* @property {string[]} read package.json files the walk read
87+
* @property {string[]} missing package.json files the walk did not find
88+
*/
89+
90+
/** @typedef {Map<string, OwningSideEffectsFlagWalk>} OwningSideEffectsFlagCache */
8391

8492
/**
8593
* Whether a resolved re-export target is reached through an `import defer` edge.
@@ -98,30 +106,21 @@ const isDeferredTargetConnection = (connection) =>
98106
/** @type {WeakMap<Compiler, CacheItem>} */
99107
const globToRegexpCache = new WeakMap();
100108

101-
/** @type {WeakMap<EXPECTED_OBJECT, OwningSideEffectsFlagCache>} */
109+
/** @type {WeakMap<Compiler, OwningSideEffectsFlagCache>} */
102110
const owningSideEffectsFlagCache = new WeakMap();
103111

104112
/** @type {WeakMap<Partial<CreateData>, ResolvedSideEffectsFlag>} */
105113
const resolvedSideEffectsFlags = new WeakMap();
106114

107115
/**
108-
* Ancestor package.json read failures that must not fail unrelated modules.
116+
* Whether a failed ancestor package.json read means nothing is there, as
117+
* opposed to something being there that cannot be used.
109118
* @param {NodeJS.ErrnoException | SyntaxError | Error} err error from readJson
110-
* @returns {boolean} true when the walk should skip this directory
119+
* @returns {boolean} true when no file was found at that path
111120
*/
112-
const isSkippablePackageJsonError = (err) => {
113-
if (err instanceof SyntaxError) return true;
114-
if (!("code" in err)) return false;
115-
switch (err.code) {
116-
case "ENOENT":
117-
case "ENOTDIR":
118-
case "EISDIR":
119-
case "ENAMETOOLONG":
120-
return true;
121-
default:
122-
return false;
123-
}
124-
};
121+
const isMissingPackageJsonError = (err) =>
122+
"code" in err && err.code === "ENOENT";
123+
125124
/**
126125
* Returns a regular expression.
127126
* @param {string} glob the pattern
@@ -166,26 +165,33 @@ const notSideEffectsTag = Symbol("NoSideEffects");
166165
const RETURNS_FALSE = () => false;
167166

168167
/**
169-
* Resolve sideEffects from the owning package, inheriting past type-only nested
170-
* package.json under node_modules. Globs are matched relative to that package root.
168+
* Resolve sideEffects from the package that declares it, inheriting past a nested
169+
* package.json that declares none. Globs are rebased on the declaring package root,
170+
* and each package.json the walk consults becomes a dependency of the resolve.
171171
* @param {InputFileSystem} fs file system
172+
* @param {ResolveData} resolveData resolve data
172173
* @param {string} descriptionFileRoot root directory of the nearest description file
173-
* @param {JsonObject} descriptionFileData nearest description file data
174-
* @param {string} relativePath path relative to descriptionFileRoot
175174
* @param {OwningSideEffectsFlagCache} owningCache walk cache keyed by descriptionFileRoot
176-
* @param {(err?: null | Error, result?: ResolvedSideEffectsFlag) => void} callback callback
175+
* @param {(result?: ResolvedSideEffectsFlag) => void} callback callback
177176
* @returns {void}
178177
*/
179178
const resolveSideEffectsFlag = (
180179
fs,
180+
resolveData,
181181
descriptionFileRoot,
182-
descriptionFileData,
183-
relativePath,
184182
owningCache,
185183
callback
186184
) => {
185+
const resolveInfo = /** @type {ResourceResolveData} */ (
186+
resolveData.createData.resourceResolveData
187+
);
188+
const descriptionFileData =
189+
/** @type {NonNullable<ResourceResolveData["descriptionFileData"]>} */ (
190+
resolveInfo.descriptionFileData
191+
);
192+
const relativePath = /** @type {string} */ (resolveInfo.relativePath);
187193
if (descriptionFileData.sideEffects !== undefined) {
188-
return callback(null, {
194+
return callback({
189195
sideEffects: /** @type {SideEffectsFlagValue} */ (
190196
descriptionFileData.sideEffects
191197
),
@@ -196,82 +202,99 @@ const resolveSideEffectsFlag = (
196202
if (descriptionFileData.name !== undefined) {
197203
return callback();
198204
}
199-
// Only inherit inside node_modules; app-local nests must keep shadowing parents.
200-
const normalizedRoot = descriptionFileRoot.replace(
201-
WINDOWS_PATH_SEPARATOR_REGEXP,
202-
"/"
203-
);
204-
if (!/(?:^|\/)node_modules(?:\/|$)/.test(normalizedRoot)) {
205+
// Only inherit for a file living under node_modules; app-local nests keep shadowing.
206+
// Read the resolved path so a symlinked package flags all of its files alike.
207+
if (
208+
typeof resolveInfo.path !== "string" ||
209+
!NODE_MODULES_REGEXP.test(resolveInfo.path)
210+
) {
205211
return callback();
206212
}
207213

214+
// stays in descriptionFileRoot's space, which is the space packageRoot is found in
208215
const resourcePath = join(fs, descriptionFileRoot, relativePath);
209216

210217
/**
211-
* @param {OwningSideEffectsFlag | null} owning owning package flag or null
218+
* @param {OwningSideEffectsFlagWalk} walk finished walk
212219
* @returns {void}
213220
*/
214-
const finishFromOwning = (owning) => {
221+
const finishFromWalk = (walk) => {
222+
for (const file of walk.read) resolveData.fileDependencies.add(file);
223+
for (const file of walk.missing) resolveData.missingDependencies.add(file);
224+
const owning = walk.owning;
215225
if (owning === null) return callback();
216226
const ancestorRelative = relative(
217227
fs,
218228
owning.packageRoot,
219229
resourcePath
220230
).replace(WINDOWS_PATH_SEPARATOR_REGEXP, "/");
221-
return callback(null, {
231+
return callback({
222232
sideEffects: owning.sideEffects,
223233
relativePath: relativePathToRequest(ancestorRelative)
224234
});
225235
};
226236

227237
const cached = owningCache.get(descriptionFileRoot);
228238
if (cached !== undefined) {
229-
return finishFromOwning(cached);
239+
return finishFromWalk(cached);
230240
}
231241

242+
/** @type {string[]} */
243+
const read = [];
244+
/** @type {string[]} */
245+
const missing = [];
246+
247+
/**
248+
* @param {OwningSideEffectsFlag | null} owning declaring package or null
249+
* @returns {void}
250+
*/
251+
const done = (owning) => {
252+
/** @type {OwningSideEffectsFlagWalk} */
253+
const walk = { owning, read, missing };
254+
owningCache.set(descriptionFileRoot, walk);
255+
return finishFromWalk(walk);
256+
};
257+
232258
let dir = dirname(fs, descriptionFileRoot);
233259

234260
const next = () => {
235261
const parent = dirname(fs, dir);
236-
if (!parent || parent === dir) {
237-
owningCache.set(descriptionFileRoot, null);
238-
return callback();
239-
}
262+
if (!parent || parent === dir) return done(null);
240263
const baseName = relative(fs, parent, dir).replace(
241264
WINDOWS_PATH_SEPARATOR_REGEXP,
242265
"/"
243266
);
244267
// Do not inherit an application's sideEffects across an install boundary.
245-
if (baseName === "node_modules") {
246-
owningCache.set(descriptionFileRoot, null);
247-
return callback();
248-
}
268+
if (baseName === "node_modules") return done(null);
249269

250-
readJson(fs, join(fs, dir, "package.json"), (err, data) => {
270+
const packageJsonPath = join(fs, dir, "package.json");
271+
272+
readJson(fs, packageJsonPath, (err, data) => {
251273
if (err) {
252-
if (isSkippablePackageJsonError(err)) {
253-
dir = parent;
254-
return next();
274+
if (!isMissingPackageJsonError(err)) {
275+
// it is there but unreadable, so what it declares is unknown:
276+
// decline rather than let an outer flag speak for it
277+
read.push(packageJsonPath);
278+
return done(null);
255279
}
256-
return callback(err);
280+
missing.push(packageJsonPath);
281+
dir = parent;
282+
return next();
257283
}
284+
read.push(packageJsonPath);
258285
if (!data || typeof data !== "object" || Array.isArray(data)) {
259286
dir = parent;
260287
return next();
261288
}
262289
if (data.sideEffects !== undefined) {
263-
/** @type {OwningSideEffectsFlag} */
264-
const owning = {
290+
return done({
265291
sideEffects: /** @type {SideEffectsFlagValue} */ (data.sideEffects),
266292
packageRoot: dir
267-
};
268-
owningCache.set(descriptionFileRoot, owning);
269-
return finishFromOwning(owning);
270-
}
271-
if (data.name !== undefined) {
272-
owningCache.set(descriptionFileRoot, null);
273-
return callback();
293+
});
274294
}
295+
// An unnamed manifest deeper in the package keeps the walk going; a named one
296+
// is a package of its own, and its silence is its answer.
297+
if (data.name !== undefined) return done(null);
275298
dir = parent;
276299
next();
277300
});
@@ -359,12 +382,10 @@ class SideEffectsFlagPlugin {
359382
if (descriptionFileRoot === undefined) return callback();
360383
resolveSideEffectsFlag(
361384
inputFileSystem,
385+
resolveData,
362386
descriptionFileRoot,
363-
resolveInfo.descriptionFileData,
364-
resolveInfo.relativePath,
365387
owningCache,
366-
(err, resolved) => {
367-
if (err) return callback(err);
388+
(resolved) => {
368389
if (resolved !== undefined) {
369390
resolvedSideEffectsFlags.set(createData, resolved);
370391
}
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import { light } from "symlinked-sef";
2+
3+
const SYMLINKED_HEAVY = ["HEAVY", "SYMLINKED", "WORKSPACE"].join("_");
4+
5+
/**
6+
* @param {string} marker
7+
* @returns {boolean}
8+
*/
9+
function moduleSourceIncludes(marker) {
10+
return Object.keys(__webpack_modules__).some((id) =>
11+
String(__webpack_modules__[id]).includes(marker)
12+
);
13+
}
14+
15+
it("still runs the used export", () => {
16+
expect(light).toBe("light");
17+
});
18+
19+
it("flags every file of a symlinked package alike", () => {
20+
// The package request arrives through node_modules and its own imports through
21+
// the real path; reading the resolved file keeps both on one answer.
22+
expect(moduleSourceIncludes(SYMLINKED_HEAVY)).toBe(true);
23+
});
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
{
2+
"name": "nested-package-json-sideEffects-symlinked",
3+
"sideEffects": false
4+
}
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
export var HeavyMode;
2+
(function (HeavyMode) {
3+
HeavyMode[(HeavyMode.A = 0)] = "A";
4+
})(HeavyMode || (HeavyMode = {}));
5+
6+
export const heavy = "HEAVY_SYMLINKED_WORKSPACE";
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
import { heavy } from "./heavy.js";
2+
import { light } from "./light.js";
3+
4+
export function useHeavy() {
5+
return heavy;
6+
}
7+
8+
export { light, heavy };
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export const light = "light";
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
{
2+
"type": "module"
3+
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
{
2+
"name": "symlinked-sef",
3+
"version": "1.0.0",
4+
"sideEffects": false,
5+
"exports": {
6+
".": {
7+
"import": "./esm/index.js",
8+
"default": "./esm/index.js"
9+
}
10+
}
11+
}

0 commit comments

Comments
 (0)