bimba-cli 0.7.8 → 0.7.9

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/INTERNALS.md ADDED
@@ -0,0 +1,300 @@
1
+ # Bimba Internals: How Imba Rendering Works and How Bimba HMR Hooks Into It
2
+
3
+ Technical reference for debugging and extending bimba's dev server (`serve.js`).
4
+
5
+ ---
6
+
7
+ ## 1. How Imba Compiles Tags to JS
8
+
9
+ Imba source:
10
+ ```imba
11
+ tag my-popup
12
+ name = ''
13
+
14
+ def mount
15
+ name = 'hello'
16
+
17
+ <self @click.self=(emit('close'))>
18
+ <div.dialog>
19
+ <span.title> "Settings"
20
+ if condition
21
+ <img src=url>
22
+ else
23
+ <span.placeholder> "?"
24
+ <button @click=save> "Save"
25
+ ```
26
+
27
+ Compiled JS (simplified):
28
+ ```js
29
+ import { Component, defineTag, createElement, createComponent, ... } from 'imba';
30
+ const $beforeReconcile$ = Symbol.for('#beforeReconcile');
31
+ const $afterVisit$ = Symbol.for('#afterVisit');
32
+ const $placeChild$ = Symbol.for('#placeChild');
33
+ const $$up$ = Symbol.for('##up');
34
+
35
+ // Anonymous Symbols — one per DOM slot in the render tree.
36
+ // These are the RENDER CACHE KEYS.
37
+ var $7 = Symbol(), $11 = Symbol(), $13 = Symbol(), $19 = Symbol(), $24 = Symbol(), ...;
38
+ let c$0 = Symbol(); // class identity symbol
39
+
40
+ class MyPopupComponent extends Component {
41
+ [__init__$]($$ = null) {
42
+ super[__init__$](...arguments);
43
+ this.name = ($$ && $$.name !== undefined) ? $$.name : '';
44
+ }
45
+
46
+ mount() { this.name = 'hello'; }
47
+
48
+ render() {
49
+ var $4, $5, $6, $8 = this._ns_ || '', $9, $18, ...;
50
+ $4 = this; // $4 = the element itself
51
+ $4[$beforeReconcile$]();
52
+
53
+ // ── "First render" check ──
54
+ // $7 is a Symbol. this[$7] is stored on the INSTANCE.
55
+ // First render: this[$7] is undefined → $5=0 (CREATE mode)
56
+ // Re-render: this[$7] === 1 → $5=1 (REUSE mode)
57
+ ($5=$6=1, $4[$7] === 1) || ($5=$6=0, $4[$7] = 1);
58
+
59
+ // ── Static children: created only on first render ($5=0) ──
60
+ $5 || ($4.on$('click', {self: true, ...}));
61
+ $5 || ($9 = createElement('div', $4, `dialog ${$8}`, null));
62
+ // ↑ createElement appends $9 as child of $4
63
+
64
+ // ── Cached children: checked on every render ──
65
+ ($10 = $4[$11]) || ($4[$11] = $10 = createElement('span', $9, ...));
66
+ // ↑ try cache ↑ miss → create and cache
67
+
68
+ // ── Conditional blocks ──
69
+ $18 = null;
70
+ if (this.condition) {
71
+ ($20=$21=1, $18=$4[$19]) || ($20=$21=0, $4[$19]=$18=createElement('img',...));
72
+ } else {
73
+ ($25=$26=1, $18=$4[$24]) || ($25=$26=0, $4[$24]=$18=createElement('span',...));
74
+ }
75
+ // placeChild manages which branch is in the DOM
76
+ ($4[$30] = $16[$placeChild$]($18, 0, $4[$30]));
77
+
78
+ $4[$afterReconcile$]($6);
79
+ return $4;
80
+ }
81
+
82
+ // Static block runs at class definition time
83
+ static {
84
+ register$(this, c$0, 'my-popup', 2); // → calls customElements.define
85
+ defineTag('my-popup', this, {cssns: 'z1abc_xy', cssid: 'z1abc-xy'});
86
+ }
87
+ }
88
+
89
+ // CSS is registered as a global stylesheet
90
+ imba_styles.register('z1abc', "...");
91
+ ```
92
+
93
+ ### Key points
94
+
95
+ | Concept | Details |
96
+ |---------|---------|
97
+ | **Render cache** | Each DOM node is cached on the element instance under an anonymous `Symbol()` key. `this[$sym] \|\| (this[$sym] = createElement(...))`. |
98
+ | **Create vs Reuse** | `this[$7] === 1` is the master flag. `$5=0` = first render (create all), `$5=1` = re-render (reuse cached). |
99
+ | **Static children** | Guarded by `$5 \|\| (...)` — created only on first render, never recreated. |
100
+ | **Conditional children** | Each branch has its own cache slot (`$19` for `if`, `$24` for `else`). `$placeChild$` swaps them in/out. |
101
+ | **CSS namespace** | `_ns_` on prototype (e.g. `"z1abc_xy "`). Used as className prefix. Hash changes when CSS content changes. |
102
+ | **Tag registration** | `register$` → `customElements.define()`. `defineTag` → sets `_ns_`, `cssid`, registers in Imba's internal tag registry (`J[name]`, `xh[name]`). |
103
+ | **Lifecycle** | `__init__$` (property defaults), `connectedCallback` (DOM attachment), `mount` (post-connect, user code), `render` (DOM creation/update). |
104
+
105
+ ### Imba runtime functions
106
+
107
+ | Function | What it does |
108
+ |----------|-------------|
109
+ | `createElement(tag, parent, className, text)` | `document.createElement` + `parent[appendChild$](el)`. For plain HTML elements. |
110
+ | `createComponent(name, parent, className, text)` | Same but for custom elements. If `name` is a string, uses `document.createElement(name)`. |
111
+ | `imba_styles.register(id, css)` | Injects/updates a `<style>` element in `<head>`. Idempotent by `id`. |
112
+ | `defineTag(name, klass, opts)` | Registers tag in Imba's internal registry. Sets `_ns_`, `cssid`, `flags$ns` on prototype. |
113
+ | `register$(klass, symbol, name, flags)` | Sets up class metadata (`__meta__$`), calls `customElements.define`. |
114
+ | `imba.commit()` | Schedules a render tick via `requestAnimationFrame`. All scheduled components re-render. |
115
+ | `$beforeReconcile$` | Called at start of render. Clears internal child tracking state. |
116
+ | `$afterReconcile$` | Called at end of render. Finalizes child list. |
117
+ | `$placeChild$(child, type, prev)` | Manages conditional/dynamic child placement. Inserts/removes/replaces nodes. |
118
+ | `$afterVisit$(flag)` | Post-render hook on a component child. Triggers its own render if needed. |
119
+
120
+ ---
121
+
122
+ ## 2. The Problem Bimba Solves
123
+
124
+ Browsers have no built-in HMR for custom elements:
125
+ - `customElements.define(name, class)` can only be called ONCE per tag name
126
+ - Re-importing a module creates fresh `Symbol()` instances — old cache keys become orphans
127
+ - Without intervention, re-importing causes full duplication of DOM children
128
+
129
+ ---
130
+
131
+ ## 3. How Bimba's HMR Works
132
+
133
+ ### 3.1 Symbol Stabilization (server-side)
134
+
135
+ **Problem:** Each `var $7 = Symbol()` creates a unique symbol. Re-importing the module creates a NEW `$7` symbol. Existing elements have DOM cached under the OLD `$7`. The new render method looks up `this[NEW_$7]` — not found → creates duplicate DOM.
136
+
137
+ **Solution:** Rewrite `Symbol()` calls to use a persistent global cache:
138
+
139
+ ```
140
+ $7 = Symbol()
141
+
142
+ $7 = (__bsyms__["$7"] ||= Symbol())
143
+ ```
144
+
145
+ Where `__bsyms__` is keyed by absolute file path:
146
+ ```js
147
+ const __bsyms__ = ((globalThis.__bimba_syms ||= {})["/abs/path/to/file.imba"] ||= {});
148
+ ```
149
+
150
+ First load: creates symbols, stores in cache.
151
+ HMR reload: reuses same symbols from cache → render finds cached DOM → REUSE mode.
152
+
153
+ **Critical:** The file path key MUST be normalized (absolute via `path.resolve`). Different string representations of the same file (e.g., `./src/foo.imba` vs `src/foo.imba`) produce different cache keys → different symbols → duplication. This was the root cause of the v0.7.8 fix.
154
+
155
+ ### 3.2 Slot Stability Detection
156
+
157
+ If the user adds/removes template elements, the number of `Symbol()` declarations changes. Variable names shift (`$7` now means a different DOM slot). Even with stable symbols, the SEMANTICS change.
158
+
159
+ Detection: count `Symbol()` calls per file. Compare to previous compilation:
160
+ - Same count → `slots: 'stable'` → safe for in-place HMR
161
+ - Different count → `slots: 'shifted'` → must do destructive HMR
162
+
163
+ ### 3.3 Prototype Patching (browser-side)
164
+
165
+ `customElements.define` is hooked:
166
+
167
+ ```
168
+ First call (page load): register normally, save class in _classes map
169
+ Repeat calls (HMR): _patchClass(originalClass, newClass)
170
+ ```
171
+
172
+ `_patchClass` copies ALL own property descriptors (string + symbol keys) from the new class prototype to the original class prototype, skipping `constructor`. Also copies static properties (skipping `length`, `name`, `prototype`, `caller`, `arguments`).
173
+
174
+ Effect: all existing element instances immediately get new methods via the prototype chain. No need to recreate elements.
175
+
176
+ ### 3.4 CSS Namespace Sync
177
+
178
+ When CSS changes, Imba generates a new hash → new `_ns_` (e.g., `"z1abc_xy "` → `"z9def_gh "`). The issue:
179
+
180
+ 1. `register$` → `customElements.define` → bimba's hook → `_patchClass` runs
181
+ 2. `defineTag` runs AFTER `register$` — sets `_ns_` on the NEW class prototype
182
+ 3. But `_patchClass` already ran, so the OLD prototype still has the old `_ns_`
183
+
184
+ Solution: after `import()` completes, sync `_ns_` manually:
185
+ ```js
186
+ oldCls.prototype._ns_ = newCls.prototype._ns_;
187
+ ```
188
+
189
+ Then patch `className` on ALL custom elements in the DOM, replacing old hash parts with new ones.
190
+
191
+ ### 3.5 Always-Destructive HMR
192
+
193
+ > **History:** Earlier versions (≤0.7.8) had two paths — "stable" (in-place
194
+ > prototype patching + `imba.commit()`) and "shifted" (destructive wipe +
195
+ > re-render). The stable path was meant to preserve DOM state (inputs, focus,
196
+ > popups) when only CSS or logic changed without adding/removing template
197
+ > elements. However, it fundamentally didn't work: imba's reconciliation uses
198
+ > slot-tracking symbols (`this[$sym] === 1`) to skip re-creating elements on
199
+ > re-render. Even when `_patchClass` installs a new `render()` method, calling
200
+ > `render()` (or `imba.commit()`) does nothing — the slot check says "already
201
+ > created" and skips `createElement`. Static text, attributes, and other
202
+ > arguments baked into `createElement` calls never update.
203
+ >
204
+ > Since 0.7.9, bimba always takes the destructive path.
205
+
206
+ The `slots` field is still computed and broadcast (for potential future use),
207
+ but the client ignores it. Every HMR update does:
208
+
209
+ 1. `_patchClass` updates prototype (during import)
210
+ 2. `_ns_` is synced
211
+ 3. For each instance of each affected tag:
212
+ - Save instance properties (`Object.keys(el)`)
213
+ - Call `disconnectedCallback` on all descendant custom elements
214
+ - Delete all anonymous Symbol properties (render cache) — skip `Symbol.for(...)` ones
215
+ - `innerHTML = ''` — wipe DOM
216
+ - Restore instance properties
217
+ - `el.render()` — rebuild DOM from scratch with new render method
218
+ - `el.connectedCallback()`, `el.mount()` — re-initialize
219
+ 4. `imba.commit()` for final sync
220
+
221
+ **Trade-off:** Input focus, scroll position, and popup state are lost on every
222
+ edit. This is acceptable because correctness beats convenience — a "stable"
223
+ update that silently ignores the change is far more confusing than losing
224
+ transient UI state.
225
+
226
+ ### 3.6 Body-level Deduplication
227
+
228
+ Some modules call `imba.mount(<app-root>)` at top level. Re-importing the module would create a second root element. After each HMR import, bimba checks for new body children with the same tag name as existing ones and removes duplicates.
229
+
230
+ ---
231
+
232
+ ## 4. Server Architecture
233
+
234
+ ```
235
+ serve.js
236
+ ├── HMR Client (injected as <script> into HTML)
237
+ │ ├── customElements.define hook
238
+ │ ├── _patchClass / _copyDescriptors
239
+ │ ├── _doUpdate (stable/shifted paths)
240
+ │ ├── WebSocket connection
241
+ │ └── Error overlay
242
+
243
+ ├── Symbol Stabilization
244
+ │ ├── stabilizeSymbols(js, absPath)
245
+ │ └── Slot count tracking (_prevSlots)
246
+
247
+ ├── Compiler
248
+ │ ├── compileFile(filepath) — compile + stabilize + cache
249
+ │ ├── _compileCache (abs path → {mtime, result})
250
+ │ └── _prevJs (abs path → js string, for change detection)
251
+
252
+ ├── Import Graph
253
+ │ ├── extractImports(js, absPath) — scan for .imba imports
254
+ │ ├── updateImportGraph(from, deps) — maintain bidirectional graph
255
+ │ └── _imports / _importers maps
256
+
257
+ ├── File Watcher
258
+ │ └── watch(srcDir) → compile → broadcast update via WebSocket
259
+
260
+ ├── HTTP Server
261
+ │ ├── / → HTML with injected import map + HMR client
262
+ │ ├── *.imba → compile on demand → serve as JS
263
+ │ ├── *.css → wrap as JS module (style injection)
264
+ │ ├── /node_modules/* → resolve entry, compile .imba, wrap CJS
265
+ │ └── Static files (htmlDir, then root)
266
+
267
+ ├── Import Map (minimal, browser-side)
268
+ │ └── bare specifier → /node_modules/pkg/ prefix mapping
269
+
270
+ └── Node Modules Resolution (server-side)
271
+ ├── resolveEntry(pkg.json) — exports/module/browser/main
272
+ ├── wrapCJS(code) — detect CJS, wrap as ESM
273
+ └── Extension fallback (.imba → .js → .mjs)
274
+ ```
275
+
276
+ ---
277
+
278
+ ## 5. Common Pitfalls and Debugging
279
+
280
+ ### Symptom: Elements duplicate on first edit, not on second
281
+ **Cause:** Symbol cache key mismatch between initial load and HMR. Check that `stabilizeSymbols` receives the same file path from both the HTTP handler and the file watcher. Must use `path.resolve()` for normalization.
282
+
283
+ ### Symptom: CSS changes don't apply after HMR
284
+ **Cause:** `_ns_` not synced. Check the `_nsPatches` logic — `defineTag` sets `_ns_` AFTER `register$`, so `_patchClass` misses it. The post-import sync block must handle this.
285
+
286
+ ### Symptom: Methods don't update after HMR
287
+ **Cause:** `_patchClass` might not be running. Check that `customElements.get(name)` returns the existing class. Verify the hook on `customElements.define` is active.
288
+
289
+ ### Symptom: State lost on edit (inputs clear, popups close)
290
+ **Cause:** Taking the shifted path when stable would suffice. Check slot count — adding a comment or whitespace shouldn't change `Symbol()` count. If it does, the stabilization regex might be too broad/narrow.
291
+
292
+ ### Symptom: 500 errors on node_modules subpaths
293
+ **Cause:** `serveResolved` trying `.imba` extension without existence check. The `.imba` path calls `compileFile()` which throws on non-existent files.
294
+
295
+ ### Debugging approach
296
+ Add to HMR client `_doUpdate`:
297
+ ```js
298
+ console.log('[bimba]', file, 'slots=' + slots, 'tags:', collected);
299
+ ```
300
+ Check `globalThis.__bimba_syms` in browser console — keys should be absolute paths, values should be objects with `$7`, `$11`, etc. If you see two entries for the same file with different path formats, that's the symbol mismatch bug.
package/README.md CHANGED
@@ -56,6 +56,8 @@ Duplicate root elements (caused by `imba.mount()` running again on re-import) ar
56
56
 
