A React component library for rendering common AT Protocol records for applications such as Bluesky and Leaflet.
1# AtReact Hooks Deep Dive 2 3## Overview 4The AtReact hooks system provides a robust, cache-optimized layer for fetching AT Protocol data. All hooks follow React best practices with proper cleanup, cancellation, and stable references. 5 6--- 7 8## Core Architecture Principles 9 10### 1. **Three-Tier Caching Strategy** 11All data flows through three cache layers: 12- **DidCache** - DID documents, handle mappings, PDS endpoints 13- **BlobCache** - Media/image blobs with reference counting 14- **RecordCache** - AT Protocol records with deduplication 15 16### 2. **Concurrent Request Deduplication** 17When multiple components request the same data, only one network request is made. Uses reference counting to manage in-flight requests. 18 19### 3. **Stable Reference Pattern** 20Caches use memoized snapshots to prevent unnecessary re-renders: 21```typescript 22// Only creates new snapshot if data actually changed 23if (existing && existing.did === did && existing.handle === handle) { 24 return toSnapshot(existing); // Reuse existing 25} 26``` 27 28### 4. **Three-Tier Fallback for Bluesky** 29For `app.bsky.*` collections: 301. Try Bluesky appview API (fastest, public) 312. Fall back to Slingshot (microcosm service) 323. Finally query PDS directly 33 34--- 35 36## Hook Catalog 37 38## 1. `useDidResolution` 39**Purpose:** Resolves handles to DIDs or fetches DID documents 40 41### Key Features: 42- **Bidirectional:** Works with handles OR DIDs 43- **Smart Caching:** Only fetches if not in cache 44- **Dual Resolution Paths:** 45 - Handle → DID: Uses Slingshot first, then appview 46 - DID → Document: Fetches full DID document for handle extraction 47 48### State Flow: 49```typescript 50Input: "alice.bsky.social" or "did:plc:xxx" 51 52Check didCache 53 54If handle: ensureHandle(resolver, handle) DID 55If DID: ensureDidDoc(resolver, did) DID doc + handle from alsoKnownAs 56 57Return: { did, handle, loading, error } 58``` 59 60### Critical Implementation Details: 61- **Normalizes input** to lowercase for handles 62- **Memoizes input** to prevent effect re-runs 63- **Stabilizes error references** - only updates if message changes 64- **Cleanup:** Cancellation token prevents stale updates 65 66--- 67 68## 2. `usePdsEndpoint` 69**Purpose:** Discovers the PDS endpoint for a DID 70 71### Key Features: 72- **Depends on DID resolution** (implicit dependency) 73- **Extracts from DID document** if already cached 74- **Lazy fetching** - only when endpoint not in cache 75 76### State Flow: 77```typescript 78Input: DID 79 80Check didCache.getByDid(did).pdsEndpoint 81 82If missing: ensurePdsEndpoint(resolver, did) 83 ├─ Tries to get from existing DID doc 84 └─ Falls back to resolver.pdsEndpointForDid() 85 86Return: { endpoint, loading, error } 87``` 88 89### Service Discovery: 90Looks for `AtprotoPersonalDataServer` service in DID document: 91```json 92{ 93 "service": [{ 94 "type": "AtprotoPersonalDataServer", 95 "serviceEndpoint": "https://pds.example.com" 96 }] 97} 98``` 99 100--- 101 102## 3. `useAtProtoRecord` 103**Purpose:** Fetches a single AT Protocol record with smart routing 104 105### Key Features: 106- **Collection-aware routing:** Bluesky vs other protocols 107- **RecordCache deduplication:** Multiple components = one fetch 108- **Cleanup with reference counting** 109 110### State Flow: 111```typescript 112Input: { did, collection, rkey } 113 114If collection.startsWith("app.bsky."): 115 └─ useBlueskyAppview() Three-tier fallback 116Else: 117 ├─ useDidResolution(did) 118 ├─ usePdsEndpoint(resolved.did) 119 └─ recordCache.ensure() Fetch from PDS 120 121Return: { record, loading, error } 122``` 123 124### RecordCache Deduplication: 125```typescript 126// First component calling this 127const { promise, release } = recordCache.ensure(did, collection, rkey, loader) 128// refCount = 1 129 130// Second component calling same record 131const { promise, release } = recordCache.ensure(...) // Same promise! 132// refCount = 2 133 134// On cleanup, both call release() 135// Only aborts when refCount reaches 0 136``` 137 138--- 139 140## 4. `useBlueskyAppview` 141**Purpose:** Fetches Bluesky records with appview optimization 142 143### Key Features: 144- **Collection-aware endpoints:** 145 - `app.bsky.actor.profile``app.bsky.actor.getProfile` 146 - `app.bsky.feed.post``app.bsky.feed.getPostThread` 147- **CDN URL extraction:** Parses CDN URLs to extract CIDs 148- **Atomic state updates:** Uses reducer for complex state 149 150### Three-Tier Fallback with Source Tracking: 151```typescript 152async function fetchWithFallback() { 153 // Tier 1: Appview (if endpoint mapped) 154 try { 155 const result = await fetchFromAppview(did, collection, rkey); 156 return { record: result, source: "appview" }; 157 } catch {} 158 159 // Tier 2: Slingshot 160 try { 161 const result = await fetchFromSlingshot(did, collection, rkey); 162 return { record: result, source: "slingshot" }; 163 } catch {} 164 165 // Tier 3: PDS 166 try { 167 const result = await fetchFromPds(did, collection, rkey); 168 return { record: result, source: "pds" }; 169 } catch {} 170 171 // All tiers failed - provide helpful error for banned Bluesky accounts 172 if (pdsEndpoint.includes('.bsky.network')) { 173 throw new Error('Record unavailable. The Bluesky PDS may be unreachable or the account may be banned.'); 174 } 175 176 throw new Error('Failed to fetch record from all sources'); 177} 178``` 179 180The `source` field in the result accurately indicates which tier successfully fetched the data, enabling debugging and analytics. 181 182### CDN URL Handling: 183Appview returns CDN URLs like: 184``` 185https://cdn.bsky.app/img/avatar/plain/did:plc:xxx/bafkreixxx@jpeg 186``` 187 188Hook extracts CID (`bafkreixxx`) and creates standard Blob object: 189```typescript 190{ 191 $type: "blob", 192 ref: { $link: "bafkreixxx" }, 193 mimeType: "image/jpeg", 194 size: 0, 195 cdnUrl: "https://cdn.bsky.app/..." // Preserved for fast rendering 196} 197``` 198 199### Reducer Pattern: 200```typescript 201type Action = 202 | { type: "SET_LOADING"; loading: boolean } 203 | { type: "SET_SUCCESS"; record: T; source: "appview" | "slingshot" | "pds" } 204 | { type: "SET_ERROR"; error: Error } 205 | { type: "RESET" }; 206 207// Atomic state updates, no race conditions 208dispatch({ type: "SET_SUCCESS", record, source }); 209``` 210 211--- 212 213## 5. `useLatestRecord` 214**Purpose:** Fetches the most recent record from a collection 215 216### Key Features: 217- **Timestamp validation:** Skips records before 2023 (pre-ATProto) 218- **PDS-only:** Slingshot doesn't support `listRecords` 219- **Smart fetching:** Gets 3 records to handle invalid timestamps 220 221### State Flow: 222```typescript 223Input: { did, collection } 224 225useDidResolution(did) 226usePdsEndpoint(did) 227 228callListRecords(endpoint, did, collection, limit: 3) 229 230Filter: isValidTimestamp(record) year >= 2023 231 232Return first valid record: { record, rkey, loading, error, empty } 233``` 234 235### Timestamp Validation: 236```typescript 237function isValidTimestamp(record: unknown): boolean { 238 const timestamp = record.createdAt || record.indexedAt; 239 if (!timestamp) return true; // No timestamp, assume valid 240 241 const date = new Date(timestamp); 242 return date.getFullYear() >= 2023; // ATProto created in 2023 243} 244``` 245 246--- 247 248## 6. `usePaginatedRecords` 249**Purpose:** Cursor-based pagination with prefetching 250 251### Key Features: 252- **Dual fetching modes:** 253 - Author feed (appview) - for Bluesky posts with filters 254 - Direct PDS - for all other collections 255- **Smart prefetching:** Loads next page in background 256- **Invalid timestamp filtering:** Same as `useLatestRecord` 257- **Request sequencing:** Prevents race conditions with `requestSeq` 258 259### State Management: 260```typescript 261// Pages stored as array 262pages: [ 263 { records: [...], cursor: "abc" }, // page 0 264 { records: [...], cursor: "def" }, // page 1 265 { records: [...], cursor: undefined } // page 2 (last) 266] 267pageIndex: 1 // Currently viewing page 1 268``` 269 270### Prefetch Logic: 271```typescript 272useEffect(() => { 273 const cursor = pages[pageIndex]?.cursor; 274 if (!cursor || pages[pageIndex + 1]) return; // No cursor or already loaded 275 276 // Prefetch next page in background 277 fetchPage(identity, cursor, pageIndex + 1, "prefetch"); 278}, [pageIndex, pages]); 279``` 280 281### Author Feed vs PDS: 282```typescript 283if (preferAuthorFeed && collection === "app.bsky.feed.post") { 284 // Use app.bsky.feed.getAuthorFeed 285 const res = await callAppviewRpc("app.bsky.feed.getAuthorFeed", { 286 actor: handle || did, 287 filter: "posts_with_media", // Optional filter 288 includePins: true 289 }); 290} else { 291 // Use com.atproto.repo.listRecords 292 const res = await callListRecords(pdsEndpoint, did, collection, limit); 293} 294``` 295 296### Race Condition Prevention: 297```typescript 298const requestSeq = useRef(0); 299 300// On identity change 301resetState(); 302requestSeq.current += 1; // Invalidate in-flight requests 303 304// In fetch callback 305const token = requestSeq.current; 306// ... do async work ... 307if (token !== requestSeq.current) return; // Stale request, abort 308``` 309 310--- 311 312## 7. `useBlob` 313**Purpose:** Fetches and caches media blobs with object URL management 314 315### Key Features: 316- **Automatic cleanup:** Revokes object URLs on unmount 317- **BlobCache deduplication:** Same blob = one fetch 318- **Reference counting:** Safe concurrent access 319 320### State Flow: 321```typescript 322Input: { did, cid } 323 324useDidResolution(did) 325usePdsEndpoint(did) 326 327Check blobCache.get(did, cid) 328 329If missing: blobCache.ensure() Fetch from PDS 330 ├─ GET /xrpc/com.atproto.sync.getBlob?did={did}&cid={cid} 331 └─ Store in cache 332 333Create object URL: URL.createObjectURL(blob) 334 335Return: { url, loading, error } 336 337Cleanup: URL.revokeObjectURL(url) 338``` 339 340### Object URL Management: 341```typescript 342const objectUrlRef = useRef<string>(); 343 344// On successful fetch 345const nextUrl = URL.createObjectURL(blob); 346const prevUrl = objectUrlRef.current; 347objectUrlRef.current = nextUrl; 348if (prevUrl) URL.revokeObjectURL(prevUrl); // Clean up old URL 349 350// On unmount 351useEffect(() => () => { 352 if (objectUrlRef.current) { 353 URL.revokeObjectURL(objectUrlRef.current); 354 } 355}, []); 356``` 357 358--- 359 360## 8. `useBlueskyProfile` 361**Purpose:** Wrapper around `useBlueskyAppview` for profile records 362 363### Key Features: 364- **Simplified interface:** Just pass DID 365- **Type conversion:** Converts ProfileRecord to BlueskyProfileData 366- **CID extraction:** Extracts avatar/banner CIDs from blobs 367 368### Implementation: 369```typescript 370export function useBlueskyProfile(did: string | undefined) { 371 const { record, loading, error } = useBlueskyAppview<ProfileRecord>({ 372 did, 373 collection: "app.bsky.actor.profile", 374 rkey: "self", 375 }); 376 377 const data = record ? { 378 did: did || "", 379 handle: "", // Populated by caller 380 displayName: record.displayName, 381 description: record.description, 382 avatar: extractCidFromBlob(record.avatar), 383 banner: extractCidFromBlob(record.banner), 384 createdAt: record.createdAt, 385 } : undefined; 386 387 return { data, loading, error }; 388} 389``` 390 391--- 392 393## 9. `useBacklinks` 394**Purpose:** Fetches backlinks from Microcosm Constellation API 395 396### Key Features: 397- **Specialized use case:** Tangled stars, etc. 398- **Abort controller:** Cancels in-flight requests 399- **Refetch support:** Manual refresh capability 400 401### State Flow: 402```typescript 403Input: { subject: "at://did:plc:xxx/sh.tangled.repo/yyy", source: "sh.tangled.feed.star:subject" } 404 405GET https://constellation.microcosm.blue/xrpc/blue.microcosm.links.getBacklinks 406 ?subject={subject}&source={source}&limit={limit} 407 408Return: { backlinks: [...], total, loading, error, refetch } 409``` 410 411--- 412 413## 10. `useRepoLanguages` 414**Purpose:** Fetches language statistics from Tangled knot server 415 416### Key Features: 417- **Branch fallback:** Tries "main", then "master" 418- **Knot server query:** For repository analysis 419 420### State Flow: 421```typescript 422Input: { knot: "knot.gaze.systems", did, repoName, branch } 423 424GET https://{knot}/xrpc/sh.tangled.repo.languages 425 ?repo={did}/{repoName}&ref={branch} 426 427If 404: Try fallback branch 428 429Return: { data: { languages: {...} }, loading, error } 430``` 431 432--- 433 434## Cache Implementation Deep Dive 435 436### DidCache 437**Purpose:** Cache DID documents, handle mappings, PDS endpoints 438 439```typescript 440class DidCache { 441 private byHandle = new Map<string, DidCacheEntry>(); 442 private byDid = new Map<string, DidCacheEntry>(); 443 private handlePromises = new Map<string, Promise<...>>(); 444 private docPromises = new Map<string, Promise<...>>(); 445 private pdsPromises = new Map<string, Promise<...>>(); 446 447 // Memoized snapshots prevent re-renders 448 private toSnapshot(entry): DidCacheSnapshot { 449 if (entry.snapshot) return entry.snapshot; // Reuse 450 entry.snapshot = { did, handle, doc, pdsEndpoint }; 451 return entry.snapshot; 452 } 453} 454``` 455 456**Key methods:** 457- `getByHandle(handle)` - Instant cache lookup 458- `getByDid(did)` - Instant cache lookup 459- `ensureHandle(resolver, handle)` - Deduplicated resolution 460- `ensureDidDoc(resolver, did)` - Deduplicated doc fetch 461- `ensurePdsEndpoint(resolver, did)` - Deduplicated PDS discovery 462 463**Snapshot stability:** 464```typescript 465memoize(entry) { 466 const existing = this.byDid.get(did); 467 468 // Data unchanged? Reuse snapshot (same reference) 469 if (existing && existing.did === did && 470 existing.handle === handle && ...) { 471 return toSnapshot(existing); // Prevents re-render! 472 } 473 474 // Data changed, create new entry 475 const merged = { did, handle, doc, pdsEndpoint, snapshot: undefined }; 476 this.byDid.set(did, merged); 477 return toSnapshot(merged); 478} 479``` 480 481### BlobCache 482**Purpose:** Cache media blobs with reference counting 483 484```typescript 485class BlobCache { 486 private store = new Map<string, BlobCacheEntry>(); 487 private inFlight = new Map<string, InFlightBlobEntry>(); 488 489 ensure(did, cid, loader) { 490 // Already cached? 491 const cached = this.get(did, cid); 492 if (cached) return { promise: Promise.resolve(cached), release: noop }; 493 494 // In-flight request? 495 const existing = this.inFlight.get(key); 496 if (existing) { 497 existing.refCount++; // Multiple consumers 498 return { promise: existing.promise, release: () => this.release(key) }; 499 } 500 501 // New request 502 const { promise, abort } = loader(); 503 this.inFlight.set(key, { promise, abort, refCount: 1 }); 504 return { promise, release: () => this.release(key) }; 505 } 506 507 private release(key) { 508 const entry = this.inFlight.get(key); 509 entry.refCount--; 510 if (entry.refCount <= 0) { 511 this.inFlight.delete(key); 512 entry.abort(); // Cancel fetch 513 } 514 } 515} 516``` 517 518### RecordCache 519**Purpose:** Cache AT Protocol records with deduplication 520 521Identical structure to BlobCache but for record data. 522 523--- 524 525## Common Patterns 526 527### 1. Cancellation Pattern 528```typescript 529useEffect(() => { 530 let cancelled = false; 531 532 const assignState = (next) => { 533 if (cancelled) return; // Don't update unmounted component 534 setState(prev => ({ ...prev, ...next })); 535 }; 536 537 // ... async work ... 538 539 return () => { 540 cancelled = true; // Mark as cancelled 541 release?.(); // Decrement refCount 542 }; 543}, [deps]); 544``` 545 546### 2. Error Stabilization Pattern 547```typescript 548setError(prevError => 549 prevError?.message === newError.message 550 ? prevError // Reuse same reference 551 : newError // New error 552); 553``` 554 555### 3. Identity Tracking Pattern 556```typescript 557const identityRef = useRef<string>(); 558const identity = did && endpoint ? `${did}::${endpoint}` : undefined; 559 560useEffect(() => { 561 if (identityRef.current !== identity) { 562 identityRef.current = identity; 563 resetState(); // Clear stale data 564 } 565 // ... 566}, [identity]); 567``` 568 569### 4. Dual-Mode Resolution 570```typescript 571const isDid = input.startsWith("did:"); 572const normalizedHandle = !isDid ? input.toLowerCase() : undefined; 573 574// Different code paths 575if (isDid) { 576 snapshot = await didCache.ensureDidDoc(resolver, input); 577} else { 578 snapshot = await didCache.ensureHandle(resolver, normalizedHandle); 579} 580``` 581 582--- 583 584## Performance Optimizations 585 586### 1. **Memoized Snapshots** 587Caches return stable references when data unchanged → prevents re-renders 588 589### 2. **Reference Counting** 590Multiple components requesting same data share one fetch 591 592### 3. **Prefetching** 593`usePaginatedRecords` loads next page in background 594 595### 4. **CDN URLs** 596Bluesky appview returns CDN URLs → skip blob fetching for images 597 598### 5. **Smart Routing** 599Bluesky collections use fast appview → non-Bluesky goes direct to PDS 600 601### 6. **Request Deduplication** 602In-flight request maps prevent duplicate fetches 603 604### 7. **Timestamp Validation** 605Skip invalid records early (before 2023) → fewer wasted cycles 606 607--- 608 609## Error Handling Strategy 610 611### 1. **Fallback Chains** 612Never fail on first attempt → try multiple sources 613 614### 2. **Graceful Degradation** 615```typescript 616// Slingshot failed? Try appview 617try { 618 return await fetchFromSlingshot(); 619} catch (slingshotError) { 620 try { 621 return await fetchFromAppview(); 622 } catch (appviewError) { 623 // Combine errors for better debugging 624 throw new Error(`${appviewError.message}; Slingshot: ${slingshotError.message}`); 625 } 626} 627``` 628 629### 3. **Component Isolation** 630Errors in one component don't crash others (via error boundaries recommended) 631 632### 4. **Abort Handling** 633```typescript 634try { 635 await fetch(url, { signal }); 636} catch (err) { 637 if (err.name === "AbortError") return; // Expected, ignore 638 throw err; 639} 640``` 641 642### 5. **Banned Bluesky Account Detection** 643When all three tiers fail and the PDS is a `.bsky.network` endpoint, provide a helpful error: 644```typescript 645// All tiers failed - check if it's a banned Bluesky account 646if (pdsEndpoint.includes('.bsky.network')) { 647 throw new Error( 648 'Record unavailable. The Bluesky PDS may be unreachable or the account may be banned.' 649 ); 650} 651``` 652 653This helps users understand why data is unavailable instead of showing generic fetch errors. Applies to both `useBlueskyAppview` and `useAtProtoRecord` hooks. 654 655--- 656 657## Testing Considerations 658 659### Key scenarios to test: 6601. **Concurrent requests:** Multiple components requesting same data 6612. **Race conditions:** Component unmounting mid-fetch 6623. **Cache invalidation:** Identity changes during fetch 6634. **Error fallbacks:** Slingshot down → appview works 6645. **Timestamp filtering:** Records before 2023 skipped 6656. **Reference counting:** Proper cleanup on unmount 6667. **Prefetching:** Background loads don't interfere with active loads 667 668--- 669 670## Common Gotchas 671 672### 1. **React Rules of Hooks** 673All hooks called unconditionally, even if results not used: 674```typescript 675// Always call, conditionally use results 676const blueskyResult = useBlueskyAppview({ 677 did: isBlueskyCollection ? handleOrDid : undefined, // Pass undefined to skip 678 collection: isBlueskyCollection ? collection : undefined, 679 rkey: isBlueskyCollection ? rkey : undefined, 680}); 681``` 682 683### 2. **Cleanup Order Matters** 684```typescript 685return () => { 686 cancelled = true; // 1. Prevent state updates 687 release?.(); // 2. Decrement refCount 688 revokeObjectURL(...); // 3. Free resources 689}; 690``` 691 692### 3. **Snapshot Reuse** 693Don't modify cached snapshots! They're shared across components. 694 695### 4. **CDN URL Extraction** 696Bluesky CDN URLs must be parsed carefully: 697``` 698https://cdn.bsky.app/img/avatar/plain/did:plc:xxx/bafkreixxx@jpeg 699 ^^^^^^^^^^^^ ^^^^^^ 700 DID CID 701```