@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 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.19",
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
  },