57
57
  **Smart HMR:** bimba detects whether a change affects the template structure (adding/removing elements) or just CSS/logic. CSS-only and logic-only changes patch prototypes in place without wiping innerHTML — preserving input focus, scroll position, and open popups. Template-structural changes do a full wipe-and-rerender to ensure correctness.
58
58
 
59
+ For a deep dive into how Imba compiles tags, how the render cache works, and how bimba hooks into it — see [INTERNALS.md](INTERNALS.md).
60
+
59
61
  **HTML setup:** add a `data-entrypoint` attribute to the script tag that loads your bundle. The dev server will replace it with your `.imba` entrypoint and inject the importmap above it:
60
62
 
61
63
  ```html
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bimba-cli",
3
- "version": "0.7.8",
3
+ "version": "0.7.9",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/HeapVoid/bimba.git"
package/serve.js CHANGED
@@ -137,28 +137,27 @@ const hmrClient = `
137
137
  }
138
138
 
139
139
  // Destructive HMR: wipe inner DOM and re-render each collected tag.
140
- // Skip when slots === 'stable' — template structure unchanged (CSS-only
141
- // or logic-only edit that didn't add/remove elements), so wiping innerHTML
142
- // would destroy DOM state (inputs, focus, popups) for nothing.
143
- // _patchClass already ran above, so methods are up-to-date either way.
144
- if (slots !== 'stable') {
145
- for (const tag of collected) {
146
- const els = document.querySelectorAll(tag);
147
- els.forEach(el => {
148
- const state = {};
149
- for (const k of Object.keys(el)) state[k] = el[k];
150
- _disconnectDescendants(el);
151
- for (const sym of Object.getOwnPropertySymbols(el)) {
152
- if (Symbol.keyFor(sym) !== undefined) continue;
153
- try { delete el[sym]; } catch(_) {}
154
- }
155
- el.innerHTML = '';
156
- Object.assign(el, state);
157
- try { el.render && el.render(); } catch(e) { console.error('[bimba] render error:', e); }
158
- try { el.connectedCallback && el.connectedCallback(); } catch(_) {}
159
- try { el.mount && el.mount(); } catch(_) {}
160
- });
161
- }
140
+ // Always destructive regardless of slots value. Imba's reconciliation
141
+ // uses slot-tracking symbols (this[$sym] === 1) to skip re-creating
142
+ // elements on re-render. Even "stable" edits (static text, attributes)
143
+ // won't apply unless we clear those symbols and force a fresh render.
144
+ // _patchClass already ran above, so the new render() method is in place.
145
+ for (const tag of collected) {
146
+ const els = document.querySelectorAll(tag);
147
+ els.forEach(el => {
148
+ const state = {};
149
+ for (const k of Object.keys(el)) state[k] = el[k];
150
+ _disconnectDescendants(el);
151
+ for (const sym of Object.getOwnPropertySymbols(el)) {
152
+ if (Symbol.keyFor(sym) !== undefined) continue;
153
+ try { delete el[sym]; } catch(_) {}
154
+ }
155
+ el.innerHTML = '';
156
+ Object.assign(el, state);
157
+ try { el.render && el.render(); } catch(e) { console.error('[bimba] render error:', e); }
158
+ try { el.connectedCallback && el.connectedCallback(); } catch(_) {}
159
+ try { el.mount && el.mount(); } catch(_) {}
160
+ });
162
161
  }
163
162
 
164
163
  if (typeof imba !== 'undefined') imba.commit();