Visitar URL original
fix(build): make Firefox builds reproducible across paths and remove the runtime-chunk mangling workaround by gauthierpetetin · Pull Request #46845 · MetaMask/metamask-extension · GitHub
Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,3 +1,46 @@
diff --git a/src/Common/Helpers.js b/src/Common/Helpers.js
index 81484701efadd9790d7a74ac385dff3b9980baad..71aee24ca9c8049cf5ba8b7a173f31f030616c2a 100644
--- a/src/Common/Helpers.js
+++ b/src/Common/Helpers.js
@@ -1,6 +1,30 @@
const path = require('path');
const { parseRequest } = require('./RequestParser');

+/**
+ * Make an absolute file path a request relative to the issuer's directory, e.g.
+ * `/abs/app/scripts/x.ts` required from `/abs/app/html/ui/home.html` becomes
+ * `../../scripts/x.ts`. webpack resolves module requests relative to the
+ * requiring module, so this is equivalent, but it keeps the absolute path of
+ * the project out of the generated module source — which webpack hashes into
+ * `buildInfo.hash`, so otherwise every chunk's provisional `[contenthash]`
+ * depends on where the project lives on disk. Non-absolute requests (URLs,
+ * aliases, `require()` expressions) are returned unchanged.
+ *
+ * @param {string} file
+ * @param {string} [issuer]
+ * @return {string}
+ */
+const toRelativeRequest = (file, issuer) => {
+ if (!issuer || !path.isAbsolute(file)) return file;
+ const [issuerFile] = issuer.split('?', 1);
+ const [filePath, ...query] = file.split('?');
+ let request = path.relative(path.dirname(issuerFile), filePath);
+ if (isWin) request = pathToPosix(request);
+ if (!request.startsWith('.')) request = './' + request;
+ return query.length ? `${request}?${query.join('?')}` : request;
+};
+
const isWin = path.sep === '\\';

