diff --git a/src/components/Global/AddressLink/index.tsx b/src/components/Global/AddressLink/index.tsx index bb9d86504..e8ae85b79 100644 --- a/src/components/Global/AddressLink/index.tsx +++ b/src/components/Global/AddressLink/index.tsx @@ -29,6 +29,9 @@ const AddressLink = ({ address, className = '', isLink = true }: AddressLinkProp setUrlAddress(normalizedEnsName) } else { setDisplayAddress(isCryptoAddress(address) ? printableAddress(address) : address) + // keep the link target in sync — a cached/evicted ens name must not + // leave the href pointing at a name this address may no longer own + setUrlAddress(address) } }, [address, ensName]) diff --git a/src/hooks/__tests__/usePrimaryNameServer.test.tsx b/src/hooks/__tests__/usePrimaryNameServer.test.tsx index ce148a5f5..3bb6da99a 100644 --- a/src/hooks/__tests__/usePrimaryNameServer.test.tsx +++ b/src/hooks/__tests__/usePrimaryNameServer.test.tsx @@ -22,6 +22,7 @@ const jsonResponse = (body: unknown, ok = true) => ({ ok, json: async () => body beforeEach(() => { jest.clearAllMocks() + window.localStorage.clear() mockUsePrimaryName.mockReturnValue({ primaryName: undefined, isLoading: false, error: null }) }) @@ -63,6 +64,44 @@ describe('usePrimaryNameServer', () => { expect(result.current.primaryName).toBeUndefined() }) + it('paints the cached name immediately on mount while the lookup is pending', async () => { + // seed the warm cache via a first mount that resolves + mockServerFetch.mockResolvedValue(jsonResponse({ name: 'alice.eth' })) + const first = renderHook(() => usePrimaryNameServer(ADDRESS), { wrapper }) + await waitFor(() => expect(first.result.current.primaryName).toBe('alice.eth')) + first.unmount() + + // fresh mount with a never-resolving lookup — cached name shows at once + mockServerFetch.mockImplementation(() => new Promise(() => {})) + const { result } = renderHook(() => usePrimaryNameServer(ADDRESS), { wrapper }) + expect(result.current.primaryName).toBe('alice.eth') + }) + + it('does not show a stale cached name once the server settles with no name', async () => { + window.localStorage.setItem( + 'ens-primary-name-cache', + JSON.stringify({ [ADDRESS.toLowerCase()]: { name: 'stale.eth', ts: Date.now() } }) + ) + mockServerFetch.mockResolvedValue(jsonResponse({ name: null })) + const { result } = renderHook(() => usePrimaryNameServer(ADDRESS), { wrapper }) + await waitFor(() => expect(result.current.primaryName).toBeUndefined()) + // authoritative "no name" also evicts the cache entry + await waitFor(() => expect(window.localStorage.getItem('ens-primary-name-cache')).not.toContain('stale.eth')) + }) + + it('evicts the cached name when the client fallback settles with no name', async () => { + window.localStorage.setItem( + 'ens-primary-name-cache', + JSON.stringify({ [ADDRESS.toLowerCase()]: { name: 'stale.eth', ts: Date.now() } }) + ) + mockServerFetch.mockResolvedValue(jsonResponse({}, false)) + // justaname settles "not found" as '' (undefined would mean still loading) + mockUsePrimaryName.mockReturnValue({ primaryName: '', isLoading: false, error: null }) + const { result } = renderHook(() => usePrimaryNameServer(ADDRESS), { wrapper }) + await waitFor(() => expect(window.localStorage.getItem('ens-primary-name-cache')).not.toContain('stale.eth')) + expect(result.current.primaryName).toBeUndefined() + }) + it('does not query for a non-address input', () => { renderHook(() => usePrimaryNameServer('not-an-address'), { wrapper }) expect(mockServerFetch).not.toHaveBeenCalled() diff --git a/src/hooks/usePrimaryNameServer.ts b/src/hooks/usePrimaryNameServer.ts index ec93fec9b..ff07a79fd 100644 --- a/src/hooks/usePrimaryNameServer.ts +++ b/src/hooks/usePrimaryNameServer.ts @@ -2,6 +2,7 @@ import { usePrimaryName } from '@justaname.id/react' import { useQuery } from '@tanstack/react-query' +import { useEffect, useMemo } from 'react' import { isAddress } from 'viem' import { serverFetch } from '@/utils/api-fetch' @@ -25,6 +26,46 @@ const PRIMARY_NAME_TTL_MS = 24 * 60 * 60 * 1000 // 1 day * `{ primaryName }` shape. If both paths fail, callers degrade to the raw * address exactly as before. */ +const CACHE_KEY = 'ens-primary-name-cache' + +type NameCache = Record + +// localstorage warm cache so a fresh page load (mobile reopening the pwa) +// paints the last-known name immediately instead of flashing the raw +// address while the async lookup runs. lookups still revalidate on mount. +function readNameCache(): NameCache { + if (typeof window === 'undefined') return {} + try { + // guard the root shape — JSON.parse("null") etc. passes but would blow up on lookup + const cache: unknown = JSON.parse(window.localStorage.getItem(CACHE_KEY) ?? '{}') + return cache && typeof cache === 'object' && !Array.isArray(cache) ? (cache as NameCache) : {} + } catch { + return {} + } +} + +function getCachedName(address?: string): string | undefined { + if (!address) return undefined + const entry = readNameCache()[address.toLowerCase()] + return entry && Date.now() - entry.ts < PRIMARY_NAME_TTL_MS ? entry.name : undefined +} + +function writeCachedName(address: string, name: string | undefined) { + if (typeof window === 'undefined') return + try { + const cache = readNameCache() + const key = address.toLowerCase() + if (name) { + cache[key] = { name, ts: Date.now() } + } else { + delete cache[key] + } + window.localStorage.setItem(CACHE_KEY, JSON.stringify(cache)) + } catch { + // storage full/blocked — warm cache is best-effort + } +} + export function usePrimaryNameServer(address?: string): { primaryName: string | undefined } { const enabled = !!address && isAddress(address) @@ -52,5 +93,21 @@ export function usePrimaryNameServer(address?: string): { primaryName: string | priority: 'onChain', }) - return { primaryName: data ?? (isError ? clientName : undefined) } + // authoritative "no name" from either path: server settles null/'' or, when + // the server call failed, the client lookup settles '' (justaname's not-found + // value — undefined means still loading). don't mask any of these with a + // stale cached name, and evict below so it can't repaint next mount. + const settledNoName = data === null || data === '' || (isError && clientName === '') + const resolved = (data || undefined) ?? (isError ? clientName || undefined : undefined) + + useEffect(() => { + if (!enabled || !address) return + if (resolved) writeCachedName(address, resolved) + else if (settledNoName) writeCachedName(address, undefined) + }, [enabled, address, resolved, settledNoName]) + + // read once per address, not on every render — history feeds mount many rows + const cachedName = useMemo(() => (enabled ? getCachedName(address) : undefined), [enabled, address]) + + return { primaryName: resolved ?? (settledNoName ? undefined : cachedName) } }