From fdbde988466578c443ca0956e7f723eba344543f Mon Sep 17 00:00:00 2001 From: kushagrasarathe <76868364+kushagrasarathe@users.noreply.github.com> Date: Wed, 29 Jul 2026 20:08:38 +0530 Subject: [PATCH 1/3] fix(ens): warm-cache resolved names in localstorage to stop address flash MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit a cold mount (mobile reopening the pwa reloads the page) showed the raw address for a beat while the lookup ran — pay the 404, engage the client fallback, resolve, flip. persist resolved names for 24h and paint them immediately on mount while the lookup revalidates. an authoritative server "no name" evicts the entry so stale names can't mask reality. --- .../__tests__/usePrimaryNameServer.test.tsx | 26 ++++++++ src/hooks/usePrimaryNameServer.ts | 60 ++++++++++++++++++- 2 files changed, 85 insertions(+), 1 deletion(-) diff --git a/src/hooks/__tests__/usePrimaryNameServer.test.tsx b/src/hooks/__tests__/usePrimaryNameServer.test.tsx index ce148a5f5..8e4bcefec 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,31 @@ 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('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..960490e18 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,44 @@ 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 { + return JSON.parse(window.localStorage.getItem(CACHE_KEY) ?? '{}') 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 +91,24 @@ export function usePrimaryNameServer(address?: string): { primaryName: string | priority: 'onChain', }) - return { primaryName: data ?? (isError ? clientName : undefined) } + // resolved: authoritative answer from either path. `data === null` means the + // server positively said "no name" — don't mask it with a stale cached name. + const settledNoName = data === null + const resolved = data ?? (isError ? normalizeToUndefined(clientName) : 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) } +} + +// justaname resolves "not found" as '' — treat it as undefined +function normalizeToUndefined(name: string | undefined): string | undefined { + return name || undefined } From 3645d598b4c8a29182d91416076f74becd1644c7 Mon Sep 17 00:00:00 2001 From: kushagrasarathe <76868364+kushagrasarathe@users.noreply.github.com> Date: Wed, 29 Jul 2026 20:19:37 +0530 Subject: [PATCH 2/3] fix(ens): guard warm-cache root shape against malformed localstorage coderabbit: JSON.parse('null') passes the try/catch but null[address] throws during render. treat any non-object root as an empty cache. --- src/hooks/usePrimaryNameServer.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/hooks/usePrimaryNameServer.ts b/src/hooks/usePrimaryNameServer.ts index 960490e18..578bd8df7 100644 --- a/src/hooks/usePrimaryNameServer.ts +++ b/src/hooks/usePrimaryNameServer.ts @@ -36,7 +36,9 @@ type NameCache = Record function readNameCache(): NameCache { if (typeof window === 'undefined') return {} try { - return JSON.parse(window.localStorage.getItem(CACHE_KEY) ?? '{}') as NameCache + // 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 {} } From aa5da016ba424a3ccc50e90a6fb36b47ea1b62e2 Mon Sep 17 00:00:00 2001 From: kushagrasarathe <76868364+kushagrasarathe@users.noreply.github.com> Date: Wed, 29 Jul 2026 20:25:44 +0530 Subject: [PATCH 3/3] fix(ens): evict warm cache on client-settled no-name; keep AddressLink href in sync MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit code-review findings: (1) with the server route erroring (today's prod state) the only eviction path was server null, which never fires — a client lookup settling '' (justaname's not-found) now also masks and evicts, so a released/transferred ens name can't keep painting from cache. (2) AddressLink's effect never reset urlAddress on the name→address downgrade, leaving the href pointing at a name the address may no longer own — reachable now that cached names can be evicted mid-mount. (3) server '' responses treated same as null. --- src/components/Global/AddressLink/index.tsx | 3 +++ src/hooks/__tests__/usePrimaryNameServer.test.tsx | 13 +++++++++++++ src/hooks/usePrimaryNameServer.ts | 15 ++++++--------- 3 files changed, 22 insertions(+), 9 deletions(-) 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 8e4bcefec..3bb6da99a 100644 --- a/src/hooks/__tests__/usePrimaryNameServer.test.tsx +++ b/src/hooks/__tests__/usePrimaryNameServer.test.tsx @@ -89,6 +89,19 @@ describe('usePrimaryNameServer', () => { 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 578bd8df7..ff07a79fd 100644 --- a/src/hooks/usePrimaryNameServer.ts +++ b/src/hooks/usePrimaryNameServer.ts @@ -93,10 +93,12 @@ export function usePrimaryNameServer(address?: string): { primaryName: string | priority: 'onChain', }) - // resolved: authoritative answer from either path. `data === null` means the - // server positively said "no name" — don't mask it with a stale cached name. - const settledNoName = data === null - const resolved = data ?? (isError ? normalizeToUndefined(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 @@ -109,8 +111,3 @@ export function usePrimaryNameServer(address?: string): { primaryName: string | return { primaryName: resolved ?? (settledNoName ? undefined : cachedName) } } - -// justaname resolves "not found" as '' — treat it as undefined -function normalizeToUndefined(name: string | undefined): string | undefined { - return name || undefined -}