Skip to content

Commit 26e346a

Browse files
fix: include referenced module's hash in HTML source/inline-style updateHash (#21018)
* fix: include referenced module's hash in HTML source/inline-style updateHash `HtmlSourceDependency` (`<img src>`, `<link href>`, `<audio src>`, …) and `HtmlInlineStyleDependency` (`<style>`) substitute content sourced from the referenced module into the rendered HTML at code-generation time — the asset's hashed filename for `HtmlSourceDependency`, the rendered CSS text for `HtmlInlineStyleDependency`. Without `updateHash`, the HTML module's hash didn't reflect changes to the referenced module, so the extracted HTML's `[contenthash]` stayed pinned across incremental rebuilds even when the rendered bytes had changed — the same long-term caching break `CssUrlDependency.updateHash` was added to prevent for CSS `url(...)`. Fold the referenced module's `buildInfo.hash` into each dependency's `updateHash`, matching the `CssUrlDependency` pattern. Adds two watch test cases under `test/watchCases/long-term-caching/`: `html-contenthash-asset-url` (regression for the fix) and `js-contenthash-asset-url` (the analogous case for `new URL(asset, import.meta.url)`, which already worked because the asset module's hash flows through the JS chunk hash — kept as a regression guard). * docs: require changeset descriptions to be sentence-case with a trailing period Recent changesets (`cjs-require-binding-tree-shake.md`, `public-path-fullhash-length-suffix.md`, `html-entry-css-chunks-link-tags.md`, `align-html-lexer-script-data.md`, …) all open with a capital letter and end with a period — that's what makes them read as proper changelog entries when changesets is concatenated into the release `CHANGELOG.md`. Document the rule explicitly and update the inline examples ("Fix split-chunks cache key collision.", "Add `module.generator.html.extract` option.") so the guide matches the convention reviewers already enforce. Also restyles the changeset added in the previous commit to match. * refactor: drop redundant `buildInfo` undefined check in HTML updateHash `module.buildInfo` is populated by `NormalModule#build` (and the equivalent hook on other module types) before any code path that calls `updateHash` runs — module hashing happens after the build phase. Cast straight to `BuildInfo` and keep only the `.hash` check (the field itself is still typed as optional on `KnownBuildInfo`). Matches what reviewers were flagging on the prior commit. * fix: recurse into inline-style module updateHash so transitive url() asset changes invalidate the HTML `HtmlInlineStyleDependency.Template.apply` pulls the rendered CSS text out of `codeGenerationResults` and substitutes it into the HTML — the rendered text already has every `url(...)` rewritten to its hashed asset filename by `CssUrlDependency`. The previous `updateHash` only folded the CSS module's `buildInfo.hash`, which captures the CSS *source*. The CSS source doesn't change when only the referenced asset's bytes change — that change rides on `CssUrlDependency.updateHash`, which contributes to the CSS module's *module* hash, not its `buildInfo.hash`. So the HTML's `[contenthash]` stayed pinned even though the rendered HTML embedded a new asset filename. Recurse into the inline-CSS module's full `updateHash` so the same asset-hash chain that already invalidates a standalone CSS chunk's contenthash now also invalidates the host HTML's. Adds `test/watchCases/long-term-caching/html-contenthash-inline-style-url/`, which fails before the fix (HTML filename stayed `page.36e6bef5…html` across the asset swap) and passes after. * fix: defer chunk-URL substitution in extracted HTML until chunk hashes are computed `HtmlInlineScriptDependency` and `HtmlScriptSrcDependency` (the new experimental HTML pipeline) used to call `compilation.getPath(chunk filenameTemplate, …)` from their `Template#apply` — i.e. during `Compilation#codeGeneration()`. That runs before `createHash()`, so `chunk.hash` and `chunk.contentHash[type]` are still `null`. Any `output.chunkFilename` (or `output.filename`) containing `[contenthash]` / `[chunkhash]` / `[fullhash]` would throw "Path variable [contenthash] not implemented in this context" from `TemplatedPathPlugin`, breaking every HTML compile that wanted hashed JS/CSS filenames. Even when the template happened to resolve, the HTML module's own `[contenthash]` was computed from placeholder-substituted bytes — so changing only an inline-script's transitive dep flipped the embedded chunk URL but left the HTML's `[contenthash]` pinned. Defer the substitution instead: dep templates now emit a sentinel (`__WEBPACK_HTML_CHUNK_URL__<hexChunkId>__<contentHashType>__END__`) via `makeHtmlChunkUrlSentinel` from a new `lib/html/htmlChunkUrl.js` helper, and `HtmlModulesPlugin#renderManifest` swaps every sentinel for `${PUBLIC_PATH_AUTO}<chunkFilename>` *before* hashing the HTML output — by that point `createHash()` has populated every chunk's `chunk.contentHash[type]`/`chunk.hash`/`compilation.hash`, so the chunk filenames' placeholders all resolve. Hashing the resolved content (rather than the raw placeholder source) is what makes the HTML's `[contenthash]` invalidate when a referenced chunk's filename changes. `HtmlGenerator#_renderHtml`'s JS-export path resolves sentinels inline too via the same helper; sentinels whose templates can't be resolved yet (e.g. an unbuildable `[contenthash]` placeholder at code-gen time) are left in place rather than thrown — `extract: true` (the HTML output path) is the one that supports dynamic filenames, and the JS export of the HTML string carries the restriction that previously surfaced as a compile-time error. Adds `test/watchCases/long-term-caching/html-contenthash-inline-script/`, which fails before the fix (compile errors with `[contenthash]` in `chunkFilename`) and after step 1 verifies the HTML invalidates when the inline-script's transitive dep flips. * refactor(html): extract duplicated `[contenthash]` recipe into HtmlModulesPlugin.computeContentHash `renderManifest` hashed HTML bytes twice with identical boilerplate — once for the `[contenthash]` substituted into `output.htmlFilename` and once for the final asset cache key — each call re-implementing the `createHash(hashFunction)` + `hashSalt` + `digest(hashDigest)` + `nonNumericOnlyHash(_, hashDigestLength)` recipe. Pull both call sites into a single static method on `HtmlModulesPlugin` so the recipe can't drift between them, and so anyone reaching for "the HTML pipeline's `[contenthash]` recipe" has a named entry point. No behaviour change; the previously inlined `createHash` / `nonNumericOnlyHash` requires are now inside the static method. * refactor(html): fold chunk-URL sentinel helpers into HtmlGenerator as static methods `lib/html/htmlChunkUrl.js` was a single-file module exporting the `makeHtmlChunkUrlSentinel` / `resolveHtmlChunkUrlSentinels` pair created in the previous commit. Inline them onto `HtmlGenerator` as `HtmlGenerator.makeChunkUrlSentinel` and `HtmlGenerator.resolveChunkUrlSentinels` — that's the class responsible for emitting both sides of the substitution (`_renderHtml` writes sentinels via dep templates and resolves them on the JS-export path), so it's the natural home for the sentinel format. `HtmlGenerator` doesn't import the HTML dependencies, so the two dep templates (`HtmlInlineScriptDependency`, `HtmlScriptSrcDependency`) can require it directly without introducing a circular import. `HtmlModulesPlugin` already imports `HtmlGenerator` and reaches the resolver through the same static method. No behaviour change; just removes the standalone helper file. * refactor(html): drop unnecessary chunk.id null-checks in chunk-URL sentinel helpers Both `HtmlGenerator.makeChunkUrlSentinel` (called from dep templates during `Compilation#codeGeneration()`) and `HtmlGenerator.resolveChunkUrlSentinels` (called from `_renderHtml`'s JS-export path and from `HtmlModulesPlugin#renderManifest`) run after `Compilation#seal`'s `optimizeChunkIds` hook, which is what `chunkIds`/`NamedChunkIdsPlugin`/`DeterministicChunkIdsPlugin` use to populate every chunk's `.id`. By the time these helpers run, every chunk has a non-null id, so the defensive `chunk.id == null` branches were dead code. * docs: require code comments to stay short and informative Adds a REQUIRED "Code comments" section to `AGENTS.md`: comments inside `lib/`, `hot/`, `tooling/`, `test/` must be one or at most two short lines and every line must add information not obvious from the code (invariant, ordering constraint, pinned-bug workaround, higher-level concept name). No multi-paragraph essays, no restating the next line, no diff narration, no PR-body restatements, no task-framing quotes. JSDoc on exported symbols is exempt because it's the type contract. Also trims the verbose comments added in this branch (chunk-URL sentinel docs on `HtmlGenerator`, the dep-template apply blocks, the `HtmlInlineStyleDependency` / `HtmlSourceDependency` `updateHash` explanations, `HtmlModulesPlugin.computeContentHash` JSDoc, and the renderManifest "resolve before hashing" comment) to fit the rule — 105 lines removed, no behaviour change. Pre-existing comments in other functions are left for their respective authors to revisit. * fix: address Copilot review on PR #21018 - `HtmlModulesPlugin.computeContentHash` passes `outputOptions.hashFunction` through to `createHash` typed as `HashFunction` instead of casting to `string`, so the constructor-function form of `output.hashFunction` keeps working. - Shorten the inline-script-chunk-url-sentinel changeset to fit the ≤80-char/no-commas guidance the previous commit added to AGENTS.md. - Trim the test-fixture explanatory comments Copilot flagged in `js-contenthash-asset-url/{0,2}`, `html-contenthash-asset-url/{0,2}`, `html-contenthash-inline-style-url/{0,2}`, and `html-contenthash-inline-script/{0/page.html,1/index.js}` — the test names already convey intent. No assertion logic changed. * fix: resolve chunk-URL sentinels in JS-export bundles too via processAssets The JS-export path in `HtmlGenerator._renderHtml` was leaving unresolved `__WEBPACK_HTML_CHUNK_URL__…__END__` sentinels in `module.exports = "<html>"` when chunk filenames carried `[contenthash]` / `[chunkhash]` / `[fullhash]` — chunk hashes don't exist at code-gen time, so the inline `compilation.getPath` fell into the catch and left the sentinel. Consumers doing `require("./page.html")` then saw the sentinel string at runtime. Drop the JS-export path's inline sentinel resolution entirely (it always leaves sentinels now) and add a global `processAssets` pass in `HtmlModulesPlugin` at `PROCESS_ASSETS_STAGE_OPTIMIZE_INLINE` that sweeps every asset, resolves any surviving sentinels via `HtmlGenerator.resolveChunkUrlSentinels`, and collapses the freshly-emitted `[webpack/auto]` placeholders to `""` (root-relative — matching the prior JS-export behaviour). Stage choice matters: it runs after `createHash()` has populated every `chunk.hash` / `chunk.contentHash[type]`, so chunk filename templates resolve, and before `PROCESS_ASSETS_STAGE_OPTIMIZE_HASH` where `realContentHash` rehashes affected chunk filenames against the resolved bytes. Adds a step-0 assertion in `html-contenthash-inline-script/0/index.js` that reads `main.<hash>.js` from disk and verifies no `__WEBPACK_HTML_CHUNK_URL__<hex>__<type>__END__` survives into the JS bundle's HTML export. * fix: reuse sentinel-resolved asset source across compilations The new `processAssets` sweep was minting a fresh `RawSource` on every compilation, even when the resolved bytes were byte-identical to the previous run. `getLazyHashedEtag`'s WeakMap is keyed by source identity, and `MemoryCachePlugin` / `PackFileCacheStrategy` compare etags with reference equality, so a new source object meant a fresh `LazyHashedEtag` → fresh `MergedEtag` → cache miss → `set()` on `RealContentHashPlugin|analyse|<asset>` → "Pack got invalid because of write to" infra log. `ConfigCacheTestCases` treats that log as a failure on the 2nd (warm) run, so every HTML test with an inline script chunk regressed. Cache the resolved `RawSource` per asset name in a plugin-scoped Map and reuse it whenever the freshly-computed `resolved` string matches the prior content. Same bytes ⇒ same source object ⇒ same etag ⇒ the analyse cache hits and no infrastructure log fires. * refactor: address Copilot perf + comment-length feedback - `HtmlGenerator.resolveChunkUrlSentinels` now caches the per-compilation `chunksById` map in a module-level `WeakMap` instead of rebuilding it on every call (was O(assets × chunks) with the new `processAssets` sweep, now O(chunks) per compilation). - `HtmlModulesPlugin`'s `processAssets` pass uses `Buffer#indexOf` on Buffer-backed sources and skips them when the ASCII sentinel marker isn't present, avoiding a full UTF-8 decode of large binary blobs. - Trim multi-line explanatory comments down to ≤2 lines per the REQUIRED `AGENTS.md` rule in `lib/` and `test/`. - Shorten the changeset description to ≤80 chars. * test: drop unused step-2 `tag` in js-contenthash-asset-url * refactor: resolve sentinels at JS chunk render time, not processAssets `processAssets` is too late: any plugin that reads the chunk source between `createChunkAssets` and the `OPTIMIZE_INLINE` stage — `SourceMapDevToolPlugin` (stage 500), size-optimize plugins (stage 400), banner injection, etc. — would see unresolved `__WEBPACK_HTML_CHUNK_URL__…__END__` placeholders embedded in the JS module's export string. Tap `JavascriptModulesPlugin.getCompilationHooks(compilation).render` instead — fires for every JS chunk after `createHash` populates the content hashes (so `getPath` resolves `[contenthash]` / `[chunkhash]` / `[fullhash]`) and *during* chunk asset assembly, so the source every later pass reads is already sentinel-free. The per-chunk RawSource identity cache stays in place so warm rebuilds with byte-identical output keep the same source object and don't invalidate `RealContentHashPlugin|analyse`. * refactor: prune sentinel-resolved source cache in afterSeal Address Copilot review: in long-running watch sessions, chunks that get removed from the graph (e.g. a dynamic import the user just deleted) would leave their entries behind in the per-chunk RawSource cache forever. Tap `compilation.hooks.afterSeal` to drop entries for chunk IDs no longer in `compilation.chunks`. Also shorten the render- tap comment to two lines per the AGENTS.md rule. * refactor: trim two more multi-line comments to two lines Address Copilot review — shorten the `sentinelResolvedSourceCache` declaration comment (4 lines → 2) and the `afterSeal` prune comment (3 lines → 2) per the AGENTS.md "Code comments" REQUIRED rule. * docs: refresh stale processAssets mention in JS-export comment The JS-export-path comment in HtmlGenerator still pointed at the old `processAssets` resolution pass; that moved to `JavascriptModulesPlugin.render` in abaaf97. Update the comment to match.
1 parent c05beb9 commit 26e346a

