html-bundle 6.3.1 → 6.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/utils.mts CHANGED
@@ -9,15 +9,24 @@ import express from "express";
9
9
  import postcssrc from "postcss-load-config";
10
10
  import cssnano from "cssnano";
11
11
  import { parse, parseFragment, serialize } from "parse5";
12
- import {
13
- createScript,
14
- getTagName,
15
- findElement,
16
- appendChild,
17
- } from "@web/parse5-utils";
12
+ import { createScript, getTagName, findElement } from "@web/parse5-utils";
18
13
 
19
14
  export const bundleConfig = await getBundleConfig();
20
15
 
16
+ // The HMR client runtime is authored in src/hmr-client.ts and compiled by tsc to
17
+ // dist/hmr-client.js alongside this module (a real file, so its code is never
18
+ // mangled by template-literal escaping). Read it once and substitute the per-page
19
+ // tokens on demand in buildHMRClient().
20
+ let hmrClientTemplate = "";
21
+ try {
22
+ hmrClientTemplate = await readFile(
23
+ new URL("./hmr-client.js", import.meta.url),
24
+ "utf-8",
25
+ );
26
+ } catch {
27
+ // Only needed when --hmr is active; addHMRCode tolerates an empty template.
28
+ }
29
+
21
30
  export function fileCopy(file: string) {
22
31
  return copyFile(file, getBuildPath(file));
23
32
  }
@@ -32,10 +41,17 @@ export function getBuildPath(file: string) {
32
41
  return file.replace(`${bundleConfig.src}/`, `${bundleConfig.build}/`);
33
42
  }
34
43
 
44
+ // Every change the watcher detects is normalised into one of these events. The
45
+ // client dispatches on `type`, so the server never needs the old .ts->.js file
46
+ // renaming: module edits are delivered as "html" updates for the owning page(s).
47
+ export type HMREvent =
48
+ | { type: "html"; file: string; html?: string; previousHtml?: string }
49
+ | { type: "css"; file: string }
50
+ | { type: "asset"; file: string }
51
+ | { type: "full-reload"; file: string };
52
+
35
53
  const CONNECTIONS = new Set<any>(); // In order to send the HMR information
36
- export let serverSentEvents:
37
- | undefined
38
- | (({ file, html }: { file: string; html?: string }) => void);
54
+ export let serverSentEvents: undefined | ((event: HMREvent) => void);
39
55
  export async function createDefaultServer(
40
56
  isSecure: boolean,
41
57
  ): Promise<[Router, Server | HTTPSServer]> {
@@ -55,17 +71,14 @@ export async function createDefaultServer(
55
71
  CONNECTIONS.delete(reply);
56
72
  });
57
73
 
58
- serverSentEvents = (data) => {
59
- if (/\.(jsx?|tsx?)$/.test(data.file)) {
60
- data.file = data.file.replace(".ts", ".js").replace(".jsx", ".js");
61
- }
74
+ serverSentEvents = (event) => {
62
75
  CONNECTIONS.forEach((rep) => {
63
76
  if (rep.destroyed || rep.writableEnded) {
64
77
  CONNECTIONS.delete(rep);
65
78
  return;
66
79
  }
67
80
 
68
- rep.write(`data: ${JSON.stringify(data)}\n\n`);
81
+ rep.write(`data: ${JSON.stringify(event)}\n\n`);
69
82
  });
70
83
  };
71
84
  });
