bimba-cli 0.5.5 → 0.5.6

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 +74 -30
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bimba-cli",
3
- "version": "0.5.5",
3
+ "version": "0.5.6",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/HeapVoid/bimba.git"
package/serve.js CHANGED
@@ -62,7 +62,7 @@ const hmrClient = `
62
62
 
63
63
  // ── HMR update handler ─────────────────────────────────────────────────────
64
64
 
65
- function _applyUpdate(file) {
65
+ function _applyUpdate(file, slots) {
66
66
  clearError();
67
67
  _hotTags = [];
68
68
 
@@ -70,33 +70,38 @@ const hmrClient = `
70
70
  const tags = _hotTags;
71
71
  _hotTags = [];
72
72
 
73
- // For each tag that was just (re)defined, find all live instances
74
- // and reset their imba render-state.
73
+ // Two HMR paths depending on whether render-cache slot symbols are
74
+ // stable across this re-import:
75
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.
76
+ // 'stable': server-side symbol stabilization made the new module's
77
+ // anonymous Symbols identical (by reference) to the previous
78
+ // compilation. Live element instances still have valid slot
79
+ // references imba's renderer will diff and update the existing
80
+ // DOM in place. We just patch class prototypes (already done in
81
+ // the customElements.define hook above) and call imba.commit().
82
+ // No DOM destruction, full inner state preserved.
82
83
  //
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) {
90
- document.querySelectorAll(tag).forEach(el => {
91
- const state = {};
92
- for (const k of Object.keys(el)) state[k] = el[k];
93
- for (const sym of Object.getOwnPropertySymbols(el)) {
94
- try { delete el[sym]; } catch(_) {}
95
- }
96
- el.innerHTML = '';
97
- Object.assign(el, state);
98
- try { el.render && el.render(); } catch(_) {}
99
- });
84
+ // 'shifted': slot count changed (user added/removed elements), so
85
+ // stabilization can't safely reuse symbols. Fall back to the
86
+ // destructive path: snapshot own enumerable properties, wipe
87
+ // ANONYMOUS symbol slots only (preserve global Symbol.for keys
88
+ // that imba's runtime uses for lifecycle), clear innerHTML,
89
+ // restore state, re-render. Loses inner DOM state for instances
90
+ // of the patched tags, but preserves their instance fields.
91
+ if (slots === 'shifted') {
92
+ for (const tag of tags) {
93
+ document.querySelectorAll(tag).forEach(el => {
94
+ const state = {};
95
+ for (const k of Object.keys(el)) state[k] = el[k];
96
+ for (const sym of Object.getOwnPropertySymbols(el)) {
97
+ if (Symbol.keyFor(sym) !== undefined) continue;
98
+ try { delete el[sym]; } catch(_) {}
99
+ }
100
+ el.innerHTML = '';
101
+ Object.assign(el, state);
102
+ try { el.render && el.render(); } catch(_) {}
103
+ });
104
+ }
100
105
  }
101
106
 
102
107
  if (typeof imba !== 'undefined') imba.commit();
@@ -128,7 +133,7 @@ const hmrClient = `
128
133
 
129
134
  ws.onmessage = (e) => {
130
135
  const msg = JSON.parse(e.data);
131
- if (msg.type === 'update') _applyUpdate(msg.file);
136
+ if (msg.type === 'update') _applyUpdate(msg.file, msg.slots);
132
137
  else if (msg.type === 'reload') location.reload();
133
138
  else if (msg.type === 'error') showError(msg.file, msg.errors);
134
139
  else if (msg.type === 'clear-error') clearError();
@@ -177,6 +182,37 @@ const hmrClient = `
177
182
 
178
183
  const _compileCache = new Map() // filepath → { mtime, result }
179
184
  const _prevJs = new Map() // filepath → compiled js — for change detection
185
+ const _prevSlots = new Map() // filepath → previous symbol slot count
186
+
187
+ // Imba compiles tag render-cache slots as anonymous local Symbols at module top
188
+ // level: `var $4 = Symbol(), $11 = Symbol(), ...; let c$0 = Symbol();`. Each
189
+ // re-import of the file creates fresh Symbol objects, so old slot data on live
190
+ // element instances no longer matches the new render's keys, and imba's diff
191
+ // can't reuse cached children — it appends new ones, causing duplication.
192
+ //
193
+ // We rewrite each `<name> = Symbol()` clause so that the Symbol is read from a
194
+ // per-file global cache, keyed by the variable name. On the first compilation
195
+ // the cache is populated; on every subsequent compilation the same Symbol
196
+ // objects are reused, slot keys stay stable, and imba's renderer happily
197
+ // diff-updates existing DOM in place.
198
+ //
199
+ // Caveat: stability is keyed by name. If the user adds/removes elements in the
200
+ // template, slot indices shift and the same name now points to a semantically
201
+ // different slot. We detect this by counting slots — if the count changes vs
202
+ // the previous compilation, we mark the file `slots: 'shifted'` and the client
203
+ // falls back to the destructive wipe-and-render path. Pure CSS/text edits keep
204
+ // counts unchanged → true in-place HMR.
205
+ function stabilizeSymbols(js, filepath) {
206
+ let count = 0
207
+ const out = js.replace(
208
+ /([A-Za-z_$][\w$]*)\s*=\s*Symbol\(\)/g,
209
+ (_m, name) => { count++; return `${name} = (__bsyms__[${JSON.stringify(name)}] ||= Symbol())` }
210
+ )
211
+ if (count === 0) return { js, slotCount: 0 }
212
+ const fileKey = JSON.stringify(filepath)
213
+ const bootstrap = `const __bsyms__ = ((globalThis.__bimba_syms ||= {})[${fileKey}] ||= {});\n`
214
+ return { js: bootstrap + out, slotCount: count }
215
+ }
180
216
 
181
217
  async function compileFile(filepath) {
182
218
  const file = Bun.file(filepath)
@@ -184,7 +220,7 @@ async function compileFile(filepath) {
184
220
  const mtime = stat.mtime.getTime()
185
221
 
186
222
  const cached = _compileCache.get(filepath)
187
- if (cached && cached.mtime === mtime) return { ...cached.result, changeType: 'cached' }
223
+ if (cached && cached.mtime === mtime) return { ...cached.result, changeType: 'cached', slots: cached.slots }
188
224
 
189
225
  const code = await file.text()
190
226
  const result = compiler.compile(code, {
@@ -193,9 +229,17 @@ async function compileFile(filepath) {
193
229
  sourcemap: 'inline',
194
230
  })
195
231
 
232
+ if (!result.errors?.length && result.js) {
233
+ const { js, slotCount } = stabilizeSymbols(result.js, filepath)
234
+ result.js = js
235
+ const prev = _prevSlots.get(filepath)
236
+ result.slots = (prev === undefined || prev === slotCount) ? 'stable' : 'shifted'
237
+ _prevSlots.set(filepath, slotCount)
238
+ }
239
+
196
240
  const changeType = _prevJs.get(filepath) === result.js ? 'none' : 'full'
197
241
  _prevJs.set(filepath, result.js)
198
- _compileCache.set(filepath, { mtime, result })
242
+ _compileCache.set(filepath, { mtime, result, slots: result.slots })
199
243
  return { ...result, changeType }
200
244
  }
201
245
 
@@ -351,7 +395,7 @@ export function serve(entrypoint, flags) {
351
395
 
352
396
  printStatus(rel, 'ok')
353
397
  broadcast({ type: 'clear-error' })
354
- broadcast({ type: 'update', file: rel })
398
+ broadcast({ type: 'update', file: rel, slots: out.slots || 'shifted' })
355
399
  } catch(e) {
356
400
  printStatus(rel, 'fail', [{ message: e.message }])
357
401
  broadcast({ type: 'error', file: rel, errors: [{ message: e.message, snippet: e.stack || e.message }] })