41 files changed

Lines changed: 698 additions & 116 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"webpack": patch
3+
---
4+
5+
Fix HTML `[contenthash]` for referenced asset and inline-style URL changes.
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"webpack": patch
3+
---
4+
5+
Resolve chunk-hash placeholders in chunk URLs embedded into extracted HTML.

AGENTS.md

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,16 @@ webpack is a JavaScript module bundler. Package manager: **yarn**.
101101

102102
`lib/` is CommonJS only. Use `module.exports` / `require()`, never `import`/`export` syntax. Types are declared via JSDoc — `@typedef {import("./Other")} Other` and friends — never TypeScript syntax inside `.js` files. The JSDoc annotations are compiled into `types.d.ts` by `yarn fix:special`.
103103

104+
## Code comments
105+
106+
> [!REQUIRED]
107+
108+
Comments inside `lib/`, `hot/`, `tooling/`, and `test/` must be **as short as possible** — ideally one line, at most two short lines. Every line must add information a careful reader can't get from the code itself: a hidden invariant, a non-obvious ordering constraint, a workaround pinned to a specific upstream bug, the name of the higher-level concept the block implements. **Never** write multi-paragraph essays, restate what the next line obviously does, narrate the diff ("previously …, now …"), restate the PR description, or quote the user/task framing. If a reader would already understand the line without the comment, delete the comment.
109+
110+
JSDoc on exported symbols stays as-is — that's the type contract, not commentary. The rule applies to free-standing `//` and `/* … */` comments, including the lead-in block above a function body.
111+
112+
Reviewers have repeatedly flagged paragraph-long comments as a blocker; this rule applies on every commit, not just the first one.
113+
104114
## Auto-generated files
105115

