Skip to content

fix(sidebar): scope channel sections storage to relay URL#1477

Merged
wpfleger96 merged 4 commits into
mainfrom
duncan/channel-sections-workspace-scope
Jul 2, 2026
Merged

fix(sidebar): scope channel sections storage to relay URL#1477
wpfleger96 merged 4 commits into
mainfrom
duncan/channel-sections-workspace-scope

Conversation

@wpfleger96

@wpfleger96 wpfleger96 commented Jul 2, 2026

Copy link
Copy Markdown
Collaborator

Problem

Custom sidebar sections use a pubkey-only localStorage key (buzz-channel-sections.v1:<pubkey>), so every relay/workspace reads the same store. Two consequences:

  1. Sections bleed across workspaces — sections created on relay A show up (empty) on relay B because the same channels don't exist there.
  2. Cross-relay publish on mountuseChannelSections publishes the local store to the current relay when no remote event exists. With a shared store, switching to a fresh workspace uploads the old workspace's sections to it.
  3. Cross-relay publish on workspace switch — a pending debounced publish queued for relay A can fire after the relayClient singleton has moved to relay B, uploading A's sections to B via the remote path.

Fix

Scope the localStorage key to relay URL: buzz-channel-sections.v1:<pubkey>:<encodedNormalizedRelay>. Two workspaces pointing at the same relay still share sections (correct); different relays are fully isolated.

Key changes:

  • channelSectionsStorage.ts: imports normalizeRelayUrl from selfProfileStorage (the same helper used by channelSnapshot.ts and messageSnapshot.ts) and applies it before encoding the relay in the storage key, so equivalent URL formatting (WSS://Relay.Example/ vs wss://relay.example) maps to the same key. readChannelSectionsStore performs a globally one-time migration — on the first scoped read it copies any non-empty legacy (pubkey-only) data to the scoped key, then deletes the legacy key so no subsequent relay scope can pick it up. writeChannelSectionsStore takes optional relayUrl third param.
  • useChannelSections(pubkey, relayUrl?): threads relayUrl through all effect/callback dep arrays so the hook re-reads storage and re-initialises its sync manager when the active workspace changes.
  • AppSidebar.tsx: passes activeWorkspace?.relayUrl. When undefined (workspace-rail experiment off, or workspace not yet loaded) the hook falls back to the legacy unscoped key — single-workspace users are unaffected.
  • channelSectionsSync.ts: destroy() now cancels any pending debounced publish instead of flushing it. The scoped localStorage write is already durable; when the user returns to relay A the existing seed-publish path re-publishes from local state on next mount. This closes the race where an in-flight publish for relay A could fire after the relayClient singleton has moved to relay B.

Cross-relay publish paths closed:

  • A→B seed-publish: B finds no scoped data (DEFAULT_STORE, legacy key already deleted by A's migration), so sections.length === 0 suppresses the seed-publish.
  • A→B debounce flush: destroy() cancels the timer without calling doPublish, so no event is sent to any relay during teardown.

Tests

9 new tests in channelSectionsStorage.test.mjs: scoped key format, normalization (case + trailing slash collapse), cross-relay isolation, migration from legacy key, migration deletes legacy key (globally one-time), migration is blocked for relay B once relay A consumed the legacy key, migration skips empty legacy stores, scoped key takes precedence post-migration.

3 new tests in channelSectionsSync.test.mjs: destroy() cancels timer and emits no publishEvent call, destroy() with no pending is safe, pendingStore is null after destroy().

npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 and others added 4 commits July 2, 2026 16:41
Each user's custom sidebar sections are semantically tied to a specific
relay/workspace. Using a pubkey-only localStorage key caused sections
created on one relay to appear (empty) on all others, and the mount-time
seed-publish path would upload one relay's sections to a freshly-opened
workspace.

- storageKey() now accepts an optional relayUrl; when present the key
  becomes buzz-channel-sections.v1:<pubkey>:<encodedRelay>, keeping
  sections from different relays completely isolated.
- readChannelSectionsStore() performs a one-time migration: on the first
  scoped read it copies any non-empty legacy (pubkey-only) data to the
  scoped key so existing users don't lose their sections on upgrade. The
  legacy key is left in place to stay compatible with older builds.
- useChannelSections() now accepts relayUrl as a second param and adds
  it to all relevant effect/callback dep arrays so the hook re-reads and
  re-initialises the sync manager whenever the active workspace changes.
  AppSidebar passes activeWorkspace?.relayUrl; when undefined (no active
  workspace or workspace-rail experiment off) the hook falls back to the
  legacy unscoped key, preserving single-workspace behaviour.
- channelSectionsStorage.test.mjs: 7 new tests covering scoped key
  format, cross-relay isolation, migration path, and precedence.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Two review findings addressed:

1. Relay URL normalization: storageKey() now applies normalizeRelayUrl()
   from selfProfileStorage before encoding the relay — matching the same
   trim/lowercase/trailing-slash semantics used by channelSnapshot.ts and
   messageSnapshot.ts. Raw relay text would have produced different keys
   for equivalent URLs (e.g. WSS://Relay.Example/ vs wss://relay.example).

2. Migration is globally one-time: readChannelSectionsStore() now deletes
   the legacy pubkey-only key immediately after a successful scoped write.
   Previously the legacy key was left in place, causing every new relay
   scope's first read to copy legacy data into itself — recreating the
   cross-relay contamination vector the PR was meant to kill.

Tests added/updated: key normalization (case + trailing slash collapse),
migration deletes legacy key after first use, globally one-time migration
(relay B returns DEFAULT_STORE after relay A migrated the legacy data).

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
destroy() was calling doPublish(pendingStore) asynchronously when a
pending debounce was in flight. On workspace switch, the [pubkey,
relayUrl] dep change triggers the effect cleanup — destroying the
manager and flushing the publish — while the relayClient singleton is
mid-transition to the new relay. The async doPublish therefore races
against the backend socket moving to relay B and can land relay A's
channel-sections event on B.

Fix: destroy() now calls cancelPendingPublish() and nulls pendingStore
instead of flushing. The scoped localStorage write is already durable;
when the user returns to relay A the existing no-remote seed-publish
path will publish A's local store to A on next mount.

Tests added: destroy() cancels timer and leaves no publishEvent call,
destroy() with no pending is safe, pendingStore is null after destroy.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
destroy() previously only cancelled the debounce timer, but once the
timer fires the callback calls doPublish() asynchronously. If destroy()
is called while doPublish() is awaiting fetchOwnBlobBeforePublish(),
the timer is already gone (nulled by the callback) so destroy() could
not stop the in-flight publish. doPublish() would swallow the
disconnect rejection inside fetchOwnBlobBeforePublish, return the local
store, and proceed to relayClient.publishEvent() against a singleton
that had already moved to relay B.

Fix: add a private `destroyed` boolean to ChannelSectionSyncManager,
set it in destroy(), and check it at two points in doPublish(): after
the fetchOwnBlobBeforePublish await and immediately before the
relayClient.publishEvent() call. Either check short-circuits the
publish if the manager has been torn down.

Regression test added: manually fire the debounce callback, hold
fetchEvents to simulate the latency window, call destroy(), release the
fetch, drain microtasks — asserts publishEvent is never called.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
@wpfleger96 wpfleger96 merged commit f9d06ae into main Jul 2, 2026
25 checks passed
@wpfleger96 wpfleger96 deleted the duncan/channel-sections-workspace-scope branch July 2, 2026 21:38
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant