A React component library for rendering common AT Protocol records for applications such as Bluesky and Leaflet.
40
fork

Configure Feed

Select the types of activity you want to include in your feed.

record caching

+297 -97
+80 -31
lib/hooks/useAtProtoRecord.ts
··· 1 - import { useEffect, useState } from "react"; 1 + import { useEffect, useState, useRef } from "react"; 2 2 import { useDidResolution } from "./useDidResolution"; 3 3 import { usePdsEndpoint } from "./usePdsEndpoint"; 4 4 import { createAtprotoClient } from "../utils/atproto-client"; 5 5 import { useBlueskyAppview } from "./useBlueskyAppview"; 6 + import { useAtProto } from "../providers/AtProtoProvider"; 6 7 7 8 /** 8 9 * Identifier trio required to address an AT Protocol record. ··· 48 49 collection, 49 50 rkey, 50 51 }: AtProtoRecordKey): AtProtoRecordState<T> { 52 + const { recordCache } = useAtProto(); 51 53 const isBlueskyCollection = collection?.startsWith("app.bsky."); 52 - 54 + 53 55 // Always call all hooks (React rules) - conditionally use results 54 56 const blueskyResult = useBlueskyAppview<T>({ 55 57 did: isBlueskyCollection ? handleOrDid : undefined, 56 58 collection: isBlueskyCollection ? collection : undefined, 57 59 rkey: isBlueskyCollection ? rkey : undefined, 58 60 }); 59 - 61 + 60 62 const { 61 63 did, 62 64 error: didError, ··· 70 72 const [state, setState] = useState<AtProtoRecordState<T>>({ 71 73 loading: !!(handleOrDid && collection && rkey), 72 74 }); 75 + 76 + const releaseRef = useRef<(() => void) | undefined>(undefined); 73 77 74 78 useEffect(() => { 75 79 let cancelled = false; ··· 87 91 }); 88 92 return () => { 89 93 cancelled = true; 94 + if (releaseRef.current) { 95 + releaseRef.current(); 96 + releaseRef.current = undefined; 97 + } 90 98 }; 91 99 } 92 100 ··· 94 102 assignState({ loading: false, error: didError }); 95 103 return () => { 96 104 cancelled = true; 105 + if (releaseRef.current) { 106 + releaseRef.current(); 107 + releaseRef.current = undefined; 108 + } 97 109 }; 98 110 } 99 111 ··· 101 113 assignState({ loading: false, error: endpointError }); 102 114 return () => { 103 115 cancelled = true; 116 + if (releaseRef.current) { 117 + releaseRef.current(); 118 + releaseRef.current = undefined; 119 + } 104 120 }; 105 121 } 106 122 ··· 108 124 assignState({ loading: true, error: undefined }); 109 125 return () => { 110 126 cancelled = true; 127 + if (releaseRef.current) { 128 + releaseRef.current(); 129 + releaseRef.current = undefined; 130 + } 111 131 }; 112 132 } 113 133 114 134 assignState({ loading: true, error: undefined, record: undefined }); 115 135 116 - (async () => { 117 - try { 118 - const { rpc } = await createAtprotoClient({ 119 - service: endpoint, 120 - }); 121 - const res = await ( 122 - rpc as unknown as { 123 - get: ( 124 - nsid: string, 125 - opts: { 126 - params: { 127 - repo: string; 128 - collection: string; 129 - rkey: string; 130 - }; 131 - }, 132 - ) => Promise<{ ok: boolean; data: { value: T } }>; 133 - } 134 - ).get("com.atproto.repo.getRecord", { 135 - params: { repo: did, collection, rkey }, 136 - }); 137 - if (!res.ok) throw new Error("Failed to load record"); 138 - const record = (res.data as { value: T }).value; 139 - assignState({ record, loading: false }); 140 - } catch (e) { 141 - const err = e instanceof Error ? e : new Error(String(e)); 142 - assignState({ error: err, loading: false }); 136 + // Use recordCache.ensure for deduplication and caching 137 + const { promise, release } = recordCache.ensure<T>( 138 + did, 139 + collection, 140 + rkey, 141 + () => { 142 + const controller = new AbortController(); 143 + 144 + const fetchPromise = (async () => { 145 + const { rpc } = await createAtprotoClient({ 146 + service: endpoint, 147 + }); 148 + const res = await ( 149 + rpc as unknown as { 150 + get: ( 151 + nsid: string, 152 + opts: { 153 + params: { 154 + repo: string; 155 + collection: string; 156 + rkey: string; 157 + }; 158 + }, 159 + ) => Promise<{ ok: boolean; data: { value: T } }>; 160 + } 161 + ).get("com.atproto.repo.getRecord", { 162 + params: { repo: did, collection, rkey }, 163 + }); 164 + if (!res.ok) throw new Error("Failed to load record"); 165 + return (res.data as { value: T }).value; 166 + })(); 167 + 168 + return { 169 + promise: fetchPromise, 170 + abort: () => controller.abort(), 171 + }; 143 172 } 144 - })(); 173 + ); 174 + 175 + releaseRef.current = release; 176 + 177 + promise 178 + .then((record) => { 179 + if (!cancelled) { 180 + assignState({ record, loading: false }); 181 + } 182 + }) 183 + .catch((e) => { 184 + if (!cancelled) { 185 + const err = e instanceof Error ? e : new Error(String(e)); 186 + assignState({ error: err, loading: false }); 187 + } 188 + }); 145 189 146 190 return () => { 147 191 cancelled = true; 192 + if (releaseRef.current) { 193 + releaseRef.current(); 194 + releaseRef.current = undefined; 195 + } 148 196 }; 149 197 }, [ 150 198 handleOrDid, ··· 156 204 resolvingEndpoint, 157 205 didError, 158 206 endpointError, 207 + recordCache, 159 208 ]); 160 209 161 210 // Return Bluesky result for app.bsky.* collections
+101 -62
lib/hooks/useBlueskyAppview.ts
··· 1 - import { useEffect, useReducer } from "react"; 1 + import { useEffect, useReducer, useRef } from "react"; 2 2 import { useDidResolution } from "./useDidResolution"; 3 3 import { usePdsEndpoint } from "./usePdsEndpoint"; 4 4 import { createAtprotoClient, SLINGSHOT_BASE_URL } from "../utils/atproto-client"; 5 + import { useAtProto } from "../providers/AtProtoProvider"; 5 6 6 7 /** 7 8 * Extended blob reference that includes CDN URL from appview responses. ··· 235 236 appviewService, 236 237 skipAppview = false, 237 238 }: UseBlueskyAppviewOptions): UseBlueskyAppviewResult<T> { 239 + const { recordCache } = useAtProto(); 238 240 const { 239 241 did, 240 242 error: didError, ··· 253 255 source: undefined, 254 256 }); 255 257 258 + const releaseRef = useRef<(() => void) | undefined>(undefined); 259 + 256 260 useEffect(() => { 257 261 let cancelled = false; 258 262 ··· 261 265 if (!cancelled) dispatch({ type: "RESET" }); 262 266 return () => { 263 267 cancelled = true; 268 + if (releaseRef.current) { 269 + releaseRef.current(); 270 + releaseRef.current = undefined; 271 + } 264 272 }; 265 273 } 266 274 ··· 268 276 if (!cancelled) dispatch({ type: "SET_ERROR", error: didError }); 269 277 return () => { 270 278 cancelled = true; 279 + if (releaseRef.current) { 280 + releaseRef.current(); 281 + releaseRef.current = undefined; 282 + } 271 283 }; 272 284 } 273 285 ··· 275 287 if (!cancelled) dispatch({ type: "SET_ERROR", error: endpointError }); 276 288 return () => { 277 289 cancelled = true; 290 + if (releaseRef.current) { 291 + releaseRef.current(); 292 + releaseRef.current = undefined; 293 + } 278 294 }; 279 295 } 280 296 ··· 282 298 if (!cancelled) dispatch({ type: "SET_LOADING", loading: true }); 283 299 return () => { 284 300 cancelled = true; 301 + if (releaseRef.current) { 302 + releaseRef.current(); 303 + releaseRef.current = undefined; 304 + } 285 305 }; 286 306 } 287 307 288 308 // Start fetching 289 309 dispatch({ type: "SET_LOADING", loading: true }); 290 310 291 - (async () => { 292 - let lastError: Error | undefined; 311 + // Use recordCache.ensure for deduplication and caching 312 + const { promise, release } = recordCache.ensure<T>( 313 + did, 314 + collection, 315 + rkey, 316 + () => { 317 + const controller = new AbortController(); 293 318 294 - // Tier 1: Try Bluesky appview API 295 - if (!skipAppview && BLUESKY_COLLECTION_TO_ENDPOINT[collection]) { 296 - try { 297 - const result = await fetchFromAppview<T>( 298 - did, 299 - collection, 300 - rkey, 301 - appviewService ?? DEFAULT_APPVIEW_SERVICE, 302 - ); 303 - if (!cancelled && result) { 304 - dispatch({ 305 - type: "SET_SUCCESS", 306 - record: result, 307 - source: "appview", 308 - }); 309 - return; 319 + const fetchPromise = (async () => { 320 + let lastError: Error | undefined; 321 + 322 + // Tier 1: Try Bluesky appview API 323 + if (!skipAppview && BLUESKY_COLLECTION_TO_ENDPOINT[collection]) { 324 + try { 325 + const result = await fetchFromAppview<T>( 326 + did, 327 + collection, 328 + rkey, 329 + appviewService ?? DEFAULT_APPVIEW_SERVICE, 330 + ); 331 + if (result) { 332 + return result; 333 + } 334 + } catch (err) { 335 + lastError = err as Error; 336 + // Continue to next tier 337 + } 310 338 } 311 - } catch (err) { 312 - lastError = err as Error; 313 - // Continue to next tier 314 - } 339 + 340 + // Tier 2: Try Slingshot getRecord 341 + try { 342 + const result = await fetchFromSlingshot<T>(did, collection, rkey); 343 + if (result) { 344 + return result; 345 + } 346 + } catch (err) { 347 + lastError = err as Error; 348 + // Continue to next tier 349 + } 350 + 351 + // Tier 3: Try PDS directly 352 + try { 353 + const result = await fetchFromPds<T>( 354 + did, 355 + collection, 356 + rkey, 357 + pdsEndpoint, 358 + ); 359 + if (result) { 360 + return result; 361 + } 362 + } catch (err) { 363 + lastError = err as Error; 364 + } 365 + 366 + // All tiers failed 367 + throw lastError ?? new Error("Failed to fetch record from all sources"); 368 + })(); 369 + 370 + return { 371 + promise: fetchPromise, 372 + abort: () => controller.abort(), 373 + }; 315 374 } 375 + ); 316 376 317 - // Tier 2: Try Slingshot getRecord 318 - try { 319 - const result = await fetchFromSlingshot<T>(did, collection, rkey); 320 - if (!cancelled && result) { 377 + releaseRef.current = release; 378 + 379 + promise 380 + .then((record) => { 381 + if (!cancelled) { 321 382 dispatch({ 322 383 type: "SET_SUCCESS", 323 - record: result, 324 - source: "slingshot", 384 + record, 385 + source: "appview", 325 386 }); 326 - return; 327 387 } 328 - } catch (err) { 329 - lastError = err as Error; 330 - // Continue to next tier 331 - } 332 - 333 - // Tier 3: Try PDS directly 334 - try { 335 - const result = await fetchFromPds<T>( 336 - did, 337 - collection, 338 - rkey, 339 - pdsEndpoint, 340 - ); 341 - if (!cancelled && result) { 388 + }) 389 + .catch((err) => { 390 + if (!cancelled) { 342 391 dispatch({ 343 - type: "SET_SUCCESS", 344 - record: result, 345 - source: "pds", 392 + type: "SET_ERROR", 393 + error: err instanceof Error ? err : new Error(String(err)), 346 394 }); 347 - return; 348 395 } 349 - } catch (err) { 350 - lastError = err as Error; 351 - } 352 - 353 - // All tiers failed 354 - if (!cancelled) { 355 - dispatch({ 356 - type: "SET_ERROR", 357 - error: 358 - lastError ?? 359 - new Error("Failed to fetch record from all sources"), 360 - }); 361 - } 362 - })(); 396 + }); 363 397 364 398 return () => { 365 399 cancelled = true; 400 + if (releaseRef.current) { 401 + releaseRef.current(); 402 + releaseRef.current = undefined; 403 + } 366 404 }; 367 405 }, [ 368 406 handleOrDid, ··· 376 414 resolvingEndpoint, 377 415 didError, 378 416 endpointError, 417 + recordCache, 379 418 ]); 380 419 381 420 return state;
+9 -4
lib/providers/AtProtoProvider.tsx
··· 6 6 useRef, 7 7 } from "react"; 8 8 import { ServiceResolver, normalizeBaseUrl } from "../utils/atproto-client"; 9 - import { BlobCache, DidCache } from "../utils/cache"; 9 + import { BlobCache, DidCache, RecordCache } from "../utils/cache"; 10 10 11 11 /** 12 12 * Props for the AT Protocol context provider. ··· 30 30 didCache: DidCache; 31 31 /** Cache for fetched blob data. */ 32 32 blobCache: BlobCache; 33 + /** Cache for fetched AT Protocol records. */ 34 + recordCache: RecordCache; 33 35 } 34 36 35 37 const AtProtoContext = createContext<AtProtoContextValue | undefined>( ··· 92 94 const cachesRef = useRef<{ 93 95 didCache: DidCache; 94 96 blobCache: BlobCache; 97 + recordCache: RecordCache; 95 98 } | null>(null); 96 99 if (!cachesRef.current) { 97 100 cachesRef.current = { 98 101 didCache: new DidCache(), 99 102 blobCache: new BlobCache(), 103 + recordCache: new RecordCache(), 100 104 }; 101 105 } 102 106 ··· 106 110 plcDirectory: normalizedPlc, 107 111 didCache: cachesRef.current!.didCache, 108 112 blobCache: cachesRef.current!.blobCache, 113 + recordCache: cachesRef.current!.recordCache, 109 114 }), 110 115 [resolver, normalizedPlc], 111 116 ); ··· 120 125 /** 121 126 * Hook that accesses the AT Protocol context provided by `AtProtoProvider`. 122 127 * 123 - * This hook exposes the service resolver, DID cache, and blob cache for building 124 - * custom AT Protocol functionality. 128 + * This hook exposes the service resolver, DID cache, blob cache, and record cache 129 + * for building custom AT Protocol functionality. 125 130 * 126 131 * @throws {Error} When called outside of an `AtProtoProvider`. 127 132 * @returns {AtProtoContextValue} Object containing resolver, caches, and PLC directory URL. ··· 131 136 * import { useAtProto } from 'atproto-ui'; 132 137 * 133 138 * function MyCustomComponent() { 134 - * const { resolver, didCache, blobCache } = useAtProto(); 139 + * const { resolver, didCache, blobCache, recordCache } = useAtProto(); 135 140 * // Use the resolver and caches for custom AT Protocol operations 136 141 * } 137 142 * ```
+107
lib/utils/cache.ts
··· 270 270 } 271 271 } 272 272 } 273 + 274 + interface RecordCacheEntry<T = unknown> { 275 + record: T; 276 + timestamp: number; 277 + } 278 + 279 + interface InFlightRecordEntry<T = unknown> { 280 + promise: Promise<T>; 281 + abort: () => void; 282 + refCount: number; 283 + } 284 + 285 + interface RecordEnsureResult<T = unknown> { 286 + promise: Promise<T>; 287 + release: () => void; 288 + } 289 + 290 + export class RecordCache { 291 + private store = new Map<string, RecordCacheEntry>(); 292 + private inFlight = new Map<string, InFlightRecordEntry>(); 293 + 294 + private key(did: string, collection: string, rkey: string): string { 295 + return `${did}::${collection}::${rkey}`; 296 + } 297 + 298 + get<T = unknown>( 299 + did?: string, 300 + collection?: string, 301 + rkey?: string, 302 + ): T | undefined { 303 + if (!did || !collection || !rkey) return undefined; 304 + return this.store.get(this.key(did, collection, rkey))?.record as 305 + | T 306 + | undefined; 307 + } 308 + 309 + set<T = unknown>( 310 + did: string, 311 + collection: string, 312 + rkey: string, 313 + record: T, 314 + ): void { 315 + this.store.set(this.key(did, collection, rkey), { 316 + record, 317 + timestamp: Date.now(), 318 + }); 319 + } 320 + 321 + ensure<T = unknown>( 322 + did: string, 323 + collection: string, 324 + rkey: string, 325 + loader: () => { promise: Promise<T>; abort: () => void }, 326 + ): RecordEnsureResult<T> { 327 + const cached = this.get<T>(did, collection, rkey); 328 + if (cached !== undefined) { 329 + return { promise: Promise.resolve(cached), release: () => {} }; 330 + } 331 + 332 + const key = this.key(did, collection, rkey); 333 + const existing = this.inFlight.get(key) as 334 + | InFlightRecordEntry<T> 335 + | undefined; 336 + if (existing) { 337 + existing.refCount += 1; 338 + return { 339 + promise: existing.promise, 340 + release: () => this.release(key), 341 + }; 342 + } 343 + 344 + const { promise, abort } = loader(); 345 + const wrapped = promise.then((record) => { 346 + this.set(did, collection, rkey, record); 347 + return record; 348 + }); 349 + 350 + const entry: InFlightRecordEntry<T> = { 351 + promise: wrapped, 352 + abort, 353 + refCount: 1, 354 + }; 355 + 356 + this.inFlight.set(key, entry as InFlightRecordEntry); 357 + 358 + wrapped 359 + .catch(() => {}) 360 + .finally(() => { 361 + this.inFlight.delete(key); 362 + }); 363 + 364 + return { 365 + promise: wrapped, 366 + release: () => this.release(key), 367 + }; 368 + } 369 + 370 + private release(key: string) { 371 + const entry = this.inFlight.get(key); 372 + if (!entry) return; 373 + entry.refCount -= 1; 374 + if (entry.refCount <= 0) { 375 + this.inFlight.delete(key); 376 + entry.abort(); 377 + } 378 + } 379 + }