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 /** Force bypass cache and refetch from network. Useful for auto-refresh scenarios. */
19 bypassCache?: boolean;
20 /** Internal refresh trigger - changes to this value force a refetch. */
21 _refreshKey?: number;
22}
23
24/**
25 * Loading state returned by {@link useAtProtoRecord}.
26 */
27export interface AtProtoRecordState<T = unknown> {
28 /** Resolved record value when fetch succeeds. */
29 record?: T;
30 /** Error thrown while loading, if any. */
31 error?: Error;
32 /** Indicates whether the hook is in a loading state. */
33 loading: boolean;
34}
35
36/**
37 * React hook that fetches a single AT Protocol record and tracks loading/error state.
38 *
39 * For Bluesky collections (app.bsky.*), uses a three-tier fallback strategy:
40 * 1. Try Bluesky appview API first
41 * 2. Fall back to Slingshot getRecord
42 * 3. Finally query the PDS directly
43 *
44 * For other collections, queries the PDS directly (with Slingshot fallback via the client handler).
45 *
46 * @param did - DID (or handle before resolution) that owns the record.
47 * @param collection - NSID collection from which to fetch the record.
48 * @param rkey - Record key identifying the record within the collection.
49 * @param bypassCache - Force bypass cache and refetch from network. Useful for auto-refresh scenarios.
50 * @param _refreshKey - Internal parameter used to trigger refetches.
51 * @returns {AtProtoRecordState<T>} Object containing the resolved record, any error, and a loading flag.
52 */
53export function useAtProtoRecord<T = unknown>({
54 did: handleOrDid,
55 collection,
56 rkey,
57 bypassCache = false,
58 _refreshKey = 0,
59}: AtProtoRecordKey): AtProtoRecordState<T> {
60 const { recordCache } = useAtProto();
61 const isBlueskyCollection = collection?.startsWith("app.bsky.");
62
63 // Always call all hooks (React rules) - conditionally use results
64 const blueskyResult = useBlueskyAppview<T>({
65 did: isBlueskyCollection ? handleOrDid : undefined,
66 collection: isBlueskyCollection ? collection : undefined,
67 rkey: isBlueskyCollection ? rkey : undefined,
68 });
69
70 const {
71 did,
72 error: didError,
73 loading: resolvingDid,
74 } = useDidResolution(handleOrDid);
75 const {
76 endpoint,
77 error: endpointError,
78 loading: resolvingEndpoint,
79 } = usePdsEndpoint(did);
80 const [state, setState] = useState<AtProtoRecordState<T>>({
81 loading: !!(handleOrDid && collection && rkey),
82 });
83
84 const releaseRef = useRef<(() => void) | undefined>(undefined);
85
86 useEffect(() => {
87 let cancelled = false;
88
89 const assignState = (next: Partial<AtProtoRecordState<T>>) => {
90 if (cancelled) return;
91 setState((prev) => ({ ...prev, ...next }));
92 };
93
94 if (!handleOrDid || !collection || !rkey) {
95 assignState({
96 loading: false,
97 record: undefined,
98 error: undefined,
99 });
100 return () => {
101 cancelled = true;
102 if (releaseRef.current) {
103 releaseRef.current();
104 releaseRef.current = undefined;
105 }
106 };
107 }
108
109 if (didError) {
110 assignState({ loading: false, error: didError });
111 return () => {
112 cancelled = true;
113 if (releaseRef.current) {
114 releaseRef.current();
115 releaseRef.current = undefined;
116 }
117 };
118 }
119
120 if (endpointError) {
121 assignState({ loading: false, error: endpointError });
122 return () => {
123 cancelled = true;
124 if (releaseRef.current) {
125 releaseRef.current();
126 releaseRef.current = undefined;
127 }
128 };
129 }
130
131 if (resolvingDid || resolvingEndpoint || !did || !endpoint) {
132 assignState({ loading: true, error: undefined });
133 return () => {
134 cancelled = true;
135 if (releaseRef.current) {
136 releaseRef.current();
137 releaseRef.current = undefined;
138 }
139 };
140 }
141
142 assignState({ loading: true, error: undefined, record: undefined });
143
144 // Bypass cache if requested (for auto-refresh scenarios)
145 if (bypassCache) {
146 assignState({ loading: true, error: undefined });
147
148 // Skip cache and fetch directly
149 const controller = new AbortController();
150
151 const fetchPromise = (async () => {
152 try {
153 const { rpc } = await createAtprotoClient({
154 service: endpoint,
155 });
156 const res = await (
157 rpc as unknown as {
158 get: (
159 nsid: string,
160 opts: {
161 params: {
162 repo: string;
163 collection: string;
164 rkey: string;
165 };
166 },
167 ) => Promise<{ ok: boolean; data: { value: T } }>;
168 }
169 ).get("com.atproto.repo.getRecord", {
170 params: { repo: did, collection, rkey },
171 });
172 if (!res.ok) throw new Error("Failed to load record");
173 return (res.data as { value: T }).value;
174 } catch (err) {
175 // Provide helpful error for banned/unreachable Bluesky PDSes
176 if (endpoint.includes('.bsky.network')) {
177 throw new Error(
178 `Record unavailable. The Bluesky PDS (${endpoint}) may be unreachable or the account may be banned.`
179 );
180 }
181 throw err;
182 }
183 })();
184
185 fetchPromise
186 .then((record) => {
187 if (!cancelled) {
188 assignState({ record, loading: false });
189 }
190 })
191 .catch((e) => {
192 if (!cancelled) {
193 const err = e instanceof Error ? e : new Error(String(e));
194 assignState({ error: err, loading: false });
195 }
196 });
197
198 return () => {
199 cancelled = true;
200 controller.abort();
201 };
202 }
203
204 // Use recordCache.ensure for deduplication and caching
205 const { promise, release } = recordCache.ensure<T>(
206 did,
207 collection,
208 rkey,
209 () => {
210 const controller = new AbortController();
211
212 const fetchPromise = (async () => {
213 try {
214 const { rpc } = await createAtprotoClient({
215 service: endpoint,
216 });
217 const res = await (
218 rpc as unknown as {
219 get: (
220 nsid: string,
221 opts: {
222 params: {
223 repo: string;
224 collection: string;
225 rkey: string;
226 };
227 },
228 ) => Promise<{ ok: boolean; data: { value: T } }>;
229 }
230 ).get("com.atproto.repo.getRecord", {
231 params: { repo: did, collection, rkey },
232 });
233 if (!res.ok) throw new Error("Failed to load record");
234 return (res.data as { value: T }).value;
235 } catch (err) {
236 // Provide helpful error for banned/unreachable Bluesky PDSes
237 if (endpoint.includes('.bsky.network')) {
238 throw new Error(
239 `Record unavailable. The Bluesky PDS (${endpoint}) may be unreachable or the account may be banned.`
240 );
241 }
242 throw err;
243 }
244 })();
245
246 return {
247 promise: fetchPromise,
248 abort: () => controller.abort(),
249 };
250 }
251 );
252
253 releaseRef.current = release;
254
255 promise
256 .then((record) => {
257 if (!cancelled) {
258 assignState({ record, loading: false });
259 }
260 })
261 .catch((e) => {
262 if (!cancelled) {
263 const err = e instanceof Error ? e : new Error(String(e));
264 assignState({ error: err, loading: false });
265 }
266 });
267
268 return () => {
269 cancelled = true;
270 if (releaseRef.current) {
271 releaseRef.current();
272 releaseRef.current = undefined;
273 }
274 };
275 }, [
276 handleOrDid,
277 did,
278 endpoint,
279 collection,
280 rkey,
281 resolvingDid,
282 resolvingEndpoint,
283 didError,
284 endpointError,
285 recordCache,
286 bypassCache,
287 _refreshKey,
288 ]);
289
290 // Return Bluesky result for app.bsky.* collections
291 if (isBlueskyCollection) {
292 return {
293 record: blueskyResult.record,
294 error: blueskyResult.error,
295 loading: blueskyResult.loading,
296 };
297 }
298
299 return state;
300}