@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 +24 -1
- package/index.js +1 -0
- package/package.json +1 -1
- package/public/apps/chat/main.js +15 -19
- package/public/apps/explorer/index.html +28 -0
- package/public/apps/explorer/main.js +64 -49
- package/public/streamo/bridgeRegistry.js +64 -0
- package/public/streamo/mount.js +21 -14
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.
|
|
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.
|
|
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",
|
package/public/apps/chat/main.js
CHANGED
|
@@ -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
|
|
85
|
-
//
|
|
86
|
-
// the slot
|
|
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
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
17
|
-
//
|
|
18
|
-
//
|
|
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
|
|
48
|
-
const dep = () => recaller.reportKeyAccess(signal, 'data')
|
|
48
|
+
const { dep, fire: bridgeFire } = bridgeRegistry(registry, recaller, 'explorer')
|
|
49
49
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
let
|
|
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
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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
|
|
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
|
|
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>${
|
|
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 =
|
|
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>${
|
|
609
|
-
<tr><td class="mono">v[1]</td><td>${
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
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 >
|
|
874
|
-
return
|
|
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
|
-
|
|
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
|
+
}
|
package/public/streamo/mount.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|