From cb0fd3139ec5bbfa2bfea99ed7d4ad4250f2dcd0 Mon Sep 17 00:00:00 2001 From: "Natalie B." <22222885+espeon@users.noreply.github.com> Date: Sun, 1 Mar 2026 13:16:09 -0600 Subject: [PATCH] profile cache context with batching if needed --- js/components/src/context/profile-cache.tsx | 71 ++++++++++++++++++-- js/components/src/hooks/useAvatars.tsx | 33 +-------- js/components/src/streamplace-store/xrpc.tsx | 8 +++ 3 files changed, 76 insertions(+), 36 deletions(-) diff --git a/js/components/src/context/profile-cache.tsx b/js/components/src/context/profile-cache.tsx index a054dc897..d5e1a8120 100644 --- a/js/components/src/context/profile-cache.tsx +++ b/js/components/src/context/profile-cache.tsx @@ -1,10 +1,16 @@ import { ProfileViewDetailed } from "@atproto/api/dist/client/types/app/bsky/actor/defs"; -import { createContext, useContext, useRef, useState } from "react"; +import { + createContext, + useCallback, + useContext, + useRef, + useState, +} from "react"; +import { useUnauthenticatedBlueskyAppViewAgent } from "../streamplace-store"; interface ProfileCacheContextValue { profiles: Record; - inFlight: React.MutableRefObject>; - addProfiles: (profiles: Record) => void; + requestProfiles: (dids: string[]) => void; } export const ProfileCacheContext = @@ -15,16 +21,69 @@ export function ProfileCacheProvider({ }: { children: React.ReactNode; }) { + const agent = useUnauthenticatedBlueskyAppViewAgent(); const [profiles, setProfiles] = useState>( {}, ); + const agentRef = useRef(agent); + agentRef.current = agent; + const profilesRef = useRef(profiles); + profilesRef.current = profiles; const inFlight = useRef>(new Set()); + const pending = useRef>(new Set()); + const timer = useRef | null>(null); - const addProfiles = (newProfiles: Record) => - setProfiles((prev) => ({ ...prev, ...newProfiles })); + const flush = useCallback(() => { + timer.current = null; + if (!agentRef.current) return; + + const toFetch = [...pending.current] + .filter((d) => !(d in profilesRef.current) && !inFlight.current.has(d)) + .slice(0, 25); + toFetch.forEach((d) => pending.current.delete(d)); + + if (toFetch.length === 0) return; + + // If there are more beyond the batch limit, schedule the remainder + if (pending.current.size > 0) { + timer.current = setTimeout(flush, 0); + } + + toFetch.forEach((d) => inFlight.current.add(d)); + + agentRef.current + .getProfiles({ actors: toFetch }) + .then((result) => { + const newProfiles: Record = {}; + result.data.profiles.forEach((p) => { + newProfiles[p.did] = p; + }); + setProfiles((prev) => ({ ...prev, ...newProfiles })); + }) + .catch((e) => { + console.error("Failed to fetch profiles", e); + }) + .finally(() => { + toFetch.forEach((d) => inFlight.current.delete(d)); + }); + }, []); + + const requestProfiles = useCallback( + (dids: string[]) => { + const toQueue = dids.filter( + (d) => !(d in profilesRef.current) && !inFlight.current.has(d), + ); + if (toQueue.length === 0) return; + toQueue.forEach((d) => pending.current.add(d)); + if (!timer.current) { + timer.current = setTimeout(flush, 50); + } + }, + [flush], + ); return ( - + {children} ); diff --git a/js/components/src/hooks/useAvatars.tsx b/js/components/src/hooks/useAvatars.tsx index ba4612a23..c52e44682 100644 --- a/js/components/src/hooks/useAvatars.tsx +++ b/js/components/src/hooks/useAvatars.tsx @@ -1,42 +1,15 @@ import { ProfileViewDetailed } from "@atproto/api/dist/client/types/app/bsky/actor/defs"; import { useEffect, useMemo } from "react"; import { useProfileCache } from "../context/profile-cache"; -import { usePDSAgent } from "../streamplace-store/xrpc"; export function useAvatars( dids: string[], ): Record { - const agent = usePDSAgent(); - const { profiles, inFlight, addProfiles } = useProfileCache(); - - const missingDids = useMemo( - () => - dids.filter((did) => !(did in profiles) && !inFlight.current.has(did)), - [dids, profiles, inFlight], - ); + const { profiles, requestProfiles } = useProfileCache(); useEffect(() => { - if (missingDids.length === 0 || !agent) return; - const toFetch = missingDids.slice(0, 25); - toFetch.forEach((did) => inFlight.current.add(did)); - - const fetchProfiles = async () => { - try { - const result = await agent.getProfiles({ actors: toFetch }); - const newProfiles: Record = {}; - result.data.profiles.forEach((p) => { - newProfiles[p.did] = p; - }); - addProfiles(newProfiles); - } catch (e) { - console.error("Failed to fetch profiles", e); - } finally { - toFetch.forEach((did) => inFlight.current.delete(did)); - } - }; - - fetchProfiles(); - }, [missingDids, agent]); + requestProfiles(dids); + }, [dids, requestProfiles]); return useMemo( () => diff --git a/js/components/src/streamplace-store/xrpc.tsx b/js/components/src/streamplace-store/xrpc.tsx index bc480b6fd..82e583b51 100644 --- a/js/components/src/streamplace-store/xrpc.tsx +++ b/js/components/src/streamplace-store/xrpc.tsx @@ -37,3 +37,11 @@ export function usePossiblyUnauthedPDSAgent(): StreamplaceAgent | null { return new StreamplaceAgent(oauthSession); }, [oauthSession]); } + +// always returns an unauthenticated agent pointed at the public bluesky API +// probably should not be used in most places, but in case we have a bug it may be useful +export function useUnauthenticatedBlueskyAppViewAgent(): StreamplaceAgent { + return useMemo(() => { + return new StreamplaceAgent("https://public.api.bsky.app"); + }, []); +} -- 2.51.2