UNPKG

@assistant-ui/react

Version:

Open-source TypeScript/React library for building production-grade AI chat experiences

179 lines (178 loc) • 5.54 kB
"use client"; import { useIsomorphicLayoutEffect } from "../utils/useIsomorphicLayoutEffect.js"; import { useCallback, useEffect, useMemo, useRef, useState } from "@assistant-ui/tap/react-shim"; // src/unstable/useLiveCompletionAdapter.ts /** Sentinel that no real query (including the empty string) equals, so the first query always fetches. */ const NO_QUERY = "\0"; /** * 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 }} * /> * ``` */ function unstable_useLiveCompletionAdapter(options) { const { fetcher, cacheKey, debounceMs = 60, enabled = true } = options; const [state, setState] = useState({ query: NO_QUERY, items: [], failed: false }); const [isLoading, setIsLoading] = useState(false); const fetcherRef = useRef(fetcher); useIsomorphicLayoutEffect(() => { fetcherRef.current = fetcher; }, [fetcher]); const timerRef = useRef(null); const tokenRef = useRef(0); const pendingQueryRef = useRef(null); const retryableQueryRef = useRef(null); const pendingRetryQueryRef = useRef(null); const inactiveRef = useRef(true); const deferredQueryRef = useRef(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) => { 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]); useIsomorphicLayoutEffect(() => { inactiveRef.current = false; const deferredQuery = deferredQueryRef.current; deferredQueryRef.current = null; if (deferredQuery !== null) scheduleFetchRef.current(deferredQuery); return () => { inactiveRef.current = true; invalidatePending(); }; }, [invalidatePending]); useEffect(() => { retryableQueryRef.current = state.failed ? state.query : null; }, [state]); return { adapter: useMemo(() => ({ categories: () => [], categoryItems: () => [], search: (query) => { if (!enabled) return []; 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 ]), isLoading }; } export { unstable_useLiveCompletionAdapter }; //# sourceMappingURL=useLiveCompletionAdapter.js.map