Generated template modules contain absolute file paths, which makes webpack module hashes depend on the build directory #195
Description
Current behaviour
The loader compiles a template into a JS module in which every resolved asset is referenced by its absolute path:
// generated for src/index.html containing <script src="./main.js">
module.exports = '…<script src="' + require('/home/alice/project/src/main.js') + '">…'webpack resolves that require() to a module id, so the emitted HTML and JS are fine. But webpack also hashes the module's source text into module.buildInfo.hash, and that hash feeds the chunk's [contenthash]. Because the source text contains the absolute path, the hash of every template module, and of every chunk containing one, changes when the same project is built from a different directory.
This matters for reproducible builds. In our case (MetaMask, a browser extension) the provisional chunk hashes end up as string literals inside the runtime chunk at the moment the minifier runs, and a different build directory changed which short variable names the minifier picked. Builds of identical sources were not byte-identical.
Expected behaviour
Building the same project from two different directories should produce identical module hashes. The generated module should not contain the absolute path of the project. A request relative to the template, which webpack resolves identically, would achieve that:
module.exports = '…<script src="' + require('./main.js') + '">…'Reproduction
- repository URL
- example code
Three files plus a 10-line plugin that prints the hash of the template module.
src/index.html
<!doctype html><html><body><script src="./main.js"></script></body></html>src/main.js
console.log('hello');webpack.config.js
const HtmlBundlerPlugin = require('html-bundler-webpack-plugin');
class PrintTemplateHash {
apply(compiler) {
compiler.hooks.compilation.tap('PrintTemplateHash', (compilation) => {
compilation.hooks.afterHash.tap('PrintTemplateHash', () => {
for (const m of compilation.modules) {
if (m.resource && m.resource.endsWith('index.html')) {
console.log('template module source:', m._source.source().toString().slice(0, 160));
console.log('template module hash :', m.buildInfo.hash);
}
}
});
});
}
}
module.exports = {
mode: 'production',
plugins: [
new HtmlBundlerPlugin({ entry: { index: 'src/index.html' } }),
new PrintTemplateHash(),
],
};Steps:
npm i -D webpack webpack-cli html-bundler-webpack-plugin@4.23.2
npx webpack # note the printed source and hash
cd .. && cp -r project project-renamed && cd project-renamed
npx webpack # source now contains the new absolute path, hash differsObserved output (Node 24.13.1, webpack 5.111.1, plugin 4.23.2):
project/ source: module.exports='<!doctype html>…<script src="' + require('/…/project/src/main.js') + '"…
hash: b388bdd1dc7d8968fc64c8a84a9849a9
project-renamed/ source: module.exports='<!doctype html>…<script src="' + require('/…/project-renamed/src/main.js') + '"…
hash: 1aa6cca1fc050c667e47a847fdb12fea
dist/index.html and dist/main.js are byte-identical in both runs (sha256 01eefd92… and 3781f94e…). Only the module hash differs, and it differs only because of the path inside the generated source.
Environment
- OS: macOS 27 and Linux (Ubuntu 24.04, GitHub Actions runners), same result on both
- version of Node.js: 24.13.1
- version of Webpack: 5.111.1 (also seen with 5.104.1)
- version of the Plugin: 4.23.2
Additional context
We have a working patch in this PR. It has two halves:
- Loader (
Loader/Modes/Compile.js,Render.js,PreprocessorModeAbstract.js):requireExpression()receives the issuer and emits the file relative to the issuer's directory (./main.js,../../scripts/x.ts), but only when the issuer is an entry template (loaderOption.isEntry(issuer)). Partials and CSSurl()keep absolute paths, because they are compiled under one file and rendered under another, so a relative path would resolve against the wrong directory. - Plugin render step (
Plugin/Resolver.jsrequire()): the plugin later executes the compiled template with its ownrequire()and looks scripts and inline styles up by absolute path, so a relative request is resolved back to absolute against the file being rendered before those lookups. Other relative requests (CSSurl()) are left alone, sinceresolveResource()already handles them through its(issuer, rawRequest)table.
With this in place, our 663-file build is byte-identical across directories, and all template pages render with the correct script, style and image references.
An alternative that would also work for us: an option to make the generated requests relative, if you prefer not to change the default.
Appreciation for the useful project
- After the problem is resolved, do not forget to give a star ⭐
Activity
webdiscus commentedon Oct 7, 2026
Hello @gauthierpetetin,
thank you for the issue reporting. I'll analyse this issue.