@dtudury/streamo 4.0.1 → 4.0.2

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/README.md CHANGED
@@ -126,6 +126,29 @@ const registry = new RepoRegistry(async key => {
126
126
  const repo = await registry.open(publicKeyHex)
127
127
  ```
128
128
 
129
+ ### bridgeRegistry — connect a multi-repo registry to your app's Recaller
130
+
131
+ Each `Repo` owns its own `Recaller` (so it can do fine-grained tracking on its
132
+ own internal keys), and your app uses a separate `Recaller` for its `mount()`
133
+ slots. Reading `repo.byteLength` inside a slot registers a dep on the *repo's*
134
+ recaller, not the app's, so without an explicit bridge the slot would never
135
+ re-run when chunks arrive. `bridgeRegistry` is that bridge:
136
+
137
+ ```js
138
+ import { Recaller, bridgeRegistry, h, mount } from '@dtudury/streamo'
139
+
140
+ const recaller = new Recaller('app')
141
+ const { dep, fire } = bridgeRegistry(registry, recaller)
142
+
143
+ mount(h`${() => {
144
+ dep()
145
+ for (const [k, r] of registry) ... // freely read any repo's state
146
+ }}`, appEl, recaller)
147
+
148
+ // Non-repo state changes (route, async results) — call fire() to force a re-render.
149
+ window.addEventListener('hashchange', fire)
150
+ ```
151
+
129
152
  ### registrySync — peer sync over WebSocket
130
153
 
131
154
  ```js
@@ -163,7 +186,7 @@ mount(h`
163
186
  `, document.body, recaller)
164
187
  ```
165
188
 
166
- Functions interpolated as `${() => ...}` are reactive cells — they re-run automatically whenever the data they read changes. No virtual DOM diffing; only the exact DOM nodes bound to changed data update. Elements are recycled across re-renders by `data-key` (or tag as a fallback), so user input and focus survive list reorders. SVG namespaces propagate automatically — `` h`<svg><path d="..."/></svg>` `` works without any extra wiring. `class` accepts an array (`['btn', isActive && 'active']`) or an object (`{btn: true, active: false}`); falsy entries are filtered out.
189
+ Functions interpolated as `${() => ...}` are reactive cells — they re-run automatically whenever the data they read changes. Only the exact DOM nodes bound to changed data update. Elements with stable `data-key` are recycled across re-renders so the outer element's identity and document position survive (helpful for animations or external DOM references). The recycled element's inner content is rebuilt from the new vnode on each re-render, so static interpolations (`${value}`) reflect current state. SVG namespaces propagate automatically — `` h`<svg><path d="..."/></svg>` `` works without any extra wiring. `class` accepts an array (`['btn', isActive && 'active']`) or an object (`{btn: true, active: false}`); falsy entries are filtered out.
167
190
 
168
191
  > **For lists that can reorder**, always set `data-key` on each item — the unkeyed positional fallback will recycle elements by tag in document order, which can attach the wrong DOM node (and any user focus/input on it) to the wrong vnode after a reorder.
169
192
 
package/index.js CHANGED
@@ -17,6 +17,7 @@ export { Repo } from './public/streamo/Repo.js'
17
17
  export { Signer, verifySignature } from './public/streamo/Signer.js'
18
18
  export { Signature } from './public/streamo/Signature.js'
19
19
  export { RepoRegistry } from './public/streamo/RepoRegistry.js'
20
+ export { bridgeRegistry } from './public/streamo/bridgeRegistry.js'
20
21
  export { registrySync, handleRegistryPeer } from './public/streamo/registrySync.js'
21
22
  export { archiveSync } from './public/streamo/archiveSync.js'
22
23
  export { fileSync } from './public/streamo/fileSync.js'
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dtudury/streamo",
3
- "version": "4.0.1",
3
+ "version": "4.0.2",
4
4
  "description": "peer-to-peer sync where your data and identity belong to you, not the server",
5
5
  "keywords": ["p2p", "peer-to-peer", "sync", "reactive", "content-addressed", "websocket", "signed", "append-only", "offline-first", "cryptographic", "identity"],
6
6
  "repository": "git@github.com:dtudury/streamo.git",
@@ -4,6 +4,7 @@ import { Recaller } from '../../streamo/utils/Recaller.js'
4
4
  import { Signer } from '../../streamo/Signer.js'
5
5
  import { RepoRegistry } from '../../streamo/RepoRegistry.js'
6
6
  import { registrySync } from '../../streamo/registrySync.js'
7
+ import { bridgeRegistry } from '../../streamo/bridgeRegistry.js'
7
8
  import { bytesToHex } from '../../streamo/utils.js'
8
9
 
9
10
  const { primaryKeyHex: rootKey } = await fetch('/api/info').then(r => r.json())
@@ -81,30 +82,25 @@ joinBtn.onclick = async () => {
81
82
  // ── Reactive message list ──────────────────────────────────────────────
82
83
  //
83
84
  // Each repo has its own internal Recaller, so repo.get() inside a mount
84
- // slot won't automatically re-trigger mount's recaller. Bridge via
85
- // reportKey*: repo.watch() calls reportKeyMutation when data changes;
86
- // the slot calls reportKeyAccess to register the dependency.
85
+ // slot doesn't automatically re-trigger mount's recaller. bridgeRegistry
86
+ // wires every repo (existing and future) into a single signal on the
87
+ // chat recaller; dep() inside the slot subscribes to it. See design.md
88
+ // §6 for the cross-recaller pattern.
87
89
 
88
90
  const recaller = new Recaller('chat')
89
- const signal = {}
90
-
91
- function triggerRender () {
92
- recaller.reportKeyMutation(signal, 'data')
91
+ const { dep } = bridgeRegistry(registry, recaller, 'chat')
92
+
93
+ // Auto-scroll to the bottom whenever any chunk arrives. Subscribing
94
+ // via the same `dep` keeps it in lockstep with the mount slot — both
95
+ // re-run when the bridge fires, the slot updates the DOM, and this
96
+ // watcher schedules a post-layout scroll.
97
+ recaller.watch('chat-scroll', () => {
98
+ dep()
93
99
  requestAnimationFrame(() => { msgsEl.scrollTop = msgsEl.scrollHeight })
94
- }
95
-
96
- function watchRepo (keyHex, repo) {
97
- repo.watch(`chat:${keyHex}`, () => {
98
- repo.byteLength // register 'length' dep → re-fires on every commit and incoming sync chunk
99
- triggerRender()
100
- })
101
- }
102
-
103
- for (const [k, r] of registry) watchRepo(k, r)
104
- registry.onOpen((keyHex, repo) => { watchRepo(keyHex, repo); triggerRender() })
100
+ })
105
101
 
106
102
  mount(h`${function messages () {
107
- recaller.reportKeyAccess(signal, 'data')
103
+ dep()
108
104
  const all = []
109
105
  for (const [keyHex, repo] of registry) {
110
106
  if (keyHex === rootKey) continue
@@ -4,6 +4,7 @@
4
4
  <meta charset="UTF-8">
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1">
6
6
  <title>streamo explorer</title>
7
+ <link rel="icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 16 16%22><text y=%2214%22 font-size=%2214%22>🌊</text></svg>">
7
8
  <link rel="stylesheet" href="/apps/styles/proto.css">
8
9
  <style>
9
10
  body { max-width: 60rem; margin: 0 auto; padding: 2rem 1.25rem; }
@@ -248,6 +249,33 @@
248
249
  .byte-map .chunk.current { stroke: var(--ink); stroke-width: 2; }
249
250
  .byte-map .chunk.hovered { fill-opacity: 0.55; }
250
251
 
252
+ /* Streamo-typed value pills — every value gets a type-specific visual
253
+ identity instead of flattening through JSON.stringify. Colors echo
254
+ the byte-strip codec palette below so the visual language carries
255
+ across the page. */
256
+ .tv {
257
+ display: inline-flex;
258
+ align-items: center;
259
+ gap: 0.25rem;
260
+ padding: 0.05rem 0.4rem;
261
+ border-radius: var(--radius);
262
+ font-size: 0.85rem;
263
+ max-width: 100%;
264
+ vertical-align: baseline;
265
+ }
266
+ .tv-string { color: #047857; background: rgba(16, 185, 129, 0.10); font-family: monospace; }
267
+ .tv-string .tv-quote { color: #10b981; opacity: 0.7; font-weight: 600; }
268
+ .tv-num { color: #475569; background: rgba(100, 116, 139, 0.10); font-family: monospace; }
269
+ .tv-date { color: #475569; background: rgba(100, 116, 139, 0.10); }
270
+ .tv-date .tv-glyph { font-size: 0.75rem; }
271
+ .tv-date time { font-variant-numeric: tabular-nums; }
272
+ .tv-bool.tv-true { color: #15803d; background: rgba(22, 163, 74, 0.10); font-family: monospace; }
273
+ .tv-bool.tv-false { color: #b91c1c; background: rgba(220, 38, 38, 0.10); font-family: monospace; }
274
+ .tv-null, .tv-undefined { color: var(--ink-dim); background: transparent; font-style: italic; font-family: monospace; }
275
+ .tv-bytes { color: #4d7c0f; background: rgba(132, 204, 22, 0.10); font-family: monospace; }
276
+ .tv-array, .tv-object { color: #1e40af; background: rgba(59, 130, 246, 0.10); }
277
+ .tv-duple { color: #6b21a8; background: rgba(168, 85, 247, 0.10); }
278
+
251
279
  /* codec category palette — used in both the legend and the SVG fills */
252
280
  .cat-commit { fill: #f59e0b; background: #f59e0b; }
253
281
  .cat-sig { fill: #ef4444; background: #ef4444; }
@@ -13,15 +13,16 @@
13
13
  // storage chunks tucked into a <details>). Otherwise it's storage
14
14
  // drilling — value/storage tabs for that chunk, no selector.
15
15
  //
16
- // State lives in plain JS variables; reactivity is bridged from each Repo's
17
- // internal Recaller into the app-level Recaller via the `signal` pattern
18
- // (see chat/main.js for the same approach).
16
+ // Reactivity is bridged from each Repo's internal Recaller into the
17
+ // app-level Recaller via bridgeRegistry see design.md §6 for why
18
+ // each Repo has its own Recaller and how the bridge connects them.
19
19
 
20
20
  import { h } from '../../streamo/h.js'
21
21
  import { mount } from '../../streamo/mount.js'
22
22
  import { Recaller } from '../../streamo/utils/Recaller.js'
23
23
  import { RepoRegistry } from '../../streamo/RepoRegistry.js'
24
24
  import { registrySync } from '../../streamo/registrySync.js'
25
+ import { bridgeRegistry } from '../../streamo/bridgeRegistry.js'
25
26
  import { changedPaths } from '../../streamo/Streamo.js'
26
27
  import { hexToBytes } from '../../streamo/utils.js'
27
28
 
@@ -44,36 +45,18 @@ try {
44
45
  // ── App-level reactivity ──────────────────────────────────────────────────
45
46
 
46
47
  const recaller = new Recaller('explorer')
47
- const signal = {}
48
- const dep = () => recaller.reportKeyAccess(signal, 'data')
48
+ const { dep, fire: bridgeFire } = bridgeRegistry(registry, recaller, 'explorer')
49
49
 
50
- const schedule = typeof requestAnimationFrame !== 'undefined'
51
- ? fn => requestAnimationFrame(fn)
52
- : fn => queueMicrotask(fn)
53
- let scheduled = false
50
+ // Wrap bridgeFire to also schedule the byte-strip pin-to-HEAD side effect
51
+ // after the next render. Reactive mutation is synchronous (so the slot
52
+ // re-runs at next tick); only the post-render DOM peek goes through rAF.
53
+ let stripSyncScheduled = false
54
54
  function fire () {
55
- if (scheduled) return
56
- scheduled = true
57
- schedule(() => {
58
- scheduled = false
59
- recaller.reportKeyMutation(signal, 'data')
60
- // After mount has updated the DOM, sync byte-strip viewport indicators
61
- // and (if appropriate) keep them pinned to HEAD on live updates.
62
- syncByteStrips()
63
- })
64
- }
65
-
66
- const watched = new Set()
67
- function watchRepo (key, repo) {
68
- if (watched.has(key)) return
69
- watched.add(key)
70
- repo.watch(`explorer:${key}`, () => {
71
- repo.byteLength
72
- fire()
73
- })
55
+ bridgeFire()
56
+ if (stripSyncScheduled) return
57
+ stripSyncScheduled = true
58
+ requestAnimationFrame(() => { stripSyncScheduled = false; syncByteStrips() })
74
59
  }
75
- for (const [k, r] of registry) watchRepo(k, r)
76
- registry.onOpen((k, r) => { watchRepo(k, r); fire() })
77
60
 
78
61
  // ── Hash routing ──────────────────────────────────────────────────────────
79
62
 
@@ -298,11 +281,18 @@ function RegistryView () {
298
281
  dep()
299
282
  const rows = []
300
283
  for (const [keyHex, repo] of registry) {
284
+ // No claims about state we can't verify — show the date when we
285
+ // resolve a commit, otherwise show the byte count. byteLength
286
+ // is honest: it's what we actually have on hand. The watcher
287
+ // fires as more chunks land and the row settles to a date once
288
+ // the commit chunk resolves at the end of the stream.
301
289
  const last = repo.lastCommit
290
+ const len = repo.byteLength
291
+ const when = last ? fmtDate(last.date) : `${len} b`
302
292
  rows.push(h`
303
293
  <div class="row" data-key=${keyHex} data-action="open-repo">
304
294
  <span class="mono">${truncKey(keyHex)}</span>
305
- <span class="when">${last ? fmtDate(last.date) : '(no commits)'}</span>
295
+ <span class=${['when', last ? null : 'dim']}>${when}</span>
306
296
  <span class="msg dim">${last?.message || ''}</span>
307
297
  </div>
308
298
  `)
@@ -407,7 +397,7 @@ function repoExtras (repo, keyHex) {
407
397
  <tr data-key=${`o${e.address}`} data-action="open-at"
408
398
  data-keyhex=${keyHex} data-addr=${e.address}>
409
399
  <td class="mono dim">${e.codecType}</td>
410
- <td>${(() => { try { return previewValue(repo.decode(e.address)) } catch { return '' } })()}</td>
400
+ <td>${(() => { try { return typedValue(repo.decode(e.address)) } catch { return '' } })()}</td>
411
401
  <td class="mono dim">@${e.address}</td>
412
402
  </tr>
413
403
  `)}
@@ -507,13 +497,13 @@ function AtView ({ keyHex, address }) {
507
497
  return h`
508
498
  <tr>
509
499
  <td class="mono">${k}</td>
510
- <td>${previewValue(inlineValue)}</td>
500
+ <td>${typedValue(inlineValue)}</td>
511
501
  <td class="dim">(inline)</td>
512
502
  </tr>
513
503
  `
514
504
  }
515
505
  let preview = ''
516
- try { preview = previewValue(repo.decode(childAddr)) }
506
+ try { preview = typedValue(repo.decode(childAddr)) }
517
507
  catch { preview = '(error)' }
518
508
  return h`
519
509
  <tr data-key=${k} data-action="open-at"
@@ -605,8 +595,8 @@ function AtView ({ keyHex, address }) {
605
595
  </p>
606
596
  <table class="kv">
607
597
  <tbody>
608
- <tr><td class="mono">v[0]</td><td>${previewValue(decoded.v[0])}</td></tr>
609
- <tr><td class="mono">v[1]</td><td>${previewValue(decoded.v[1])}</td></tr>
598
+ <tr><td class="mono">v[0]</td><td>${typedValue(decoded.v[0])}</td></tr>
599
+ <tr><td class="mono">v[1]</td><td>${typedValue(decoded.v[1])}</td></tr>
610
600
  </tbody>
611
601
  </table>
612
602
  `
@@ -802,7 +792,7 @@ function outgoingReferencesSection (repo, keyHex, address) {
802
792
  try {
803
793
  const childCode = repo.resolve(childAddr)
804
794
  codecType = repo.footerToCodec[childCode.at(-1)]?.type || '?'
805
- preview = previewValue(repo.decode(childAddr))
795
+ preview = typedValue(repo.decode(childAddr))
806
796
  } catch { preview = '(error)' }
807
797
  return h`
808
798
  <tr data-key=${`out${i}@${childAddr}`} data-action="open-at"
@@ -840,7 +830,7 @@ function referrersSection (repo, keyHex, address) {
840
830
  <tbody>
841
831
  ${refs.map(r => {
842
832
  let preview = ''
843
- try { preview = previewValue(repo.decode(r.address)) }
833
+ try { preview = typedValue(repo.decode(r.address)) }
844
834
  catch { preview = '(error)' }
845
835
  return h`
846
836
  <tr data-key=${`r${r.address}`} data-action="open-at"
@@ -863,19 +853,44 @@ function isDuple (v) {
863
853
  return v && typeof v === 'object' && Array.isArray(v.v) && v.v.length === 2 && Object.keys(v).length === 1
864
854
  }
865
855
 
866
- function previewValue (v, depth = 0) {
867
- if (v == null) return String(v)
868
- if (typeof v === 'string') return v.length > 60 ? JSON.stringify(v.slice(0, 60)) + '…' : JSON.stringify(v)
869
- if (typeof v === 'number' || typeof v === 'boolean') return String(v)
870
- if (v instanceof Date) return v.toISOString()
871
- if (v instanceof Uint8Array) return `Uint8Array(${v.length})`
856
+ // Streamo-typed value renderer every value gets a visual identity
857
+ // matching its underlying codec, instead of being flattened through
858
+ // JSON.stringify. Primitives render with type-specific styling
859
+ // (string quoted mono in green frame, date <time> with calendar
860
+ // chip, number number chip, etc.); composites currently render as
861
+ // count chips ({ N fields } / [ N elements ]) depth-controlled
862
+ // expansion is the next step in this thread (see THREADS.md).
863
+ function typedValue (v, depth = 0) {
864
+ if (v === null) return h`<span class="tv tv-null">null</span>`
865
+ if (v === undefined) return h`<span class="tv tv-undefined">undefined</span>`
866
+ if (typeof v === 'boolean') {
867
+ return h`<span class=${['tv', 'tv-bool', v ? 'tv-true' : 'tv-false']}>${v ? '✓' : '✗'} ${String(v)}</span>`
868
+ }
869
+ if (typeof v === 'string') {
870
+ const display = v.length > 60 ? v.slice(0, 60) + '…' : v
871
+ return h`<span class="tv tv-string"><span class="tv-quote">“</span>${display}<span class="tv-quote">”</span></span>`
872
+ }
873
+ if (typeof v === 'number') {
874
+ return h`<span class="tv tv-num">${String(v)}</span>`
875
+ }
876
+ if (v instanceof Date) {
877
+ return h`<span class="tv tv-date"><span class="tv-glyph">📅</span><time datetime=${v.toISOString()}>${v.toLocaleString()}</time></span>`
878
+ }
879
+ if (v instanceof Uint8Array) {
880
+ return h`<span class="tv tv-bytes">Uint8Array(${v.length})</span>`
881
+ }
872
882
  if (isDuple(v)) {
873
- if (depth > 2) return 'Duple(…)'
874
- return `Duple(${previewValue(v.v[0], depth + 1)}, ${previewValue(v.v[1], depth + 1)})`
883
+ if (depth > 1) return h`<span class="tv tv-duple">Duple(…)</span>`
884
+ return h`<span class="tv tv-duple">Duple(${typedValue(v.v[0], depth + 1)}, ${typedValue(v.v[1], depth + 1)})</span>`
885
+ }
886
+ if (Array.isArray(v)) {
887
+ return h`<span class="tv tv-array">[ ${v.length} ${v.length === 1 ? 'element' : 'elements'} ]</span>`
888
+ }
889
+ if (typeof v === 'object') {
890
+ const n = Object.keys(v).length
891
+ return h`<span class="tv tv-object">{ ${n} ${n === 1 ? 'field' : 'fields'} }</span>`
875
892
  }
876
- if (Array.isArray(v)) return `[…] (${v.length})`
877
- if (typeof v === 'object') return `{…} (${Object.keys(v).length})`
878
- return String(v)
893
+ return h`<span class="tv">${String(v)}</span>`
879
894
  }
880
895
 
881
896
  function safeGet (f) { try { return f() } catch { return undefined } }
@@ -0,0 +1,64 @@
1
+ /**
2
+ * @file bridgeRegistry — connect a multi-repo registry to an app Recaller.
3
+ *
4
+ * Each Repo has its own Recaller so it can track fine-grained dependencies
5
+ * on its own internal keys. An app that uses many Repos has its own
6
+ * different Recaller for its mount() slots. Reading repo.byteLength inside
7
+ * a slot registers a dep on the *repo's* recaller, not the app's, so
8
+ * without an explicit bridge the slot would never re-run when chunks
9
+ * arrive at the repo.
10
+ *
11
+ * bridgeRegistry sets up that bridge once: it watches every repo in the
12
+ * registry (existing and future) for chunk arrivals and forwards them as
13
+ * a single signal on the app recaller. The returned `dep` function is
14
+ * what slots call to register on that signal — call it inside any
15
+ * reactive cell that should re-run on any-repo-changed.
16
+ *
17
+ * const recaller = new Recaller('app')
18
+ * const { dep, fire } = bridgeRegistry(registry, recaller)
19
+ *
20
+ * mount(h`${() => {
21
+ * dep()
22
+ * for (const [k, r] of registry) ... // freely read any repo's state
23
+ * }}`, appEl, recaller)
24
+ *
25
+ * // Non-repo state changes (route, async results, etc.) — call fire()
26
+ * // to force a re-render; the slot re-runs at next tick.
27
+ * window.addEventListener('hashchange', fire)
28
+ *
29
+ * Mutation is synchronous so multiple mutations in a tick coalesce via the
30
+ * Recaller's own nextTick flush — one slot re-run per tick regardless of
31
+ * how many chunks arrive. Don't wrap fire() in requestAnimationFrame:
32
+ * when the tab loses focus, queued rAFs throttle and the display freezes
33
+ * (we learned this the hard way; see the design.md cross-recaller note).
34
+ *
35
+ * @param {import('./RepoRegistry.js').RepoRegistry} registry
36
+ * @param {import('./utils/Recaller.js').Recaller} recaller
37
+ * @param {string} [name='bridge'] used in watch names for debugging
38
+ * @returns {{dep: () => void, fire: () => void}}
39
+ * `dep()` registers the calling reactive cell as depending on bridge state.
40
+ * `fire()` forces the slot to re-run at next tick — useful for app-level
41
+ * state changes (route, async results, tab switches) that aren't repo
42
+ * mutations the bridge already forwards.
43
+ */
44
+ export function bridgeRegistry (registry, recaller, name = 'bridge') {
45
+ const signal = {}
46
+ const watched = new Set()
47
+
48
+ const fire = () => recaller.reportKeyMutation(signal, 'data')
49
+ const dep = () => recaller.reportKeyAccess(signal, 'data')
50
+
51
+ function watchRepo (keyHex, repo) {
52
+ if (watched.has(keyHex)) return
53
+ watched.add(keyHex)
54
+ repo.watch(`${name}:${keyHex}`, () => {
55
+ repo.byteLength // register 'length' dep — fires on every chunk
56
+ fire()
57
+ })
58
+ }
59
+
60
+ for (const [k, r] of registry) watchRepo(k, r)
61
+ registry.onOpen((k, r) => { watchRepo(k, r); fire() })
62
+
63
+ return { dep, fire }
64
+ }
@@ -192,11 +192,30 @@ function reconcileSlot (start, end, newVNodes, recaller, ns = HTML_NS) {
192
192
  old.remove()
193
193
  }
194
194
 
195
- // Reinsert recycled elements (static attrs patched) and mount fresh ones, in order
195
+ // Reinsert recycled elements and mount fresh ones, in order. For
196
+ // recycled elements we re-mount: clean up the existing subtree's
197
+ // watchers, clear inner DOM and any attributes set on the outer
198
+ // element, then apply fresh attrs and mount fresh children. The
199
+ // OUTER node identity is preserved (so document position and DOM
200
+ // references survive), but inner state (focus, scroll, slot
201
+ // anchors) is rebuilt — a static `${value}` child captured at
202
+ // first mount would otherwise be stale on every re-render.
203
+ // Inputs that need focus preservation across re-renders should
204
+ // be kept in their own data-keyed slot so reconcileSlot's matcher
205
+ // recycles them in place separately from this outer rebuild.
196
206
  for (const vnode of newVNodes) {
197
207
  const recycled = vnodeToEl.get(vnode)
198
208
  if (recycled) {
199
- patchElement(recycled, vnode)
209
+ cleanupNode(recycled, recaller)
210
+ while (recycled.firstChild) recycled.firstChild.remove()
211
+ // Clear all attributes (snapshot the names — removeAttribute mutates the live list)
212
+ const oldAttrNames = Array.from(recycled.attributes, a => a.name)
213
+ for (const name of oldAttrNames) recycled.removeAttribute(name)
214
+ for (const attr of vnode.attrs) {
215
+ if (attr == null) continue
216
+ applyAttr(recycled, attr, recaller)
217
+ }
218
+ mount(vnode.children, recycled, recaller, ns)
200
219
  end.before(recycled)
201
220
  } else {
202
221
  const frag = document.createDocumentFragment()
@@ -206,18 +225,6 @@ function reconcileSlot (start, end, newVNodes, recaller, ns = HTML_NS) {
206
225
  }
207
226
  }
208
227
 
209
- // Update static attributes on a recycled element.
210
- // Reactive (function/array) attrs are already self-updating via their existing watchers.
211
- function patchElement (el, vnode) {
212
- for (const attr of vnode.attrs) {
213
- if (attr == null) continue
214
- if (typeof attr === 'object' && !attr.name) continue // spread — skip
215
- if (typeof attr.value === 'function' || Array.isArray(attr.value)) continue // reactive — skip
216
- if (attr.value !== undefined) setAttr(el, attr.name, attr.value)
217
- else el.toggleAttribute(attr.name, true)
218
- }
219
- }
220
-
221
228
  // ── Function components ───────────────────────────────────────────────────
222
229
  //
223
230
  // When an HElement's tag is a function, call it with a props object instead