bimba-cli 0.5.5 → 0.5.7
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/package.json +1 -1
- package/serve.js +151 -42
package/package.json
CHANGED
package/serve.js
CHANGED
|
@@ -25,10 +25,10 @@ const hmrClient = `
|
|
|
25
25
|
//
|
|
26
26
|
const _origDefine = customElements.define.bind(customElements);
|
|
27
27
|
const _classes = new Map(); // tagName → first-registered constructor
|
|
28
|
-
let
|
|
28
|
+
let _collector = null; // when set, captures tag names defined during one HMR import
|
|
29
29
|
|
|
30
30
|
customElements.define = function(name, cls, opts) {
|
|
31
|
-
|
|
31
|
+
if (_collector) _collector.push(name);
|
|
32
32
|
const existing = customElements.get(name);
|
|
33
33
|
if (!existing) {
|
|
34
34
|
_origDefine(name, cls, opts);
|
|
@@ -62,35 +62,66 @@ const hmrClient = `
|
|
|
62
62
|
|
|
63
63
|
// ── HMR update handler ─────────────────────────────────────────────────────
|
|
64
64
|
|
|
65
|
-
|
|
65
|
+
// Updates are serialized via a promise queue. Without this, two file edits
|
|
66
|
+
// arriving back-to-back would race on the shared collector and on imba's
|
|
67
|
+
// reconcile loop, with the second update potentially missing tags from
|
|
68
|
+
// the first.
|
|
69
|
+
let _queue = Promise.resolve();
|
|
70
|
+
|
|
71
|
+
function _applyUpdate(file, slots) {
|
|
72
|
+
_queue = _queue.then(() => _doUpdate(file, slots)).catch(err => {
|
|
73
|
+
console.error('[bimba HMR]', err);
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async function _doUpdate(file, slots) {
|
|
66
78
|
clearError();
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
79
|
+
|
|
80
|
+
// Snapshot direct body children BEFORE importing the new module, so we
|
|
81
|
+
// know which elements pre-existed. After commit we only dedupe NEW
|
|
82
|
+
// elements whose tag also existed before — this preserves legitimate
|
|
83
|
+
// multi-instance roots like toasts and parallel popups, while still
|
|
84
|
+
// catching accidental re-mounts from re-running top-level code.
|
|
85
|
+
const bodyBefore = new Set(document.body.children);
|
|
86
|
+
const tagsBefore = new Set();
|
|
87
|
+
for (const el of bodyBefore) tagsBefore.add(el.tagName.toLowerCase());
|
|
88
|
+
|
|
89
|
+
// Use a local collector instead of a shared variable so concurrent
|
|
90
|
+
// imports can't clobber each other's tag lists.
|
|
91
|
+
const collected = [];
|
|
92
|
+
const prev = _collector;
|
|
93
|
+
_collector = collected;
|
|
94
|
+
try {
|
|
95
|
+
await import('/' + file + '?t=' + Date.now());
|
|
96
|
+
} finally {
|
|
97
|
+
_collector = prev;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Two HMR paths depending on whether render-cache slot symbols are
|
|
101
|
+
// stable across this re-import:
|
|
102
|
+
//
|
|
103
|
+
// 'stable': server-side symbol stabilization made the new module's
|
|
104
|
+
// anonymous Symbols identical (by reference) to the previous
|
|
105
|
+
// compilation. Live element instances still have valid slot
|
|
106
|
+
// references → imba's renderer will diff and update the existing
|
|
107
|
+
// DOM in place. We just patch class prototypes (already done in
|
|
108
|
+
// the customElements.define hook above) and call imba.commit().
|
|
109
|
+
// No DOM destruction, full inner state preserved.
|
|
110
|
+
//
|
|
111
|
+
// 'shifted': slot count changed (user added/removed elements), so
|
|
112
|
+
// stabilization can't safely reuse symbols. Fall back to the
|
|
113
|
+
// destructive path: snapshot own enumerable properties, wipe
|
|
114
|
+
// ANONYMOUS symbol slots only (preserve global Symbol.for keys
|
|
115
|
+
// that imba's runtime uses for lifecycle), clear innerHTML,
|
|
116
|
+
// restore state, re-render. Loses inner DOM state for instances
|
|
117
|
+
// of the patched tags, but preserves their instance fields.
|
|
118
|
+
if (slots === 'shifted') {
|
|
119
|
+
for (const tag of collected) {
|
|
90
120
|
document.querySelectorAll(tag).forEach(el => {
|
|
91
121
|
const state = {};
|
|
92
122
|
for (const k of Object.keys(el)) state[k] = el[k];
|
|
93
123
|
for (const sym of Object.getOwnPropertySymbols(el)) {
|
|
124
|
+
if (Symbol.keyFor(sym) !== undefined) continue;
|
|
94
125
|
try { delete el[sym]; } catch(_) {}
|
|
95
126
|
}
|
|
96
127
|
el.innerHTML = '';
|
|
@@ -98,19 +129,58 @@ const hmrClient = `
|
|
|
98
129
|
try { el.render && el.render(); } catch(_) {}
|
|
99
130
|
});
|
|
100
131
|
}
|
|
132
|
+
}
|
|
101
133
|
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
134
|
+
if (typeof imba !== 'undefined') imba.commit();
|
|
135
|
+
|
|
136
|
+
// Smart body dedupe: remove only elements that were ADDED during this
|
|
137
|
+
// HMR cycle and whose tag already existed in body before. This catches
|
|
138
|
+
// accidental re-mounts from top-level imba.mount() re-runs, but
|
|
139
|
+
// preserves toasts, multiple modals, and devtools-injected siblings.
|
|
140
|
+
for (const el of [...document.body.children]) {
|
|
141
|
+
if (bodyBefore.has(el)) continue;
|
|
142
|
+
if (tagsBefore.has(el.tagName.toLowerCase())) el.remove();
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Reap orphaned imba style blocks. Each compilation that produces a
|
|
146
|
+
// different content hash leaves behind a <style> whose rules target
|
|
147
|
+
// classnames no element in the DOM uses anymore. Walk our tracked
|
|
148
|
+
// styles and drop the unused ones — keeps head clean and removes
|
|
149
|
+
// stale rules that would otherwise still apply to live elements with
|
|
150
|
+
// matching classnames (e.g. a "stuck" old text color).
|
|
151
|
+
_reapStyles();
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// ── Style reaper ───────────────────────────────────────────────────────────
|
|
155
|
+
|
|
156
|
+
// imba_styles.register inserts <style id="_<hash>"> blocks into <head>.
|
|
157
|
+
// We can't intercept register directly (it's inside the imba runtime), so
|
|
158
|
+
// we walk all <style> elements with imba-style ids and check whether any
|
|
159
|
+
// element in the document still uses one of the classnames declared in
|
|
160
|
+
// the stylesheet. If not, the block is dead and gets removed.
|
|
161
|
+
function _reapStyles() {
|
|
162
|
+
const styles = document.head.querySelectorAll('style[id^="_"]');
|
|
163
|
+
for (const style of styles) {
|
|
164
|
+
try {
|
|
165
|
+
const sheet = style.sheet;
|
|
166
|
+
if (!sheet) continue;
|
|
167
|
+
let used = false;
|
|
168
|
+
// Sample a few class selectors and check the document for them.
|
|
169
|
+
const probes = [];
|
|
170
|
+
for (const rule of sheet.cssRules) {
|
|
171
|
+
if (probes.length >= 4) break;
|
|
172
|
+
const sel = rule.selectorText;
|
|
173
|
+
if (!sel) continue;
|
|
174
|
+
const m = sel.match(/\.(z[a-z0-9_-]+)/i);
|
|
175
|
+
if (m) probes.push(m[1]);
|
|
176
|
+
}
|
|
177
|
+
if (!probes.length) continue;
|
|
178
|
+
for (const cls of probes) {
|
|
179
|
+
if (document.querySelector('.' + CSS.escape(cls))) { used = true; break; }
|
|
180
|
+
}
|
|
181
|
+
if (!used) style.remove();
|
|
182
|
+
} catch(_) { /* cross-origin or detached */ }
|
|
183
|
+
}
|
|
114
184
|
}
|
|
115
185
|
|
|
116
186
|
// ── WebSocket connection ───────────────────────────────────────────────────
|
|
@@ -128,7 +198,7 @@ const hmrClient = `
|
|
|
128
198
|
|
|
129
199
|
ws.onmessage = (e) => {
|
|
130
200
|
const msg = JSON.parse(e.data);
|
|
131
|
-
if (msg.type === 'update') _applyUpdate(msg.file);
|
|
201
|
+
if (msg.type === 'update') _applyUpdate(msg.file, msg.slots);
|
|
132
202
|
else if (msg.type === 'reload') location.reload();
|
|
133
203
|
else if (msg.type === 'error') showError(msg.file, msg.errors);
|
|
134
204
|
else if (msg.type === 'clear-error') clearError();
|
|
@@ -177,6 +247,37 @@ const hmrClient = `
|
|
|
177
247
|
|
|
178
248
|
const _compileCache = new Map() // filepath → { mtime, result }
|
|
179
249
|
const _prevJs = new Map() // filepath → compiled js — for change detection
|
|
250
|
+
const _prevSlots = new Map() // filepath → previous symbol slot count
|
|
251
|
+
|
|
252
|
+
// Imba compiles tag render-cache slots as anonymous local Symbols at module top
|
|
253
|
+
// level: `var $4 = Symbol(), $11 = Symbol(), ...; let c$0 = Symbol();`. Each
|
|
254
|
+
// re-import of the file creates fresh Symbol objects, so old slot data on live
|
|
255
|
+
// element instances no longer matches the new render's keys, and imba's diff
|
|
256
|
+
// can't reuse cached children — it appends new ones, causing duplication.
|
|
257
|
+
//
|
|
258
|
+
// We rewrite each `<name> = Symbol()` clause so that the Symbol is read from a
|
|
259
|
+
// per-file global cache, keyed by the variable name. On the first compilation
|
|
260
|
+
// the cache is populated; on every subsequent compilation the same Symbol
|
|
261
|
+
// objects are reused, slot keys stay stable, and imba's renderer happily
|
|
262
|
+
// diff-updates existing DOM in place.
|
|
263
|
+
//
|
|
264
|
+
// Caveat: stability is keyed by name. If the user adds/removes elements in the
|
|
265
|
+
// template, slot indices shift and the same name now points to a semantically
|
|
266
|
+
// different slot. We detect this by counting slots — if the count changes vs
|
|
267
|
+
// the previous compilation, we mark the file `slots: 'shifted'` and the client
|
|
268
|
+
// falls back to the destructive wipe-and-render path. Pure CSS/text edits keep
|
|
269
|
+
// counts unchanged → true in-place HMR.
|
|
270
|
+
function stabilizeSymbols(js, filepath) {
|
|
271
|
+
let count = 0
|
|
272
|
+
const out = js.replace(
|
|
273
|
+
/([A-Za-z_$][\w$]*)\s*=\s*Symbol\(\)/g,
|
|
274
|
+
(_m, name) => { count++; return `${name} = (__bsyms__[${JSON.stringify(name)}] ||= Symbol())` }
|
|
275
|
+
)
|
|
276
|
+
if (count === 0) return { js, slotCount: 0 }
|
|
277
|
+
const fileKey = JSON.stringify(filepath)
|
|
278
|
+
const bootstrap = `const __bsyms__ = ((globalThis.__bimba_syms ||= {})[${fileKey}] ||= {});\n`
|
|
279
|
+
return { js: bootstrap + out, slotCount: count }
|
|
280
|
+
}
|
|
180
281
|
|
|
181
282
|
async function compileFile(filepath) {
|
|
182
283
|
const file = Bun.file(filepath)
|
|
@@ -184,7 +285,7 @@ async function compileFile(filepath) {
|
|
|
184
285
|
const mtime = stat.mtime.getTime()
|
|
185
286
|
|
|
186
287
|
const cached = _compileCache.get(filepath)
|
|
187
|
-
if (cached && cached.mtime === mtime) return { ...cached.result, changeType: 'cached' }
|
|
288
|
+
if (cached && cached.mtime === mtime) return { ...cached.result, changeType: 'cached', slots: cached.slots }
|
|
188
289
|
|
|
189
290
|
const code = await file.text()
|
|
190
291
|
const result = compiler.compile(code, {
|
|
@@ -193,9 +294,17 @@ async function compileFile(filepath) {
|
|
|
193
294
|
sourcemap: 'inline',
|
|
194
295
|
})
|
|
195
296
|
|
|
297
|
+
if (!result.errors?.length && result.js) {
|
|
298
|
+
const { js, slotCount } = stabilizeSymbols(result.js, filepath)
|
|
299
|
+
result.js = js
|
|
300
|
+
const prev = _prevSlots.get(filepath)
|
|
301
|
+
result.slots = (prev === undefined || prev === slotCount) ? 'stable' : 'shifted'
|
|
302
|
+
_prevSlots.set(filepath, slotCount)
|
|
303
|
+
}
|
|
304
|
+
|
|
196
305
|
const changeType = _prevJs.get(filepath) === result.js ? 'none' : 'full'
|
|
197
306
|
_prevJs.set(filepath, result.js)
|
|
198
|
-
_compileCache.set(filepath, { mtime, result })
|
|
307
|
+
_compileCache.set(filepath, { mtime, result, slots: result.slots })
|
|
199
308
|
return { ...result, changeType }
|
|
200
309
|
}
|
|
201
310
|
|
|
@@ -351,7 +460,7 @@ export function serve(entrypoint, flags) {
|
|
|
351
460
|
|
|
352
461
|
printStatus(rel, 'ok')
|
|
353
462
|
broadcast({ type: 'clear-error' })
|
|
354
|
-
broadcast({ type: 'update', file: rel })
|
|
463
|
+
broadcast({ type: 'update', file: rel, slots: out.slots || 'shifted' })
|
|
355
464
|
} catch(e) {
|
|
356
465
|
printStatus(rel, 'fail', [{ message: e.message }])
|
|
357
466
|
broadcast({ type: 'error', file: rel, errors: [{ message: e.message, snippet: e.stack || e.message }] })
|