fix: batch profile label updates to prevent UI flickering

- Use requestAnimationFrame to batch rapid profile label updates
- Collect pending updates in a ref instead of updating state immediately
- Apply all pending updates in one render cycle
- Add cleanup to cancel pending RAF on unmount/effect cleanup

This prevents flickering when multiple profiles stream in quickly while
still maintaining progressive updates as profiles arrive.
This commit is contained in:
Gigi
2025-11-02 21:53:22 +01:00
parent d4c6747d98
commit 76a117cdda

View File

@@ -1,4 +1,4 @@
import { useMemo, useState, useEffect } from 'react' import { useMemo, useState, useEffect, useRef } from 'react'
import { Hooks } from 'applesauce-react' import { Hooks } from 'applesauce-react'
import { Helpers, IEventStore } from 'applesauce-core' import { Helpers, IEventStore } from 'applesauce-core'
import { getContentPointers } from 'applesauce-factory/helpers' import { getContentPointers } from 'applesauce-factory/helpers'
@@ -85,6 +85,10 @@ export function useProfileLabels(content: string, relayPool?: RelayPool | null):
const [profileLabels, setProfileLabels] = useState<Map<string, string>>(initialLabels) const [profileLabels, setProfileLabels] = useState<Map<string, string>>(initialLabels)
// Refs for batching updates to prevent flickering
const pendingUpdatesRef = useRef<Map<string, string>>(new Map())
const rafScheduledRef = useRef<number | null>(null)
// Build initial labels: localStorage cache -> eventStore -> fetch from relays // Build initial labels: localStorage cache -> eventStore -> fetch from relays
useEffect(() => { useEffect(() => {
// Extract all pubkeys // Extract all pubkeys
@@ -185,7 +189,7 @@ export function useProfileLabels(content: string, relayPool?: RelayPool | null):
console.log(`[profile-labels] Fetching ${pubkeysToFetch.length} profiles from relays`) console.log(`[profile-labels] Fetching ${pubkeysToFetch.length} profiles from relays`)
console.log(`[profile-labels] Calling fetchProfiles with relayPool and ${pubkeysToFetch.length} pubkeys`) console.log(`[profile-labels] Calling fetchProfiles with relayPool and ${pubkeysToFetch.length} pubkeys`)
// Reactive callback: update labels as profiles stream in // Reactive callback: batch updates to prevent flickering
const handleProfileEvent = (event: NostrEvent) => { const handleProfileEvent = (event: NostrEvent) => {
const encoded = pubkeyToEncoded.get(event.pubkey) const encoded = pubkeyToEncoded.get(event.pubkey)
if (!encoded) { if (!encoded) {
@@ -194,44 +198,85 @@ export function useProfileLabels(content: string, relayPool?: RelayPool | null):
} }
console.log(`[profile-labels] Received profile event for ${encoded.slice(0, 20)}...`) console.log(`[profile-labels] Received profile event for ${encoded.slice(0, 20)}...`)
setProfileLabels(prevLabels => {
const updatedLabels = new Map(prevLabels) // Determine the label for this profile
let label: string
try { try {
const profileData = JSON.parse(event.content || '{}') as { name?: string; display_name?: string; nip05?: string } const profileData = JSON.parse(event.content || '{}') as { name?: string; display_name?: string; nip05?: string }
const displayName = profileData.display_name || profileData.name || profileData.nip05 const displayName = profileData.display_name || profileData.name || profileData.nip05
if (displayName) { if (displayName) {
updatedLabels.set(encoded, `@${displayName}`) label = `@${displayName}`
console.log(`[profile-labels] Updated label reactively for ${encoded.slice(0, 20)}... to @${displayName}`) console.log(`[profile-labels] Updated label reactively for ${encoded.slice(0, 20)}... to @${displayName}`)
} else { } else {
// Use fallback npub display if profile has no name // Use fallback npub display if profile has no name
const fallback = getNpubFallbackDisplay(event.pubkey) label = getNpubFallbackDisplay(event.pubkey)
updatedLabels.set(encoded, fallback) console.log(`[profile-labels] Profile for ${encoded.slice(0, 20)}... has no name, keeping fallback: ${label}`)
console.log(`[profile-labels] Profile for ${encoded.slice(0, 20)}... has no name, keeping fallback: ${fallback}`)
} }
} catch (error) { } catch (error) {
// Use fallback npub display if parsing fails // Use fallback npub display if parsing fails
const fallback = getNpubFallbackDisplay(event.pubkey) label = getNpubFallbackDisplay(event.pubkey)
updatedLabels.set(encoded, fallback)
console.warn(`[profile-labels] Error parsing profile for ${encoded.slice(0, 20)}..., using fallback:`, error) console.warn(`[profile-labels] Error parsing profile for ${encoded.slice(0, 20)}..., using fallback:`, error)
} }
// Add to pending updates
pendingUpdatesRef.current.set(encoded, label)
// Schedule batched update if not already scheduled
if (rafScheduledRef.current === null) {
rafScheduledRef.current = requestAnimationFrame(() => {
// Apply all pending updates in one batch
setProfileLabels(prevLabels => {
const updatedLabels = new Map(prevLabels)
const pendingUpdates = pendingUpdatesRef.current
// Apply all pending updates
for (const [encoded, label] of pendingUpdates.entries()) {
updatedLabels.set(encoded, label)
}
// Clear pending updates
pendingUpdates.clear()
rafScheduledRef.current = null
return updatedLabels return updatedLabels
}) })
})
}
} }
fetchProfiles(relayPool, eventStore as unknown as IEventStore, pubkeysToFetch, undefined, handleProfileEvent) fetchProfiles(relayPool, eventStore as unknown as IEventStore, pubkeysToFetch, undefined, handleProfileEvent)
.then((fetchedProfiles) => { .then((fetchedProfiles) => {
console.log(`[profile-labels] Fetch completed (EOSE), received ${fetchedProfiles.length} profiles total`) console.log(`[profile-labels] Fetch completed (EOSE), received ${fetchedProfiles.length} profiles total`)
// Labels have already been updated reactively via handleProfileEvent // Ensure any pending batched updates are applied
// Just log final state for debugging if (rafScheduledRef.current !== null) {
// Wait for the scheduled RAF to complete
requestAnimationFrame(() => {
setProfileLabels(prevLabels => { setProfileLabels(prevLabels => {
console.log(`[profile-labels] Final labels after EOSE:`, Array.from(prevLabels.entries()).map(([enc, label]) => ({ encoded: enc.slice(0, 20) + '...', label }))) console.log(`[profile-labels] Final labels after EOSE:`, Array.from(prevLabels.entries()).map(([enc, label]) => ({ encoded: enc.slice(0, 20) + '...', label })))
return prevLabels // No change needed, already updated reactively return prevLabels
}) })
}) })
} else {
// No pending updates, just log final state
setProfileLabels(prevLabels => {
console.log(`[profile-labels] Final labels after EOSE:`, Array.from(prevLabels.entries()).map(([enc, label]) => ({ encoded: enc.slice(0, 20) + '...', label })))
return prevLabels
})
}
})
.catch((error) => { .catch((error) => {
console.error(`[profile-labels] Error fetching profiles:`, error) console.error(`[profile-labels] Error fetching profiles:`, error)
// Silently handle fetch errors // Silently handle fetch errors
}) })
// Cleanup: cancel any pending RAF and clear pending updates
return () => {
if (rafScheduledRef.current !== null) {
cancelAnimationFrame(rafScheduledRef.current)
rafScheduledRef.current = null
}
pendingUpdatesRef.current.clear()
}
} else { } else {
if (pubkeysToFetch.length === 0) { if (pubkeysToFetch.length === 0) {
console.log(`[profile-labels] No profiles to fetch`) console.log(`[profile-labels] No profiles to fetch`)