Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions src/components/Global/AddressLink/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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])

Expand Down
39 changes: 39 additions & 0 deletions src/hooks/__tests__/usePrimaryNameServer.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 })
})

Expand Down Expand Up @@ -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()
Expand Down
59 changes: 58 additions & 1 deletion src/hooks/usePrimaryNameServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand All @@ -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<string, { name: string; ts: number }>

// 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
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

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)

Expand Down Expand Up @@ -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) }
}
Loading