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 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 };
172 }
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 });
189
190 return () => {
191 cancelled = true;
192 if (releaseRef.current) {
193 releaseRef.current();
194 releaseRef.current = undefined;
195 }
196 };
197 }, [
198 handleOrDid,
199 did,
200 endpoint,
201 collection,
202 rkey,
203 resolvingDid,
204 resolvingEndpoint,
205 didError,
206 endpointError,
207 recordCache,
208 ]);
209
210 // Return Bluesky result for app.bsky.* collections
211 if (isBlueskyCollection) {
212 return {
213 record: blueskyResult.record,
214 error: blueskyResult.error,
215 loading: blueskyResult.loading,
216 };
217 }
218
219 return state;
220}