106116
> [!REQUIRED]
@@ -177,7 +187,7 @@ Description of the change.
177187

178188
Use `patch` for bug fixes, `minor` for new features, `major` for breaking changes. Do not prefix the description with `fix:`, `feat:`, etc. — the change type is already indicated by `patch`/`minor`/`major`.
179189

180-
**Keep the description as short as possible** — ideally a single sentence, ≤ 80 characters, written in the imperative ("fix split-chunks cache key collision", "add `module.generator.html.extract` option"). Changesets are concatenated into the release `CHANGELOG.md` verbatim, so multi-paragraph rationale, "why" context, migration notes, repro steps, or links to discussions belong in the PR body, not the changeset. If a sentence needs commas to fit, it is already too long — split the work or shorten the wording.
190+
**Keep the description as short as possible** — ideally a single sentence, ≤ 80 characters, written in the imperative, with the **first character capitalized** and a **trailing period** ("Fix split-chunks cache key collision.", "Add `module.generator.html.extract` option."). Changesets are concatenated into the release `CHANGELOG.md` verbatim — sentence-case and the period are what makes them read as proper changelog entries rather than commit-message fragments. Multi-paragraph rationale, "why" context, migration notes, repro steps, or links to discussions belong in the PR body, not the changeset. If a sentence needs commas to fit, it is already too long — split the work or shorten the wording.
181191

