bimba-cli 0.5.4 → 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 +82 -21
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bimba-cli",
3
- "version": "0.5.4",
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,31 +62,53 @@ 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
 
69
69
  import('/' + file + '?t=' + Date.now()).then(() => {
70
+ const tags = _hotTags;
70
71
  _hotTags = [];
71
72
 
72
- // Let Imba re-render in place from the patched prototypes. We do NOT
73
- // touch instance DOM (no innerHTML reset, no symbol cleanup) — that
74
- // would destroy rendered children like open popups / dropdowns and
75
- // collapse any transient UI state. Prototype patching already makes
76
- // the next render use the new methods; imba.commit() triggers it.
73
+ // Two HMR paths depending on whether render-cache slot symbols are
74
+ // stable across this re-import:
75
+ //
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.
83
+ //
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
+ }
105
+ }
106
+
77
107
  if (typeof imba !== 'undefined') imba.commit();
78
108
 
79
- // Dedupe direct body children by tag — runs AFTER commit so it
80
- // catches both:
81
- // 1) Re-importing top-level code that calls imba.mount() again
82
- // (appends a second root tag to body).
83
- // 2) Detached popups: a popup uses imba.mount(self) to reparent
84
- // itself to body, so on commit the parent re-renders, doesn't
85
- // see the popup among its children, and creates a brand-new
86
- // instance which gets appended to body. The original (with
87
- // its state) is first in body.children; the freshly-created
88
- // duplicate is last — removing later occurrences preserves
89
- // state.
109
+ // Dedupe direct body children by tag — handles re-importing
110
+ // top-level code that calls imba.mount() again, which would
111
+ // append a second copy of the root tag.
90
112
  const seen = new Set();
91
113
  [...document.body.children].forEach(el => {
92
114
  const tag = el.tagName.toLowerCase();
@@ -111,7 +133,7 @@ const hmrClient = `
111
133
 
112
134
  ws.onmessage = (e) => {
113
135
  const msg = JSON.parse(e.data);
114
- if (msg.type === 'update') _applyUpdate(msg.file);
136
+ if (msg.type === 'update') _applyUpdate(msg.file, msg.slots);
115
137
  else if (msg.type === 'reload') location.reload();
116
138
  else if (msg.type === 'error') showError(msg.file, msg.errors);
117
139
  else if (msg.type === 'clear-error') clearError();
@@ -160,6 +182,37 @@ const hmrClient = `
160
182
 
161
183
  const _compileCache = new Map() // filepath → { mtime, result }
162
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
+ }
163
216
 
164
217
  async function compileFile(filepath) {
165
218
  const file = Bun.file(filepath)
@@ -167,7 +220,7 @@ async function compileFile(filepath) {
167
220
  const mtime = stat.mtime.getTime()
168
221
 
169
222
  const cached = _compileCache.get(filepath)
170
- if (cached && cached.mtime === mtime) return { ...cached.result, changeType: 'cached' }
223
+ if (cached && cached.mtime === mtime) return { ...cached.result, changeType: 'cached', slots: cached.slots }
171
224
 
172
225
  const code = await file.text()
173
226
  const result = compiler.compile(code, {
@@ -176,9 +229,17 @@ async function compileFile(filepath) {
176
229
  sourcemap: 'inline',
177
230
  })
178
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
+
179
240
  const changeType = _prevJs.get(filepath) === result.js ? 'none' : 'full'
180
241
  _prevJs.set(filepath, result.js)
181
- _compileCache.set(filepath, { mtime, result })
242
+ _compileCache.set(filepath, { mtime, result, slots: result.slots })
182
243
  return { ...result, changeType }
183
244
  }
184
245
 
@@ -334,7 +395,7 @@ export function serve(entrypoint, flags) {
334
395
 
335
396
  printStatus(rel, 'ok')
336
397
  broadcast({ type: 'clear-error' })
337
- broadcast({ type: 'update', file: rel })
398
+ broadcast({ type: 'update', file: rel, slots: out.slots || 'shifted' })
338
399
  } catch(e) {
339
400
  printStatus(rel, 'fail', [{ message: e.message }])
340
401
  broadcast({ type: 'error', file: rel, errors: [{ message: e.message, snippet: e.stack || e.message }] })