tangled
alpha
login
or
join now
nekomimi.pet
/
atproto-ui
41
fork
atom
A React component library for rendering common AT Protocol records for applications such as Bluesky and Leaflet.
41
fork
atom
overview
issues
2
pulls
pipelines
record caching
waveringana
5 months ago
fa3d1ff5
836a459c
+297
-97
4 changed files
expand all
collapse all
unified
split
lib
hooks
useAtProtoRecord.ts
useBlueskyAppview.ts
providers
AtProtoProvider.tsx
utils
cache.ts
+80
-31
lib/hooks/useAtProtoRecord.ts
···
1
1
-
import { useEffect, useState } from "react";
1
1
+
import { useEffect, useState, useRef } from "react";
2
2
import { useDidResolution } from "./useDidResolution";
3
3
import { usePdsEndpoint } from "./usePdsEndpoint";
4
4
import { createAtprotoClient } from "../utils/atproto-client";
5
5
import { useBlueskyAppview } from "./useBlueskyAppview";
6
6
+
import { useAtProto } from "../providers/AtProtoProvider";
6
7
7
8
/**
8
9
* Identifier trio required to address an AT Protocol record.
···
48
49
collection,
49
50
rkey,
50
51
}: AtProtoRecordKey): AtProtoRecordState<T> {
52
52
+
const { recordCache } = useAtProto();
51
53
const isBlueskyCollection = collection?.startsWith("app.bsky.");
52
52
-
54
54
+
53
55
// Always call all hooks (React rules) - conditionally use results
54
56
const blueskyResult = useBlueskyAppview<T>({
55
57
did: isBlueskyCollection ? handleOrDid : undefined,
56
58
collection: isBlueskyCollection ? collection : undefined,
57
59
rkey: isBlueskyCollection ? rkey : undefined,
58
60
});
59
59
-
61
61
+
60
62
const {
61
63
did,
62
64
error: didError,
···
70
72
const [state, setState] = useState<AtProtoRecordState<T>>({
71
73
loading: !!(handleOrDid && collection && rkey),
72
74
});
75
75
+
76
76
+
const releaseRef = useRef<(() => void) | undefined>(undefined);
73
77
74
78
useEffect(() => {
75
79
let cancelled = false;
···
87
91
});
88
92
return () => {
89
93
cancelled = true;
94
94
+
if (releaseRef.current) {
95
95
+
releaseRef.current();
96
96
+
releaseRef.current = undefined;
97
97
+
}
90
98
};
91
99
}
92
100
···
94
102
assignState({ loading: false, error: didError });
95
103
return () => {
96
104
cancelled = true;
105
105
+
if (releaseRef.current) {
106
106
+
releaseRef.current();
107
107
+
releaseRef.current = undefined;
108
108
+
}
97
109
};
98
110
}
99
111
···
101
113
assignState({ loading: false, error: endpointError });
102
114
return () => {
103
115
cancelled = true;
116
116
+
if (releaseRef.current) {
117
117
+
releaseRef.current();
118
118
+
releaseRef.current = undefined;
119
119
+
}
104
120
};
105
121
}
106
122
···
108
124
assignState({ loading: true, error: undefined });
109
125
return () => {
110
126
cancelled = true;
127
127
+
if (releaseRef.current) {
128
128
+
releaseRef.current();
129
129
+
releaseRef.current = undefined;
130
130
+
}
111
131
};
112
132
}
113
133
114
134
assignState({ loading: true, error: undefined, record: undefined });
115
135
116
116
-
(async () => {
117
117
-
try {
118
118
-
const { rpc } = await createAtprotoClient({
119
119
-
service: endpoint,
120
120
-
});
121
121
-
const res = await (
122
122
-
rpc as unknown as {
123
123
-
get: (
124
124
-
nsid: string,
125
125
-
opts: {
126
126
-
params: {
127
127
-
repo: string;
128
128
-
collection: string;
129
129
-
rkey: string;
130
130
-
};
131
131
-
},
132
132
-
) => Promise<{ ok: boolean; data: { value: T } }>;
133
133
-
}
134
134
-
).get("com.atproto.repo.getRecord", {
135
135
-
params: { repo: did, collection, rkey },
136
136
-
});
137
137
-
if (!res.ok) throw new Error("Failed to load record");
138
138
-
const record = (res.data as { value: T }).value;
139
139
-
assignState({ record, loading: false });
140
140
-
} catch (e) {
141
141
-
const err = e instanceof Error ? e : new Error(String(e));
142
142
-
assignState({ error: err, loading: false });
136
136
+
// Use recordCache.ensure for deduplication and caching
137
137
+
const { promise, release } = recordCache.ensure<T>(
138
138
+
did,
139
139
+
collection,
140
140
+
rkey,
141
141
+
() => {
142
142
+
const controller = new AbortController();
143
143
+
144
144
+
const fetchPromise = (async () => {
145
145
+
const { rpc } = await createAtprotoClient({
146
146
+
service: endpoint,
147
147
+
});
148
148
+
const res = await (
149
149
+
rpc as unknown as {
150
150
+
get: (
151
151
+
nsid: string,
152
152
+
opts: {
153
153
+
params: {
154
154
+
repo: string;
155
155
+
collection: string;
156
156
+
rkey: string;
157
157
+
};
158
158
+
},
159
159
+
) => Promise<{ ok: boolean; data: { value: T } }>;
160
160
+
}
161
161
+
).get("com.atproto.repo.getRecord", {
162
162
+
params: { repo: did, collection, rkey },
163
163
+
});
164
164
+
if (!res.ok) throw new Error("Failed to load record");
165
165
+
return (res.data as { value: T }).value;
166
166
+
})();
167
167
+
168
168
+
return {
169
169
+
promise: fetchPromise,
170
170
+
abort: () => controller.abort(),
171
171
+
};
143
172
}
144
144
-
})();
173
173
+
);
174
174
+
175
175
+
releaseRef.current = release;
176
176
+
177
177
+
promise
178
178
+
.then((record) => {
179
179
+
if (!cancelled) {
180
180
+
assignState({ record, loading: false });
181
181
+
}
182
182
+
})
183
183
+
.catch((e) => {
184
184
+
if (!cancelled) {
185
185
+
const err = e instanceof Error ? e : new Error(String(e));
186
186
+
assignState({ error: err, loading: false });
187
187
+
}
188
188
+
});
145
189
146
190
return () => {
147
191
cancelled = true;
192
192
+
if (releaseRef.current) {
193
193
+
releaseRef.current();
194
194
+
releaseRef.current = undefined;
195
195
+
}
148
196
};
149
197
}, [
150
198
handleOrDid,
···
156
204
resolvingEndpoint,
157
205
didError,
158
206
endpointError,
207
207
+
recordCache,
159
208
]);
160
209
161
210
// Return Bluesky result for app.bsky.* collections
+101
-62
lib/hooks/useBlueskyAppview.ts
···
1
1
-
import { useEffect, useReducer } from "react";
1
1
+
import { useEffect, useReducer, useRef } from "react";
2
2
import { useDidResolution } from "./useDidResolution";
3
3
import { usePdsEndpoint } from "./usePdsEndpoint";
4
4
import { createAtprotoClient, SLINGSHOT_BASE_URL } from "../utils/atproto-client";
5
5
+
import { useAtProto } from "../providers/AtProtoProvider";
5
6
6
7
/**
7
8
* Extended blob reference that includes CDN URL from appview responses.
···
235
236
appviewService,
236
237
skipAppview = false,
237
238
}: UseBlueskyAppviewOptions): UseBlueskyAppviewResult<T> {
239
239
+
const { recordCache } = useAtProto();
238
240
const {
239
241
did,
240
242
error: didError,
···
253
255
source: undefined,
254
256
});
255
257
258
258
+
const releaseRef = useRef<(() => void) | undefined>(undefined);
259
259
+
256
260
useEffect(() => {
257
261
let cancelled = false;
258
262
···
261
265
if (!cancelled) dispatch({ type: "RESET" });
262
266
return () => {
263
267
cancelled = true;
268
268
+
if (releaseRef.current) {
269
269
+
releaseRef.current();
270
270
+
releaseRef.current = undefined;
271
271
+
}
264
272
};
265
273
}
266
274
···
268
276
if (!cancelled) dispatch({ type: "SET_ERROR", error: didError });
269
277
return () => {
270
278
cancelled = true;
279
279
+
if (releaseRef.current) {
280
280
+
releaseRef.current();
281
281
+
releaseRef.current = undefined;
282
282
+
}
271
283
};
272
284
}
273
285
···
275
287
if (!cancelled) dispatch({ type: "SET_ERROR", error: endpointError });
276
288
return () => {
277
289
cancelled = true;
290
290
+
if (releaseRef.current) {
291
291
+
releaseRef.current();
292
292
+
releaseRef.current = undefined;
293
293
+
}
278
294
};
279
295
}
280
296
···
282
298
if (!cancelled) dispatch({ type: "SET_LOADING", loading: true });
283
299
return () => {
284
300
cancelled = true;
301
301
+
if (releaseRef.current) {
302
302
+
releaseRef.current();
303
303
+
releaseRef.current = undefined;
304
304
+
}
285
305
};
286
306
}
287
307
288
308
// Start fetching
289
309
dispatch({ type: "SET_LOADING", loading: true });
290
310
291
291
-
(async () => {
292
292
-
let lastError: Error | undefined;
311
311
+
// Use recordCache.ensure for deduplication and caching
312
312
+
const { promise, release } = recordCache.ensure<T>(
313
313
+
did,
314
314
+
collection,
315
315
+
rkey,
316
316
+
() => {
317
317
+
const controller = new AbortController();
293
318
294
294
-
// Tier 1: Try Bluesky appview API
295
295
-
if (!skipAppview && BLUESKY_COLLECTION_TO_ENDPOINT[collection]) {
296
296
-
try {
297
297
-
const result = await fetchFromAppview<T>(
298
298
-
did,
299
299
-
collection,
300
300
-
rkey,
301
301
-
appviewService ?? DEFAULT_APPVIEW_SERVICE,
302
302
-
);
303
303
-
if (!cancelled && result) {
304
304
-
dispatch({
305
305
-
type: "SET_SUCCESS",
306
306
-
record: result,
307
307
-
source: "appview",
308
308
-
});
309
309
-
return;
319
319
+
const fetchPromise = (async () => {
320
320
+
let lastError: Error | undefined;
321
321
+
322
322
+
// Tier 1: Try Bluesky appview API
323
323
+
if (!skipAppview && BLUESKY_COLLECTION_TO_ENDPOINT[collection]) {
324
324
+
try {
325
325
+
const result = await fetchFromAppview<T>(
326
326
+
did,
327
327
+
collection,
328
328
+
rkey,
329
329
+
appviewService ?? DEFAULT_APPVIEW_SERVICE,
330
330
+
);
331
331
+
if (result) {
332
332
+
return result;
333
333
+
}
334
334
+
} catch (err) {
335
335
+
lastError = err as Error;
336
336
+
// Continue to next tier
337
337
+
}
310
338
}
311
311
-
} catch (err) {
312
312
-
lastError = err as Error;
313
313
-
// Continue to next tier
314
314
-
}
339
339
+
340
340
+
// Tier 2: Try Slingshot getRecord
341
341
+
try {
342
342
+
const result = await fetchFromSlingshot<T>(did, collection, rkey);
343
343
+
if (result) {
344
344
+
return result;
345
345
+
}
346
346
+
} catch (err) {
347
347
+
lastError = err as Error;
348
348
+
// Continue to next tier
349
349
+
}
350
350
+
351
351
+
// Tier 3: Try PDS directly
352
352
+
try {
353
353
+
const result = await fetchFromPds<T>(
354
354
+
did,
355
355
+
collection,
356
356
+
rkey,
357
357
+
pdsEndpoint,
358
358
+
);
359
359
+
if (result) {
360
360
+
return result;
361
361
+
}
362
362
+
} catch (err) {
363
363
+
lastError = err as Error;
364
364
+
}
365
365
+
366
366
+
// All tiers failed
367
367
+
throw lastError ?? new Error("Failed to fetch record from all sources");
368
368
+
})();
369
369
+
370
370
+
return {
371
371
+
promise: fetchPromise,
372
372
+
abort: () => controller.abort(),
373
373
+
};
315
374
}
375
375
+
);
316
376
317
317
-
// Tier 2: Try Slingshot getRecord
318
318
-
try {
319
319
-
const result = await fetchFromSlingshot<T>(did, collection, rkey);
320
320
-
if (!cancelled && result) {
377
377
+
releaseRef.current = release;
378
378
+
379
379
+
promise
380
380
+
.then((record) => {
381
381
+
if (!cancelled) {
321
382
dispatch({
322
383
type: "SET_SUCCESS",
323
323
-
record: result,
324
324
-
source: "slingshot",
384
384
+
record,
385
385
+
source: "appview",
325
386
});
326
326
-
return;
327
387
}
328
328
-
} catch (err) {
329
329
-
lastError = err as Error;
330
330
-
// Continue to next tier
331
331
-
}
332
332
-
333
333
-
// Tier 3: Try PDS directly
334
334
-
try {
335
335
-
const result = await fetchFromPds<T>(
336
336
-
did,
337
337
-
collection,
338
338
-
rkey,
339
339
-
pdsEndpoint,
340
340
-
);
341
341
-
if (!cancelled && result) {
388
388
+
})
389
389
+
.catch((err) => {
390
390
+
if (!cancelled) {
342
391
dispatch({
343
343
-
type: "SET_SUCCESS",
344
344
-
record: result,
345
345
-
source: "pds",
392
392
+
type: "SET_ERROR",
393
393
+
error: err instanceof Error ? err : new Error(String(err)),
346
394
});
347
347
-
return;
348
395
}
349
349
-
} catch (err) {
350
350
-
lastError = err as Error;
351
351
-
}
352
352
-
353
353
-
// All tiers failed
354
354
-
if (!cancelled) {
355
355
-
dispatch({
356
356
-
type: "SET_ERROR",
357
357
-
error:
358
358
-
lastError ??
359
359
-
new Error("Failed to fetch record from all sources"),
360
360
-
});
361
361
-
}
362
362
-
})();
396
396
+
});
363
397
364
398
return () => {
365
399
cancelled = true;
400
400
+
if (releaseRef.current) {
401
401
+
releaseRef.current();
402
402
+
releaseRef.current = undefined;
403
403
+
}
366
404
};
367
405
}, [
368
406
handleOrDid,
···
376
414
resolvingEndpoint,
377
415
didError,
378
416
endpointError,
417
417
+
recordCache,
379
418
]);
380
419
381
420
return state;
+9
-4
lib/providers/AtProtoProvider.tsx
···
6
6
useRef,
7
7
} from "react";
8
8
import { ServiceResolver, normalizeBaseUrl } from "../utils/atproto-client";
9
9
-
import { BlobCache, DidCache } from "../utils/cache";
9
9
+
import { BlobCache, DidCache, RecordCache } from "../utils/cache";
10
10
11
11
/**
12
12
* Props for the AT Protocol context provider.
···
30
30
didCache: DidCache;
31
31
/** Cache for fetched blob data. */
32
32
blobCache: BlobCache;
33
33
+
/** Cache for fetched AT Protocol records. */
34
34
+
recordCache: RecordCache;
33
35
}
34
36
35
37
const AtProtoContext = createContext<AtProtoContextValue | undefined>(
···
92
94
const cachesRef = useRef<{
93
95
didCache: DidCache;
94
96
blobCache: BlobCache;
97
97
+
recordCache: RecordCache;
95
98
} | null>(null);
96
99
if (!cachesRef.current) {
97
100
cachesRef.current = {
98
101
didCache: new DidCache(),
99
102
blobCache: new BlobCache(),
103
103
+
recordCache: new RecordCache(),
100
104
};
101
105
}
102
106
···
106
110
plcDirectory: normalizedPlc,
107
111
didCache: cachesRef.current!.didCache,
108
112
blobCache: cachesRef.current!.blobCache,
113
113
+
recordCache: cachesRef.current!.recordCache,
109
114
}),
110
115
[resolver, normalizedPlc],
111
116
);
···
120
125
/**
121
126
* Hook that accesses the AT Protocol context provided by `AtProtoProvider`.
122
127
*
123
123
-
* This hook exposes the service resolver, DID cache, and blob cache for building
124
124
-
* custom AT Protocol functionality.
128
128
+
* This hook exposes the service resolver, DID cache, blob cache, and record cache
129
129
+
* for building custom AT Protocol functionality.
125
130
*
126
131
* @throws {Error} When called outside of an `AtProtoProvider`.
127
132
* @returns {AtProtoContextValue} Object containing resolver, caches, and PLC directory URL.
···
131
136
* import { useAtProto } from 'atproto-ui';
132
137
*
133
138
* function MyCustomComponent() {
134
134
-
* const { resolver, didCache, blobCache } = useAtProto();
139
139
+
* const { resolver, didCache, blobCache, recordCache } = useAtProto();
135
140
* // Use the resolver and caches for custom AT Protocol operations
136
141
* }
137
142
* ```
+107
lib/utils/cache.ts
···
270
270
}
271
271
}
272
272
}
273
273
+
274
274
+
interface RecordCacheEntry<T = unknown> {
275
275
+
record: T;
276
276
+
timestamp: number;
277
277
+
}
278
278
+
279
279
+
interface InFlightRecordEntry<T = unknown> {
280
280
+
promise: Promise<T>;
281
281
+
abort: () => void;
282
282
+
refCount: number;
283
283
+
}
284
284
+
285
285
+
interface RecordEnsureResult<T = unknown> {
286
286
+
promise: Promise<T>;
287
287
+
release: () => void;
288
288
+
}
289
289
+
290
290
+
export class RecordCache {
291
291
+
private store = new Map<string, RecordCacheEntry>();
292
292
+
private inFlight = new Map<string, InFlightRecordEntry>();
293
293
+
294
294
+
private key(did: string, collection: string, rkey: string): string {
295
295
+
return `${did}::${collection}::${rkey}`;
296
296
+
}
297
297
+
298
298
+
get<T = unknown>(
299
299
+
did?: string,
300
300
+
collection?: string,
301
301
+
rkey?: string,
302
302
+
): T | undefined {
303
303
+
if (!did || !collection || !rkey) return undefined;
304
304
+
return this.store.get(this.key(did, collection, rkey))?.record as
305
305
+
| T
306
306
+
| undefined;
307
307
+
}
308
308
+
309
309
+
set<T = unknown>(
310
310
+
did: string,
311
311
+
collection: string,
312
312
+
rkey: string,
313
313
+
record: T,
314
314
+
): void {
315
315
+
this.store.set(this.key(did, collection, rkey), {
316
316
+
record,
317
317
+
timestamp: Date.now(),
318
318
+
});
319
319
+
}
320
320
+
321
321
+
ensure<T = unknown>(
322
322
+
did: string,
323
323
+
collection: string,
324
324
+
rkey: string,
325
325
+
loader: () => { promise: Promise<T>; abort: () => void },
326
326
+
): RecordEnsureResult<T> {
327
327
+
const cached = this.get<T>(did, collection, rkey);
328
328
+
if (cached !== undefined) {
329
329
+
return { promise: Promise.resolve(cached), release: () => {} };
330
330
+
}
331
331
+
332
332
+
const key = this.key(did, collection, rkey);
333
333
+
const existing = this.inFlight.get(key) as
334
334
+
| InFlightRecordEntry<T>
335
335
+
| undefined;
336
336
+
if (existing) {
337
337
+
existing.refCount += 1;
338
338
+
return {
339
339
+
promise: existing.promise,
340
340
+
release: () => this.release(key),
341
341
+
};
342
342
+
}
343
343
+
344
344
+
const { promise, abort } = loader();
345
345
+
const wrapped = promise.then((record) => {
346
346
+
this.set(did, collection, rkey, record);
347
347
+
return record;
348
348
+
});
349
349
+
350
350
+
const entry: InFlightRecordEntry<T> = {
351
351
+
promise: wrapped,
352
352
+
abort,
353
353
+
refCount: 1,
354
354
+
};
355
355
+
356
356
+
this.inFlight.set(key, entry as InFlightRecordEntry);
357
357
+
358
358
+
wrapped
359
359
+
.catch(() => {})
360
360
+
.finally(() => {
361
361
+
this.inFlight.delete(key);
362
362
+
});
363
363
+
364
364
+
return {
365
365
+
promise: wrapped,
366
366
+
release: () => this.release(key),
367
367
+
};
368
368
+
}
369
369
+
370
370
+
private release(key: string) {
371
371
+
const entry = this.inFlight.get(key);
372
372
+
if (!entry) return;
373
373
+
entry.refCount -= 1;
374
374
+
if (entry.refCount <= 0) {
375
375
+
this.inFlight.delete(key);
376
376
+
entry.abort();
377
377
+
}
378
378
+
}
379
379
+
}