182192
### 4. Updating Examples (if needed)
183193

lib/dependencies/HtmlInlineScriptDependency.js

Lines changed: 3 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,8 @@
44

55
"use strict";
66

7+
const HtmlGenerator = require("../html/HtmlGenerator");
78
const makeSerializable = require("../util/makeSerializable");
8-
const CssUrlDependency = require("./CssUrlDependency");
99
const ModuleDependency = require("./ModuleDependency");
1010

1111
/** @typedef {import("webpack-sources").ReplaceSource} ReplaceSource */
@@ -101,19 +101,8 @@ HtmlInlineScriptDependency.Template = class HtmlInlineScriptDependencyTemplate e
101101

102102
if (entrypoint) {
103103
const chunk = /** @type {Chunk} */ (entrypoint.getEntrypointChunk());
104-
const outputOptions = runtimeTemplate.outputOptions;
105-
const filenameTemplate =
106-
chunk.filenameTemplate ||
107-
(chunk.canBeInitial()
108-
? outputOptions.filename
109-
: outputOptions.chunkFilename);
110-
111-
const filename = compilation.getPath(filenameTemplate, {
112-
chunk,
113-
contentHashType: "javascript"
114-
});
115-
116-
url = `${CssUrlDependency.PUBLIC_PATH_AUTO}${filename}`;
104+
// Defer chunk-URL substitution to renderManifest — chunk hashes aren't ready yet.
105+
url = HtmlGenerator.makeChunkUrlSentinel(chunk, "javascript");
117106
}
118107

