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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/serve.js +151 -42
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bimba-cli",
3
- "version": "0.5.5",
3
+ "version": "0.5.7",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/HeapVoid/bimba.git"
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 _hotTags = []; // tags defined during the current hot import
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
- _hotTags.push(name);
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
- function _applyUpdate(file) {
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
- _hotTags = [];
68
-
69
- import('/' + file + '?t=' + Date.now()).then(() => {
70
- const tags = _hotTags;
71
- _hotTags = [];
72
-
73
- // For each tag that was just (re)defined, find all live instances
74
- // and reset their imba render-state.
75
- //
76
- // Why this is needed: imba stores cached references to rendered
77
- // child slots on the element instance under Symbol keys. Each
78
- // compilation of a .imba file creates a NEW set of Symbols, so
79
- // after HMR the new render() walks new keys, finds nothing, and
80
- // APPENDS a fresh subtree alongside the stale one — visible as a
81
- // duplicated popup/section inside the same tag.
82
- //
83
- // Fix: snapshot own enumerable properties (instance state like
84
- // "opened", "data", etc.), wipe Symbol-keyed slots and innerHTML,
85
- // restore instance state, then re-render. The DOM is rebuilt for
86
- // the patched tag only, but instance state (and therefore "the
87
- // popup is open with these props") is preserved. Other tags are
88
- // untouched, so unrelated popups/dropdowns keep their state.
89
- for (const tag of tags) {
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
- if (typeof imba !== 'undefined') imba.commit();
103
-
104
- // Dedupe direct body children by tag handles re-importing
105
- // top-level code that calls imba.mount() again, which would
106
- // append a second copy of the root tag.
107
- const seen = new Set();
108
- [...document.body.children].forEach(el => {
109
- const tag = el.tagName.toLowerCase();
110
- if (seen.has(tag)) el.remove();
111
- else seen.add(tag);
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 }] })