A React component library for rendering common AT Protocol records for applications such as Bluesky and Leaflet.
1import React, { useState, useEffect, useRef } from "react";
2import { useAtProtoRecord } from "../hooks/useAtProtoRecord";
3
4/**
5 * Common rendering customization props for AT Protocol records.
6 */
7interface AtProtoRecordRenderProps<T> {
8 /** Custom renderer component that receives the fetched record and loading state. */
9 renderer?: React.ComponentType<{
10 record: T;
11 loading: boolean;
12 error?: Error;
13 }>;
14 /** React node displayed when no record is available (after error or before load). */
15 fallback?: React.ReactNode;
16 /** React node shown while the record is being fetched. */
17 loadingIndicator?: React.ReactNode;
18 /** Auto-refresh interval in milliseconds. When set, the record will be refetched at this interval. */
19 refreshInterval?: number;
20 /** Comparison function to determine if a record has changed. Used to prevent unnecessary re-renders during auto-refresh. */
21 compareRecords?: (prev: T | undefined, next: T | undefined) => boolean;
22}
23
24/**
25 * Props for fetching an AT Protocol record from the network.
26 */
27type AtProtoRecordFetchProps<T> = AtProtoRecordRenderProps<T> & {
28 /** Repository DID that owns the record. */
29 did: string;
30 /** NSID collection containing the record. */
31 collection: string;
32 /** Record key identifying the specific record. */
33 rkey: string;
34 /** Must be undefined when fetching (discriminates the union type). */
35 record?: undefined;
36};
37
38/**
39 * Props for rendering a prefetched AT Protocol record.
40 */
41type AtProtoRecordProvidedRecordProps<T> = AtProtoRecordRenderProps<T> & {
42 /** Prefetched record value to render (skips network fetch). */
43 record: T;
44 /** Optional DID for context (not used for fetching). */
45 did?: string;
46 /** Optional collection for context (not used for fetching). */
47 collection?: string;
48 /** Optional rkey for context (not used for fetching). */
49 rkey?: string;
50};
51
52/**
53 * Union type for AT Protocol record props - supports both fetching and prefetched records.
54 */
55export type AtProtoRecordProps<T = unknown> =
56 | AtProtoRecordFetchProps<T>
57 | AtProtoRecordProvidedRecordProps<T>;
58
59/**
60 * Core component for fetching and rendering AT Protocol records with customizable presentation.
61 *
62 * Supports two modes:
63 * 1. **Fetch mode**: Provide `did`, `collection`, and `rkey` to fetch the record from the network
64 * 2. **Prefetch mode**: Provide a `record` directly to skip fetching (useful for SSR/caching)
65 *
66 * When no custom renderer is provided, displays the record as formatted JSON.
67 *
68 * **Auto-refresh**: Set `refreshInterval` to automatically refetch the record at the specified interval.
69 * The component intelligently avoids re-rendering if the record hasn't changed (using `compareRecords`).
70 *
71 * @example
72 * ```tsx
73 * // Fetch mode - retrieves record from network
74 * <AtProtoRecord
75 * did="did:plc:example"
76 * collection="app.bsky.feed.post"
77 * rkey="3k2aexample"
78 * renderer={MyCustomRenderer}
79 * />
80 * ```
81 *
82 * @example
83 * ```tsx
84 * // Prefetch mode - uses provided record
85 * <AtProtoRecord
86 * record={myPrefetchedRecord}
87 * renderer={MyCustomRenderer}
88 * />
89 * ```
90 *
91 * @example
92 * ```tsx
93 * // Auto-refresh mode - refetches every 15 seconds
94 * <AtProtoRecord
95 * did="did:plc:example"
96 * collection="fm.teal.alpha.actor.status"
97 * rkey="self"
98 * refreshInterval={15000}
99 * compareRecords={(prev, next) => JSON.stringify(prev) === JSON.stringify(next)}
100 * renderer={MyCustomRenderer}
101 * />
102 * ```
103 *
104 * @param props - Either fetch props (did/collection/rkey) or prefetch props (record).
105 * @returns A rendered AT Protocol record with loading/error states handled.
106 */
107export function AtProtoRecord<T = unknown>(props: AtProtoRecordProps<T>) {
108 const {
109 renderer: Renderer,
110 fallback = null,
111 loadingIndicator = "Loading…",
112 refreshInterval,
113 compareRecords,
114 } = props;
115 const hasProvidedRecord = "record" in props;
116 const providedRecord = hasProvidedRecord ? props.record : undefined;
117
118 // Extract fetch props for logging
119 const fetchDid = hasProvidedRecord ? undefined : (props as any).did;
120 const fetchCollection = hasProvidedRecord ? undefined : (props as any).collection;
121 const fetchRkey = hasProvidedRecord ? undefined : (props as any).rkey;
122
123 // State for managing auto-refresh
124 const [refreshKey, setRefreshKey] = useState(0);
125 const [stableRecord, setStableRecord] = useState<T | undefined>(providedRecord);
126 const previousRecordRef = useRef<T | undefined>(providedRecord);
127
128 // Auto-refresh interval
129 useEffect(() => {
130 if (!refreshInterval || hasProvidedRecord) return;
131
132 const interval = setInterval(() => {
133 setRefreshKey((prev) => prev + 1);
134 }, refreshInterval);
135
136 return () => clearInterval(interval);
137 }, [refreshInterval, hasProvidedRecord, fetchCollection, fetchDid]);
138
139 const {
140 record: fetchedRecord,
141 error,
142 loading,
143 } = useAtProtoRecord<T>({
144 did: fetchDid,
145 collection: fetchCollection,
146 rkey: fetchRkey,
147 bypassCache: !!refreshInterval && refreshKey > 0, // Bypass cache on auto-refresh (but not initial load)
148 _refreshKey: refreshKey, // Force hook to re-run
149 });
150
151 // Determine which record to use
152 const currentRecord = providedRecord ?? fetchedRecord;
153
154 // Handle record changes with optional comparison
155 useEffect(() => {
156 if (!currentRecord) return;
157
158 const hasChanged = compareRecords
159 ? !compareRecords(previousRecordRef.current, currentRecord)
160 : previousRecordRef.current !== currentRecord;
161
162 if (hasChanged) {
163 setStableRecord(currentRecord);
164 previousRecordRef.current = currentRecord;
165 }
166 }, [currentRecord, compareRecords]);
167
168 const record = stableRecord;
169 const isLoading = loading && !providedRecord && !stableRecord;
170
171 if (error && !record) return <>{fallback}</>;
172 if (!record) return <>{isLoading ? loadingIndicator : fallback}</>;
173 if (Renderer)
174 return <Renderer record={record} loading={isLoading} error={error} />;
175 return (
176 <pre
177 style={{
178 fontSize: 12,
179 padding: 8,
180 background: "#f5f5f5",
181 overflow: "auto",
182 }}
183 >
184 {JSON.stringify(record, null, 2)}
185 </pre>
186 );
187}