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
12 changes: 6 additions & 6 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@bsv/identity-react",
"version": "1.1.12",
"version": "1.1.13",
"description": "React components for resolving identity information on the MetaNet.",
"repository": {
"type": "git",
Expand All @@ -25,7 +25,7 @@
}
},
"dependencies": {
"@bsv/sdk": "^2.0.0",
"@bsv/sdk": "^2.0.3",
"@bsv/uhrp-react": "^1.0.5",
"@types/jest": "^29.5.11",
"@types/node": "^20.11.0",
Expand Down
44 changes: 18 additions & 26 deletions src/components/IdentityCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,64 +14,56 @@ const CACHE_EXPIRY = 5 * 60 * 1000; // 5 minutes
const MAX_CACHE_ENTRIES = 100; // Prevent unbounded growth

class IdentityCache {
private getCache(): Map<string, { identity: DisplayableIdentity; timestamp: number }> {
private cache: Map<string, { identity: DisplayableIdentity; timestamp: number }>;

constructor() {
// Hydrate once from sessionStorage on init
this.cache = new Map();
try {
const stored = sessionStorage.getItem(CACHE_KEY);
if (stored) {
const parsed = JSON.parse(stored);
return new Map(Object.entries(parsed));
for (const [key, value] of Object.entries(parsed)) {
this.cache.set(key, value as { identity: DisplayableIdentity; timestamp: number });
}
}
} catch (e) {
console.warn('Failed to load identity cache from sessionStorage:', e);
}
return new Map();
}

private saveCache(cache: Map<string, { identity: DisplayableIdentity; timestamp: number }>): void {
private persist(): void {
try {
const obj = Object.fromEntries(cache);
sessionStorage.setItem(CACHE_KEY, JSON.stringify(obj));
sessionStorage.setItem(CACHE_KEY, JSON.stringify(Object.fromEntries(this.cache)));
} catch (e) {
console.warn('Failed to save identity cache to sessionStorage:', e);
}
}

get(identityKey: string): DisplayableIdentity | null {
const cache = this.getCache();
const entry = cache.get(identityKey);

const entry = this.cache.get(identityKey);
if (!entry) return null;

// Check if expired
if (Date.now() - entry.timestamp > CACHE_EXPIRY) {
cache.delete(identityKey);
this.saveCache(cache);
this.cache.delete(identityKey);
this.persist();
return null;
}

return entry.identity;
}

set(identityKey: string, identity: DisplayableIdentity): void {
const cache = this.getCache();

// Simple LRU: remove oldest entries if cache is full
if (cache.size >= MAX_CACHE_ENTRIES) {
const firstKey = cache.keys().next().value;
if (this.cache.size >= MAX_CACHE_ENTRIES) {
const firstKey = this.cache.keys().next().value;
if (firstKey) {
cache.delete(firstKey);
this.cache.delete(firstKey);
}
}

cache.set(identityKey, {
identity,
timestamp: Date.now()
});
this.saveCache(cache);
}

size(): number {
return this.getCache().size;
this.cache.set(identityKey, { identity, timestamp: Date.now() });
this.persist();
}
}

Expand Down
14 changes: 9 additions & 5 deletions src/components/IdentitySearchField.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -225,11 +225,15 @@ const IdentitySearchField: React.FC<IdentitySearchFieldProps> = ({
return []
}

const uniqueOptions = deduplicate
? identities.filter((identity, index, array) =>
array.findIndex(i => i.identityKey === identity.identityKey) === index
)
: identities
let uniqueOptions = identities
if (deduplicate) {
const seen = new Set<string>()
uniqueOptions = identities.filter(identity => {
if (seen.has(identity.identityKey)) return false
seen.add(identity.identityKey)
return true
})
}

return filterOptions(uniqueOptions, { inputValue })
}, [identities, deduplicate, filterOptions, inputValue])
Expand Down
18 changes: 14 additions & 4 deletions src/hooks/useIdentitySearch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ export const useIdentitySearch = ({
// Refs for managing async operations
const debounceTimeoutRef = useRef<NodeJS.Timeout | null>(null)
const lastRequestIdRef = useRef<number>(0)
const abortControllerRef = useRef<AbortController | null>(null)
const justSelectedRef = useRef<boolean>(false) // Prevent search after selection
const shouldClearResultsRef = useRef<boolean>(false) // Track explicit clear actions (X button)

Expand All @@ -100,9 +101,16 @@ export const useIdentitySearch = ({
return
}

// Abort any previous in-flight request
if (abortControllerRef.current) {
abortControllerRef.current.abort()
}
const controller = new AbortController()
abortControllerRef.current = controller

setIsLoading(true)

const searchResults = await fetchIdentities(query, wallet, options, originator)
const searchResults = await fetchIdentities(query, wallet, options, originator, controller.signal)

// Verify this is still the latest request before updating state (prevents race conditions)
if (requestId === lastRequestIdRef.current) {
Expand All @@ -112,10 +120,11 @@ export const useIdentitySearch = ({
setIsLoading(false)
}
} catch (error: unknown) {
// Silently ignore aborted requests
if (error instanceof DOMException && error.name === 'AbortError') return
// Only handle error if this is still the latest request
if (requestId === lastRequestIdRef.current) {
console.error('Identity search failed:', error)
console.error('Search params:', { query, wallet: !!wallet, options, originator })
setIdentities([])
setIsLoading(false)
}
Expand Down Expand Up @@ -168,10 +177,10 @@ export const useIdentitySearch = ({
// Show loading state immediately for non-cached searches
setIsLoading(true)

// Debounce the search - wait 300ms after user stops typing
// Debounce the search - wait 400ms after user stops typing
debounceTimeoutRef.current = setTimeout(() => {
performSearch(inputValue.trim(), requestId)
}, 300)
}, 400)

// Cleanup function
return () => {
Expand All @@ -188,6 +197,7 @@ export const useIdentitySearch = ({
if (debounceTimeoutRef.current) {
clearTimeout(debounceTimeoutRef.current)
}
abortControllerRef.current?.abort()
}
}, [])

Expand Down
60 changes: 45 additions & 15 deletions src/utils/identityUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,22 +9,52 @@ export const isIdentityKey = (key: string) => {
return regex.test(key)
}

export const fetchIdentities = async (query: string, wallet?: WalletInterface | undefined, options?: IdentityClientOptions | undefined, originator?: OriginatorDomainNameStringUnder250Bytes | undefined) => {
let results
const client = new IdentityClient(wallet, options, originator)
// Figure out if the query is by IdentityKey
if (isIdentityKey(query)) {
results = await client.resolveByIdentityKey({
identityKey: query
})
} else {
results = await client.resolveByAttributes({
attributes: {
any: query,
}
})
// Cache the IdentityClient so ContactsManager's in-memory cache persists across searches
let cachedClient: IdentityClient | null = null
let cachedWallet: WalletInterface | undefined
let cachedOptionsJson: string | undefined
let cachedOriginator: OriginatorDomainNameStringUnder250Bytes | undefined

function getClient(wallet?: WalletInterface, options?: IdentityClientOptions, originator?: OriginatorDomainNameStringUnder250Bytes): IdentityClient {
// wallet is compared by reference — callers should pass a stable instance
// options is compared by value (JSON) since callers may create new object literals
const optionsJson = options != null ? JSON.stringify(options) : undefined
if (cachedClient && cachedWallet === wallet && cachedOptionsJson === optionsJson && cachedOriginator === originator) {
return cachedClient
}
return results
cachedClient = new IdentityClient(wallet, options, originator)
cachedWallet = wallet
cachedOptionsJson = optionsJson
cachedOriginator = originator
return cachedClient
}

export const fetchIdentities = async (
query: string,
wallet?: WalletInterface | undefined,
options?: IdentityClientOptions | undefined,
originator?: OriginatorDomainNameStringUnder250Bytes | undefined,
signal?: AbortSignal
) => {
// Bail immediately if already aborted
if (signal?.aborted) throw new DOMException('Aborted', 'AbortError')

const client = getClient(wallet, options, originator)

// Race the actual fetch against the abort signal so callers can cancel
// in-flight requests when a newer query supersedes this one.
const fetchPromise = isIdentityKey(query)
? client.resolveByIdentityKey({ identityKey: query }, true)
: client.resolveByAttributes({ attributes: { any: query } }, true)

if (!signal) return await fetchPromise

return await Promise.race([
fetchPromise,
new Promise<never>((_, reject) => {
signal.addEventListener('abort', () => reject(new DOMException('Aborted', 'AbortError')), { once: true })
})
])
}

// Returns the correct tool tip depending on the certifier and certificate type
Expand Down
Loading