@assistant-ui/react
Version:
Open-source TypeScript/React library for building production-grade AI chat experiences
224 lines (205 loc) • 7.82 kB
text/typescript
"use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useIsomorphicLayoutEffect } from "../utils/useIsomorphicLayoutEffect";
import type {
Unstable_TriggerAdapter,
Unstable_TriggerItem,
} from "@assistant-ui/core";
/** @deprecated Experimental since 2026-06-15. Not scheduled for removal; the API may change in any release. */
export type Unstable_UseLiveCompletionAdapterOptions = {
/**
* Fetches the items for a query from an async source. Called debounced; the
* resolved items are cached and returned synchronously to the popover on the
* next render.
*/
readonly fetcher: (query: string) => Promise<readonly Unstable_TriggerItem[]>;
/**
* Identifies the fetcher's data source. Change this when switching accounts,
* workspaces, or another boundary that should invalidate cached results.
*/
readonly cacheKey?: string | number | undefined;
/** Debounce applied before a fetch fires, in milliseconds. @default 60 */
readonly debounceMs?: number | undefined;
/** When `false`, no fetch is scheduled and the adapter stays empty. @default true */
readonly enabled?: boolean | undefined;
};
/** Sentinel that no real query (including the empty string) equals, so the first query always fetches. */
const NO_QUERY = "\u0000";
/**
* Bridges an async completion source (a server search, a gateway RPC) into the
* synchronous `Unstable_TriggerAdapter` that `ComposerTriggerPopover` consumes.
* `search(query)` returns the last fetched items synchronously and schedules a
* debounced fetch when the query changes; when results arrive the returned
* `adapter` identity changes, which re-runs the popover's lookup so the fresh
* items render. This is a search-only adapter (`categories` are empty).
*
* `isLoading` is `true` while a fetch is in flight. Pass it to the popover's
* `isLoading` prop to render a loading state.
*
* @deprecated Experimental since 2026-06-15. Not scheduled for removal; the API may change in any release.
*
* @example
* ```tsx
* const mentions = unstable_useLiveCompletionAdapter({
* fetcher: (query) => searchUsers(query),
* });
*
* <ComposerTriggerPopover
* char="@"
* adapter={mentions.adapter}
* isLoading={mentions.isLoading}
* directive={{ onInserted }}
* />
* ```
*/
export function unstable_useLiveCompletionAdapter(
options: Unstable_UseLiveCompletionAdapterOptions,
): { adapter: Unstable_TriggerAdapter; isLoading: boolean } {
const { fetcher, cacheKey, debounceMs = 60, enabled = true } = options;
const [state, setState] = useState<{
query: string;
items: readonly Unstable_TriggerItem[];
failed: boolean;
}>({ query: NO_QUERY, items: [], failed: false });
const [isLoading, setIsLoading] = useState(false);
const fetcherRef = useRef(fetcher);
// The debounce timer must only observe fetchers from committed renders.
useIsomorphicLayoutEffect(() => {
fetcherRef.current = fetcher;
}, [fetcher]);
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const tokenRef = useRef(0);
const pendingQueryRef = useRef<string | null>(null);
const retryableQueryRef = useRef<string | null>(null);
const pendingRetryQueryRef = useRef<string | null>(null);
const inactiveRef = useRef(true);
const deferredQueryRef = useRef<string | null>(null);
const cancelTimer = useCallback(() => {
if (timerRef.current !== null) {
clearTimeout(timerRef.current);
timerRef.current = null;
}
}, []);
const rearmPendingRetry = useCallback(() => {
const query = pendingRetryQueryRef.current;
if (query === null) return;
retryableQueryRef.current = query;
pendingRetryQueryRef.current = null;
}, []);
const scheduleFetch = useCallback(
(query: string) => {
if (!enabled) return;
if (inactiveRef.current) {
deferredQueryRef.current = query;
return;
}
if (pendingQueryRef.current === query) return;
rearmPendingRetry();
if (retryableQueryRef.current === query) {
retryableQueryRef.current = null;
pendingRetryQueryRef.current = query;
}
pendingQueryRef.current = query;
cancelTimer();
const token = ++tokenRef.current;
setIsLoading(true);
timerRef.current = setTimeout(() => {
timerRef.current = null;
Promise.resolve()
.then(() => fetcherRef.current(query))
.then(
(items) => {
if (token !== tokenRef.current) return;
pendingRetryQueryRef.current = null;
setState({ query, items, failed: false });
setIsLoading(false);
},
() => {
if (token !== tokenRef.current) return;
pendingQueryRef.current = null;
pendingRetryQueryRef.current = null;
setState({ query, items: [], failed: true });
setIsLoading(false);
},
);
}, debounceMs);
},
[enabled, debounceMs, cancelTimer, rearmPendingRetry],
);
const scheduleFetchRef = useRef(scheduleFetch);
useIsomorphicLayoutEffect(() => {
scheduleFetchRef.current = scheduleFetch;
}, [scheduleFetch]);
const invalidatePending = useCallback(() => {
rearmPendingRetry();
cancelTimer();
pendingQueryRef.current = null;
tokenRef.current += 1;
setIsLoading(false);
}, [cancelTimer, rearmPendingRetry]);
const cacheKeyRef = useRef(cacheKey);
useIsomorphicLayoutEffect(() => {
if (cacheKeyRef.current === cacheKey) return;
cacheKeyRef.current = cacheKey;
invalidatePending();
retryableQueryRef.current = null;
pendingRetryQueryRef.current = null;
setState({ query: NO_QUERY, items: [], failed: false });
}, [cacheKey, invalidatePending]);
useEffect(() => {
if (enabled) return;
invalidatePending();
setState((s) =>
s.query === NO_QUERY ? s : { query: NO_QUERY, items: [], failed: false },
);
}, [enabled, invalidatePending]);
// Render-time searches can outlive an abandoned render, so they only arm
// request work after this hook commits.
useIsomorphicLayoutEffect(() => {
inactiveRef.current = false;
const deferredQuery = deferredQueryRef.current;
deferredQueryRef.current = null;
if (deferredQuery !== null) scheduleFetchRef.current(deferredQuery);
return () => {
inactiveRef.current = true;
invalidatePending();
};
}, [invalidatePending]);
// Arm retries only after the failed state commits. Arming during rejection
// would let the failure render immediately schedule another request.
useEffect(() => {
retryableQueryRef.current = state.failed ? state.query : null;
}, [state]);
const adapter = useMemo<Unstable_TriggerAdapter>(
() => ({
categories: () => [],
categoryItems: () => [],
search: (query: string) => {
if (!enabled) return [];
// search() runs inside the popover's render; defer state updates with
// queueMicrotask so they are not dispatched while another component renders.
if (query !== state.query || retryableQueryRef.current === query) {
queueMicrotask(() => scheduleFetchRef.current(query));
} else {
queueMicrotask(() => {
if (
deferredQueryRef.current !== null &&
deferredQueryRef.current !== query
) {
deferredQueryRef.current = null;
}
if (
pendingQueryRef.current !== null &&
pendingQueryRef.current !== query
) {
invalidatePending();
}
});
}
return state.items;
},
}),
[enabled, state, invalidatePending],
);
return { adapter, isLoading };
}