/**
@@ -334,6 +358,7 @@ const compareVersions = (version1, compare, version2) => {
};

module.exports = {
+ toRelativeRequest,
isWin,
isFunction,
isUrl,
diff --git a/src/Common/Utils.js b/src/Common/Utils.js
index e923daa8a95ab70ae5f1dbc883f5e90420b14b1a..ca21e8ac6c566820236aa682becd03b6c05e592a 100644
--- a/src/Common/Utils.js
Expand All @@ -13,3 +56,140 @@ index e923daa8a95ab70ae5f1dbc883f5e90420b14b1a..ca21e8ac6c566820236aa682becd03b6
for (const [match, variable, file] of importMatches) {
code = code.replace(match, `var ${variable} = require('${file}');`);
}
diff --git a/src/Loader/Modes/Compile.js b/src/Loader/Modes/Compile.js
index 4b810d82dab33495b6dd69695f2e0f42ccb04449..5c07c1a6f0adf69acc3b916240400f6706174d4b 100644
--- a/src/Loader/Modes/Compile.js
+++ b/src/Loader/Modes/Compile.js
@@ -1,6 +1,6 @@
const PreprocessorModeAbstract = require('./PreprocessorModeAbstract');
const { decodeReservedChars, escapeSequences } = require('../Utils');
-const { isWin, pathToPosix } = require('../../Common/Helpers');
+const { isWin, pathToPosix, toRelativeRequest } = require('../../Common/Helpers');
const { errorToHtml } = require('../Messages/Exeptions');

/**
@@ -28,11 +28,13 @@ class Compile extends PreprocessorModeAbstract {

/**
* @param {string} file
+ * @param {string} [issuer]
* @return {string}
*/
- requireExpression(file) {
+ requireExpression(file, issuer) {
const quote = this.enclosingQuotes;

+ file = toRelativeRequest(file, issuer);
if (isWin) file = pathToPosix(file);

return `${quote} + require('${file}') + ${quote}`;
diff --git a/src/Loader/Modes/PreprocessorModeAbstract.js b/src/Loader/Modes/PreprocessorModeAbstract.js
index 5c1266d7d69b9509ec733e4536d56c5aea0918a0..ba431c0cda3922eb952e83089f32318540d67f9d 100644
--- a/src/Loader/Modes/PreprocessorModeAbstract.js
+++ b/src/Loader/Modes/PreprocessorModeAbstract.js
@@ -49,11 +49,26 @@ class PreprocessorModeAbstract {
setEnclosingQuotes(enclosingQuotes) {}

/**
- * @param {string} file
+ * @param {string} file The resolved absolute path of the required file.
+ * @param {string} [issuer] The template file that requires it.
* @return {string}
* @abstract This method must be defined in the child class.
*/
- requireExpression(file) {}
+ requireExpression(file, issuer) {}
+
+ /**
+ * Requests are emitted relative to the issuer only when the issuer is an
+ * entry template: the plugin renders that same file later, so the request
+ * can be resolved back against the same directory (Plugin/Resolver.require).
+ * Partials, styles and other non-entry issuers are compiled under one file
+ * but rendered under another, so for those the request stays absolute.
+ *
+ * @param {string} issuer
+ * @return {string|undefined}
+ */
+ relativeRequestBase(issuer) {
+ return this.loaderOption.isEntry(issuer) ? issuer : undefined;
+ }

/**
* Export template code with rendered HTML.
@@ -118,7 +133,7 @@ class PreprocessorModeAbstract {

return {
resolvedFile,
- requireExpression: this.requireExpression(resolvedFile),
+ requireExpression: this.requireExpression(resolvedFile, this.relativeRequestBase(issuer)),
};
}

@@ -151,7 +166,7 @@ class PreprocessorModeAbstract {

return {
resolvedFile,
- requireExpression: this.requireExpression(resolvedFile),
+ requireExpression: this.requireExpression(resolvedFile, this.relativeRequestBase(issuer)),
};
}

@@ -184,7 +199,7 @@ class PreprocessorModeAbstract {

return {
resolvedFile,
- requireExpression: this.requireExpression(resolvedFile),
+ requireExpression: this.requireExpression(resolvedFile, this.relativeRequestBase(issuer)),
};
}
}
diff --git a/src/Loader/Modes/Render.js b/src/Loader/Modes/Render.js
index c272d80008cc14f8af7926603c09309ce3884c46..d45238b0dc161ee19bb30e767ee3709d79c12cdc 100644
--- a/src/Loader/Modes/Render.js
+++ b/src/Loader/Modes/Render.js
@@ -1,4 +1,5 @@
const PreprocessorModeAbstract = require('./PreprocessorModeAbstract');
+const { toRelativeRequest } = require('../../Common/Helpers');
const PluginService = require('../../Plugin/PluginService');
const { hotUpdateFile, injectBeforeEndHead, decodeReservedChars } = require('../Utils');
const { errorToHtml } = require('../Messages/Exeptions');
@@ -23,9 +24,11 @@ class Render extends PreprocessorModeAbstract {

/**
* @param {string} file
+ * @param {string} [issuer]
* @return {string}
*/
- requireExpression(file) {
+ requireExpression(file, issuer) {
+ file = toRelativeRequest(file, issuer);
return `\\u0027 + require(\\u0027${file}\\u0027) + \\u0027`;
}

diff --git a/src/Plugin/Resolver.js b/src/Plugin/Resolver.js
index 7ef14e658c0b1efeb388e0a7275414e45dc08f13..959ae9f102c5188dbc47c3b170d3a6fd66cc87ec 100644
--- a/src/Plugin/Resolver.js
+++ b/src/Plugin/Resolver.js
@@ -222,6 +222,22 @@ class Resolver {
require(rawRequest) {
const { issuer, issuerFile } = this;

+ // The loader emits script/style requests of entry templates relative to
+ // the template's directory (see `toRelativeRequest` in Common/Helpers.js)
+ // so that the generated module source — which webpack hashes — contains no
+ // absolute path. Scripts and inline styles are looked up below by their
+ // absolute resource, so map such a request back to it. Other relative
+ // requests (e.g. `url()` in CSS) were always relative and are resolved via
+ // `resolveResource()`'s (issuer, rawRequest) table, so leave them as is.
+ if (rawRequest.startsWith('./') || rawRequest.startsWith('../')) {
+ const [requestFile, ...query] = rawRequest.split('?');
+ const absoluteFile = path.resolve(path.dirname(issuerFile), requestFile);
+ const absolute = query.length ? `${absoluteFile}?${query.join('?')}` : absoluteFile;
+ if (this.collection.hasScript(absolute) || this.collection.isInlineStyle(absolute)) {
+ rawRequest = absolute;
+ }
+ }
+
// @import CSS rule is not supported
if (rawRequest.includes('??ruleSet')) {
resolveException(rawRequest, issuer.resource, this.rootContext, this.pluginOption);
10 changes: 8 additions & 2 deletions development/webpack/test/loaders.swcLoader.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,8 +49,14 @@ describe('swcLoader', () => {
const [err, content, map] = await deferredPromise;
assert.strictEqual(err, null);
assert.strictEqual(content, expected);
const mapObj = JSON.parse(map as string);
assert.deepStrictEqual(mapObj.sources, [context.resourcePath]);
// The map must be handed to webpack as an object, not a JSON string: only
// object maps get their absolute `sources` rewritten relative to the build
// context (`NormalModule.contextifySourceMap`). A string map is hashed
// verbatim, which leaks the absolute build path into every module hash.
assert.strictEqual(typeof map, 'object');
assert.deepStrictEqual((map as unknown as { sources: string[] }).sources, [
context.resourcePath,
]);
});

it('should throw an error when options are invalid', () => {
Expand Down
8 changes: 6 additions & 2 deletions development/webpack/test/webpack.config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -357,11 +357,15 @@ ${Object.entries(env)
);
assert.deepStrictEqual(
reactRefreshRules.map((rule) => rule.test?.toString()),
[/\.(?:ts|mts|tsx)$/u.toString(), /\.(?:js|mjs|jsx)$/u.toString()],
[
/\.(?:ts|mts)$/u.toString(),
/\.tsx$/u.toString(),
/\.(?:js|mjs|jsx)$/u.toString(),
],
);
assert.deepStrictEqual(
reactRefreshRules.map((rule) => rule.exclude),
[undefined, undefined],
[undefined, undefined, undefined],
);
assert.ok(
reactRefreshRules.every(
Expand Down
41 changes: 16 additions & 25 deletions development/webpack/utils/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,12 @@ export const UI_COMPONENT_RE = new RegExp(

export const TYPESCRIPT_FILE_RE = /\.(?:ts|mts|tsx)$/u;

/** TypeScript without JSX (`.ts` / `.mts`). */
export const TYPESCRIPT_NON_TSX_FILE_RE = /\.(?:ts|mts)$/u;

/** TypeScript with JSX (`.tsx` only). */
export const TYPESCRIPT_TSX_FILE_RE = /\.tsx$/u;

export const JAVASCRIPT_FILE_RE = /\.(?:js|mjs|jsx)$/u;

/**
Expand Down Expand Up @@ -104,16 +110,17 @@ export const extensionToJs = (filename: string) =>
/**
* It gets minimizers for the webpack build.
*
* SWC mangling can still produce different `runtime.[contenthash].js` output
* across Linux rebuilds (short-name swaps such as `c`/`l`), even with
* TerserPlugin `parallel: false`. That breaks Firefox AMO reviewer `mtree`
* comparisons. Disabling mangling for the runtime chunk keeps it
* content-stable while leaving mangling ON for all other chunks.
* Mangling is enabled for every chunk, including the webpack runtime chunk.
* `runtime.[contenthash].js` used to come out differently for identical
* sources (short-name swaps such as `c`/`l`) because two things fed
* build-specific bytes into SWC's character-frequency analysis: the swc loader
* leaked the absolute build path into every module hash (see `swcLoader.ts`),
* and `@swc/core` < 1.16.2 attached stale, thread-timing dependent source-map
* positions to inlined `process.env` values. Both are fixed at the source, so
* no per-chunk special casing is needed here.
*/
export function getMinimizers() {
const TerserPlugin: typeof TerserPluginType = require('terser-webpack-plugin');
// Match webpack asset names like `chrome/runtime.<hash>.js` or `runtime.<hash>.js`.
const runtimeChunkRe = /(?:^|[/\\])runtime\./u;
return [
new TerserPlugin({
// use SWC to minify (about 7x faster than Terser)
Expand All @@ -123,25 +130,9 @@ export function getMinimizers() {
// an unknown field. Earlier versions ignored it for `swcMinify`, so no
// comments were ever extracted here; `false` keeps that behavior.
extractComments: false,
// Determinism (and a small local build-time win): one minifier process.
parallel: false,
terserOptions: {
mangle: true,
},
// do not minify snow or the runtime chunk (handled below).
exclude: [/snow\.prod/u, runtimeChunkRe],
}),
new TerserPlugin({
// use SWC to minify (about 7x faster than Terser)
minify: TerserPlugin.swcMinify,
// see the note on the minimizer above
extractComments: false,
parallel: false,
terserOptions: {
// Disable mangling for the runtime chunk so AMO Linux rebuilds stay
// content-stable.
mangle: false,
},
include: runtimeChunkRe,
// do not minify snow.
exclude: /snow\.prod/u,
}),
];
Expand Down
5 changes: 3 additions & 2 deletions development/webpack/utils/loaders/envValidationLoader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import type {
AssignmentPatternProperty,
KeyValuePatternProperty,
} from '@swc/types';
import { TYPESCRIPT_FILE_RE } from '../helpers';
import { TYPESCRIPT_FILE_RE, TYPESCRIPT_TSX_FILE_RE } from '../helpers';

// Options are serialized to a JSON array for thread-loader compatibility
export type EnvValidationLoaderOptions = {
Expand All @@ -30,7 +30,8 @@ function getParseOptions(resourcePath: string): ParseOptions {
const isTypeScript = TYPESCRIPT_FILE_RE.test(resourcePath);

if (isTypeScript) {
return { syntax: 'typescript', tsx: true };
const tsx = TYPESCRIPT_TSX_FILE_RE.test(resourcePath);
return { syntax: 'typescript', tsx };
}
return { syntax: 'ecmascript', jsx: true };
}
Expand Down
13 changes: 7 additions & 6 deletions development/webpack/utils/loaders/reactCompilerLoader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,10 @@ import {
const getWrapperPath = () => require.resolve('./reactCompilerLoaderWrapper');

/**
* React Compiler result status stored in module.buildMeta.
* React Compiler result status stored in module.buildInfo (not buildMeta,
* which webpack hashes into the module hash).
* This allows statistics to be collected from all modules after compilation.
* NOTE: buildMeta tracking only works without thread-loader (this._module is
* NOTE: buildInfo tracking only works without thread-loader (this._module is
* null in worker contexts), so it is only active in verbose mode.
*/
export type ReactCompilerStatus =
Expand All @@ -22,7 +23,7 @@ export type ReactCompilerStatus =
export const REACT_COMPILER_STATUS_KEY = '__reactCompilerStatus__';

/**
* Per-file or per-compilation component counts. Stored in buildMeta when
* Per-file or per-compilation component counts. Stored in buildInfo when
* using the wrapper loader; aggregated by ReactCompilerPlugin.
*/
export type ReactCompilerStatusCounts = Record<ReactCompilerStatus, number>;
Expand All @@ -47,7 +48,7 @@ export type ReactCompilerFileDetail = {
};

/**
* Statistics collected from module buildMeta after compilation.
* Statistics collected from module buildInfo after compilation.
* File-level counts use worst-status-wins; component-level counts are raw.
*/
export type ReactCompilerStats = {
Expand Down Expand Up @@ -75,7 +76,7 @@ export type ReactCompilerLoaderConfig = {
/**
* When true, uses the wrapper loader so the same source works in
* thread-loader workers and the emitted CJS build. Verbose mode also uses
* the wrapper for logging and buildMeta tracking.
* the wrapper for logging and buildInfo tracking.
*/
threadLoaderEnabled: boolean;
};
Expand All @@ -86,7 +87,7 @@ export type ReactCompilerLoaderConfig = {
* Uses the wrapper loader when thread-loader is active or when verbose logging
* is requested. Falls back to the direct `react-compiler-webpack` loader
* otherwise (e.g. LavaMoat policy generation where the wrapper isn't
* resolvable). While worker builds still cannot record buildMeta stats, using
* resolvable). While worker builds still cannot record buildInfo stats, using
* the wrapper there keeps the loader path consistent across the direct `tsx`
* and emitted `.webpack` CJS execution paths.
*
Expand Down
Loading
Loading