Skip to content

feat: HMR for HTML modules with DOM patching for extracted pages#21011

Merged
alexander-akait merged 17 commits into
mainfrom
claude/html-module-tests-hmr-ZFebA
May 27, 2026
Merged

feat: HMR for HTML modules with DOM patching for extracted pages#21011
alexander-akait merged 17 commits into
mainfrom
claude/html-module-tests-hmr-ZFebA

Conversation

@alexander-akait

@alexander-akait alexander-akait commented May 21, 2026

Copy link
Copy Markdown
Member

Summary

Experimental HTML modules had no Hot Module Replacement support: editing a .html entry (or its inline/external dependencies) forced a full rebuild with no in-place update.

This adds HMR for HTML modules:

  • The JS shim emitted for .html modules now self-accepts when HotModuleReplacementPlugin is enabled, so re-requiring the module after an HMR cycle returns the updated content.
  • For modules that are also extracted to a real .html file, the shim patches document.body.innerHTML and document.title on every hot update so the rendered page reflects the new HTML without a full reload. Changes to <head> beyond <title> (a new <meta>, a swapped <link rel=icon>, …) can't be safely DOM-patched, so the shim falls back to a full reload.
  • HTML comments are masked (not stripped) before the body/title tag regexes run, so a <body>/<title> inside a comment can't fool the extractor while real comments inside <body> are preserved verbatim in the patched DOM — matching what a full reload renders.
  • HtmlGenerator bails out of module concatenation when HMR is active, since the per-module module.hot scope is required for self-acceptance.

Asset/chunk URLs in the patched body inherit the existing build-time placeholder resolution (the chunk-URL sentinel + [webpack/auto] render tap added to main), so the DOM-patched body uses the same resolved URLs as the module's JS export.

FakeDocument gains minimal innerHTML and title support so the DOM-patch path is verifiable in the test harness. The branch is rebased on the latest main.

What kind of change does this PR introduce?

feat

Did you add tests for your changes?

Yes — hot test cases under test/hotCases/html/ covering import (no-extract), extract + DOM-patch, head-change reload fallback, comment decoys / preservation, a body <script src> chunk-URL DOM-patch (html-extract-body-asset), inline <style>, inline <script>, combined inline script+style, external <script src>, <link rel="stylesheet">, inline/external update-tracking, and output.module.

Does this PR introduce a breaking change?

No. HTML modules are experimental and the new behavior only activates when HMR is enabled.

If relevant, what needs to be documented once your changes are merged or what have you already documented?

The experimental HTML-module docs should note that HMR is now supported, that extracted pages are DOM-patched (with a full-reload fallback for <head> changes beyond <title>), and that module concatenation is disabled for HTML modules while HMR is active.

Use of AI

Claude Code was used to draft the implementation, the test cases, and this PR description, and to address review feedback, all under human review.

Copilot AI review requested due to automatic review settings May 21, 2026 16:36
@changeset-bot

changeset-bot Bot commented May 21, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: b205a24

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
webpack Minor

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@github-actions

github-actions Bot commented May 21, 2026

Copy link
Copy Markdown
Contributor

This PR is packaged and the instant preview is available (04be1fe).

Install it locally:

  • npm
npm i -D webpack@https://pkg.pr.new/webpack@04be1fe
  • yarn
yarn add -D webpack@https://pkg.pr.new/webpack@04be1fe
  • pnpm
pnpm add -D webpack@https://pkg.pr.new/webpack@04be1fe

@codecov

codecov Bot commented May 21, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 93.75000% with 1 line in your changes missing coverage. Please review.
✅ Project coverage is 91.67%. Comparing base (f8537a2) to head (b205a24).
⚠️ Report is 1 commits behind head on main.

Files with missing lines Patch % Lines
lib/html/HtmlGenerator.js 93.75% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main   #21011      +/-   ##
==========================================
+ Coverage   91.65%   91.67%   +0.02%     
==========================================
  Files         573      574       +1     
  Lines       59912    60030     +118     
  Branches    16169    16211      +42     
