Audit of the newsletter section found two real bugs.
1) /newsletter rendered in an infinite loop (100% CPU, React 'Maximum update
depth exceeded'). Chain: useSyncedAppData returned a fresh object each render
-> useReadState.apply (dep: synced) changed identity -> markNewsletterRead /
markAllRead changed every render -> the page effect (deps include it) re-ran
after every render -> the mark set a NEW state object -> loop. The
notification bell looped the same way while open.
- useLocalStorage: setter is now referentially stable (ref + functional set).
- useSyncedAppData: returned object is memoized.
- useReadState: apply reads synced/interactive from refs (stable); marks are
idempotent (same object when already marked) so React bails.
- NewsletterPage marks read once per visit (mount-once ref guard).
2) Issues + subscription markers were published via the default pool (the
author's own NIP-65 relays) but READ via useSkateRelays (community relay +
reader's relays; logged-out = community relay only), so an admin without
goskatespots in their list would publish drops nobody could see. Composer,
backfill panel and the subscribe hook now publish to getWriteRelays() and
fail loudly if every relay rejects (useSpotChat pattern). NIP.md untouched
(no schema change).
Also: guard the overlay pill/reminder against an empty archive, and drop a
redundant canPublish clause.
Root cause: Layout is NOT a router layout — ~62 pages wrap themselves in
<Layout>, so its chrome remounts on every route change. The header's
NotificationBell -> useNotificationsRead -> useReadState -> useSyncedAppData
re-decrypts an encrypted kind-30078 event on mount; with Amber (NIP-55) every
nip44.decrypt opens the signer app, so it popped on each menu click.
Fix: for interactive signers, decrypt once per session and serve remounts from
cache (staleTime/gcTime Infinity, refetchOnMount/refetchOnReconnect false) in
useSyncedAppData and useStash. Non-interactive signers refetch as before.
Follow-up (noted in AGENTS.md): hoist Layout into the router so the chrome mounts
once — removes the whole remount class (and SasquatchGuide state resets).
Adopt PosterChan's "encrypted events are the datastore" idea for two
per-browser bits of state, so they follow the user's npub across devices.
New shared engine (src/hooks/useSyncedAppData.ts):
- Stores one encrypted kind-30078 (NIP-78) event per d-tag, NIP-44
encrypt-to-self, modeled on the proven useStash pattern.
- Clobber-guard: re-fetch + re-decrypt the freshest remote copy and
MERGE the local patch in before publishing (never overwrites a field
another device just set).
- Debounced publisher (coalesces rapid writes, flushes on pagehide/
beforeunload) so frequent read-marker changes don't spam relays.
- No-op for anon / non-NIP-44 signers.
Read-state sync (src/hooks/useReadState.ts), d-tag skatespots:read-state:
- Unifies the four previously localStorage-only markers (notification
bell, DMs, crew chat, newsletter) into one synced blob.
- Merge = field-wise MAX (markers only move forward → commutative, safe).
- The four legacy hooks now re-export drop-in replacements with identical
signatures, so NO consumer component changed. localStorage stays as the
instant/offline/anon fast path; the relay copy hydrates and max-merges.
- Fixes the real bug where clearing a badge on one device left it lit on
another.
Preference sync (src/hooks/usePreferences.ts), d-tag skatespots:preferences:
- Mobile + desktop nav layouts now sync (language field wired for later).
- Merge = last-write-wins by updatedAt (a pref replaces, not accumulates).
- useNavPreferences / useDesktopNavPreferences adopt a synced layout when
present and mirror changes; public APIs unchanged, localStorage fallback
intact.
Docs: NIP.md gains a "Cross-device app data (kind 30078, encrypted)"
section documenting both d-tags, their content shapes, merge policies, and
the clobber-guard/debounce/anon safety notes.
Build + typecheck pass.
Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>