import { createContext, createEffect, createMemo, createSignal, onCleanup, useContext, type Accessor, type JSX } from 'solid-js'; import { getSharedCaches, clearSharedCaches } from '@atproto-ui/core'; type QueryKey = readonly unknown[]; type QueryKeyInput = QueryKey | unknown[]; type QueryFn = () => Promise | T; type QueryUpdater = T | ((old: T | undefined) => T | undefined); interface QueryOptions { queryKey: QueryKeyInput; queryFn: QueryFn; enabled?: boolean; staleTime?: number; placeholderData?: unknown; refetchOnWindowFocus?: boolean; } interface QueryEntry { key: QueryKey; keyHash: string; data?: T; error?: Error; updatedAt: number; promise?: Promise; listeners: Set<() => void>; queryFn?: QueryFn; staleTime: number; invalidated: boolean; } export interface QueryResult { readonly data: T | undefined; readonly error: Error | undefined; readonly isLoading: boolean; readonly isFetching: boolean; readonly isPending: boolean; readonly isError: boolean; readonly isSuccess: boolean; readonly status: 'pending' | 'error' | 'success'; refetch: () => Promise; } const UNTANGLED_QUERY_DID = 'did:web:untangled.local'; const UNTANGLED_QUERY_COLLECTION = 'app.untangled.query'; const queryKeyHash = (key: QueryKeyInput): string => JSON.stringify(key); const atprotoQueryCache = () => getSharedCaches().recordCache; const queryKeyStartsWith = (candidate: QueryKey, prefix?: QueryKeyInput): boolean => { if (!prefix) return true; if (prefix.length > candidate.length) return false; return prefix.every((part, index) => Object.is(part, candidate[index])); }; const toError = (error: unknown): Error => error instanceof Error ? error : new Error(String(error)); export class QueryClient { private entries = new Map(); private defaultStaleTime: number; constructor(options?: { defaultOptions?: { queries?: { staleTime?: number; retry?: number | boolean } } }) { this.defaultStaleTime = options?.defaultOptions?.queries?.staleTime ?? 30_000; } getOrCreateEntry(key: QueryKeyInput, staleTime?: number): QueryEntry { const normalizedKey = [...key]; const hash = queryKeyHash(normalizedKey); let entry = this.entries.get(hash) as QueryEntry | undefined; if (!entry) { const cached = atprotoQueryCache().get<{ data: T; updatedAt: number; }>(UNTANGLED_QUERY_DID, UNTANGLED_QUERY_COLLECTION, hash); entry = { key: normalizedKey, keyHash: hash, data: cached?.data, updatedAt: cached?.updatedAt ?? 0, listeners: new Set(), staleTime: staleTime ?? this.defaultStaleTime, invalidated: false, }; this.entries.set(hash, entry); } else if (staleTime !== undefined) { entry.staleTime = staleTime; } return entry; } subscribe(entry: QueryEntry, listener: () => void): () => void { entry.listeners.add(listener); return () => entry.listeners.delete(listener); } private notify(entry: QueryEntry) { for (const listener of entry.listeners) listener(); } private isFresh(entry: QueryEntry): boolean { return !entry.invalidated && entry.data !== undefined && Date.now() - entry.updatedAt < entry.staleTime; } async fetchQuery(options: { queryKey: QueryKeyInput; queryFn: QueryFn; staleTime?: number }): Promise { const entry = this.getOrCreateEntry(options.queryKey, options.staleTime); entry.queryFn = options.queryFn; if (this.isFresh(entry)) return entry.data as T; return this.fetchEntry(entry, options.queryFn); } async ensureQueryData(options: { queryKey: QueryKeyInput; queryFn: QueryFn; staleTime?: number }): Promise { const entry = this.getOrCreateEntry(options.queryKey, options.staleTime); entry.queryFn = options.queryFn; if (entry.data !== undefined && !entry.invalidated) return entry.data as T; return this.fetchEntry(entry, options.queryFn); } prefetchQuery(options: { queryKey: QueryKeyInput; queryFn: QueryFn; staleTime?: number }): Promise { return this.fetchQuery(options).then(() => undefined, () => undefined); } async refetchQueries(filter: { queryKey?: QueryKeyInput } = {}): Promise { await Promise.all(this.matchingEntries(filter.queryKey).map((entry) => { if (!entry.queryFn) return undefined; return this.fetchEntry(entry, entry.queryFn).then(() => undefined, () => undefined); })); } async invalidateQueries(filter: { queryKey?: QueryKeyInput; refetchType?: 'none' | string } = {}): Promise { const entries = this.matchingEntries(filter.queryKey); for (const entry of entries) { entry.invalidated = true; this.notify(entry); } if (filter.refetchType === 'none') return; await this.refetchQueries(filter); } setQueryData(queryKey: QueryKeyInput, updater: QueryUpdater): T | undefined { const entry = this.getOrCreateEntry(queryKey); const next = typeof updater === 'function' ? (updater as (old: T | undefined) => T | undefined)(entry.data as T | undefined) : updater; entry.data = next; entry.error = undefined; entry.updatedAt = Date.now(); entry.invalidated = false; if (next !== undefined) { atprotoQueryCache().set(UNTANGLED_QUERY_DID, UNTANGLED_QUERY_COLLECTION, entry.keyHash, { data: next, updatedAt: entry.updatedAt, }); } this.notify(entry); return next; } setQueriesData(filter: { queryKey?: QueryKeyInput }, updater: QueryUpdater): Array<[QueryKey, T | undefined]> { return this.matchingEntries(filter.queryKey).map((entry) => [ entry.key, this.setQueryData(entry.key, updater), ]); } getQueryData(queryKey: QueryKeyInput): T | undefined { return this.entries.get(queryKeyHash(queryKey))?.data as T | undefined; } clear(): void { this.entries.clear(); clearSharedCaches(); } private matchingEntries(prefix?: QueryKeyInput): QueryEntry[] { return [...this.entries.values()].filter((entry) => queryKeyStartsWith(entry.key, prefix)); } private fetchEntry(entry: QueryEntry, queryFn: QueryFn): Promise { if (entry.promise) return entry.promise; entry.queryFn = queryFn; const promise = Promise.resolve() .then(queryFn) .then((data) => { entry.data = data; entry.error = undefined; entry.updatedAt = Date.now(); entry.invalidated = false; atprotoQueryCache().set(UNTANGLED_QUERY_DID, UNTANGLED_QUERY_COLLECTION, entry.keyHash, { data, updatedAt: entry.updatedAt, }); return data; }) .catch((error) => { entry.error = toError(error); throw entry.error; }) .finally(() => { entry.promise = undefined; this.notify(entry); }); entry.promise = promise; this.notify(entry); return promise; } } const AtprotoQueryContext = createContext(); export const defaultQueryClient = new QueryClient(); export function QueryClientProvider(props: { client?: QueryClient; children: JSX.Element }) { return ( {props.children} ); } export function useQueryClient(): QueryClient { return useContext(AtprotoQueryContext) ?? defaultQueryClient; } const accessOptions = (options: QueryOptions | Accessor>): QueryOptions => typeof options === 'function' ? (options as Accessor>)() : options; export function createQuery(options: QueryOptions | Accessor>): QueryResult { const client = useQueryClient(); const getOptions = createMemo(() => accessOptions(options)); const [version, setVersion] = createSignal(0); const [fetching, setFetching] = createSignal(false); let activeEntry: QueryEntry | undefined; let unsubscribe: (() => void) | undefined; let token = 0; const bump = () => setVersion((value) => value + 1); createEffect(() => { const opts = getOptions(); const enabled = opts.enabled ?? true; const key = opts.queryKey; const entry = client.getOrCreateEntry(key, opts.staleTime); entry.queryFn = opts.queryFn; activeEntry = entry; unsubscribe?.(); unsubscribe = client.subscribe(entry, bump); bump(); const runToken = ++token; if (!enabled) { setFetching(false); return; } if (entry.data !== undefined && !entry.invalidated && Date.now() - entry.updatedAt < entry.staleTime) { setFetching(false); return; } setFetching(true); client.fetchQuery({ queryKey: key, queryFn: opts.queryFn, staleTime: opts.staleTime }) .catch(() => undefined) .finally(() => { if (runToken === token) setFetching(false); }); }); onCleanup(() => unsubscribe?.()); const entry = () => { version(); return activeEntry; }; return { get data() { return entry()?.data as T | undefined; }, get error() { return entry()?.error; }, get isFetching() { return fetching() || !!entry()?.promise; }, get isLoading() { return (fetching() || !!entry()?.promise) && entry()?.data === undefined; }, get isPending() { return this.isLoading; }, get isError() { return !!entry()?.error; }, get isSuccess() { return entry()?.data !== undefined && !entry()?.error; }, get status() { return this.isError ? 'error' : this.isSuccess ? 'success' : 'pending'; }, refetch: async () => { const opts = getOptions(); setFetching(true); try { return await client.fetchQuery({ ...opts, staleTime: -1 }); } finally { setFetching(false); } }, }; } export const keepPreviousData = Symbol('keepPreviousData'); export { getSharedCaches };