==========================================
+ Hits        54914    55035     +121     
+ Misses       4998     4995       -3     
Flag Coverage Δ
integration 89.57% <93.75%> (+0.01%) ⬆️
test262 45.33% <ø> (-0.04%) ⬇️
unit 38.16% <ø> (+0.12%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR adds Hot Module Replacement behavior for experimental HTML modules by emitting an HMR-aware JS shim (self-accept + optional DOM patching when the HTML is extracted), and expands the hot test suite to cover common HTML dependency patterns.

Changes:

  • Emit an HMR self-accepting JS shim for .html modules, with DOM patching (document.body.innerHTML / document.title) when the HTML is extracted.
  • Prevent HTML module concatenation when HMR is enabled to preserve per-module module.hot scope.
  • Add hot test cases for import-only HTML, extracted HTML DOM patching + reload fallback, and external/inline <script>/<style>/<link rel="stylesheet"> scenarios; extend the test DOM harness accordingly.

Reviewed changes

Copilot reviewed 63 out of 63 changed files in this pull request and generated 4 comments.

Show a summary per file
File Description
lib/html/HtmlGenerator.js Emits HMR-aware HTML JS shim + concatenation bailout + hash invalidation when module.hot toggles.
test/helpers/FakeDocument.js Extends fake DOM to support document.title and element.innerHTML for DOM-patch test assertions.
.changeset/html-module-hmr.md Changeset entry documenting HTML-module HMR behavior.
test/hotCases/html/html-import/webpack.config.js Hot case config for importing an HTML module as a string.
test/hotCases/html/html-import/page.html Versioned HTML input for hot updates (v1→v3).
test/hotCases/html/html-import/index.js Verifies re-requiring an HTML module returns updated content across multiple HMR cycles.
test/hotCases/html/html-extract/webpack.config.js Hot case config for extracted HTML module DOM patching path.
test/hotCases/html/html-extract/test.filter.js Filters extract DOM-patching test to target: "web".
test/hotCases/html/html-extract/test.config.js JSDOM environment + reload counter plumbing for extract DOM-patch assertions.
test/hotCases/html/html-extract/page.html Versioned extracted HTML input for DOM patching (v1→v3).
test/hotCases/html/html-extract/index.js Verifies document.body/document.title patching without reload for extracted HTML updates.
test/hotCases/html/html-head-change/webpack.config.js Hot case config for extracted HTML with <head> mutation beyond <title>.
test/hotCases/html/html-head-change/test.filter.js Filters head-change reload fallback test to target: "web".
test/hotCases/html/html-head-change/test.config.js JSDOM environment + reload counter plumbing for reload-fallback assertions.
test/hotCases/html/html-head-change/page.html Versioned HTML input with <meta> head change (v1→v2).
test/hotCases/html/html-head-change/index.js Verifies head changes beyond <title> trigger reload fallback instead of DOM patch.
test/hotCases/html/html-comments/webpack.config.js Hot case config for extracted HTML containing tag-like markers in comments.
test/hotCases/html/html-comments/test.filter.js Filters comment-decoy test to target: "web".
test/hotCases/html/html-comments/test.config.js JSDOM environment + reload counter plumbing for comment-decoy assertions.
test/hotCases/html/html-comments/page.html Versioned HTML with decoy <title>/<body> markers inside comments (v1→v2).
test/hotCases/html/html-comments/index.js Verifies DOM patching ignores <body>/<title> markers inside HTML comments.
test/hotCases/html/html-with-inline-style/webpack.config.js Hot case config for HTML with inline <style> routed through CSS pipeline.
test/hotCases/html/html-with-inline-style/test.filter.js Filters inline-style test to target: "web" for CSS URL rewriting assertions.
test/hotCases/html/html-with-inline-style/test.config.js JSDOM environment for inline-style hot update test.
test/hotCases/html/html-with-inline-style/page.html Versioned HTML where inline <style> body changes (v1→v2).
test/hotCases/html/html-with-inline-style/index.js Verifies inline style changes are reflected in the rewritten exported HTML string after HMR.
test/hotCases/html/html-with-inline-script/webpack.config.js Hot case config for HTML with inline <script> turned into chunk-referenced code.
test/hotCases/html/html-with-inline-script/test.filter.js Filters inline-script test to target: "web" for chunk-loading runtime.
test/hotCases/html/html-with-inline-script/test.config.js JSDOM environment for inline-script hot update test.
test/hotCases/html/html-with-inline-script/page.html Versioned HTML where inline <script> body changes (v1→v2).
test/hotCases/html/html-with-inline-script/index.js Verifies inline script changes update the rewritten HTML (title + chunk src shape) after HMR.
test/hotCases/html/html-with-link-stylesheet/webpack.config.js Hot case config for <link rel="stylesheet"> becoming a CSS entry chunk.
test/hotCases/html/html-with-link-stylesheet/test.filter.js Filters link-stylesheet test to target: "web" for CSS extraction behavior.
test/hotCases/html/html-with-link-stylesheet/test.config.js JSDOM environment for link-stylesheet hot update test.
test/hotCases/html/html-with-link-stylesheet/style.css Versioned CSS input for stylesheet hot updates (red→blue).
test/hotCases/html/html-with-link-stylesheet/page.html Versioned HTML referencing external stylesheet (v1→v2).
test/hotCases/html/html-with-link-stylesheet/index.js Verifies rewritten stylesheet href points to bundled CSS chunk and HTML updates on HMR.
test/hotCases/html/html-with-script-src/webpack.config.js Hot case config for <script src> becoming its own entry chunk.
test/hotCases/html/html-with-script-src/test.filter.js Filters script-src test to target: "web" for chunk-loading runtime.
test/hotCases/html/html-with-script-src/test.config.js JSDOM environment for script-src hot update test.
test/hotCases/html/html-with-script-src/page.html Versioned HTML referencing external script (v1→v2).
test/hotCases/html/html-with-script-src/index.js Verifies rewritten <script src> points to bundled chunk URL and HTML updates on HMR.
test/hotCases/html/html-with-script-src/external.js Versioned external script entry that self-accepts on HMR (v1→v2).
test/hotCases/html/html-inline-script-and-style/webpack.config.js Hot case config combining inline <style> (CSS pipeline) + inline <script> (chunk).
test/hotCases/html/html-inline-script-and-style/test.filter.js Filters combined inline script/style test to target: "web".
test/hotCases/html/html-inline-script-and-style/test.config.js JSDOM environment for combined inline script/style hot update test.
test/hotCases/html/html-inline-script-and-style/page.html Versioned HTML with both inline <style> and inline <script> changes (v1→v2).
test/hotCases/html/html-inline-script-and-style/index.js Verifies both inline style and inline script handling + HTML string update on HMR.
test/hotCases/html/html-inline-update-tracking/webpack.config.js Hot case config for inspecting updated module IDs when inline data-URI modules change.
test/hotCases/html/html-inline-update-tracking/test.filter.js Filters inline update-tracking test to target: "web".
test/hotCases/html/html-inline-update-tracking/test.config.js JSDOM environment for inline update-tracking test.
test/hotCases/html/html-inline-update-tracking/page.html Versioned HTML where inline <style> and <script> bodies both change (v1→v2).
test/hotCases/html/html-inline-update-tracking/index.js Verifies module.hot.check(true) reports HTML updated + old inline data-URI modules removed.
test/hotCases/html/html-external-update-tracking/webpack.config.js Hot case config for external <script src> + external stylesheet update behavior.
test/hotCases/html/html-external-update-tracking/test.filter.js Filters external update-tracking test to target: "web".
test/hotCases/html/html-external-update-tracking/test.config.js JSDOM environment for external update-tracking test.
test/hotCases/html/html-external-update-tracking/style.css Versioned external stylesheet content for update-tracking (red→blue).
test/hotCases/html/html-external-update-tracking/page.html HTML referencing external script + stylesheet for update-tracking scenario.
test/hotCases/html/html-external-update-tracking/index.js Verifies HTML module runtime sees an empty changeset when only external resources update.
test/hotCases/html/html-external-update-tracking/external.js Versioned external script entry that self-accepts on HMR (1→2).
test/hotCases/html/html-with-output-module/webpack.config.js Hot case config for HTML modules under experiments.outputModule.
test/hotCases/html/html-with-output-module/page.html Versioned HTML input for ESM output test (v1→v2).
test/hotCases/html/html-with-output-module/index.js Verifies HTML module can be re-imported after HMR in ESM output (accept done by importer).

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread lib/html/HtmlGenerator.js Outdated
Comment on lines +412 to +416
"var __webpack_stripped__ = __webpack_strip_comments__(__webpack_html__);",
"var __webpack_body_m__ = /<body[^>]*>([\\s\\S]*?)<\\/body>/i.exec(__webpack_stripped__);",
"if (__webpack_body_m__ && document.body) document.body.innerHTML = __webpack_body_m__[1];",
"var __webpack_title_m__ = /<title[^>]*>([\\s\\S]*?)<\\/title>/i.exec(__webpack_stripped__);",
"if (__webpack_title_m__) document.title = __webpack_title_m__[1];"
Comment thread lib/html/HtmlGenerator.js Outdated
"}",
// Capture this evaluation's head (sans title, sans comments)
// on dispose so the next module instance can diff against it.
"module.hot.dispose(function (data) { data.__webpack_html_updated__ = true; data.__webpack_head__ = __webpack_extract_head__(__webpack_html__); });"
Comment thread test/helpers/FakeDocument.js Outdated
// FakeDocument doesn't parse HTML — the HMR DOM-patch test only
// asserts on the raw string passed in, so storing it verbatim is
// enough for verification.
this._innerHTML = String(value || "");
Comment thread .changeset/html-module-hmr.md Outdated
"webpack": minor
---

Add HMR support for HTML modules. The JS shim a `.html` module exports now self-accepts when `HotModuleReplacementPlugin` is enabled, so re-requiring the module after an HMR cycle returns the updated content. When the module is also being extracted to a real `.html` file (`module.generator.html.extract: true`, or the implicit default for HTML entry points), the shim additionally patches `document.body.innerHTML` and `document.title` on every hot update, so the rendered page reflects the new HTML without a full reload. If the `<head>` changes beyond `<title>` (e.g. a new `<meta>` tag, a swapped `<link rel=icon>`, an inline `<style>` block), the shim falls back to `window.location.reload()` — webpack injects its own runtime scripts and stylesheet links into the head on initial load, so a blanket head replacement isn't safe; in a dev-server context the reloaded page picks up the new head from the regular `page.html` chunk re-emitted on the latest rebuild.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The changeset has since been rewritten to a single imperative sentence (Add HMR support for HTML modules with body/title DOM patching on update.) to comply with the one-sentence ≤80-char changeset rule, so the flagged "The JS shim a .html module exports" wording no longer exists. The detailed rationale now lives in the PR body instead.


Generated by Claude Code

@codspeed-hq

codspeed-hq Bot commented May 21, 2026

Copy link
Copy Markdown

Merging this PR will improve performance by 32.91%

⚡ 5 improved benchmarks
❌ 1 regressed benchmark
✅ 138 untouched benchmarks
⏩ 72 skipped benchmarks1

Warning

Please fix the performance issues or acknowledge them on CodSpeed.

Performance Changes

Mode Benchmark BASE HEAD Efficiency
Memory benchmark "many-modules-commonjs", scenario '{"name":"mode-production","mode":"production"}' 9.5 MB 7.8 MB +21.75%
Memory benchmark "cache-filesystem", scenario '{"name":"mode-development-rebuild","mode":"development","watch":true}' 697.8 KB 1,056.6 KB -33.96%
Memory benchmark "context-esm", scenario '{"name":"mode-production","mode":"production"}' 10 MB 7.8 MB +28.77%
Memory benchmark "asset-modules-bytes", scenario '{"name":"mode-development-rebuild","mode":"development","watch":true}' 319.8 KB 134.4 KB ×2.4
Memory benchmark "many-chunks-commonjs", scenario '{"name":"mode-production","mode":"production"}' 10 MB 7 MB +42.2%
Memory benchmark "concatenate-modules", scenario '{"name":"mode-development","mode":"development"}' 1,234.9 KB 784.6 KB +57.39%

Tip

Investigate this regression by commenting @codspeedbot fix this regression on this PR, or directly use the CodSpeed MCP with your agent.


Comparing claude/html-module-tests-hmr-ZFebA (b205a24) with main (9c84d8e)

Open in CodSpeed

Footnotes

  1. 72 benchmarks were skipped, so the baseline results were used instead. If they were deleted from the codebase, click here and archive them to remove them from the performance reports.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 63 out of 63 changed files in this pull request and generated 1 comment.

Comment thread lib/html/HtmlGenerator.js Outdated
Comment on lines 326 to 329
@@ -320,7 +329,12 @@ class HtmlGenerator extends Generator {

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch — fixed in a214a07. The extract HMR shim now keeps the [webpack/auto] placeholder in the body HTML and resolves it at runtime against __webpack_require__.p (the shim builds __webpack_html__ via .split("__WEBPACK_CSS_PUBLIC_PATH_AUTO__").join(__webpack_require__.p), and generate adds RuntimeGlobals.publicPath to the runtime requirements). So a DOM-patched document.body now points at the output root regardless of where the extracted .html document lives, matching the undo-path semantics HtmlModulesPlugin applies to the extracted asset. Added test/hotCases/html/html-extract-public-path/ covering publicPath:"auto" + a body <script src> to lock in that the placeholder is resolved at runtime and never leaks into the patched DOM.


Generated by Claude Code

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Update: after rebasing this branch onto the latest main, the runtime-resolution fix I described above was dropped. main reworked HTML placeholder handling — chunk URLs are emitted as sentinels and a JavascriptModulesPlugin.render tap resolves them and blanket-replaces [webpack/auto] to root-relative URLs across the entire rendered JS chunk at build time. That tap is incompatible with a runtime .split("[webpack/auto]").join(__webpack_require__.p) in the shim (it rewrites the literal placeholder argument itself), and it makes the JS export deliberately root-relative. The HMR DOM-patch now reuses that same build-time-resolved HTML, so it's consistent with the non-HMR JS export rather than fighting main's mechanism. Added test/hotCases/html/html-extract-body-asset/ to cover a body <script src> being DOM-patched as a resolved chunk URL.


Generated by Claude Code

Copilot AI review requested due to automatic review settings May 25, 2026 19:23
@alexander-akait alexander-akait force-pushed the claude/html-module-tests-hmr-ZFebA branch from a214a07 to 64b87ae Compare May 25, 2026 19:23

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 69 out of 69 changed files in this pull request and generated 1 comment.

Comment thread lib/html/HtmlGenerator.js
Comment on lines 382 to 386
// JS export: resolve `[webpack/auto]` to root-relative URLs.
const generated = this._renderHtml(module, generateContext, "");

/** @type {string} */
let sourceContent;

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the same concern as the (now-outdated) thread above, re-surfaced after the rebase — and the suggested fix unfortunately can't work on current main. main's JavascriptModulesPlugin.render tap (added in #21018) resolves chunk-URL sentinels and then does .split(CssUrlDependency.PUBLIC_PATH_AUTO).join("") across the entire rendered JS chunk at build time. So:

  1. Keeping the placeholder and emitting …split("__WEBPACK_CSS_PUBLIC_PATH_AUTO__").join(__webpack_require__.p) in the shim doesn't survive — the tap rewrites that literal split(...) argument to split(""), which shatters the HTML at runtime (verified: it splits the string into single characters). I tried exactly this and it broke.
  2. The tap makes the JS export of an HTML module deliberately root-relative regardless of document location. The DOM-patch now reuses that same JS-export HTML, so it's consistent with import html from "./page.html" rather than diverging from it.

The residual subdirectory case (output.htmlFilename: "pages/[name].html" + publicPath: "auto") is therefore a property of main's JS-export URL resolution — the extracted .html asset uses an undo-path while the JS export is root-relative — not something this HMR PR introduces. Making the DOM-patch use undo-path semantics would mean changing that build-time mechanism in HtmlModulesPlugin/the render tap, which is out of scope here. Happy to open a follow-up for it if you'd like the JS export (and thus the DOM-patch) to honor the document's undo path.


Generated by Claude Code

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 69 out of 69 changed files in this pull request and generated 1 comment.

Comment thread lib/html/HtmlGenerator.js Outdated
Comment on lines +476 to +481
"var __webpack_extract_head__ = function (h) {",
Template.indent([
// Diff the head with comments masked out so a comment-only
// head edit doesn't needlessly force a full reload.
'var head = __webpack_extract__(__webpack_mask_comments__(h), "head");',
'return head === null ? "" : head.replace(/<title[^>]*>[\\s\\S]*?<\\/title>/i, "").trim();'

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 2eec875. __webpack_extract_head__ now extracts from the original HTML (__webpack_extract__ already masks internally for boundary detection) and masks only the small extracted head substring for the comment-insensitive diff, so the whole document is no longer masked twice per dispose/eval. Behavior is unchanged — html-head-change and html-comments (the comment-only-head-edit and decoy cases) still pass.


Generated by Claude Code

Copilot AI review requested due to automatic review settings May 26, 2026 09:05

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 69 out of 69 changed files in this pull request and generated 1 comment.

Comment thread lib/html/HtmlGenerator.js Outdated
Comment on lines +480 to +481
'var head = __webpack_extract__(__webpack_mask_comments__(h), "head");',
'return head === null ? "" : head.replace(/<title[^>]*>[\\s\\S]*?<\\/title>/i, "").trim();'

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This re-surfaced because the previous commit (5985477) was a temporary revert of the head-mask optimization while I was bisecting a flaky CI failure. The current HEAD (c3c5935) re-applies the fix you originally suggested: __webpack_extract_head__ now calls __webpack_extract__(h, "head") and masks only the returned head substring before stripping <title>/trimming — no full-document double-mask. So this is already addressed on the latest commit. (The flaky a-partition CI failure turned out to be unrelated to this PR — a tree byte-identical to a previously-green commit also failed it.)


Generated by Claude Code

Copilot AI review requested due to automatic review settings May 26, 2026 10:04
@alexander-akait alexander-akait force-pushed the claude/html-module-tests-hmr-ZFebA branch from c3c5935 to 0bc17fc Compare May 26, 2026 10:04

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 69 out of 69 changed files in this pull request and generated 1 comment.

Comment thread lib/html/HtmlGenerator.js Outdated
Comment on lines +462 to +472
'var __webpack_mask_comments__ = function (h) { return h.replace(/<!--[\\s\\S]*?-->/g, function (c) { return c.replace(/[^\\n]/g, " "); }); };',
// Slice the inner content of `<tag>…</tag>` out of `h`,
// using the masked copy only to locate the tag boundaries.
"var __webpack_extract__ = function (h, tag) {",
Template.indent([
"var masked = __webpack_mask_comments__(h);",
'var open = new RegExp("<" + tag + "[^>]*>", "i").exec(masked);',
"if (!open) return null;",
"var start = open.index + open[0].length;",
'var close = new RegExp("</" + tag + ">", "i").exec(masked.slice(start));',
"if (!close) return null;",

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 6fafb26. The shim now precomputes a single masked copy per evaluation — var __webpack_masked_html__ = __webpack_mask_comments__(__webpack_html__); — and __webpack_extract__(tag) / __webpack_extract_head__() close over it for all head/body/title boundary searches (and the dispose-time head diff), so the full document is masked once per update instead of ~4×. Only the small extracted head is masked again (for the comment-insensitive reload diff). Behavior unchanged — html-extract, html-head-change, html-comments, and html-extract-body-asset all pass.


Generated by Claude Code

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 69 out of 69 changed files in this pull request and generated no new comments.

The JS shim emitted for `.html` modules now self-accepts when
HotModuleReplacementPlugin is enabled, so re-requiring the module after
an HMR cycle returns the updated content. For modules that are also
extracted to a real `.html` file, the shim additionally patches
`document.body.innerHTML` and `document.title` on every hot update so
the rendered page reflects the new HTML without a full reload.

The HtmlGenerator bails out of module concatenation when HMR is active,
since the per-module `module.hot` scope is required for self-acceptance.

Adds hot test cases covering the import (no-extract), extract+DOM-patch,
inline-style and inline-script scenarios. FakeDocument gains minimal
`innerHTML` and `title` support so the DOM-patch path is verifiable in
the test harness.
…module

Adds three more hot test cases:

- html-with-script-src — HTML referencing an external `<script src>`,
  verifying that the parser-generated script entry chunk and the
  HTML module's self-acceptance survive an HMR cycle.
- html-with-link-stylesheet — HTML referencing a `<link rel=stylesheet>`,
  verifying that the CSS entry chunk created from the link tag still
  resolves correctly after an HMR cycle.
- html-with-output-module — same flow exercised under ESM output
  (`output.module: true`, `chunkFormat: "module"`, `.mjs` filenames) so
  the JS shim's `module.hot.accept()` is verified in the ESM wrapper as
  well as the CJS one.
Adds three more hot test cases that document and verify *which* modules
land in `module.hot.check`'s update list when different parts of an HTML
module change:

- html-inline-script-and-style — HTML with both inline `<style>` and
  inline `<script>` in the same document; verifies the HMR cycle
  succeeds with both forms present and that the rewritten output shape
  (inlined CSS body + extracted `<script src=…>` URL) is preserved
  across updates.

- html-inline-update-tracking — only changes the inline `<style>` and
  `<script>` bodies between updates and asserts the update list:
    1. the HTML module itself (its rewritten string changed), and
    2. the OLD `data:text/css,…` / `data:text/javascript;base64,…`
       modules (removed because the body change reshapes the data-URI
       identifier).
  The NEW data-URI modules aren't in the changeset since they hadn't
  been loaded at the previous evaluation — they're added on demand.

- html-external-update-tracking — changes only `external.js` and
  `style.css` (the targets of `<script src>` / `<link
  rel=stylesheet>`), with named chunk filenames so the URLs stay
  stable. The HTML module's runtime sees an empty update list — proving
  the HTML module is NOT re-emitted just because external resources
  changed. The actual updates flow through the separate entry chunks'
  own runtimes.
The DOM-patch path in the HTML module HMR shim only safely covers
`document.body.innerHTML` and `document.title`. Patching the entire
`<head>` would also tear down the runtime `<script>` tags and stylesheet
`<link>` tags webpack injects there on initial load.

When the new HTML's head (sans title) differs from the captured head of
the previous evaluation, the shim now calls `window.location.reload()`
instead of attempting a DOM patch. In a dev-server context this picks
up the new head from the regular `page.html` chunk re-emitted on the
latest rebuild — hot-update chunks intentionally skip emitting `.html`,
but the regular non-hot-update chunk is still produced on every
rebuild.

Body / title-only changes still take the DOM-patch path; the existing
`html-extract` test asserts `window.location.reload` is never invoked
across its three updates.

Adds an `html-head-change` hot case that flips a `<meta>` attribute and
verifies the shim falls back to a reload.
The DOM-patch HMR shim used three regexes at runtime to extract the
`<body>` content, `<title>` text, and the `<head>` content sans title.
Regex-based HTML parsing is fragile by nature (nested `</body>` inside
attributes or comments, mixed-case tags, weird whitespace, …) and the
runtime parsing cost was paid on every hot update.

Walk the *rewritten* HTML once at build time with the existing
`walkHtmlTokens` tokenizer to locate the three byte ranges, slice them
out, and emit them as plain string literals in the shim. The runtime
side is now just:

  var __webpack_body__  = "...";
  var __webpack_title__ = "...";
  var __webpack_head__  = "...";

…with the patch / reload decision still made on `module.hot.data`.
Zero regex, zero parsing, no tokenizer ship to the client; the
generated shim is also significantly smaller (no `__webpack_get_head__`
helper, no inline regex literals).

The first `<title>` is only captured while inside `<head>`, so a stray
SVG `<title>` in the body isn't mistakenly used as the document title.
The first `<head>` / `<body>` are taken; nested or out-of-document
duplicates are ignored.
…irst

Bring back the runtime regex-based extraction for `<body>`, `<title>`
and `<head>` content (smaller build-time bookkeeping, no second walker
pass at generate time) — but strip HTML comments first so decoy
tags inside `<!-- … -->` can't fool the non-greedy `*?` in the tag
regexes.

Without the comment strip, a comment like

  <!-- <body>example</body> -->

would be picked up as the actual `<body>` because the `*?` stops at
the FIRST `</body>` it sees. With

  h.replace(/<!--[\s\S]*?-->/g, "")

run before any tag-extraction regex, the decoys disappear and the real
`<body>` / `<title>` / `<head>` ranges win.

Adds a hot test (`html-comments`) whose source HTML has decoy `<title>`
and `<body>` markers inside HTML comments — the DOM-patch path must
land the REAL body / title content, not the decoys, and the
head-vs-decoy diff must stay stable (so the reload branch isn't
spuriously triggered).
The HMR shim was hand-indenting each line with hard-coded `\t` /
`\t\t` / `\t\t\t` prefixes. Webpack already exposes `Template.indent`
and `Template.asString` for exactly this — `CssModulesPlugin`'s HMR
snippet uses the same pattern. Switching over removes the manual
indentation arithmetic, makes the nesting explicit in source (the
indent function calls match the indent structure of the generated
code), and keeps the emitted output byte-identical.
AGENTS.md (after #21005) caps changeset descriptions at one sentence
≤ 80 characters in the imperative — multi-paragraph rationale belongs
in the PR body, not the changeset.
Address Copilot review on the HTML-module HMR shim:

- The shim stripped HTML comments before extracting the body/title,
  so the DOM-patched `document.body.innerHTML` dropped real comments
  that live inside `<body>` — diverging from what a full page reload
  renders. Switch from deleting comments to *masking* them (replace
  their characters with equal-length spaces). Masking keeps string
  offsets stable, so tag boundaries are still located on a copy where
  `<body>`/`</body>`/`<title>` inside a comment can't fool the regex,
  while the body/title content is sliced back out of the original HTML
  with comments intact.
- Drop the write-only `data.__webpack_html_updated__` dispose flag —
  nothing read it.
- FakeDocument: coerce `innerHTML` with a nullish check so `0`/`false`
  round-trip like the real DOM instead of collapsing to "".
The extracted HMR shim patches document.body with the JS-export HTML, whose
chunk URLs are resolved at build time (sentinel + render tap). Add a hot case
asserting a body <script src> is patched as a resolved chunk URL with no
unresolved sentinel and without a full reload.
__webpack_extract_head__ pre-masked the whole HTML and then __webpack_extract__
masked it again. Extract from the original HTML and mask only the small
extracted head substring instead.
main's new module.parser.html.sources (#21022) encodes inline <style> as
data:text/css;base64,… instead of data:text/css,…. Match the new prefix; the
old style module is uniquely identified by prefix in the changeset, so no
base64 decoding is needed.
The extract shim masked the full HTML on every tag-boundary search
(head/body/title + dispose). Precompute one masked copy per evaluation and
reuse it, so a large page isn't re-scanned per section.
@alexander-akait alexander-akait force-pushed the claude/html-module-tests-hmr-ZFebA branch from 6fafb26 to d1b7f27 Compare May 26, 2026 15:41
Use runtimeTemplate.renderConst()/basicFunction()/returningFunction() so
the HMR shim emits const and arrow functions when the target environment
supports them, falling back to var/function otherwise.
Copilot AI review requested due to automatic review settings May 26, 2026 17:42
Collapse the multi-line explanatory comments in CssParser to one-line
sentences; no behavior change.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 70 out of 70 changed files in this pull request and generated 1 comment.

Comment thread lib/html/HtmlGenerator.js
Comment on lines 383 to 385
// JS export: resolve `[webpack/auto]` to root-relative URLs.
const generated = this._renderHtml(module, generateContext, "");

@github-actions

Copy link
Copy Markdown
Contributor

Types Coverage

Coverage after merging claude/html-module-tests-hmr-ZFebA into main will be
98.97%
Coverage Report
FileStmtsBranchesFuncsLinesUncovered Lines
bin
   webpack.js98.77%100%100%98.77%91
examples
   build-common.js100%100%100%100%
   buildAll.js100%100%100%100%
   examples.js100%100%100%100%
   template-common.js98.21%100%100%98.21%72
examples/custom-javascript-parser
   test.filter.js100%100%100%100%
examples/custom-javascript-parser/internals
   acorn-parse.js100%100%100%100%
   meriyah-parse.js100%100%100%100%
   oxc-parse.js91.30%100%100%91.30%140, 142–143, 145, 147, 153–154, 161, 168, 90
examples/markdown
   webpack.config.mjs100%100%100%100%
examples/typescript
   test.filter.js100%100%100%100%
examples/typescript-non-erasable
   test.filter.js50%100%100%50%5
examples/virtual-modules
   test.filter.js100%100%100%100%
examples/wasm-bindgen-esm
   test.filter.js100%100%100%100%
examples/wasm-complex
   test.filter.js100%100%100%100%
examples/wasm-simple
   test.filter.js100%100%100%100%
examples/wasm-simple-source-phase
   test.filter.js100%100%100%100%
lib
   APIPlugin.js100%100%100%100%
   AsyncDependenciesBlock.js100%100%100%100%
   AutomaticPrefetchPlugin.js100%100%100%100%
   BannerPlugin.js100%100%100%100%
   Cache.js98.21%100%100%98.21%101
   CacheFacade.js100%100%100%100%
   Chunk.js99.72%100%100%99.72%39
   ChunkGraph.js100%100%100%100%
   ChunkGroup.js100%100%100%100%
   ChunkTemplate.js100%100%100%100%
   CleanPlugin.js99.15%100%100%99.15%206, 226
   CodeGenerationResults.js100%100%100%100%
   CompatibilityPlugin.js100%100%100%100%
   Compilation.js98.48%100%100%98.48%1576, 1872, 1879, 1887, 1909, 2805, 3230, 3894, 3923, 3976–3977, 3981, 3986, 4002–4003, 4017–4018, 4023–4024, 4501, 4527, 511, 516, 5335, 5367, 5384, 5400, 5416, 5431, 5456–5457, 5459, 5787, 5792, 5798, 5801, 5813, 5815, 5819, 5835, 5850, 5882, 5936, 5960, 6074, 730–731
   Compiler.js99.56%100%100%99.56%1135–1136, 1144
   ConcatenationScope.js98.59%100%100%98.59%189
   ConditionalInitFragment.js100%100%100%100%
   ConstPlugin.js100%100%100%100%
   ContextExclusionPlugin.js100%100%100%100%
   ContextModule.js100%100%100%100%
   ContextModuleFactory.js97.40%100%100%97.40%258, 395, 418, 420, 424, 433–434
   ContextReplacementPlugin.js100%100%100%100%
   DefinePlugin.js99%100%100%99%170–171, 187, 206, 280
   DependenciesBlock.js100%100%100%100%
   Dependency.js98.20%100%100%98.20%381, 427
   DependencyTemplate.js100%100%100%100%
   DependencyTemplates.js100%100%100%100%
   DotenvPlugin.js98.41%100%100%98.41%378, 391–392
   DynamicEntryPlugin.js100%100%100%100%
   EntryOptionPlugin.js100%100%100%100%
   EntryPlugin.js100%100%100%100%
   Entrypoint.js100%100%100%100%
   EnvironmentPlugin.js97.14%100%100%97.14%49
   ErrorHelpers.js100%100%100%100%
   EvalDevToolModulePlugin.js100%100%100%100%
   EvalSourceMapDevToolPlugin.js100%100%100%100%
   ExportsInfo.js100%100%100%100%
   ExportsInfoApiPlugin.js100%100%100%100%
   ExternalModule.js98.97%100%100%98.97%425–429, 577
   ExternalModuleFactoryPlugin.js100%100%100%100%
   ExternalsPlugin.js100%100%100%100%
   FileSystemInfo.js99.50%100%100%99.50%182, 2252–2253, 2256, 2267, 2278, 2289, 278, 3694, 3709, 3733
   FlagAllModulesAsUsedPlugin.js100%100%100%100%
   FlagDependencyExportsPlugin.js98.78%100%100%98.78%409, 411, 415
   FlagDependencyUsagePlugin.js100%100%100%100%
   FlagEntryExportAsUsedPlugin.js100%100%100%100%
   Generator.js100%100%100%100%
   HotModuleReplacementPlugin.js100%100%100%100%
   HotUpdateChunk.js100%100%100%100%
   IgnorePlugin.js100%100%100%100%
   IgnoreWarningsPlugin.js100%100%100%100%
   InitFragment.js100%100%100%100%
   JavascriptMetaInfoPlugin.js100%100%100%100%
   LibraryTemplatePlugin.js100%100%100%100%
   LoaderOptionsPlugin.js100%100%100%100%
   LoaderTargetPlugin.js100%100%100%100%
   MainTemplate.js100%100%100%100%
   ManifestPlugin.js100%100%100%100%
   Module.js98.49%100%100%98.49%1301, 1306, 1366, 1380, 1442, 1451
   ModuleFactory.js100%100%100%100%
   ModuleFilenameHelpers.js98.85%100%100%98.85%106, 108
   ModuleGraph.js99.73%100%100%99.73%1004
   ModuleGraphConnection.js100%100%100%100%
   ModuleInfoHeaderPlugin.js100%100%100%100%
   ModuleNotFoundError.js100%100%100%100%
   ModuleProfile.js100%100%100%100%
   ModuleSourceTypeConstants.js100%100%100%100%
   ModuleTemplate.js100%100%100%100%
   ModuleTypeConstants.js100%100%100%100%
   MultiCompiler.js99.69%100%100%99.69%659
   MultiStats.js100%100%100%100%
   MultiWatching.js100%100%100%100%
   NoEmitOnErrorsPlugin.js100%100%100%100%
   NodeStuffPlugin.js100%100%100%100%
   NormalModule.js98.12%100%100%98.12%1212, 1215, 1232, 1249, 1496, 1530, 1546, 1633, 2257, 2262–2272, 569
   NormalModuleFactory.js99.47%100%100%99.47%1083, 1392, 486, 498
   NormalModuleReplacementPlugin.js100%100%100%100%
   NullFactory.js100%100%100%100%
   OptimizationStages.js100%100%100%100%
   OptionsApply.js100%100%100%100%
   Parser.js100%100%100%100%
   PlatformPlugin.js100%100%100%100%
   PrefetchPlugin.js100%100%100%100%
   ProgressPlugin.js98.85%100%100%98.85%519–520, 525, 527, 591
   ProvidePlugin.js100%100%100%100%
   RawModule.js100%100%100%100%
   RecordIdsPlugin.js100%100%100%100%
   RequestShortener.js100%100%100%100%
   ResolverFactory.js100%100%100%100%
   RuntimeGlobals.js100%100%100%100%
   RuntimeModule.js100%100%100%100%
   RuntimePlugin.js100%100%100%100%
   RuntimeTemplate.js100%100%100%100%
   SelfModuleFactory.js100%100%100%100%
   SingleEntryPlugin.js100%100%100%100%
   SourceMapDevToolModuleOptionsPlugin.js100%100%100%100%
   SourceMapDevToolPlugin.js98.62%100%100%98.62%220, 224, 226, 419, 430, 891
   Stats.js100%100%100%100%
   Template.js100%100%100%100%
   TemplatedPathPlugin.js98.86%100%100%98.86%136–137
   UseStrictPlugin.js100%100%100%100%
   WarnCaseSensitiveModulesPlugin.js100%100%100%100%
   WarnDeprecatedOptionPlugin.js100%100%100%100%
   WarnNoModeSetPlugin.js100%100%100%100%
   WatchIgnorePlugin.js100%100%100%100%
   Watching.js100%100%100%100%
   WebpackError.js100%100%100%100%
   WebpackIsIncludedPlugin.js100%100%100%100%
   WebpackOptionsApply.js100%100%100%100%
   WebpackOptionsDefaulter.js100%100%100%100%
   buildChunkGraph.js99.87%100%100%99.87%326
   cli.js98.46%100%100%98.46%10, 119, 471, 503, 545, 815
   index.js99.72%100%100%99.72%165
   validateSchema.js94.67%100%100%94.67%100, 87, 89, 98
   webpack.js96.33%100%100%96.33%10, 198, 220, 222
lib/asset
   AssetBytesGenerator.js100%100%100%100%
   AssetBytesParser.js100%100%100%100%
   AssetGenerator.js100%100%100%100%
   AssetModulesPlugin.js97.32%100%100%97.32%283, 307, 310, 36, 362, 41
   AssetParser.js100%100%100%100%
   AssetSourceGenerator.js100%100%100%100%
   AssetSourceParser.js100%100%100%100%
   RawDataUrlModule.js100%100%100%100%
lib/async-modules
   AsyncModuleHelpers.js100%100%100%100%
   AwaitDependenciesInitFragment.js100%100%100%100%
   InferAsyncModulesPlugin.js100%100%100%100%
lib/cache
   AddBuildDependenciesPlugin.js100%100%100%100%
   AddManagedPathsPlugin.js100%100%100%100%
   IdleFileCachePlugin.js97.92%100%100%97.92%71, 83, 91
   MemoryCachePlugin.js95.83%100%100%95.83%33
   MemoryWithGcCachePlugin.js93.15%100%100%93.15%106, 113–114, 122, 89
   PackFileCacheStrategy.js96.40%100%100%96.40%1250, 1350, 1354, 1416, 628, 647, 657–659, 661, 677–678, 683, 686, 688, 693, 698, 722, 728, 762, 768, 774, 779, 790, 799, 804–805, 807, 824, 830–831, 833
   ResolverCachePlugin.js100%100%100%100%
   getLazyHashedEtag.js100%100%100%100%
   mergeEtags.js100%100%100%100%
lib/config
   browserslistTargetHandler.js100%100%100%100%
   defaults.js99.29%100%100%99.29%1426–1428, 1436, 271, 274, 279, 283
   normalization.js99%100%100%99%191–192, 258, 273
   target.js100%100%100%100%
lib/container
   ContainerEntryDependency.js100%100%100%100%
   ContainerEntryModule.js100%100%100%100%
   ContainerEntryModuleFactory.js100%100%100%100%
   ContainerExposedDependency.js100%100%100%100%
   ContainerPlugin.js100%100%100%100%
   ContainerReferencePlugin.js100%100%100%100%
   FallbackDependency.js100%100%100%100%
   

@alexander-akait alexander-akait merged commit 04be1fe into main May 27, 2026
61 checks passed
@alexander-akait alexander-akait deleted the claude/html-module-tests-hmr-ZFebA branch May 27, 2026 12:35
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants