SkateSpots/src/hooks/useSyncedAppData.ts
SkateSpots e98c996945 fix(newsletter): infinite render loop + issues published to the wrong relays
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.
2026-09-27 17:40:02 +07:00

233 lines
9.7 KiB
TypeScript

import { useCallback, useEffect, useMemo, useRef } from 'react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { useNostr } from '@nostrify/react';
import { useNostrLogin } from '@nostrify/react/login';
import { useCurrentUser } from './useCurrentUser';
import { useNostrPublish } from './useNostrPublish';
import { hasNip44 as checkNip44, isInteractiveSigner } from '@/lib/signerCapabilities';
/**
* useSyncedAppData — a small engine for storing a blob of app state as ONE
* encrypted, addressable NIP-78 (`kind-30078`) event, keyed by a `d` tag, and
* synced across the logged-in user's devices.
*
* It exists so features like read-state and preferences don't each re-implement
* the encrypt-to-self + replaceable-event + clobber-guard dance that `useStash`
* pioneered. The design mirrors `useStash` deliberately (same NIP-44 posture,
* same "re-fetch fresh before write" safety), with two additions the Stash
* doesn't need:
*
* 1. **Deep field-merge on write.** These blobs are edited from MULTIPLE
* devices (phone marks the bell read; laptop marks a DM read). A blind
* overwrite by last-write-wins would let one device wipe the other's
* fields. So before publishing we re-fetch the freshest remote copy and
* merge the caller's patch INTO it (see `mergeStrategy`), rather than
* replacing it wholesale.
*
* 2. **Debounced publish.** Read-state changes fire often (every glance at the
* bell). We coalesce rapid writes into at most one relay publish per
* `debounceMs`, and flush on unload, so we never spam relays.
*
* ANON / NO-NIP44 USERS: this engine simply reports `data: null` and its
* `patch()` becomes a no-op. Callers layer a localStorage fallback on top so
* logged-out (or non-encrypting) users keep the exact behavior they had before.
*/
/** How to combine a locally-produced patch with the freshest remote object. */
export type MergeStrategy<T> = (remote: T, patch: T) => T;
interface UseSyncedAppDataOptions<T> {
/** Stable `d` tag, e.g. `"skatespots:read-state"`. One event per tag. */
dTag: string;
/** Human-readable NIP-31 alt describing the event. */
alt: string;
/** Value returned before anything loads / when unavailable. */
empty: T;
/** Combine the freshest remote copy with the caller's patch. */
merge: MergeStrategy<T>;
/**
* When false, the event is NOT fetched/decrypted. Useful when the hook is
* mounted only for its writer API. Defaults to true.
*/
enabled?: boolean;
/** Coalesce writes into at most one publish per this many ms (default 4000). */
debounceMs?: number;
}
const APP_DATA_KIND = 30078; // NIP-78 application-specific data
export function useSyncedAppData<T>(options: UseSyncedAppDataOptions<T>) {
const { dTag, alt, empty, merge, enabled = true, debounceMs = 4000 } = options;
const { nostr } = useNostr();
const { user } = useCurrentUser();
const { logins } = useNostrLogin();
const queryClient = useQueryClient();
const { mutateAsync: createEvent } = useNostrPublish();
const hasNip44 = useMemo(() => checkNip44(user, logins), [user, logins]);
const canSync = !!user?.pubkey && hasNip44;
// Amber (NIP-55) opens the signer app on EVERY nip44 decrypt. This engine is
// used by chrome that is mounted per page (e.g. the notification bell's read
// state), so re-decrypting on every navigation spams Amber popups. For an
// interactive signer, decrypt once per session and keep it cached — remounts
// read the cache instead of prompting. (Non-interactive signers are cheap, so
// they keep normal refetching.)
const interactive = isInteractiveSigner(user, logins);
const queryKey = useMemo(
() => ['synced-app-data', dTag, user?.pubkey] as const,
[dTag, user?.pubkey],
);
// ── Fetch + decrypt the single event ──────────────────────────────────────
const { data, isLoading } = useQuery({
queryKey,
enabled: enabled && canSync,
// For interactive signers: never consider it stale and never evict it from
// memory during the session → one decrypt, then cache hits.
staleTime: interactive ? Infinity : 60_000,
gcTime: interactive ? Infinity : undefined,
refetchOnMount: !interactive,
refetchOnWindowFocus: false,
refetchOnReconnect: !interactive,
queryFn: async ({ signal }): Promise<T> => {
if (!user?.pubkey || !hasNip44) return empty;
const combined = AbortSignal.any([signal, AbortSignal.timeout(10_000)]);
const events = await nostr.query(
[{ kinds: [APP_DATA_KIND], authors: [user.pubkey], '#d': [dTag], limit: 1 }],
{ signal: combined },
);
if (events.length === 0) return empty;
try {
let dec: typeof user.signer.nip44;
try { dec = user.signer?.nip44; } catch { return empty; }
if (!dec) return empty;
const plaintext = await dec.decrypt(user.pubkey, events[0].content);
return JSON.parse(plaintext) as T;
} catch (err) {
console.error(`[SyncedAppData:${dTag}] decrypt failed:`, err);
return empty;
}
},
});
// ── Fresh re-fetch right before a write (clobber-guard, mirrors useStash) ──
const fetchLatest = useCallback(async (): Promise<T> => {
if (!user?.pubkey || !hasNip44) return empty;
let dec: typeof user.signer.nip44;
try { dec = user.signer?.nip44; } catch { dec = undefined; }
if (!dec) return queryClient.getQueryData<T>(queryKey) ?? data ?? empty;
try {
const events = await nostr.query(
[{ kinds: [APP_DATA_KIND], authors: [user.pubkey], '#d': [dTag], limit: 1 }],
{ signal: AbortSignal.timeout(10_000) },
);
if (events.length === 0) {
return queryClient.getQueryData<T>(queryKey) ?? data ?? empty;
}
const plaintext = await dec.decrypt(user.pubkey, events[0].content);
return JSON.parse(plaintext) as T;
} catch (err) {
// Never wipe on failure — prefer cached over empty.
console.error(`[SyncedAppData:${dTag}] refetch-before-write failed:`, err);
return queryClient.getQueryData<T>(queryKey) ?? data ?? empty;
}
}, [user, hasNip44, nostr, queryClient, queryKey, data, empty, dTag]);
// ── Debounced publisher ────────────────────────────────────────────────────
// We keep the newest desired object in a ref and flush it after a quiet
// period. Multiple rapid patches collapse into one publish.
const pendingRef = useRef<T | null>(null);
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const publishNow = useCallback(async () => {
if (timerRef.current) {
clearTimeout(timerRef.current);
timerRef.current = null;
}
const toPublish = pendingRef.current;
pendingRef.current = null;
if (toPublish == null || !user?.pubkey || !hasNip44) return;
let enc: typeof user.signer.nip44;
try { enc = user.signer?.nip44; } catch { return; }
if (!enc) return;
try {
const ciphertext = await enc.encrypt(user.pubkey, JSON.stringify(toPublish));
await createEvent({
kind: APP_DATA_KIND,
content: ciphertext,
tags: [['d', dTag], ['alt', alt]],
});
} catch (err) {
console.error(`[SyncedAppData:${dTag}] publish failed:`, err);
}
}, [user, hasNip44, createEvent, dTag, alt]);
/**
* Apply a patch: merge it into the freshest remote copy, update the local
* cache immediately (so the UI is instant), and schedule a debounced publish.
* No-op for anon / non-encrypting users.
*/
const patch = useCallback(
async (producePatch: T | ((current: T) => T)) => {
if (!canSync) return;
const latest = await fetchLatest();
const asPatch =
typeof producePatch === 'function'
? (producePatch as (c: T) => T)(latest)
: producePatch;
const merged = merge(latest, asPatch);
// Optimistic local cache — UI reflects it without waiting for the relay.
queryClient.setQueryData<T>(queryKey, merged);
// Queue the debounced publish.
pendingRef.current = merged;
if (timerRef.current) clearTimeout(timerRef.current);
timerRef.current = setTimeout(() => { void publishNow(); }, debounceMs);
},
[canSync, fetchLatest, merge, queryClient, queryKey, publishNow, debounceMs],
);
// Flush any pending write when the tab is hidden/closed so a quick glance +
// navigation away still persists.
useEffect(() => {
const flush = () => { if (pendingRef.current != null) void publishNow(); };
window.addEventListener('pagehide', flush);
window.addEventListener('beforeunload', flush);
return () => {
window.removeEventListener('pagehide', flush);
window.removeEventListener('beforeunload', flush);
// Flush on unmount too.
if (pendingRef.current != null) void publishNow();
};
}, [publishNow]);
// Memoize the returned object: it feeds `useCallback`/`useEffect` deps in
// consumers (e.g. useReadState → mark* → page effects). A fresh object literal
// every render made those identities churn, which turned "mark read on mount"
// effects into an infinite render loop.
return useMemo(
() => ({
/** Decrypted synced object, or `null` while loading / when unavailable. */
data: (data ?? null) as T | null,
isLoading,
/** True when the user can actually sync (logged in + NIP-44). */
canSync,
/** Merge a patch in and schedule a debounced, clobber-safe publish. */
patch,
/** Force an immediate publish of any queued write. */
flush: publishNow,
}),
[data, isLoading, canSync, patch, publishNow],
);
}