119108
// Insert ` src="…"` right after `<script` so the inline body is

lib/dependencies/HtmlInlineStyleDependency.js

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,15 @@ const ModuleDependency = require("./ModuleDependency");
1010

1111
/** @typedef {import("webpack-sources").ReplaceSource} ReplaceSource */
1212
/** @typedef {import("../CodeGenerationResults")} CodeGenerationResults */
13+
/** @typedef {import("../Dependency").UpdateHashContext} UpdateHashContext */
1314
/** @typedef {import("../Dependency")} Dependency */
1415
/** @typedef {import("../DependencyTemplate").DependencyTemplateContext} DependencyTemplateContext */
1516
/** @typedef {import("../Module")} Module */
17+
/** @typedef {import("../Module").BuildInfo} BuildInfo */
1618
/** @typedef {import("../javascript/JavascriptParser").Range} Range */
1719
/** @typedef {import("../serialization/ObjectMiddleware").ObjectDeserializerContext} ObjectDeserializerContext */
1820
/** @typedef {import("../serialization/ObjectMiddleware").ObjectSerializerContext} ObjectSerializerContext */
21+
/** @typedef {import("../util/Hash")} Hash */
1922
/** @typedef {import("../util/runtime").RuntimeSpec} RuntimeSpec */
2023

2124
/**
@@ -45,6 +48,20 @@ class HtmlInlineStyleDependency extends ModuleDependency {
4548
return "html-style";
4649
}
4750

51+
/**
52+
* Updates the hash with the data contributed by this instance.
53+
* @param {Hash} hash hash to be updated
54+
* @param {UpdateHashContext} context context
55+
* @returns {void}
56+
*/
57+
updateHash(hash, context) {
58+
// Recurse so the inline CSS's transitive deps (e.g. `url(asset)`) propagate up.
59+
const { chunkGraph } = context;
60+
const module = chunkGraph.moduleGraph.getModule(this);
61+
if (!module) return;
62+
module.updateHash(hash, context);
63+
}
64+
4865
/**
4966
* Serializes this instance into the provided serializer context.
5067
* @param {ObjectSerializerContext} context context

lib/dependencies/HtmlScriptSrcDependency.js

Lines changed: 6 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,8 @@ const {
99
CSS_TYPE,
1010
JAVASCRIPT_TYPE
1111
} = require("../ModuleSourceTypeConstants");
12+
const HtmlGenerator = require("../html/HtmlGenerator");
1213
const makeSerializable = require("../util/makeSerializable");
13-
const CssUrlDependency = require("./CssUrlDependency");
1414
const ModuleDependency = require("./ModuleDependency");
1515

1616
/** @typedef {import("webpack-sources").ReplaceSource} ReplaceSource */
@@ -95,39 +95,6 @@ class HtmlScriptSrcDependency extends ModuleDependency {
9595
}
9696
}
9797

