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

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 + }