A React component library for rendering common AT Protocol records for applications such as Bluesky and Leaflet.
1import { useEffect, useState, useRef } from "react";
2import { useDidResolution } from "./useDidResolution";
3import { usePdsEndpoint } from "./usePdsEndpoint";
4import { createAtprotoClient } from "../utils/atproto-client";
5import { useBlueskyAppview } from "./useBlueskyAppview";
6import { useAtProto } from "../providers/AtProtoProvider";
7
8/**
9 * Identifier trio required to address an AT Protocol record.
10 */
11export interface AtProtoRecordKey {
12 /** Repository DID (or handle prior to resolution) containing the record. */
13 did?: string;
14 /** NSID collection in which the record resides. */
15 collection?: string;
16 /** Record key string uniquely identifying the record within the collection. */
17 rkey?: string;
18}
19
20/**
21 * Loading state returned by {@link useAtProtoRecord}.
22 */
23export interface AtProtoRecordState<T = unknown> {
24 /** Resolved record value when fetch succeeds. */
25 record?: T;
26 /** Error thrown while loading, if any. */
27 error?: Error;
28 /** Indicates whether the hook is in a loading state. */
29 loading: boolean;
30}
31
32/**
33 * React hook that fetches a single AT Protocol record and tracks loading/error state.
34 *
35 * For Bluesky collections (app.bsky.*), uses a three-tier fallback strategy:
36 * 1. Try Bluesky appview API first
37 * 2. Fall back to Slingshot getRecord
38 * 3. Finally query the PDS directly
39 *
40 * For other collections, queries the PDS directly (with Slingshot fallback via the client handler).
41 *
42 * @param did - DID (or handle before resolution) that owns the record.
43 * @param collection - NSID collection from which to fetch the record.
44 * @param rkey - Record key identifying the record within the collection.
45 * @returns {AtProtoRecordState<T>} Object containing the resolved record, any error, and a loading flag.
46 */
47export function useAtProtoRecord<T = unknown>({
48 did: handleOrDid,
49 collection,
50 rkey,
51}: AtProtoRecordKey): AtProtoRecordState<T> {
52 const { recordCache } = useAtProto();
53 const isBlueskyCollection = collection?.startsWith("app.bsky.");
54
55 // Always call all hooks (React rules) - conditionally use results
56 const blueskyResult = useBlueskyAppview<T>({
57 did: isBlueskyCollection ? handleOrDid : undefined,
58 collection: isBlueskyCollection ? collection : undefined,
59 rkey: isBlueskyCollection ? rkey : undefined,
60 });
61
62 const {
63 did,
64 error: didError,
65 loading: resolvingDid,
66 } = useDidResolution(handleOrDid);
67 const {
68 endpoint,
69 error: endpointError,
70 loading: resolvingEndpoint,
71 } = usePdsEndpoint(did);
72 const [state, setState] = useState<AtProtoRecordState<T>>({
73 loading: !!(handleOrDid && collection && rkey),
74 });
75
76 const releaseRef = useRef<(() => void) | undefined>(undefined);
77
78 useEffect(() => {
79 let cancelled = false;
80
81 const assignState = (next: Partial<AtProtoRecordState<T>>) => {
82 if (cancelled) return;
83 setState((prev) => ({ ...prev, ...next }));
84 };
85
86 if (!handleOrDid || !collection || !rkey) {
87 assignState({
88 loading: false,
89 record: undefined,
90 error: undefined,
91 });
92 return () => {
93 cancelled = true;
94 if (releaseRef.current) {
95 releaseRef.current();
96 releaseRef.current = undefined;
97 }
98 };
99 }
100
101 if (didError) {
102 assignState({ loading: false, error: didError });
103 return () => {
104 cancelled = true;
105 if (releaseRef.current) {
106 releaseRef.current();
107 releaseRef.current = undefined;
108 }
109 };
110 }
111
112 if (endpointError) {
113 assignState({ loading: false, error: endpointError });
114 return () => {
115 cancelled = true;
116 if (releaseRef.current) {
117 releaseRef.current();
118 releaseRef.current = undefined;
119 }
120 };
121 }
122
123 if (resolvingDid || resolvingEndpoint || !did || !endpoint) {
124 assignState({ loading: true, error: undefined });
125 return () => {
126 cancelled = true;
127 if (releaseRef.current) {
128 releaseRef.current();
129 releaseRef.current = undefined;
130 }
131 };
132 }
133
134 assignState({ loading: true, error: undefined, record: undefined });
135
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 try {
146 const { rpc } = await createAtprotoClient({
147 service: endpoint,
148 });
149 const res = await (
150 rpc as unknown as {
151 get: (
152 nsid: string,
153 opts: {
154 params: {
155 repo: string;
156 collection: string;
157 rkey: string;
158 };
159 },
160 ) => Promise<{ ok: boolean; data: { value: T } }>;
161 }
162 ).get("com.atproto.repo.getRecord", {
163 params: { repo: did, collection, rkey },
164 });
165 if (!res.ok) throw new Error("Failed to load record");
166 return (res.data as { value: T }).value;
167 } catch (err) {
168 // Provide helpful error for banned/unreachable Bluesky PDSes
169 if (endpoint.includes('.bsky.network')) {
170 throw new Error(
171 `Record unavailable. The Bluesky PDS (${endpoint}) may be unreachable or the account may be banned.`
172 );
173 }
174 throw err;
175 }
176 })();
177
178 return {
179 promise: fetchPromise,
180 abort: () => controller.abort(),
181 };
182 }
183 );
184
185 releaseRef.current = release;
186
187 promise
188 .then((record) => {
189 if (!cancelled) {
190 assignState({ record, loading: false });
191 }
192 })
193 .catch((e) => {
194 if (!cancelled) {
195 const err = e instanceof Error ? e : new Error(String(e));
196 assignState({ error: err, loading: false });
197 }
198 });
199
200 return () => {
201 cancelled = true;
202 if (releaseRef.current) {
203 releaseRef.current();
204 releaseRef.current = undefined;
205 }
206 };
207 }, [
208 handleOrDid,
209 did,
210 endpoint,
211 collection,
212 rkey,
213 resolvingDid,
214 resolvingEndpoint,
215 didError,
216 endpointError,
217 recordCache,
218 ]);
219
220 // Return Bluesky result for app.bsky.* collections
221 if (isBlueskyCollection) {
222 return {
223 record: blueskyResult.record,
224 error: blueskyResult.error,
225 loading: blueskyResult.loading,
226 };
227 }
228
229 return state;
230}