98-
/**
99-
* @param {Chunk} chunk a chunk
100-
* @param {import("../Compilation")} compilation compilation
101-
* @param {"javascript" | "css"} contentHashType which content hash to plug into the filename template
102-
* @returns {string} chunk filename path (no public-path prefix)
103-
*/
104-
const getChunkFilename = (chunk, compilation, contentHashType) => {
105-
const outputOptions = compilation.outputOptions;
106-
let filenameTemplate;
107-
if (contentHashType === "css") {
108-
// For a CSS-typed chunk, use the same template the CSS pipeline
109-
// will use when it actually emits the `.css` file, so the `<link
110-
// rel="stylesheet" href>` URL we write into the HTML matches the
111-
// asset on disk.
112-
filenameTemplate =
113-
require("../css/CssModulesPlugin").getChunkFilenameTemplate(
114-
chunk,
115-
outputOptions
116-
);
117-
} else {
118-
filenameTemplate =
119-
chunk.filenameTemplate ||
120-
(chunk.canBeInitial()
121-
? outputOptions.filename
122-
: outputOptions.chunkFilename);
123-
}
124-
125-
return compilation.getPath(filenameTemplate, {
126-
chunk,
127-
contentHashType
128-
});
129-
};
130-
13198
/**
13299
* @param {Entrypoint} entrypoint entrypoint
133100
* @returns {Chunk[]} every chunk this entrypoint needs in load order: the
@@ -410,15 +377,13 @@ HtmlScriptSrcDependency.Template = class HtmlScriptSrcDependencyTemplate extends
410377
const entryChunk = orderedChunks[orderedChunks.length - 1];
411378
const isStylesheet = dep.elementKind === "stylesheet";
412379

413-
// Rewrite the originating tag's src/href to the entry chunk's
414-
// primary asset for that element kind: `.css` for
415-
// `<link rel="stylesheet">`, `.js` for everything else.
380+
// Rewrite src/href to a chunk-URL sentinel (resolved by renderManifest):
381+
// `.css` for `<link rel="stylesheet">`, `.js` for everything else.
416382
const entryContentHashType = isStylesheet ? "css" : "javascript";
417-
const entryUrl = `${CssUrlDependency.PUBLIC_PATH_AUTO}${getChunkFilename(
383+
const entryUrl = HtmlGenerator.makeChunkUrlSentinel(
418384
entryChunk,
419-
compilation,
420385
entryContentHashType
421-
)}`;
386+
);
422387
source.replace(dep.range[0], dep.range[1] - 1, entryUrl);
423388

424389
if (dep.tagStart < 0 || dep.tagOpenEnd <= dep.tagStart) {
@@ -446,11 +411,7 @@ HtmlScriptSrcDependency.Template = class HtmlScriptSrcDependencyTemplate extends
446411
* @returns {string} a single sibling tag's HTML
447412
*/
448413
const buildSibling = (chunk, kind) => {
449-
const url = `${CssUrlDependency.PUBLIC_PATH_AUTO}${getChunkFilename(
450-
chunk,
451-
compilation,
452-
kind
453-
)}`;
414+
const url = HtmlGenerator.makeChunkUrlSentinel(chunk, kind);
454415
if (kind === "css" && !isStylesheet) {
455416
// Originating tag is `<script>` (or `<link rel=modulepreload>`)
456417
// but this chunk is CSS — emit a fresh `<link>` rather than

lib/dependencies/HtmlSourceDependency.js

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,14 +13,17 @@ const ModuleDependency = require("./ModuleDependency");
1313

1414
/** @typedef {import("webpack-sources").ReplaceSource} ReplaceSource */
1515
/** @typedef {import("../CodeGenerationResults")} CodeGenerationResults */
16+
/** @typedef {import("../Dependency").UpdateHashContext} UpdateHashContext */
1617
/** @typedef {import("../Dependency")} Dependency */
1718
/** @typedef {import("../DependencyTemplate").CssDependencyTemplateContext} DependencyTemplateContext */
1819
/** @typedef {import("../Module")} Module */
20+
/** @typedef {import("../Module").BuildInfo} BuildInfo */
1921
/** @typedef {import("../Module").CodeGenerationResult} CodeGenerationResult */
2022
/** @typedef {import("../Module").CodeGenerationResultData} CodeGenerationResultData */
2123
/** @typedef {import("../javascript/JavascriptParser").Range} Range */
2224
/** @typedef {import("../serialization/ObjectMiddleware").ObjectDeserializerContext} ObjectDeserializerContext */
2325
/** @typedef {import("../serialization/ObjectMiddleware").ObjectSerializerContext} ObjectSerializerContext */
26+
/** @typedef {import("../util/Hash")} Hash */
2427
/** @typedef {import("../util/runtime").RuntimeSpec} RuntimeSpec */
2528

2629
const getIgnoredRawDataUrlModule = memoize(
@@ -55,6 +58,21 @@ class HtmlSourceDependency extends ModuleDependency {
5558
return getIgnoredRawDataUrlModule();
5659
}
5760

61+
/**
62+
* Updates the hash with the data contributed by this instance.
63+
* @param {Hash} hash hash to be updated
64+
* @param {UpdateHashContext} context context
65+
* @returns {void}
66+
*/
67+
updateHash(hash, context) {
68+
// Fold in the asset's hash so the HTML invalidates when the embedded URL changes.
69+
const { chunkGraph } = context;
70+
const module = chunkGraph.moduleGraph.getModule(this);
71+
if (!module) return;
72+
const { hash: buildHash } = /** @type {BuildInfo} */ (module.buildInfo);
73+
if (buildHash) hash.update(buildHash);
74+
}
75+
5876
/**
5977
* Serializes this instance into the provided serializer context.
6078
* @param {ObjectSerializerContext} context context

lib/html/HtmlGenerator.js

Lines changed: 81 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ const CssUrlDependency = require("../dependencies/CssUrlDependency");
1717

1818
/** @typedef {import("webpack-sources").Source} Source */
1919
/** @typedef {import("../../declarations/WebpackOptions").HtmlGeneratorOptions} HtmlGeneratorOptions */
20+
/** @typedef {import("../Chunk")} Chunk */
21+
/** @typedef {import("../Compilation")} Compilation */
2022
/** @typedef {import("../Compilation").DependencyConstructor} DependencyConstructor */
2123
/** @typedef {import("../CodeGenerationResults")} CodeGenerationResults */
2224
/** @typedef {import("../Dependency")} Dependency */
@@ -39,7 +41,76 @@ const CssUrlDependency = require("../dependencies/CssUrlDependency");
3941
*/
4042
const JAVASCRIPT_AND_HTML_TYPES = new Set([JAVASCRIPT_TYPE, HTML_TYPE]);
4143

44+
/** @type {WeakMap<Compilation, Map<string, Chunk>>} */
45+
const chunksByIdCache = new WeakMap();
46+
4247
class HtmlGenerator extends Generator {
48+
/**
49+
* Emit a sentinel for a chunk URL that can't be resolved at code-gen time
50+
* (chunk hashes aren't computed yet); `resolveChunkUrlSentinels` swaps it
51+
* for `${PUBLIC_PATH_AUTO}<chunkFilename>` once they are.
52+
* @param {Chunk} chunk chunk
53+
* @param {"javascript" | "css"} contentHashType which chunk content hash slot the resolved URL should reference
54+
* @returns {string} sentinel
55+
*/
56+
static makeChunkUrlSentinel(chunk, contentHashType) {
57+
const hexId = Buffer.from(String(chunk.id), "utf8").toString("hex");
58+
return `__WEBPACK_HTML_CHUNK_URL__${hexId}__${contentHashType}__END__`;
59+
}
60+
61+
/**
62+
* Replace every `makeChunkUrlSentinel` sentinel in `content` with
63+
* `${PUBLIC_PATH_AUTO}<chunkFilename>`. Must run after
64+
* `Compilation#createHash()` so `[contenthash]` resolves.
65+
* @param {string} content content
66+
* @param {Compilation} compilation compilation
67+
* @returns {string} resolved content
68+
*/
69+
static resolveChunkUrlSentinels(content, compilation) {
70+
if (!content.includes("__WEBPACK_HTML_CHUNK_URL__")) return content;
71+
const outputOptions = compilation.outputOptions;
72+
let chunksById = chunksByIdCache.get(compilation);
73+
if (chunksById === undefined) {
74+
chunksById = new Map();
75+
for (const chunk of compilation.chunks) {
76+
chunksById.set(String(chunk.id), chunk);
77+
}
78+
chunksByIdCache.set(compilation, chunksById);
79+
}
80+
return content.replace(
81+
/__WEBPACK_HTML_CHUNK_URL__([0-9a-f]+)__([a-z]+)__END__/g,
82+
(_, hexId, contentHashType) => {
83+
const chunkId = Buffer.from(hexId, "hex").toString("utf8");
84+
const chunk = chunksById.get(chunkId);
85+
if (!chunk) return "data:,";
86+
let filenameTemplate;
87+
if (contentHashType === "css") {
88+
const CssModulesPlugin = require("../css/CssModulesPlugin");
89+
90+
filenameTemplate = CssModulesPlugin.getChunkFilenameTemplate(
91+
chunk,
92+
outputOptions
93+
);
94+
} else {
95+
filenameTemplate =
96+
chunk.filenameTemplate ||
97+
(chunk.canBeInitial()
98+
? outputOptions.filename
99+
: outputOptions.chunkFilename);
100+
}
101+
const filename = compilation.getPath(
102+
/** @type {import("../TemplatedPathPlugin").TemplatePath} */
103+
(filenameTemplate),
104+
{
105+
chunk,
106+
contentHashType
107+
}
108+
);
109+
return `${CssUrlDependency.PUBLIC_PATH_AUTO}${filename}`;
110+
}
111+
);
112+
}
113+
43114
/**
44115
* Creates an instance of HtmlGenerator.
45116
* @param {HtmlGeneratorOptions=} options generator options
@@ -268,28 +339,15 @@ class HtmlGenerator extends Generator {
268339
this.sourceModule(module, initFragments, source, generateContext);
269340

270341
if (undoPath === undefined) {
342+
// HTML output — leave sentinels and `[webpack/auto]` for renderManifest.
271343
return /** @type {string} */ (source.source());
272344
}
273345

274-
const moduleSourceContent = source.source();
275-
const generatedSource = new ReplaceSource(source);
276-
277-
const autoPlaceholder = CssUrlDependency.PUBLIC_PATH_AUTO;
278-
const autoPlaceholderLen = autoPlaceholder.length;
279-
for (
280-
let idx = moduleSourceContent.indexOf(autoPlaceholder);
281-
idx !== -1;
282-
idx = moduleSourceContent.indexOf(
283-
autoPlaceholder,
284-
idx + autoPlaceholderLen
285-
)
286-
) {
287-
generatedSource.replace(idx, idx + autoPlaceholderLen - 1, undoPath);
288-
}
289-
290-
// TODO handle `[fullhash]`
291-
292-
return /** @type {string} */ (generatedSource.source());
346+
// JS-export path — resolve `[webpack/auto]` inline; chunk-URL sentinels
347+
// stay for `HtmlModulesPlugin`'s `JavascriptModulesPlugin.render` tap.
348+
let content = /** @type {string} */ (source.source());
349+
content = content.split(CssUrlDependency.PUBLIC_PATH_AUTO).join(undoPath);
350+
return content;
293351
}
294352

295353
/**
@@ -306,16 +364,13 @@ class HtmlGenerator extends Generator {
306364
}
307365

308366
if (generateContext.type === HTML_TYPE) {
309-
// Preserve `[webpack/auto]` placeholders here — the plugin's
310-
// `renderManifest` hook knows the final `.html` filename and
311-
// resolves them to an undo path relative to that location.
367+
// Preserve `[webpack/auto]`; renderManifest resolves it once `.html` filename is known.
312368
return new RawSource(
313369
this._renderHtml(module, generateContext, undefined)
314370
);
315371
}
316372

317-
// JS export: the rewritten HTML is a string the consumer reads at
318-
// runtime, so resolve placeholders to root-relative URLs.
373+
// JS export: resolve `[webpack/auto]` to root-relative URLs.
319374
const generated = this._renderHtml(module, generateContext, "");
320375

321376
/** @type {string} */
@@ -346,10 +401,7 @@ class HtmlGenerator extends Generator {
346401
*/
347402
generateError(error, module, generateContext) {
348403
if (generateContext.type === HTML_TYPE) {
349-
// The error message can contain arbitrary text (file paths, user
350-
// input, dep request strings). Strip `<`, `>`, and `--` runs so a
351-
// crafted message can't close the comment with `-->` (or open a
352-
// fake nested comment) and inject HTML into the extracted page.
404+
// Strip `<`, `>`, `--` runs from `error.message` so it can't escape the comment.
353405
const safe = String(error.message)
354406
.replace(/[<>]/g, "")
355407
.replace(/-{2,}/g, (m) => `${"-".repeat(m.length - 1)} `);
@@ -365,11 +417,7 @@ class HtmlGenerator extends Generator {
365417
*/
366418
updateHash(hash, updateHashContext) {
367419
hash.update("html");
368-
// Hash the *effective* extraction state, not just the raw option,
369-
// so the module hash flips when a module becomes (or stops being)
370-
// a compilation entry under the `extract: undefined` default — the
371-
// generator's source-type set changes with it, so any cached
372-
// HTML-type codegen result must be invalidated.
420+
// Hash effective extraction state — source-type set changes when this flips.
373421
if (this._shouldExtract(updateHashContext.module)) {
374422
hash.update("extract");
375423
}

0 commit comments

Comments
 (0)