@ape-egg/vibe 2.1.19 → 2.1.20
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/CHANGELOG.md +6 -0
- package/hot-module-refresh.js +384 -0
- package/package.json +2 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [2.1.20] - 2026-06-25
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
|
|
7
|
+
- **`@ape-egg/vibe/hot-module-refresh` — transport-agnostic browser HMR client** (`hot-module-refresh.js` (new), exported from `package.json`) — the surgical-HMR "brain" that reconciles a code edit into the live DOM instead of reloading the page is extracted out of `vite-plugin-vibe` into a standalone flat module the vibe package now exports. It's a *soft dependency*: nothing in the runtime imports it and it imports nothing from the runtime, depending only on the public `window.$` surface (`reconcile` / `renderComponent` / `clearComponentCache`), so a plain static server can serve it as-is — no bundler, no Vite. A transport adapter wires its channel to the brain through a single seam, `setupHotModuleRefresh({ debug, subscribe })`, where `subscribe` delivers two callbacks: `componentUpdate(path)` — re-fetch a changed component template and, for each live instance, reconcile surgically when its scripts are unchanged or re-mount otherwise (runtime mode only, since compiled output inlines components into pages) — and `pageUpdate(payload)` — re-fetch the current page and reconcile the `[vibe]` root (mode-agnostic; raw and compiled pages reconcile the same way). This lets the Vite plugin and any other dev transport share one HMR implementation. Tests: `e2e-runtime/hot-module-refresh.html`, `tests/e2e/hot-module-refresh.spec.js`.
|
|
8
|
+
|
|
3
9
|
## [2.1.19] - 2026-06-25
|
|
4
10
|
|
|
5
11
|
### Fixed
|
|
@@ -0,0 +1,384 @@
|
|
|
1
|
+
// @ape-egg/vibe/hot-module-refresh
|
|
2
|
+
//
|
|
3
|
+
// Transport-agnostic browser HMR client — the "brain" that reconciles a code
|
|
4
|
+
// edit into the live DOM instead of reloading the page. Extracted out of
|
|
5
|
+
// vite-plugin-vibe so both dev modes share one implementation.
|
|
6
|
+
//
|
|
7
|
+
// SOFT DEPENDENCY: nothing in Vibe's runtime imports this, it imports nothing
|
|
8
|
+
// from Vibe, and deleting it leaves a working framework. It depends solely on
|
|
9
|
+
// the runtime global `window.$` (reconcile / renderComponent /
|
|
10
|
+
// clearComponentCache), so it ships as a flat module a plain static server can
|
|
11
|
+
// serve as-is — no bundler, no Vite.
|
|
12
|
+
//
|
|
13
|
+
// A transport adapter wires its channel to the brain through one seam:
|
|
14
|
+
//
|
|
15
|
+
// setupHotModuleRefresh({ debug, subscribe })
|
|
16
|
+
// subscribe({ componentUpdate, pageUpdate })
|
|
17
|
+
//
|
|
18
|
+
// • componentUpdate(path) — a component template changed. Fetch the raw
|
|
19
|
+
// template; for every live instance, reconcile surgically when scripts
|
|
20
|
+
// are unchanged, else re-mount. Runtime-mode only — compiled output
|
|
21
|
+
// inlines components into pages, so its adapter never calls this.
|
|
22
|
+
// • pageUpdate(payload) — the current page changed. Fetch it and reconcile
|
|
23
|
+
// the [vibe] root. Mode-agnostic: a raw page (runtime) and a compiled
|
|
24
|
+
// page (compiled) reconcile the same way.
|
|
25
|
+
|
|
26
|
+
export const setupHotModuleRefresh = ({ debug = false, subscribe }) => {
|
|
27
|
+
const dbg = {
|
|
28
|
+
info: (...a) => { if (debug) console.info(...a); },
|
|
29
|
+
group: (...a) => { if (debug) console.group(...a); },
|
|
30
|
+
groupEnd: () => { if (debug) console.groupEnd(); },
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
// Track which live <component> wrappers correspond to which source file.
|
|
34
|
+
// Vibe's component.js replaces <component src='X'> with a fresh <component>
|
|
35
|
+
// (no src). The spy below tags the new wrapper with _vibeSrc + the slot
|
|
36
|
+
// content, so on a component-update we can find every live instance and
|
|
37
|
+
// hand the slot back to Vibe verbatim during re-mount.
|
|
38
|
+
const liveBySrc = new Map();
|
|
39
|
+
|
|
40
|
+
const isComponentSrc = (n) =>
|
|
41
|
+
n?.nodeType === 1 && n.hasAttribute?.('src') &&
|
|
42
|
+
(n.nodeName === 'COMPONENT' || (n.nodeName === 'DIV' && n.classList?.contains?.('component')));
|
|
43
|
+
const isProcessedComponent = (n) =>
|
|
44
|
+
n?.nodeType === 1 && !n.hasAttribute?.('src') &&
|
|
45
|
+
(n.nodeName === 'COMPONENT' || (n.nodeName === 'DIV' && n.classList?.contains?.('component')));
|
|
46
|
+
|
|
47
|
+
// Snapshot slot innerHTML on a <component src> BEFORE vibe strips iteration/
|
|
48
|
+
// conditional templates from its subtree. vibe's renderAllIterations removes
|
|
49
|
+
// nodes between <!-- each -->/<!-- /each --> comments synchronously after
|
|
50
|
+
// hydrate; if we wait until the component is swapped out (spy's removedNodes
|
|
51
|
+
// path) and read innerHTML then, the templates are gone and HMR re-mounts
|
|
52
|
+
// receive an empty slot. setup runs before the page's boot script calls into
|
|
53
|
+
// vibe — the initial pass captures everything present at parse time.
|
|
54
|
+
const captureSlot = (el) => {
|
|
55
|
+
if (el._vibePluginSlot === undefined) el._vibePluginSlot = el.innerHTML;
|
|
56
|
+
};
|
|
57
|
+
document.querySelectorAll('component[src], div.component[src]').forEach(captureSlot);
|
|
58
|
+
|
|
59
|
+
// Mirrors vibe/runtime/cleanup.js#shouldCleanup: a subtree is 'done' once
|
|
60
|
+
// no <component[src]> remains pending and no literal @[...] sits in a
|
|
61
|
+
// text node. Used to release vibe-fouc once Vibe finishes processing the
|
|
62
|
+
// re-mounted component subtree.
|
|
63
|
+
const waitForVibeReady = (target, timeout = 2000) => new Promise((done) => {
|
|
64
|
+
const deadline = performance.now() + timeout;
|
|
65
|
+
const tick = () => {
|
|
66
|
+
if (!target.isConnected) return done();
|
|
67
|
+
if (!target.querySelector('component[src], div.component[src]')) {
|
|
68
|
+
const walker = document.createTreeWalker(target, NodeFilter.SHOW_TEXT);
|
|
69
|
+
let pending = false;
|
|
70
|
+
let n;
|
|
71
|
+
while ((n = walker.nextNode())) {
|
|
72
|
+
if (/@\[.+?\]/.test(n.textContent)) { pending = true; break; }
|
|
73
|
+
}
|
|
74
|
+
if (!pending) return done();
|
|
75
|
+
}
|
|
76
|
+
if (performance.now() > deadline) return done();
|
|
77
|
+
requestAnimationFrame(tick);
|
|
78
|
+
};
|
|
79
|
+
requestAnimationFrame(tick);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
new MutationObserver((mutations) => {
|
|
83
|
+
// First pass: capture slot content on any <component src> that gets
|
|
84
|
+
// added dynamically (conditional/iteration branches mounting nested
|
|
85
|
+
// components). Must happen before the removal/addition logic runs, so
|
|
86
|
+
// that if a subsequent mutation re-mounts or strips this element, the
|
|
87
|
+
// snapshot is already in place.
|
|
88
|
+
for (const { addedNodes } of mutations) {
|
|
89
|
+
for (const n of addedNodes) {
|
|
90
|
+
if (isComponentSrc(n)) captureSlot(n);
|
|
91
|
+
if (n?.nodeType === 1 && n.querySelectorAll) {
|
|
92
|
+
n.querySelectorAll('component[src], div.component[src]').forEach(captureSlot);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
for (const { addedNodes, removedNodes } of mutations) {
|
|
97
|
+
let src = null;
|
|
98
|
+
let slotContent = '';
|
|
99
|
+
let hadFouc = false;
|
|
100
|
+
let props = null;
|
|
101
|
+
let scriptHash;
|
|
102
|
+
for (const n of removedNodes) {
|
|
103
|
+
if (isComponentSrc(n)) {
|
|
104
|
+
src = n.getAttribute('src');
|
|
105
|
+
// vibe's component.js deletes _vibeSlotContent synchronously after
|
|
106
|
+
// reading it, so on re-mount transitions the prop is already gone
|
|
107
|
+
// by the time we see the removal. _vibePluginSlot is our own
|
|
108
|
+
// mirror that vibe never touches — read it preferentially, fall
|
|
109
|
+
// back to vibe's prop (initial page load), then innerHTML (also
|
|
110
|
+
// initial, when source was authored inline).
|
|
111
|
+
slotContent = (
|
|
112
|
+
n._vibePluginSlot !== undefined ? n._vibePluginSlot :
|
|
113
|
+
n._vibeSlotContent !== undefined ? n._vibeSlotContent :
|
|
114
|
+
n.innerHTML
|
|
115
|
+
).trim();
|
|
116
|
+
hadFouc = n.hasAttribute('vibe-fouc');
|
|
117
|
+
// Script hash is set by remount() on the <component src> before it
|
|
118
|
+
// hits the DOM, OR carried from a previous wrapper via this same
|
|
119
|
+
// forward path. Either way, stash it so the processed wrapper can
|
|
120
|
+
// use it as baseline for future surgical reconciles.
|
|
121
|
+
scriptHash = n._vibeScriptHash;
|
|
122
|
+
// Capture all authored attrs so reconcile can detect prop changes
|
|
123
|
+
// on the callsite and re-mount when they differ. vibe-fouc is a
|
|
124
|
+
// transient HMR marker — in either attribute or class form — and
|
|
125
|
+
// must never end up in the authored set or every FOUC flip would
|
|
126
|
+
// falsely look like a prop change.
|
|
127
|
+
props = {};
|
|
128
|
+
for (const a of n.attributes) {
|
|
129
|
+
if (a.name === 'vibe-fouc') continue;
|
|
130
|
+
if (a.name === 'class') {
|
|
131
|
+
const kept = a.value.split(/\s+/).filter((t) => t && t !== 'vibe-fouc');
|
|
132
|
+
if (kept.length) props.class = kept.join(' ');
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
props[a.name] = a.value;
|
|
136
|
+
}
|
|
137
|
+
break;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
if (!src) continue;
|
|
141
|
+
for (const n of addedNodes) {
|
|
142
|
+
if (isProcessedComponent(n)) {
|
|
143
|
+
n._vibeSrc = src;
|
|
144
|
+
n._vibeSlotContent = slotContent;
|
|
145
|
+
// Brain-owned mirror so we can recover slot content after vibe's
|
|
146
|
+
// processSingle deletes _vibeSlotContent on the next re-mount.
|
|
147
|
+
n._vibePluginSlot = slotContent;
|
|
148
|
+
n._vibeProps = props;
|
|
149
|
+
if (scriptHash !== undefined) n._vibeScriptHash = scriptHash;
|
|
150
|
+
if (!liveBySrc.has(src)) liveBySrc.set(src, new Set());
|
|
151
|
+
liveBySrc.get(src).add(n);
|
|
152
|
+
if (hadFouc) {
|
|
153
|
+
n.setAttribute('vibe-fouc', '');
|
|
154
|
+
waitForVibeReady(n).then(() => n.removeAttribute('vibe-fouc'));
|
|
155
|
+
}
|
|
156
|
+
break;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}).observe(document.body, { childList: true, subtree: true });
|
|
161
|
+
|
|
162
|
+
// Cheap string hash (djb2). Used to detect <script type="module"> changes
|
|
163
|
+
// between HMR fetches — unchanged scripts mean registered component state
|
|
164
|
+
// is still valid, so we can reconcile in place instead of re-mounting.
|
|
165
|
+
const hashString = (s) => {
|
|
166
|
+
let h = 5381;
|
|
167
|
+
for (let i = 0; i < s.length; i++) h = (((h << 5) + h) + s.charCodeAt(i)) | 0;
|
|
168
|
+
return h;
|
|
169
|
+
};
|
|
170
|
+
const hashScripts = (rawHtml) => {
|
|
171
|
+
const temp = document.createElement('div');
|
|
172
|
+
temp.innerHTML = rawHtml;
|
|
173
|
+
const scripts = temp.querySelectorAll('script[type="module"]');
|
|
174
|
+
return hashString([...scripts].map((s) => s.textContent || '').join(''));
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
// Return the baseline script hash for a live wrapper. On initial mount,
|
|
178
|
+
// vibe's component.js stashes the raw fetched HTML on the wrapper as
|
|
179
|
+
// _vibeRawSource. We hash it lazily on first HMR check and cache the
|
|
180
|
+
// result. This makes the very first save after page load surgical
|
|
181
|
+
// (assuming the script body hasn't changed) rather than always falling
|
|
182
|
+
// back to re-mount.
|
|
183
|
+
const getBaselineScriptHash = (el) => {
|
|
184
|
+
if (el._vibeScriptHash !== undefined) return el._vibeScriptHash;
|
|
185
|
+
if (el._vibeRawSource) {
|
|
186
|
+
el._vibeScriptHash = hashScripts(el._vibeRawSource);
|
|
187
|
+
return el._vibeScriptHash;
|
|
188
|
+
}
|
|
189
|
+
return undefined;
|
|
190
|
+
};
|
|
191
|
+
|
|
192
|
+
// Collect data-vibe-component-id values from the live wrapper's subtree in
|
|
193
|
+
// DOM order. Script processing inside the component assigns ids in script
|
|
194
|
+
// order → DOM order (each script's sibling group follows it), so reusing
|
|
195
|
+
// ids in DOM order keeps state bindings aligned across HMR renders.
|
|
196
|
+
const collectIdsInOrder = (el) => {
|
|
197
|
+
const ids = [];
|
|
198
|
+
const seen = new Set();
|
|
199
|
+
el.querySelectorAll('[data-vibe-component-id]').forEach((node) => {
|
|
200
|
+
const id = node.getAttribute('data-vibe-component-id');
|
|
201
|
+
if (id && !seen.has(id)) { seen.add(id); ids.push(id); }
|
|
202
|
+
});
|
|
203
|
+
return ids;
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
// True when an element lives inside a vibe iteration region (between
|
|
207
|
+
// <!-- each ... --> and <!-- /each -->). Iterations materialize the
|
|
208
|
+
// template N times with pre-resolved props; iterate.js caches the template
|
|
209
|
+
// at mount time, so surgical changes to one instance would be clobbered on
|
|
210
|
+
// the next array mutation. Fall back to re-mount in that case.
|
|
211
|
+
const isInsideIteration = (el) => {
|
|
212
|
+
let cur = el;
|
|
213
|
+
while (cur && cur.parentNode) {
|
|
214
|
+
let depth = 0;
|
|
215
|
+
let sib = cur.previousSibling;
|
|
216
|
+
while (sib) {
|
|
217
|
+
if (sib.nodeType === 8) {
|
|
218
|
+
const t = sib.textContent.trim();
|
|
219
|
+
if (t === '/each') depth++;
|
|
220
|
+
else if (t.startsWith('each ')) {
|
|
221
|
+
if (depth === 0) return true;
|
|
222
|
+
depth--;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
sib = sib.previousSibling;
|
|
226
|
+
}
|
|
227
|
+
cur = cur.parentNode;
|
|
228
|
+
if (!cur || cur === document.body) return false;
|
|
229
|
+
}
|
|
230
|
+
return false;
|
|
231
|
+
};
|
|
232
|
+
|
|
233
|
+
// Re-mount a single live instance by swapping its wrapper for a fresh
|
|
234
|
+
// <component src>. Vibe re-fetches, re-executes scripts, and re-inlines
|
|
235
|
+
// the template. Used for first-HMR (no baseline hash), script changes,
|
|
236
|
+
// iteration-nested instances, and as a failure fallback.
|
|
237
|
+
const remount = (el, path, scriptHash) => {
|
|
238
|
+
const oldComponentIds = collectIdsInOrder(el);
|
|
239
|
+
const fresh = document.createElement(el.tagName);
|
|
240
|
+
if (el.tagName === 'DIV') fresh.className = 'component';
|
|
241
|
+
const props = el._vibeProps || { src: path };
|
|
242
|
+
for (const [name, value] of Object.entries(props)) {
|
|
243
|
+
fresh.setAttribute(name, value);
|
|
244
|
+
}
|
|
245
|
+
fresh.setAttribute('vibe-fouc', '');
|
|
246
|
+
const slot = el._vibePluginSlot !== undefined
|
|
247
|
+
? el._vibePluginSlot
|
|
248
|
+
: (el._vibeSlotContent || '');
|
|
249
|
+
fresh._vibeSlotContent = slot;
|
|
250
|
+
fresh._vibePluginSlot = slot;
|
|
251
|
+
fresh._vibeScriptHash = scriptHash;
|
|
252
|
+
if (oldComponentIds.length) fresh._vibeReuseComponentIds = oldComponentIds;
|
|
253
|
+
// Transfer iteration-prop registry ownership from the soon-to-be-detached
|
|
254
|
+
// wrapper to the fresh one. The detach would otherwise trigger Vibe's
|
|
255
|
+
// releaseOrphanedIterationProps and free the registry slots that the
|
|
256
|
+
// copied prop attributes (e.g. node='@[window.__vibeIterProps._pN]') still
|
|
257
|
+
// reference, leaving every binding to render undefined after the HMR swap.
|
|
258
|
+
if (el._vibeIterPropIds) {
|
|
259
|
+
fresh._vibeIterPropIds = el._vibeIterPropIds;
|
|
260
|
+
fresh.setAttribute('data-vibe-iter-prop', '');
|
|
261
|
+
el._vibeIterPropIds = null;
|
|
262
|
+
el.removeAttribute('data-vibe-iter-prop');
|
|
263
|
+
}
|
|
264
|
+
el.replaceWith(fresh);
|
|
265
|
+
};
|
|
266
|
+
|
|
267
|
+
// Component file changed. Strategy:
|
|
268
|
+
// 1. Fetch the raw template once per update; hash its <script type="module">
|
|
269
|
+
// contents. Script hash unchanged + not inside an iteration → surgical
|
|
270
|
+
// path: $.renderComponent produces the processed HTML (props + slot
|
|
271
|
+
// substituted, componentIds reused) and $.reconcile diffs it against
|
|
272
|
+
// the live wrapper's children. DOM identity, focus, and component
|
|
273
|
+
// state are preserved.
|
|
274
|
+
// 2. First HMR for any instance (no baseline hash stored), script changes,
|
|
275
|
+
// or iteration-scoped callsites → full re-mount (same path as before
|
|
276
|
+
// the surgical rewrite). vibe-fouc hides the subtree until ready.
|
|
277
|
+
//
|
|
278
|
+
// Props and slot content always come from the spy's _vibeProps /
|
|
279
|
+
// _vibePluginSlot snapshot.
|
|
280
|
+
let componentUpdateCount = 0;
|
|
281
|
+
const componentUpdate = async (path) => {
|
|
282
|
+
const n = ++componentUpdateCount;
|
|
283
|
+
// Drop the runtime's cached template for this file so any component
|
|
284
|
+
// mounted *after* this edit fetches the fresh version. Already-mounted
|
|
285
|
+
// instances are refreshed surgically below; this covers future mounts.
|
|
286
|
+
window.$?.clearComponentCache?.(path);
|
|
287
|
+
const instances = liveBySrc.get(path);
|
|
288
|
+
dbg.group('[vibe-hmr] component-update #' + n, path,
|
|
289
|
+
'— instances:', instances?.size || 0);
|
|
290
|
+
try {
|
|
291
|
+
if (!instances || !instances.size) {
|
|
292
|
+
dbg.info('[vibe-hmr] no live instances; nothing to do');
|
|
293
|
+
return;
|
|
294
|
+
}
|
|
295
|
+
const rawUrl = path + (path.includes('?') ? '&' : '?') + '_t=' + Date.now();
|
|
296
|
+
const rawHtml = await fetch(rawUrl, { cache: 'no-store' }).then((r) => r.text());
|
|
297
|
+
const scriptHash = hashScripts(rawHtml);
|
|
298
|
+
const canSurgical = typeof window.$?.renderComponent === 'function'
|
|
299
|
+
&& typeof window.$?.reconcile === 'function';
|
|
300
|
+
|
|
301
|
+
let surgical = 0;
|
|
302
|
+
let remounted = 0;
|
|
303
|
+
for (const el of [...instances]) {
|
|
304
|
+
instances.delete(el);
|
|
305
|
+
if (!el.parentNode) continue;
|
|
306
|
+
|
|
307
|
+
const oldHash = getBaselineScriptHash(el);
|
|
308
|
+
const inIter = isInsideIteration(el);
|
|
309
|
+
|
|
310
|
+
if (canSurgical && oldHash !== undefined && oldHash === scriptHash && !inIter) {
|
|
311
|
+
try {
|
|
312
|
+
const componentIds = collectIdsInOrder(el);
|
|
313
|
+
const slot = el._vibePluginSlot !== undefined
|
|
314
|
+
? el._vibePluginSlot
|
|
315
|
+
: (el._vibeSlotContent || '');
|
|
316
|
+
const props = el._vibeProps || {};
|
|
317
|
+
const processedHtml = window.$.renderComponent(rawHtml, props, slot, { componentIds });
|
|
318
|
+
const summary = await window.$.reconcile(el, processedHtml);
|
|
319
|
+
el._vibeScriptHash = scriptHash;
|
|
320
|
+
el._vibeRawSource = rawHtml;
|
|
321
|
+
if (!liveBySrc.has(path)) liveBySrc.set(path, new Set());
|
|
322
|
+
liveBySrc.get(path).add(el);
|
|
323
|
+
surgical++;
|
|
324
|
+
dbg.info('[vibe-hmr] surgical reconcile:', summary);
|
|
325
|
+
continue;
|
|
326
|
+
} catch (err) {
|
|
327
|
+
console.warn('[vibe-hmr] surgical failed, falling back to re-mount:', err);
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
remount(el, path, scriptHash);
|
|
332
|
+
remounted++;
|
|
333
|
+
}
|
|
334
|
+
dbg.info('[vibe-hmr] surgical:', surgical, 'remounted:', remounted);
|
|
335
|
+
} catch (err) {
|
|
336
|
+
console.error('[vibe-hmr] component-update failed:', err);
|
|
337
|
+
} finally {
|
|
338
|
+
dbg.groupEnd();
|
|
339
|
+
}
|
|
340
|
+
};
|
|
341
|
+
|
|
342
|
+
// Page file changed — fetch new HTML and hand the [vibe] root + new inner
|
|
343
|
+
// content to $.reconcile. Vibe walks live vs. source and applies the
|
|
344
|
+
// minimal mutation; iteration / conditional / component regions are
|
|
345
|
+
// opaque (their interiors are state-driven). Page-level JS state on $
|
|
346
|
+
// is preserved (no full reload).
|
|
347
|
+
let pageUpdateCount = 0;
|
|
348
|
+
const pageUpdate = async (payload) => {
|
|
349
|
+
const n = ++pageUpdateCount;
|
|
350
|
+
dbg.group('[vibe-hmr] page-update #' + n, payload?.path || '(no path)');
|
|
351
|
+
try {
|
|
352
|
+
if (!window.$ || typeof window.$.reconcile !== 'function') {
|
|
353
|
+
console.warn('[vibe-hmr] $.reconcile not available — skipped');
|
|
354
|
+
return;
|
|
355
|
+
}
|
|
356
|
+
const url = location.href + (location.href.includes('?') ? '&' : '?') + '_t=' + Date.now();
|
|
357
|
+
const t0 = performance.now();
|
|
358
|
+
const html = await fetch(url).then((r) => r.text());
|
|
359
|
+
dbg.info('[vibe-hmr] fetched', html.length, 'bytes in', (performance.now() - t0).toFixed(1) + 'ms');
|
|
360
|
+
const doc = new DOMParser().parseFromString(html, 'text/html');
|
|
361
|
+
const newRoot = doc.querySelector('[vibe]') || doc.body;
|
|
362
|
+
const liveRoot = document.querySelector('[vibe]') || document.body;
|
|
363
|
+
dbg.info('[vibe-hmr] target [vibe] root:', liveRoot, 'isConnected=', liveRoot.isConnected);
|
|
364
|
+
// Note: we no longer strip <script type='module'> from source. Stripping
|
|
365
|
+
// misaligned source vs. live and caused cascading replaces. Reconcile's
|
|
366
|
+
// tag-aligned walk now matches scripts at their position; updating a
|
|
367
|
+
// script's textContent doesn't re-execute it (a known limitation —
|
|
368
|
+
// editing inline page scripts requires a real reload to take effect).
|
|
369
|
+
const summary = await window.$.reconcile(liveRoot, newRoot.innerHTML);
|
|
370
|
+
dbg.info('[vibe-hmr] summary:', summary);
|
|
371
|
+
if (summary?.changes?.length) {
|
|
372
|
+
for (const c of summary.changes) dbg.info('[vibe-hmr] ·', c);
|
|
373
|
+
} else {
|
|
374
|
+
dbg.info('[vibe-hmr] no changes — source matches live');
|
|
375
|
+
}
|
|
376
|
+
} catch (err) {
|
|
377
|
+
console.error('[vibe-hmr] page-update failed:', err);
|
|
378
|
+
} finally {
|
|
379
|
+
dbg.groupEnd();
|
|
380
|
+
}
|
|
381
|
+
};
|
|
382
|
+
|
|
383
|
+
subscribe({ componentUpdate, pageUpdate });
|
|
384
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ape-egg/vibe",
|
|
3
|
-
"version": "2.1.
|
|
3
|
+
"version": "2.1.20",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Runtime-first reactivity with optional compiler",
|
|
6
6
|
"main": "index.js",
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
".": "./index.js",
|
|
10
10
|
"./boot": "./boot.js",
|
|
11
11
|
"./component": "./component.js",
|
|
12
|
+
"./hot-module-refresh": "./hot-module-refresh.js",
|
|
12
13
|
"./runtime": "./runtime/index.js",
|
|
13
14
|
"./compiler": "./compiler/bin/vibe-compile.js"
|
|
14
15
|
},
|