fix(amber): stop re-decrypting on every navigation

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).
This commit is contained in:
SkateSpots 2026-09-27 13:28:36 +07:00
parent 1e4f3302dc
commit 55b4dd2e86
3 changed files with 27 additions and 2 deletions

View file

@ -150,6 +150,13 @@ session with **no other memory of this project**. So:
MOTIS 2, open CORS) — note origin/destination go to that service, unlike road
modes. **No external map-app handoff anywhere** (product decision) and no
scooter/moped profile.
- **⚠️ `Layout` is NOT a router layout** — ~62 pages wrap themselves in
`<Layout>`, so its chrome (`NotificationBell`, `SasquatchGuide`, nav) **remounts
on every navigation**. Consequence: any *decrypt-on-mount* hook in that chrome
re-opens Amber (NIP-55) on every page change. Encrypted hooks must be gated for
interactive signers: `staleTime/gcTime: Infinity`, `refetchOnMount: false`,
`refetchOnReconnect: false` (see `useSyncedAppData` / `useStash`). Hoisting
`Layout` into the router would fix the whole class — a known follow-up.
- **Mints directory**: `/mints` page lists Cashu mints from the community
**MintRadar** project. MintRadar has **no CORS**, so it's fetched same-origin
at `/api/mints/*` — prod proxies via `public/_redirects` (`/api/mints/* →

View file

@ -110,7 +110,12 @@ export function useStash(options?: { enabled?: boolean }) {
},
enabled: callerEnabled && !!user?.pubkey && hasNip44,
staleTime: interactive ? Infinity : 60000,
// Keep the decrypted stash in memory for the session so navigating away and
// back doesn't re-open Amber (gcTime would otherwise evict it after 5 min).
gcTime: interactive ? Infinity : undefined,
refetchOnMount: !interactive,
refetchOnWindowFocus: false,
refetchOnReconnect: !interactive,
});
/**

View file

@ -4,7 +4,7 @@ import { useNostr } from '@nostrify/react';
import { useNostrLogin } from '@nostrify/react/login';
import { useCurrentUser } from './useCurrentUser';
import { useNostrPublish } from './useNostrPublish';
import { hasNip44 as checkNip44 } from '@/lib/signerCapabilities';
import { hasNip44 as checkNip44, isInteractiveSigner } from '@/lib/signerCapabilities';
/**
* useSyncedAppData — a small engine for storing a blob of app state as ONE
@ -68,6 +68,14 @@ export function useSyncedAppData<T>(options: UseSyncedAppDataOptions<T>) {
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],
@ -77,8 +85,13 @@ export function useSyncedAppData<T>(options: UseSyncedAppDataOptions<T>) {
const { data, isLoading } = useQuery({
queryKey,
enabled: enabled && canSync,
staleTime: 60_000,
// 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;