Minimal reproduction for a cache-correctness bug in webpack.
When optimization.inlineExports is enabled (the default in production mode since
v5.108.0) together with cache.type: "filesystem", changing the value of an inlined
constant export does not invalidate the code generation cache of the modules that
consume it.
The provider module is rebuilt correctly, but its consumers keep the value that was inlined during a previous build. The emitted bundle silently differs from what a cold build produces for the same inputs.
npm installnpm run clean
FLAG=on npm run build && grep -o 'if ((.*=== "on")' dist/out.js
FLAG=off npm run build && grep -o 'if ((.*=== "on")' dist/out.js| build | expected | actual |
|---|---|---|
1st (FLAG=on) |
if ((/* inlined export .FLAG */"on") === "on") |
same |
2nd (FLAG=off) |
if ((/* inlined export .FLAG */"off") === "on") |
"on" — stale |
On the second build the inlined literal should have become "off", but it is still "on"
from the first build.
minimize is disabled in this reproduction so that the inlined values stay visible in the
output. The if statement itself is therefore present in both builds — what matters is the
literal inside it.
src/index.js is not modified in this case. Only the provider changes.
npm run clean
FLAG=off npm run build && grep -o 'NUM_PLUS_ONE.*' dist/out.js
sed -i '' 's/NUM = 5/NUM = 6/' src/env.js # GNU sed: sed -i 's/NUM = 5/NUM = 6/'
FLAG=off npm run build && grep -o 'NUM_PLUS_ONE.*' dist/out.js| build | expected | actual |
|---|---|---|
1st (NUM = 5) |
(/* inlined export .NUM */5) + 1 |
same |
2nd (NUM = 6) |
(/* inlined export .NUM */6) + 1 |
5 — stale |
npm run clean
FLAG=off npm run build && grep -o 'NUM_PLUS_ONE.*' dist/out.js
# => NUM_PLUS_ONE", (/* inlined export .NUM */6) + 1);The inputs are correct; only the cached build is wrong.
Uncomment inlineExports: false in webpack.config.js, then re-run Case B:
1st (NUM = 5) => const NUM = 5; / console.log("NUM_PLUS_ONE", NUM + 1);
2nd (NUM = 6) => const NUM = 6; / console.log("NUM_PLUS_ONE", NUM + 1);
The consumer references the export instead of embedding the value, so the change propagates correctly.
| webpack | inlineExports |
Case A | Case B |
|---|---|---|---|
| 5.107.2 | n/a (option does not exist) | correct | correct |
| 5.108.0 | default (true) |
stale | stale |
| 5.109.2 | default (true) |
stale | stale |
| 5.109.2 | false |
correct | correct |
| 5.110.3 | default (true) |
stale | stale |
| 5.110.3 | false |
correct | correct |
optimization.inlineExports was added and enabled by default for production in
v5.108.0
(D(optimization, "inlineExports", production) in lib/config/defaults.js).
These are observations from running the builds above; I have not traced the cause in webpack's source.
Only changes where both the old and the new value are inlinable go unnoticed. A change
that crosses the inlinable size boundary is picked up correctly. Starting from
export const NUM = 5;, building once, then changing the value and rebuilding with the
same cache:
2nd build changes NUM to |
inlinable? | result |
|---|---|---|
6 (1 digit) |
yes → yes | (/* inlined export .NUM */5) + 1 — stale |
1234567 (7 digits) |
yes → no | NUM + 1 — updated correctly |
Which declarations get inlined. The documented scope of the option is:
Inline ESM exports that bind to small primitive constants (≤6-byte null/undefined/boolean/number/string). Inlining makes the import dependency inactive so DCE can drop the export and possibly the module.
Testing declaration shapes one by one matches this: export default, computed expressions
such as 1 + 2, and access through import * as ns are inlined, while let, objects and
arrays are not. Note that booleans, null and undefined are inlined regardless of
length — the 6-byte limit only applies to strings and numbers.