@@ -102,7 +115,17 @@ export async function createDefaultServer(
102
115
  export async function getPostCSSConfig() {
103
116
  try {
104
117
  return await postcssrc({});
105
- } catch {
118
+ } catch (err) {
119
+ const message = err instanceof Error ? err.message : String(err);
120
+ // postcssrc throws "No PostCSS Config found" when the project is zero-config;
121
+ // that is expected, so stay silent. Any other failure (e.g. a broken config
122
+ // or a TypeScript config that cannot be loaded) would otherwise silently
123
+ // degrade the build to cssnano-only, so surface it.
124
+ if (!/No PostCSS Config found/i.test(message)) {
125
+ console.error(
126
+ `\u26A0\uFE0F Could not load your PostCSS config \u2013 falling back to cssnano only. ${message}`,
127
+ );
128
+ }
106
129
  return { plugins: [cssnano], options: {}, file: "" };
107
130
  }
108
131
  }
@@ -141,25 +164,27 @@ export function addHMRCode(
141
164
  if (!htmlIdMap.has(file)) {
142
165
  htmlIdMap.set(file, randomText());
143
166
  }
167
+ const id = htmlIdMap.get(file);
144
168
 
145
169
  const script = createScript(
146
- { type: "module" },
147
- getHMRCode(file, htmlIdMap.get(file), bundleConfig.src),
170
+ { type: "module", "data-hmr-client": id },
171
+ buildHMRClient(file, id, bundleConfig.src),
148
172
  );
149
173
 
150
174
  let DOM;
151
175
  if (html.includes("<!DOCTYPE html>") || html.includes("<html")) {
152
176
  DOM = ast || parse(html);
153
177
  const headNode = findElement(DOM as Node, (e) => getTagName(e) === "head");
154
- appendChild(headNode, script);
178
+ // Inject first in <head> so the client runs before the page's own scripts.
179
+ prependChild(headNode as Node, script);
155
180
  } else {
156
181
  DOM = ast || parseFragment(html);
157
- appendChild(DOM, script);
182
+ prependChild(DOM as Node, script);
158
183
  }
159
184
 
160
185
  //@ts-ignore
161
186
  DOM.childNodes.forEach((node) =>
162
- node.attrs?.push({ name: "data-hmr", value: htmlIdMap.get(file) }),
187
+ node.attrs?.push({ name: "data-hmr", value: id }),
163
188
  );
164
189
 
165
190
  return serialize(DOM as any);
@@ -169,171 +194,21 @@ function randomText() {
169
194
  return Math.random().toString(32).slice(2);
170
195
  }
171
196
 
172
- function getHMRCode(file: string, id: string, src: string) {
173
- return `import { render, html, $, $$, setShouldSetReactivity } from "hydro-js";
174
- window.isHMR = true;
175
- window.lastCalled = new Map();
176
- window.htmlBundleHMRConnections ||= new Map();
177
- window.htmlBundleHMRActiveId = "${id}";
178
- let shouldReconnect = true;
179
- let reconnectTimer;
180
-
181
- for (const [hmrId, eventSource] of window.htmlBundleHMRConnections) {
182
- if (hmrId !== "${id}") {
183
- eventSource.close();
184
- window.htmlBundleHMRConnections.delete(hmrId);
185
- delete window["eventsource" + hmrId];
186
- }
187
- }
188
-
189
- function closeEventSource() {
190
- const eventSource = window.eventsource${id};
191
- if (eventSource) {
192
- eventSource.close();
193
- window.htmlBundleHMRConnections.delete("${id}");
194
- delete window.eventsource${id};
195
- }
196
- }
197
-
198
- function connectEventSource() {
199
- closeEventSource();
200
- shouldReconnect = true;
201
-
202
- const eventSource = new EventSource("/hmr");
203
- window.eventsource${id} = eventSource;
204
- window.htmlBundleHMRConnections.set("${id}", eventSource);
205
-
206
- eventSource.addEventListener('error', () => {
207
- eventSource.close();
208
- if (window.eventsource${id} === eventSource) {
209
- window.htmlBundleHMRConnections.delete("${id}");
210
- delete window.eventsource${id};
211
- }
212
-
213
- if (shouldReconnect && window.htmlBundleHMRActiveId === "${id}") {
214
- clearTimeout(reconnectTimer);
215
- reconnectTimer = setTimeout(connectEventSource, 1000);
216
- }
217
- });
218
-
219
- eventSource.addEventListener("message", ({ data }) => {
220
- if (window.lastScroll == null) {
221
- window.lastScroll = window.scrollY;
222
- }
223
- const dataObj = JSON.parse(data);
224
- const file = "${file}";
225
-
226
- if (file === dataObj.file && "html" in dataObj) {
227
- let newHTML;
228
- try {
229
- newHTML = html\`\${dataObj.html}\`
230
- } catch {
231
- setShouldSetReactivity(false);
232
- newHTML = html\`\${dataObj.html}\`
233
- setShouldSetReactivity(true);
234
- }
235
-
236
- if (dataObj.html.startsWith('<!DOCTYPE html>') || dataObj.html.startsWith('<html')) {
237
- document.head.remove(); // Don't try to diff the head – just re-run the scripts
238
-
239
- // Restore Scroll
240
- window.addEventListener("afterRouting", () => {
241
- window.scrollTo(0, window.lastScroll);
242
- delete window.lastScroll;
243
- }, { once: true })
244
-
245
- render(newHTML, document.documentElement, false);
246
- } else {
247
- const hmrID = "${id}";
248
- const hmrElems = Array.from(newHTML.childNodes);
249
- const hmrWheres = Array.from($$(\`[data-hmr="\${hmrID}"]\`))
250
- // render new elements for old elements. Then, remove rest old elements and add add new elements after the last old one
251
- hmrWheres.forEach((where, index) => {
252
- if (index < hmrElems.length) {
253
- render(hmrElems[index], where, false);
254
- } else {
255
- where.remove();
256
- }
257
- });
258
- for (let rest = hmrWheres.length; rest < hmrElems.length; rest++) {
259
- if (hmrWheres.length) {
260
- const template = document.createElement('template');
261
- hmrElems[hmrWheres.length - 1].after(template);
262
- render(hmrElems[rest], template, false);
263
- template.remove();
264
- } else {
265
- render(hmrElems[rest], false, false)
266
- }
267
- }
268
- }
269
-
270
- $$('link[rel="stylesheet"][href]').forEach(link => {
271
- link.setAttribute("href", link.getAttribute("href") + "?v=" + String(Math.random().toFixed(4)).slice(2));
272
- })
273
- if (dataObj.html.includes("<script")) updateElem("script");
274
-
275
-
276
- if (dataObj.file === \`${src}/index.html\`) {
277
- dispatchEvent(new Event("popstate"));
278
- }
279
- } else if (dataObj.file.endsWith(".css")) {
280
- const now = performance.now();
281
- if (!window.lastCalled.has(dataObj.file) || now - window.lastCalled.get(dataObj.file) > 100) {
282
- $$('link[rel="stylesheet"][href]').forEach(link => {
283
- link.setAttribute("href", link.getAttribute("href") + "?v=" + String(Math.random().toFixed(4)).slice(2));
284
- })
285
- window.lastCalled.set(dataObj.file, now)
286
- }
287
- } else if (dataObj.file.endsWith(".js")) {
288
- const now = performance.now();
289
- if (!window.lastCalled.has(dataObj.file) || now - window.lastCalled.get(dataObj.file) > 100) {
290
- $$('link[rel="stylesheet"][href]').forEach(link => {
291
- link.setAttribute("href", link.getAttribute("href") + "?v=" + String(Math.random().toFixed(4)).slice(2));
292
- })
293
- updateElem("script");
294
- window.lastCalled.set(dataObj.file, now)
295
- }
296
- }
297
-
298
-
299
- function updateElem(type) {
300
- const hmrId = "${id}";
301
- const noSrcFile = dataObj.file.replace(\`${src}/\`, '');
302
- const attr = type === "script" ? "src" : "href";
303
- const elem = $(\`[data-hmr="\${hmrId}"] \${type}[\${attr}^="\${noSrcFile}"]\`); // could be $(\`\${type}[data-hmr="\${hmrId}"][\${attr}^="\${noSrcFile}"]\`) ?
304
-
305
- if (elem) {
306
- updateOne(type, attr, elem)
307
- } else {
308
- for(const e of $$(\`[data-hmr="\${hmrId}"] \${type}\`)) {
309
- updateOne(type, attr, e);
310
- }
311
- }
312
- }
313
-
314
- function updateOne(type, attr, elem) {
315
- const clone = document.createElement(type);
316
- for (const key of elem.getAttributeNames()) {
317
- clone.setAttribute(key, elem.getAttribute(key));
318
- }
319
- const attrVal = elem.getAttribute(attr);
320
- if (attrVal) clone.setAttribute(attr, attrVal + "?v=" + String(Math.random().toFixed(4)).slice(2));
321
- render(clone, elem, false);
322
- }
323
- });
324
- }
325
-
326
- window.addEventListener("pagehide", () => {
327
- shouldReconnect = false;
328
- clearTimeout(reconnectTimer);
329
- if (window.htmlBundleHMRActiveId === "${id}") {
330
- delete window.htmlBundleHMRActiveId;
331
- }
332
- closeEventSource();
333
- }, { once: true });
197
+ // Produce the per-page HMR client by substituting tokens into the shared runtime
198
+ // template (src/hmr-client.ts). The runtime is injected as an inline module so
199
+ // esbuild bundles hydro-js for it, but it coordinates through a single global hub
200
+ // so every composed page shares one EventSource and patches its own region.
201
+ function buildHMRClient(file: string, id: string, src: string) {
202
+ return hmrClientTemplate
203
+ .replaceAll("__HMR_FILE__", file)
204
+ .replaceAll("__HMR_ID__", id)
205
+ .replaceAll("__HMR_SRC__", src);
206
+ }
334
207
 
335
- if (!window.eventsource${id}) {
336
- connectEventSource();
337
- }
338
- `;
208
+ function prependChild(parent: Node, node: unknown) {
209
+ // Insert as the first child so the HMR client runs before the page's own
210
+ // scripts — required for window.htmlBundleHMR.dispose()/data to be usable on
211
+ // initial load.
212
+ (node as { parentNode?: unknown }).parentNode = parent;
213
+ (parent as unknown as { childNodes: unknown[] }).childNodes.unshift(node);
339
214
  }
@@ -89,23 +89,75 @@ test("addHMRCode injects stable HMR wiring", async () => {
89
89
  );
90
90
  const fragment = addHMRCode("<main>Hi</main>", "src/fragment.html");
91
91
 
92
- assert.match(fullDocument, /<script type="module">/);
92
+ assert.match(fullDocument, /<script type="module" data-hmr-client="[^\"]+">/);
93
93
  assert.match(fullDocument, /new EventSource\("\/hmr"\)/);
94
- assert.match(
95
- fullDocument,
96
- /window\.htmlBundleHMRConnections \|\|= new Map\(\)/,
97
- );
98
- assert.match(fullDocument, /window\.addEventListener\("pagehide"/);
99
- assert.match(fullDocument, /eventSource\.close\(\)/);
94
+ // Single shared hub + public opt-in API replace the old per-page globals.
95
+ assert.match(fullDocument, /window\.__htmlBundleHMR/);
96
+ assert.match(fullDocument, /window\.htmlBundleHMR = \{/);
97
+ assert.match(fullDocument, /hub\.register\(FILE, ID/);
98
+ assert.match(fullDocument, /"pagehide"/);
99
+ assert.match(fullDocument, /\.close\(\)/);
100
100
  assert.match(fullDocument, /data-hmr="[^"]+"/);
101
+ assert.match(fullDocument, /function patchDocument\(/);
102
+ assert.match(fullDocument, /function patchScript\(/);
103
+ // Single-root fragments must be normalised (hydro-js returns the element, not
104
+ // a DocumentFragment) so patching targets the right nodes.
105
+ assert.match(fullDocument, /Node\.DOCUMENT_FRAGMENT_NODE/);
106
+ // tsc formats the tagged template with a space (`html `...``), so allow it.
107
+ assert.match(fullDocument, /html\s*`\$\{htmlText\}`/);
108
+ assert.doesNotMatch(fullDocument, /document\.head\.remove\(\)/);
109
+ assert.doesNotMatch(fullDocument, /DOMParser/);
110
+ // The client is injected first in <head> so it runs before the page's own
111
+ // scripts (needed for window.htmlBundleHMR.dispose()/data on initial load).
101
112
  assert.ok(
102
113
  fullDocument.indexOf("<head>") <
103
- fullDocument.indexOf('<script type="module">'),
114
+ fullDocument.indexOf('<script type="module" data-hmr-client='),
104
115
  );
105
116
 
106
- assert.match(fragment, /^<main data-hmr="[^"]+">Hi<\/main>/);
117
+ assert.match(fragment, /<main data-hmr="[^"]+">Hi<\/main>/);
107
118
  assert.match(fragment, /new EventSource\("\/hmr"\)/);
108
- assert.match(fragment, /window\.htmlBundleHMRActiveId ===/);
119
+ assert.match(fragment, /hub\.register\(FILE, ID/);
120
+ });
121
+
122
+ test("addHMRCode injects fragment client before fragment scripts", async () => {
123
+ const { addHMRCode } = await import(pathToFileURL(utilsPath).href);
124
+
125
+ const fragment = addHMRCode(
126
+ '<script type="module">window.htmlBundleHMR.dispose(() => {});</script><main>Hi</main>',
127
+ "src/scripted-fragment.html",
128
+ );
129
+
130
+ assert.ok(
131
+ fragment.indexOf("data-hmr-client") <
132
+ fragment.indexOf("window.htmlBundleHMR.dispose"),
133
+ );
134
+ });
135
+
136
+ test("HMR full-document detection survives template-literal escaping", async () => {
137
+ const { addHMRCode } = await import(pathToFileURL(utilsPath).href);
138
+
139
+ const doc = addHMRCode(
140
+ "<!DOCTYPE html><html><head></head><body>x</body></html>",
141
+ "src/detect.html",
142
+ );
143
+ const code = doc.match(/data-hmr-client="[^"]+">([\s\S]*?)<\/script>/)[1];
144
+ const start = code.indexOf("function isFullDocument");
145
+ const end = code.indexOf("function parseHTML");
146
+ const fnText = code.slice(start, end);
147
+ const isFullDocument = new Function(`${fnText}\n return isFullDocument;`)();
148
+
149
+ // Real payloads emitted by the server (parse5 + html-minifier-terser).
150
+ assert.equal(
151
+ isFullDocument("<!DOCTYPE html><html><body></body></html>"),
152
+ true,
153
+ );
154
+ assert.equal(
155
+ isFullDocument("<!doctype html><html><body></body></html>"),
156
+ true,
157
+ );
158
+ assert.equal(isFullDocument('<html lang="en"><body></body></html>'), true);
159
+ // Fragments must still take the fragment path.
160
+ assert.equal(isFullDocument("<main>fragment</main>"), false);
109
161
  });
110
162
 
111
163
  test("getBuildPath maps src paths into the build directory", async () => {
@@ -0,0 +1,245 @@
1
+ // Client-runtime tests for the compiled HMR client (dist/hmr-client.js, built
2
+ // from src/hmr-client.ts).
3
+ //
4
+ // The runtime is a real browser module, so we exercise it under happy-dom with
5
+ // the real hydro-js: replace its `hydro-js` import with injected deps, load it
6
+ // per page via `new Function`, then drive it through the shared hub the same way
7
+ // the browser's EventSource would.
8
+ import test from "node:test";
9
+ import assert from "node:assert/strict";
10
+ import { readFile } from "node:fs/promises";
11
+ import path from "node:path";
12
+ import { Window } from "happy-dom";
13
+
14
+ const repoRoot = path.resolve(new URL("..", import.meta.url).pathname);
15
+ const clientPath = path.join(repoRoot, "dist", "hmr-client.js");
16
+ const clientSrc = await readFile(clientPath, "utf8");
17
+
18
+ // Fresh DOM, globals and hub per test so state never leaks between cases.
19
+ async function setup() {
20
+ const w = new Window({ url: "http://localhost:5000/" });
21
+ globalThis.window = w;
22
+ globalThis.document = w.document;
23
+ globalThis.Node = w.Node;
24
+ globalThis.HTMLHtmlElement = w.HTMLHtmlElement;
25
+ globalThis.HTMLElement = w.HTMLElement;
26
+ globalThis.Event = w.Event;
27
+ globalThis.performance = w.performance ?? { now: () => Date.now() };
28
+ globalThis.dispatchEvent = w.dispatchEvent
29
+ ? w.dispatchEvent.bind(w)
30
+ : () => {};
31
+ w.scrollTo = () => {};
32
+
33
+ let eventSourceCount = 0;
34
+ globalThis.EventSource = class {
35
+ constructor() {
36
+ eventSourceCount++;
37
+ }
38
+ addEventListener() {}
39
+ close() {}
40
+ };
41
+
42
+ let reloadCount = 0;
43
+ w.location.reload = () => {
44
+ reloadCount++;
45
+ };
46
+ globalThis.__hydro = await import("hydro-js");
47
+
48
+ function loadPage(file, id, src = "src") {
49
+ const code = clientSrc
50
+ .replace(
51
+ 'import { render as hydroRender, html, setShouldSetReactivity } from "hydro-js";',
52
+ "const { render: hydroRender, html, setShouldSetReactivity } = globalThis.__hydro;",
53
+ )
54
+ .replaceAll("__HMR_FILE__", file)
55
+ .replaceAll("__HMR_ID__", id)
56
+ .replaceAll("__HMR_SRC__", src);
57
+ // eslint-disable-next-line no-new-func
58
+ new Function(code)();
59
+ }
60
+
61
+ return {
62
+ window: w,
63
+ document: w.document,
64
+ loadPage,
65
+ eventSourceCount: () => eventSourceCount,
66
+ reloadCount: () => reloadCount,
67
+ };
68
+ }
69
+
70
+ const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
71
+
72
+ const FULL_BEFORE =
73
+ "<!DOCTYPE html><html><head><title>Before</title></head><body><main class='center'></main></body></html>";
74
+ const FULL_AFTER =
75
+ "<!DOCTYPE html><html><head><title>After</title></head><body><main class='center'></main></body></html>";
76
+
77
+ test("shared hub patches a full-document title change in place", async () => {
78
+ const { document, loadPage } = await setup();
79
+ document.documentElement.innerHTML =
80
+ "<head><title>Before</title></head><body><main class='center'></main></body>";
81
+
82
+ loadPage("src/index.html", "idx1");
83
+ assert.ok(window.__htmlBundleHMR, "hub is created");
84
+ assert.equal(typeof window.htmlBundleHMR.dispose, "function");
85
+
86
+ window.__htmlBundleHMR.dispatch({
87
+ type: "html",
88
+ file: "src/index.html",
89
+ previousHtml: FULL_BEFORE,
90
+ html: FULL_AFTER,
91
+ });
92
+
93
+ assert.equal(document.querySelector("title").textContent, "After");
94
+ });
95
+
96
+ test("full-document insertion before a composed mount preserves fetched content", async () => {
97
+ const { document, loadPage, reloadCount } = await setup();
98
+ const before =
99
+ "<!DOCTYPE html><html><head><title>Fixture</title></head><body><noscript>Enable JS</noscript><main class='center'></main></body></html>";
100
+ const after =
101
+ "<!DOCTYPE html><html><head><title>Fixture</title></head><body><p id='new-copy'>New copy</p><noscript>Enable JS</noscript><main class='center'></main></body></html>";
102
+ document.documentElement.innerHTML =
103
+ "<head><title>Fixture</title></head><body><noscript>Enable JS</noscript><main class='center'><picture><img src='@public/splash.webp'></picture></main></body>";
104
+
105
+ loadPage("src/index.html", "idx1");
106
+ window.__htmlBundleHMR.dispatch({
107
+ type: "html",
108
+ file: "src/index.html",
109
+ previousHtml: before,
110
+ html: after,
111
+ });
112
+
113
+ assert.equal(document.querySelector("#new-copy")?.textContent, "New copy");
114
+ assert.ok(document.querySelector("main.center img"));
115
+ assert.equal(reloadCount(), 0);
116
+ });
117
+
118
+ test("same-tag insertion before a composed mount preserves fetched content", async () => {
119
+ const { document, loadPage } = await setup();
120
+ const before =
121
+ "<!DOCTYPE html><html><head><title>Fixture</title></head><body><main class='center'></main></body></html>";
122
+ const after =
123
+ "<!DOCTYPE html><html><head><title>Fixture</title></head><body><main id='banner'>New copy</main><main class='center'></main></body></html>";
124
+ document.documentElement.innerHTML =
125
+ "<head><title>Fixture</title></head><body><main class='center'><picture><img src='@public/splash.webp'></picture></main></body>";
126
+
127
+ loadPage("src/index.html", "idx1");
128
+ window.__htmlBundleHMR.dispatch({
129
+ type: "html",
130
+ file: "src/index.html",
131
+ previousHtml: before,
132
+ html: after,
133
+ });
134
+
135
+ assert.equal(document.querySelector("#banner")?.textContent, "New copy");
136
+ assert.ok(document.querySelector("main.center img"));
137
+ });
138
+
139
+ test("full-reload events trigger a debounced page reload", async () => {
140
+ const { loadPage, reloadCount } = await setup();
141
+ loadPage("src/index.html", "idx1");
142
+
143
+ window.__htmlBundleHMR.dispatch({
144
+ type: "full-reload",
145
+ file: "src/@shared/workerCode.ts",
146
+ });
147
+ window.__htmlBundleHMR.dispatch({
148
+ type: "full-reload",
149
+ file: "src/@shared/workerCode.ts",
150
+ });
151
+ await wait(30);
152
+
153
+ assert.equal(reloadCount(), 1);
154
+ });
155
+
156
+ test("single-root fragment update patches only its own region", async () => {
157
+ const { document, loadPage } = await setup();
158
+ document.body.innerHTML =
159
+ "<main class='center'><div data-hmr='app1'>Old</div></main>";
160
+
161
+ loadPage("src/app/index.html", "app1");
162
+ window.__htmlBundleHMR.dispatch({
163
+ type: "html",
164
+ file: "src/app/index.html",
165
+ html: "<main data-hmr='app1'>New</main>",
166
+ });
167
+
168
+ const region = document.querySelector('[data-hmr="app1"]');
169
+ assert.equal(region.textContent, "New");
170
+ });
171
+
172
+ test("dispose runs before re-patch and data persists across updates", async () => {
173
+ const { document, loadPage } = await setup();
174
+ document.body.innerHTML = "<div data-hmr='app1'>Old</div>";
175
+ loadPage("src/app/index.html", "app1");
176
+
177
+ let disposed = false;
178
+ window.htmlBundleHMR.dispose(() => {
179
+ disposed = true;
180
+ });
181
+ window.htmlBundleHMR.data.persisted = 42;
182
+
183
+ window.__htmlBundleHMR.dispatch({
184
+ type: "html",
185
+ file: "src/app/index.html",
186
+ html: "<div data-hmr='app1'>New</div>",
187
+ });
188
+
189
+ assert.equal(disposed, true);
190
+ assert.equal(window.htmlBundleHMR.data.persisted, 42);
191
+ });
192
+
193
+ test("css event cache-busts stylesheet links", async () => {
194
+ const { document, loadPage } = await setup();
195
+ document.documentElement.innerHTML =
196
+ '<head><link rel="stylesheet" href="@public/base.css"></head><body></body>';
197
+ loadPage("src/index.html", "idx1");
198
+
199
+ window.__htmlBundleHMR.dispatch({
200
+ type: "css",
201
+ file: "src/@public/base.css",
202
+ });
203
+
204
+ const href = document.querySelector("link").getAttribute("href");
205
+ assert.match(href, /^@public\/base\.css\?v=\d+$/);
206
+ });
207
+
208
+ test("asset event cache-busts the matching element", async () => {
209
+ const { document, loadPage } = await setup();
210
+ document.body.innerHTML = '<img src="@public/splash.webp">';
211
+ loadPage("src/index.html", "idx1");
212
+
213
+ window.__htmlBundleHMR.dispatch({
214
+ type: "asset",
215
+ file: "src/@public/splash.webp",
216
+ });
217
+
218
+ const src = document.querySelector("img").getAttribute("src");
219
+ assert.match(src, /^@public\/splash\.webp\?v=\d+$/);
220
+ });
221
+
222
+ test("a change to an unregistered page is a safe no-op", async () => {
223
+ const { document, loadPage } = await setup();
224
+ document.documentElement.innerHTML =
225
+ "<head><title>Before</title></head><body></body>";
226
+ loadPage("src/index.html", "idx1");
227
+
228
+ assert.doesNotThrow(() =>
229
+ window.__htmlBundleHMR.dispatch({
230
+ type: "html",
231
+ file: "src/never.html",
232
+ html: "<div></div>",
233
+ }),
234
+ );
235
+ assert.equal(document.querySelector("title").textContent, "Before");
236
+ });
237
+
238
+ test("all composed pages share exactly one EventSource", async () => {
239
+ const { loadPage, eventSourceCount } = await setup();
240
+ loadPage("src/index.html", "idx1");
241
+ loadPage("src/app/index.html", "app1");
242
+ loadPage("src/link/index.html", "lnk1");
243
+
244
+ assert.equal(eventSourceCount(), 1);
245
+ });