OverviewFilesCommitsIssuesDocsReleasesPeers

p2pbuilders commits / 450dcb4

web: fix stale-cache in multi-tab gossip and never-applied user blocks; found via in-browser E2E
Three UI/engine bugs surfaced by driving the web build in headless Chromium (44 checks across submit/comments/votes/boards/profiles/search/multi-user/ cross-tab replication — all passing after these fixes): - gossip: a tab's merged-view cache was only invalidated when an incoming outbox message changed stored records. Browser tabs share localStorage, so the sender's tab had already persisted them — `changed` stayed false, the cache was never dropped, and other tabs rendered a stale (often empty) feed forever. Same-user sibling tabs were filtered out entirely. Now: any outbox/hello triggers invalidation; same-user messages invalidate without re-broadcast (no ping-pong). - data/app: blocking a user appended a signed block record that nothing read back — the feed passed [] for local blocks, so blocks had no effect. Added data.blockedTargets() and wired it into the feed's blocked set. - app: viewProfile loaded the viewer's following list only on their OWN profile (where it's unused) and passed [] on others' profiles (where the follow button lives), so the button never showed "unfollow". Engine tests grow 24 -> 28: own-block resolution, unblock, and a shared-storage two-tab regression for the cache invalidation (verified to fail against the previous gossip.js). README: landing domain is p2pbuilders.org. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QAqfE2dpo3vsVd9BorLY6w
450dcb45e9bcb80a3e8ec056db979bc63b57865a
by Claude <noreply@anthropic.com> · 2026-07-29T12:02:45.000Z

Changed files (5) +51 -7

README.md +1 -1
……@@ -101,7 +101,7 @@ src/
101101└── relay/
102102 └── server.js self-hosted hiverelay with @hyperswarm/dht-relay endpoint
103103
104landing/ static site for p2pbuilders.com
104landing/ static site for p2pbuilders.org
105105public/ parked browser/desktop UI (dev mode) — not the terminal app
106106ios-app/ minimal Xcode project using bare-kit-pear (parked)
107107scripts/
web/js/app.js +3 -3
……@@ -138,7 +138,7 @@ async function viewFeed ({ board, query, guard, token }) {
138138 const sort = query.sort || prefs.sort || 'hot'
139139 guard(skeleton())
140140 let posts = board === 'all' ? await data.listAllPosts() : await data.listPostsIn(board)
141 const blocked = await data.blockedSet([], prefs.blocklistSubs())
141 const blocked = await data.blockedSet(await data.blockedTargets(identity.me().pubkey), prefs.blocklistSubs())
142142 posts = posts.filter(p => !p.deleted && !blocked.has(p.author) && !prefs.isHidden(p.board + '/' + p.cid))
143143 posts = await data.withTallies(posts)
144144 await primeNames(posts.map(p => p.author))
……@@ -296,11 +296,11 @@ async function viewProfile ({ pub, guard, token }) {
296296 const profile = await data.getProfile(pub)
297297 const act = await data.userActivity(pub)
298298 const inputs = await data.weightInputsFor(pub)
299 const followers = mine ? await data.following(me.pubkey) : []
299 const myFollowing = mine ? [] : await data.following(me.pubkey)
300300 await primeNames([pub])
301301 if (token !== renderToken) return
302302 const items = [...act.posts.map(p => ({ k: 'post', t: p.createdAt, p })), ...act.comments.map(c => ({ k: 'comment', t: c.createdAt, c }))].sort((a, b) => b.t - a.t).slice(0, 60)
303 const isFollowing = followers.includes(pub)
303 const isFollowing = myFollowing.includes(pub)
304304 const feed = items.length ? items.map(it => it.k === 'post'
305305 ? `<li class="act"><span class="atag">post</span> <a href="${buildRoute(['b', it.p.board, 'item', it.p.cid])}">${esc(it.p.title)}</a> <span class="dim">in b/${esc(it.p.board)} · ${timeAgo(it.p.createdAt)}</span></li>`
306306 : `<li class="act"><span class="atag">comment</span> on <a href="${buildRoute(['b', it.c.board || 'front', 'item', it.c.postCid])}">${esc(it.c.postTitle || 'a post')}</a> <span class="dim">${timeAgo(it.c.createdAt)}</span><div class="md small">${renderMarkdown(it.c.body)}</div></li>`).join('') : '<li class="dim">No activity yet.</li>'
web/js/data.js +4 -0
……@@ -254,6 +254,10 @@ export class Data {
254254 return data
255255 }
256256 unblock (target) { return this.block(target, false, false) }
257 async blockedTargets (pub) {
258 const rows = await this.sync.list(keys.blocksBy(pub), { limit: 1000 })
259 return rows.map(r => r.value).filter(v => v && v.active).map(v => v.target)
260 }
257261
258262 async publishBlocklist (list) {
259263 const me = this.me()
web/js/gossip.js +15 -3
……@@ -166,9 +166,17 @@ class GossipSync {
166166 async _onBus (m) {
167167 if (!m || !m.t) return
168168 const me = this.getMe()
169 if (m.t === 'hello' && m.pub && m.pub !== me) {
169 if (m.pub === me) {
170 // A sibling tab running the SAME user wrote our outbox (tabs share
171 // localStorage). Nothing to ingest — the records are already on disk —
172 // but our merged cache predates them. Drop it and re-render. No
173 // re-broadcast here, or two same-user tabs would ping-pong forever.
174 if (m.t === 'hello' || m.t === 'outbox') { this._invalidate(); this._emit() }
175 return
176 }
177 if (m.t === 'hello' && m.pub) {
170178 this._addPeer(m.pub); await this._broadcastMine(); this._invalidate(); this._emit()
171 } else if (m.t === 'outbox' && m.pub && m.pub !== me) {
179 } else if (m.t === 'outbox' && m.pub) {
172180 this._addPeer(m.pub)
173181 const incoming = m.view || {}
174182 const secure = isSecure()
……@@ -188,7 +196,11 @@ class GossipSync {
188196 const sk = stickyKey(typeFromKey(k), iv)
189197 if (!cur[k] || (sk != null ? nameWins(iv, cur[k]) : laterRecord(iv, cur[k]))) { cur[k] = iv; changed = true }
190198 }
191 if (changed) { this._write(outboxKey(m.pub), cur); this._invalidate() }
199 if (changed) this._write(outboxKey(m.pub), cur)
200 // Invalidate even when nothing was written: when tabs share storage the
201 // sender's tab already persisted these records, so `changed` is false
202 // here while our merged cache still predates them.
203 this._invalidate()
192204 this._emit()
193205 }
194206 }
web/test/engine.mjs +28 -0
……@@ -105,6 +105,34 @@ async function main () {
105105 const notBlocked = await alice.data.blockedSet([], [])
106106 ok(!notBlocked.has('spammerPubkeyXYZ'), 'unsubscribed = not blocked')
107107
108 console.log('\n— own blocks —')
109 await alice.data.block(bob.pub, true)
110 ok((await alice.data.blockedTargets(alice.pub)).includes(bob.pub), 'blockedTargets lists own active blocks')
111 const withOwn = await alice.data.blockedSet(await alice.data.blockedTargets(alice.pub), [])
112 ok(withOwn.has(bob.pub), 'own block flows into the effective blocked set')
113 await alice.data.unblock(bob.pub)
114 ok(!(await alice.data.blockedTargets(alice.pub)).includes(bob.pub), 'unblock deactivates the record')
115
116 console.log('\n— shared-storage tabs (browser multi-tab dev mode) —')
117 // Two "tabs": distinct users, SAME storage object (like two browser tabs
118 // sharing localStorage). The sender's tab persists records before the bus
119 // message arrives, so the receiver ingests nothing new — its merged cache
120 // must still be invalidated or it renders a stale (empty) view forever.
121 const tabHub = makeHub()
122 const tabStorage = mem()
123 const idT1 = new DevIdentity(tabStorage, mem()); await idT1.ready(); await idT1.createUser('tab1')
124 const idT2 = new DevIdentity(tabStorage, mem()); await idT2.ready(); await idT2.createUser('tab2')
125 const tv = makeValidator(BITS)
126 const tab1 = new GossipSync({ storage: tabStorage, bus: tabHub.connect(), getMe: () => idT1.me().pubkey, validate: tv })
127 const tab2 = new GossipSync({ storage: tabStorage, bus: tabHub.connect(), getMe: () => idT2.me().pubkey, validate: tv })
128 await tab1.ready(); await tab2.ready()
129 const d1 = createData(tab1, idT1, { minBits: BITS })
130 const d2 = createData(tab2, idT2, { minBits: BITS })
131 await d1.range?.() // warm nothing; just ensure instances are live
132 await tab1.list('post!') // build tab1's merged cache BEFORE tab2 writes
133 const tp = await d2.submitPost({ board: 'front', title: 'from tab2' })
134 ok((await d1.listAllPosts()).some(x => x.cid === tp.cid), 'sibling-tab write reaches the other tab live (cache invalidated)')
135
108136 console.log('\n— profiles —')
109137 await alice.data.setProfile({ nick: 'alice', bio: 'builds p2p' })
110138 ok((await alice.data.nickOf(alice.pub)) === 'alice', 'nick resolves from profile')