components/ai_chat/resources/page/api/ai_chat_api.ts had trouble with typescript intellisense due to the process file mutation functions now having an array as their first param. Apparently due to contravariance checks
970 lines
33 KiB
TypeScript
970 lines
33 KiB
TypeScript
// Copyright (c) 2025 The Brave Authors. All rights reserved.
|
||
// This Source Code Form is subject to the terms of the Mozilla Public
|
||
// License, v. 2.0. If a copy of the MPL was not distributed with this file,
|
||
// You can obtain one at https://mozilla.org/MPL/2.0/.
|
||
|
||
import * as React from 'react'
|
||
import {
|
||
QueryClient,
|
||
QueryObserver,
|
||
useQuery,
|
||
UseQueryResult as ReactQueryUseQueryResult,
|
||
useMutation,
|
||
UseMutationResult as ReactQueryUseMutationResult,
|
||
UseMutationOptions,
|
||
QueryObserverOptions,
|
||
MutationOptions,
|
||
MutationObserver,
|
||
MutateOptions,
|
||
} from '@tanstack/react-query'
|
||
|
||
/**
|
||
* Shared QueryClient singleton used by all APIs created with createInterfaceApi.
|
||
* Each API differentiates its data via unique key prefixes.
|
||
* Callers can still pass their own QueryClient if needed.
|
||
*/
|
||
let sharedQueryClient = new QueryClient()
|
||
|
||
/**
|
||
* Removes all data from the shared QueryClient used by all calls to
|
||
* createInterfaceApi which do not provide their own QueryClient.
|
||
*/
|
||
export function clearAllDataForTesting() {
|
||
// sharedQueryClient.clear() or .cancelQueries() can throw a CancelledError,
|
||
// and it's probably best to move to a new instance in-between tests anyway.
|
||
sharedQueryClient = new QueryClient()
|
||
}
|
||
|
||
type ChangeReturnType<T extends (...args: any[]) => any, R> = T extends (
|
||
...args: infer P
|
||
) => any
|
||
? (...args: P) => R
|
||
: never
|
||
|
||
export type NoPlaceholderQueryEndpointDefinition<
|
||
P extends readonly any[],
|
||
R,
|
||
> = {
|
||
query: (...args: P) => Promise<R>
|
||
prefetchWithArgs?: NoInfer<P>
|
||
} & Omit<
|
||
QueryObserverOptions<NoInfer<R>>,
|
||
'queryKey' | 'queryFn' | 'placeholderData'
|
||
>
|
||
|
||
export type PlaceholderQueryEndpointDefinition<
|
||
P extends readonly any[],
|
||
R,
|
||
> = NoPlaceholderQueryEndpointDefinition<P, R> & {
|
||
placeholderData: NoInfer<R>
|
||
}
|
||
|
||
export type QueryEndpointDefinition<P extends readonly any[], R> =
|
||
| NoPlaceholderQueryEndpointDefinition<P, R>
|
||
| PlaceholderQueryEndpointDefinition<P, R>
|
||
|
||
export type MutationEndpointDefinition<P extends readonly any[], R> = {
|
||
mutation: (...args: P) => Promise<R>
|
||
} & Omit<MutationOptions<NoInfer<R>, unknown, P>, 'mutationKey' | 'mutationFn'>
|
||
|
||
/**
|
||
* Each endpoint must be one of:
|
||
* { query: (...args: A) => Promise<T>, prefetch?: boolean }
|
||
* or
|
||
* { mutation: (...args: A) => Promise<T> }
|
||
*
|
||
* For query endpoints, you get `.fetch(...)`, `.useQuery(...)`, `.invalidate(...)`, and `.update(...)`.
|
||
* For mutation endpoints, you get `.mutate(...)` and `.useMutation()`.
|
||
*/
|
||
export type EndpointDef<P extends readonly any[], R> =
|
||
| QueryEndpointDefinition<P, R>
|
||
| MutationEndpointDefinition<P, R>
|
||
|
||
/**
|
||
* EventDef<Args, Payload> is just a phantom object whose sole job is to carry
|
||
* the tuple‐type `Args` (an array of key‐argument types) and the `Payload` type.
|
||
*
|
||
* At runtime `event()` returns a dummy object; we only use its type for inference.
|
||
*/
|
||
export type EventDef<Args extends any[], Payload extends any[]> = {
|
||
registerEmitter: (emitter: (...args: Payload) => void) => void
|
||
/**
|
||
* phantom field so we can extract `Args` at the type level
|
||
*/
|
||
__args: Args
|
||
}
|
||
|
||
// This function only exists to actually prevent inferring of
|
||
// query args from prefetchWithArgs so that typescript avoids
|
||
// confusion if the query() parameters don't match the prefetchWithArgs type
|
||
// and informs the consumer that there's a mismatch that needs to be fixed.
|
||
// Ideally consumers would not need to wrap their endpoints in query()
|
||
// and they don't if they don't define prefetchWithArgs.
|
||
export function query<const P extends readonly any[], R>(
|
||
endpoint: QueryEndpointDefinition<P, R>,
|
||
): QueryEndpointDefinition<P, R> {
|
||
return endpoint
|
||
}
|
||
|
||
/**
|
||
* Wrapper for an endpoint which creates data that is not fetched but is
|
||
* updated by events and provided with initial data.
|
||
* @param data optional initial data
|
||
* @returns query endpoint definition
|
||
*/
|
||
// Overload: when data is provided, return type includes placeholderData
|
||
export function state<R>(data: R): PlaceholderQueryEndpointDefinition<[], R>
|
||
// Overload: when no data, return type without placeholderData
|
||
export function state<R>(): NoPlaceholderQueryEndpointDefinition<[], R>
|
||
// Implementation
|
||
export function state<R>(data?: R): QueryEndpointDefinition<[], R> {
|
||
const base = {
|
||
query: () =>
|
||
Promise.reject(new Error('State endpoint should not be called')),
|
||
enabled: false,
|
||
prefetchWithArgs: [] as [],
|
||
staleTime: 'static' as const,
|
||
}
|
||
if (data !== undefined) {
|
||
return { ...base, placeholderData: data }
|
||
}
|
||
return base
|
||
}
|
||
|
||
/**
|
||
* Call this for each event in your config. For example:
|
||
* events: {
|
||
* onConversationDeleted: event<[string], boolean>(),
|
||
* onUserRenamed: event<[number], { id: number; newName: string }>(),
|
||
* }
|
||
*
|
||
* That tells the factory "`onConversationDeleted` takes one key‐arg (string),
|
||
* and its payload is a boolean."
|
||
*/
|
||
export function event<Args extends any[], Payload extends any[]>(
|
||
registerEmitter: (emitter: (...args: Payload) => void) => void,
|
||
): EventDef<Args, Payload> {
|
||
return {
|
||
registerEmitter,
|
||
__args: [] as any as Args,
|
||
}
|
||
}
|
||
|
||
// Basis for a unique key for each call to createInterfaceApi for the current scope
|
||
let globalRootInstanceCount = 0
|
||
|
||
/**
|
||
* Factory function to create a subscribable API with a shared cache based off
|
||
* (usually a mojom) interface which exposes features of Tanstack Query in a
|
||
* similar fashion to RTK Query's createApi.
|
||
*
|
||
* Whilst this sets up a store that could be (ab)used to store, update and
|
||
* subscribe to any data, it encourages its use only for remote-fetched data,
|
||
* and not reactive state needed for the UI. The intention is for that kind of
|
||
* state to be handled by the more featureful UI framework.
|
||
*
|
||
* See readme.md for detailed usage instructions and examples.
|
||
*/
|
||
export function createInterfaceApi<
|
||
/**
|
||
* ExposedActions is a record of simple actions that can be called directly from the UI.
|
||
* These actions do not require any status helpers or long-term data caching.
|
||
* For example:
|
||
* {
|
||
* sendMessage: (message: string) => void,
|
||
* deleteConversation: (id: string) => void,
|
||
* …
|
||
* }
|
||
*/
|
||
const ExposedActions extends Record<
|
||
string,
|
||
Function | Record<string, Function>
|
||
>,
|
||
// Use `EndpointDef<any, any>` (not `any[]`) so that specific endpoint
|
||
// definitions remain assignable under `strictFunctionTypes`. With `any[]`,
|
||
// mutation callbacks like `onMutate: (variables: [a, b]) => …` fail
|
||
// contravariance checks against `(variables: any[]) => …` because tuples
|
||
// are narrower than `any[]`. Switching to `any` makes the parameter slot
|
||
// bivariant and side-steps the variance issue without losing inference on
|
||
// the concrete `RawEndpoints` type below.
|
||
const RawEndpoints extends Record<string, EndpointDef<any, any>>,
|
||
EventDefinitions extends Record<string, EventDef<any[], any>> = {},
|
||
>(config: {
|
||
/**
|
||
* Simple actions that can be called directly from the UI. These
|
||
* don't need any status helpers or data caching. If helper data for status,
|
||
* e.g. "in progress", would be useful then consider using a mutation endpoint instead.
|
||
*/
|
||
actions?: ExposedActions
|
||
|
||
/**
|
||
* Actions to expose to the UI, each have a result that will be cached or mutated
|
||
*/
|
||
endpoints: RawEndpoints
|
||
|
||
/** An array of event names to broadcast */
|
||
events?: EventDefinitions
|
||
}) {
|
||
// Use shared QueryClient by default for proper React context integration.
|
||
// All APIs share the same client; keys differentiate the data.
|
||
const queryClient = sharedQueryClient
|
||
|
||
const rootKey = globalRootInstanceCount++
|
||
|
||
type ValidKey = keyof RawEndpoints & string
|
||
|
||
// For each endpoint K:
|
||
// ArgsOf<K> = the parameter‐tuple of raw[K].[query|mutation]
|
||
// DataOf<K> = the (Promise-d) returned type of raw[K].[query|mutation]
|
||
type ArgsOf<K extends ValidKey> =
|
||
RawEndpoints[K] extends QueryEndpointDefinition<infer QueryArgs, any>
|
||
? QueryArgs
|
||
: RawEndpoints[K] extends MutationEndpointDefinition<
|
||
infer MutationArgs,
|
||
any
|
||
>
|
||
? MutationArgs
|
||
: never
|
||
type DataOf<K extends keyof RawEndpoints> =
|
||
RawEndpoints[K] extends QueryEndpointDefinition<any, infer QueryResult>
|
||
? QueryResult
|
||
: RawEndpoints[K] extends MutationEndpointDefinition<
|
||
any,
|
||
infer MutationResult
|
||
>
|
||
? MutationResult
|
||
: never
|
||
|
||
// If placeholder data is provided, we can assume that data is never undefined
|
||
type EndpointDataForKey<K extends ValidKey> = RawEndpoints[K] extends {
|
||
placeholderData: DataOf<K>
|
||
}
|
||
? DataOf<K>
|
||
: DataOf<K> | undefined
|
||
|
||
// Custom results
|
||
type BaseUseQueryResult<K extends ValidKey> = ReactQueryUseQueryResult<
|
||
DataOf<K>
|
||
>
|
||
type UseQueryResult<K extends ValidKey> = BaseUseQueryResult<K> & {
|
||
// Convenience - a nicer name for data
|
||
// *and* never undefined if placeholder data is provided, e.g.
|
||
// `const { getThingsData } = useGetThings()`
|
||
// instead of
|
||
// ```
|
||
// const { data: getThingsData } = useSaveSomething()
|
||
// if (!getThingsData) {
|
||
// throw new Error('getThingsData should never be undefined as it has placeholder data')
|
||
// }
|
||
// ```
|
||
[P in K as `${P}Data`]: EndpointDataForKey<K>
|
||
// Note: We could add more convenience properties here,
|
||
// e.g. `isMyQueryLoading`.
|
||
} & {
|
||
// Never undefined if placeholderData is provided
|
||
data: EndpointDataForKey<K>
|
||
}
|
||
|
||
type BaseUseMutationResult<K extends ValidKey> = ReactQueryUseMutationResult<
|
||
DataOf<K>,
|
||
unknown,
|
||
ArgsOf<K>
|
||
> & {
|
||
// Nicer version of mutate where args are optional if no parameters
|
||
mutate: ArgsOf<K> extends []
|
||
? () => ReturnType<
|
||
ReactQueryUseMutationResult<DataOf<K>, unknown, ArgsOf<K>>['mutate']
|
||
>
|
||
: ReactQueryUseMutationResult<DataOf<K>, unknown, ArgsOf<K>>['mutate']
|
||
}
|
||
type UseMutationResult<K extends ValidKey> = BaseUseMutationResult<K> & {
|
||
// Convenience - a nicer name for mutate, e.g.
|
||
// `const { saveSomething } = useSaveSomething()`
|
||
// instead of
|
||
// `const { mutate: saveSomething } = useSaveSomething()`
|
||
[P in K]: BaseUseMutationResult<K>['mutate']
|
||
}
|
||
|
||
type APIActions = {
|
||
[K in keyof ExposedActions]: ExposedActions[K] extends (
|
||
...args: infer P
|
||
) => any
|
||
? ExposedActions[K]
|
||
: {
|
||
[P in keyof ExposedActions[K]]: ExposedActions[K][P]
|
||
}
|
||
}
|
||
|
||
// Don't allow individual hook uses to specify events since those are specified either at the endpoint
|
||
// definition or by the mutation function call.
|
||
type APIUseMutationOptions<K extends ValidKey> = Omit<
|
||
UseMutationOptions<DataOf<K>, unknown, ArgsOf<K>>,
|
||
'onMutate' | 'onError' | 'onSuccess' | 'onSettled'
|
||
>
|
||
|
||
// Build out methods for every K in RawEndpoints, depending on whether it's a query or mutation
|
||
type APIEndpoints = {
|
||
[K in ValidKey]: RawEndpoints[K] extends QueryEndpointDefinition<any, any>
|
||
? {
|
||
/**
|
||
* Call the underlying query and return the data. This will
|
||
* also cause anyone subscribed to this endpoint to receive the
|
||
* updated data.
|
||
*/
|
||
fetch: (...args: ArgsOf<K>) => Promise<DataOf<K>>
|
||
|
||
/**
|
||
* Imperative (non-reactive) way to retrieve data for this endpoint.
|
||
* Should only be used in callbacks or functions where reading the
|
||
* latest data is necessary, e.g. for optimistic updates.
|
||
*
|
||
* Hint: Do not use this function inside a component, because it won't
|
||
* receive updates. Use useQuery to create a QueryObserver that
|
||
* subscribes to changes.
|
||
*/
|
||
current: (...args: ArgsOf<K>) => EndpointDataForKey<K>
|
||
|
||
/**
|
||
* Reset the cache for this endpoint
|
||
* (specifying optional arguments as the key)
|
||
* which will remove the data from the cache
|
||
* and reset the state to undefined or placeholderData.
|
||
*/
|
||
reset: (...args: ArgsOf<K>) => void
|
||
|
||
/**
|
||
* Invalidate the cache for this endpoint
|
||
* (specifying optional arguments as the key to invalidate)
|
||
* which will force a re-fetch of the data if currently
|
||
* subscribed to, or on the next call to `fetch()`.
|
||
*/
|
||
invalidate: (...args: ArgsOf<K>) => void
|
||
|
||
/**
|
||
* React hook for accessing state of a query endpoint including progress, cached data and re-fetching
|
||
* See https://tanstack.com/query/latest/docs/framework/react/reference/useQuery
|
||
*/
|
||
useQuery: (...args: ArgsOf<K>) => UseQueryResult<K>
|
||
|
||
/**
|
||
* Manually update the cache for this endpoint
|
||
* (specifying optional arguments as the key to update).
|
||
* Usage: api.myEndpoint.update(arg1, arg2, ..., updaterFnOrUpdate)
|
||
* where updaterFnOrUpdate has signature (old: Data | undefined) => Data
|
||
* OR is a Partial<Data> to update the object directly.
|
||
*
|
||
* This is useful in event handlers from the remote interface, or
|
||
* optimistic updates.
|
||
*/
|
||
update: (
|
||
...argsAndUpdater: [
|
||
...Params: ArgsOf<K>,
|
||
updater:
|
||
| ((old: DataOf<K>) => Partial<DataOf<K>>)
|
||
| Partial<DataOf<K>>,
|
||
]
|
||
) => void
|
||
} & (RawEndpoints[K] extends { placeholderData: DataOf<K> }
|
||
? {
|
||
// If placeholder data is provided, we know
|
||
// the data will never be undefined, so we can
|
||
// provide a potentially even more convenient hook.
|
||
useData: (...args: ArgsOf<K>) => DataOf<K>
|
||
}
|
||
: {})
|
||
: RawEndpoints[K] extends MutationEndpointDefinition<any, any>
|
||
? {
|
||
/**
|
||
* Call the underlying `raw[K].mutation(...args)` and return the data.
|
||
*/
|
||
mutate: ChangeReturnType<
|
||
BaseUseMutationResult<K>['mutate'],
|
||
Promise<DataOf<K>>
|
||
>
|
||
|
||
/**
|
||
* Hook: runs `useMutation` for this mutation endpoint.
|
||
* You can call `mutate(variables)` or `mutateAsync(variables)` on the result.
|
||
*/
|
||
useMutation: (
|
||
options?: APIUseMutationOptions<K>,
|
||
) => UseMutationResult<K>
|
||
}
|
||
: never
|
||
}
|
||
|
||
const endpoints = {} as APIEndpoints
|
||
|
||
// Build endpoints - Queries and Mutations
|
||
//
|
||
// Expose React Query's hooks and methods for each endpoint,
|
||
// deciding if it's a query or mutation endpoint.
|
||
//
|
||
//
|
||
;(Object.keys(config.endpoints) as Array<ValidKey>).forEach((name) => {
|
||
const endpointDef = config.endpoints[name]
|
||
const baseKey = [rootKey, name]
|
||
|
||
if ('query' in endpointDef) {
|
||
const { query, prefetchWithArgs, ...queryOptions } =
|
||
endpointDef as QueryEndpointDefinition<any, any>
|
||
type QArgs = ArgsOf<typeof name>
|
||
type QData = DataOf<typeof name>
|
||
|
||
if (!queryOptions.staleTime) {
|
||
// The default for staleTime is 0, meaning a result is stale as soon
|
||
// as it is received. That results in every useQuery call causing a
|
||
// re-fetch after returning the stale data.
|
||
// Infinity will make data always considered fresh and re-fetch will
|
||
// only occur when invalidated, manually refetched, or if refetchOnX is
|
||
// set.
|
||
queryOptions.staleTime = Infinity
|
||
}
|
||
|
||
// fetch(...args):
|
||
// - calls `query(...args)`
|
||
// - sets the result into cache under [...baseKey, ...args]
|
||
// - returns the data
|
||
const fetcher = async (...args: QArgs) => {
|
||
const data = await queryClient.fetchQuery<QData, never, QArgs>({
|
||
queryKey: [...baseKey, ...args],
|
||
queryFn: () => {
|
||
const queryResult = query(...args)
|
||
return queryResult
|
||
},
|
||
...queryOptions,
|
||
})
|
||
return data
|
||
}
|
||
|
||
const getCachedData = (...args: QArgs) => {
|
||
const queryKey = [...baseKey, ...args] as readonly any[]
|
||
let cachedData = queryClient.getQueryData<QData>(queryKey)
|
||
if (cachedData === undefined && 'placeholderData' in queryOptions) {
|
||
cachedData = queryOptions.placeholderData
|
||
}
|
||
return cachedData
|
||
}
|
||
|
||
// invalidates the cache so that it will be refetched
|
||
// if the data is needed.
|
||
const invalidate = (...args: QArgs) => {
|
||
const queryKey = [...baseKey, ...args]
|
||
queryClient.invalidateQueries({
|
||
queryKey,
|
||
exact: true,
|
||
})
|
||
}
|
||
|
||
// Removes state and resets to undefined or placeholderData
|
||
const reset = (...args: QArgs) => {
|
||
const queryKey = [...baseKey, ...args]
|
||
// We can use removeQueries if we don't want
|
||
// to reset to placeholderData.
|
||
queryClient.resetQueries({
|
||
queryKey,
|
||
exact: true,
|
||
})
|
||
}
|
||
|
||
// useQuery(...args): wrapper around React-Query's useQuery
|
||
const useQ = (...args: QArgs) => {
|
||
const useQueryResult = useQuery<QData>(
|
||
{
|
||
queryKey: [...baseKey, ...args],
|
||
queryFn: () => {
|
||
const queryResult = query(...args)
|
||
return queryResult
|
||
},
|
||
...queryOptions,
|
||
},
|
||
queryClient,
|
||
)
|
||
return {
|
||
...useQueryResult,
|
||
// Typescript will handle whether this property
|
||
// is accessible as definitely QData or undefined | QData.
|
||
// We don't need to check if placeholderData is provided.
|
||
[`${name}Data`]: useQueryResult.data,
|
||
} as UseQueryResult<typeof name>
|
||
}
|
||
|
||
// update(...args, updaterFn): manually setQueryData for that key
|
||
// We can accept a partial update only if the data is an object
|
||
type AllowedUpdateParam = QData extends {} ? Partial<QData> : QData
|
||
function updateFromOld(
|
||
old: QData | undefined,
|
||
update: AllowedUpdateParam,
|
||
): QData {
|
||
if (
|
||
old === undefined
|
||
|| Array.isArray(old)
|
||
|| typeof old !== 'object'
|
||
) {
|
||
// Technically we shouldn't allow this as it might just be partial
|
||
return update as QData
|
||
}
|
||
// Objects can be combined
|
||
return { ...old, ...(update as Partial<QData>) } as QData
|
||
}
|
||
|
||
const updater = (
|
||
...argsAndUpdater: [
|
||
...Params: QArgs,
|
||
updaterFn: ((old: QData) => AllowedUpdateParam) | AllowedUpdateParam,
|
||
]
|
||
) => {
|
||
const up = argsAndUpdater[argsAndUpdater.length - 1] as
|
||
| ((old: QData) => AllowedUpdateParam)
|
||
| Partial<QData>
|
||
// args is everything except the updater function (or updated state) which is
|
||
// always the last argument.
|
||
const args = argsAndUpdater.slice(
|
||
0,
|
||
argsAndUpdater.length - 1,
|
||
) as unknown as QArgs
|
||
|
||
const queryKey = [...baseKey, ...args]
|
||
|
||
queryClient.setQueryData<QData>(queryKey, (old) => {
|
||
const updateData: AllowedUpdateParam =
|
||
typeof up === 'function'
|
||
? up(old as QData)
|
||
: (up as AllowedUpdateParam)
|
||
const oldOrPlaceholder = getCachedData(...args)
|
||
if (!old && oldOrPlaceholder) {
|
||
// Warn only if this is a regular query. "state" queries will always
|
||
// use the update mechanism - the initial data is always considered
|
||
// placeholder.
|
||
if (endpointDef.enabled) {
|
||
console.warn(
|
||
'Updating data for query when base data has not yet'
|
||
+ ' been received. Placeholder data will convert to "real" data.'
|
||
+ ' `isPlaceholder` can no longer be relied upon for this query.',
|
||
{ queryKey, old, oldOrPlaceholder, updateData },
|
||
)
|
||
}
|
||
old = oldOrPlaceholder
|
||
}
|
||
const newData = updateFromOld(old, updateData)
|
||
return newData
|
||
})
|
||
|
||
// Cancel and re-queue because our set could be replaced if there is a
|
||
// query in-progress.
|
||
if (queryClient.isFetching({ queryKey, exact: true })) {
|
||
queryClient.cancelQueries({
|
||
queryKey,
|
||
exact: true,
|
||
})
|
||
// re-queue, but must first invalidate so that fetcher performs a new
|
||
// fetch instead of return the cached result
|
||
queryClient.invalidateQueries({
|
||
queryKey,
|
||
exact: true,
|
||
})
|
||
fetcher(...args)
|
||
}
|
||
}
|
||
|
||
if (prefetchWithArgs && queryOptions.enabled !== false) {
|
||
// If this endpoint is marked as prefetch, we prefetch it immediately
|
||
// even if the UI doesn't call it yet - we don't
|
||
// want to wait for React to initialize.
|
||
fetcher(...(prefetchWithArgs as QArgs))
|
||
}
|
||
|
||
;(endpoints as any)[name] = {
|
||
fetch: fetcher,
|
||
current: getCachedData,
|
||
invalidate,
|
||
reset,
|
||
useQuery: useQ,
|
||
update: updater,
|
||
}
|
||
} else if ('mutation' in endpointDef) {
|
||
const { mutation, ...mutationOptions } = endpointDef
|
||
type MArgs = ArgsOf<typeof name> | never
|
||
type MData = DataOf<typeof name>
|
||
|
||
// Allow the caller to e.g. handle mutation results
|
||
queryClient.setMutationDefaults(baseKey, mutationOptions)
|
||
|
||
type EndpointUseMutationOptions = UseMutationOptions<
|
||
MData,
|
||
unknown,
|
||
MArgs
|
||
>
|
||
|
||
// useMutation(): wrap React-Query's useMutation
|
||
const useMut = (
|
||
options?: APIUseMutationOptions<typeof name>,
|
||
...args: MArgs
|
||
): UseMutationResult<typeof name> => {
|
||
const mutationOptions: EndpointUseMutationOptions = { ...options }
|
||
// Do not allow overriding the events - they can be specified in the
|
||
// endpoint definition or at the mutation call. The typescript type prohibits
|
||
// this but we should enforce it at runtime for JS or ignored TS errors in order
|
||
// to avoid unexpected behavior.
|
||
for (const key of [
|
||
'onSuccess',
|
||
'onSettled',
|
||
'onError',
|
||
'onMutate',
|
||
] as Partial<keyof EndpointUseMutationOptions>[]) {
|
||
if (Object.hasOwn(mutationOptions, key)) {
|
||
delete mutationOptions[key]
|
||
}
|
||
}
|
||
const useMutationResult = useMutation<MData, unknown, MArgs>(
|
||
{
|
||
mutationKey: [...baseKey, ...args],
|
||
mutationFn: (variables) => mutation(...variables),
|
||
...mutationOptions,
|
||
},
|
||
queryClient,
|
||
)
|
||
|
||
return {
|
||
...useMutationResult,
|
||
[name]: useMutationResult.mutate,
|
||
} as UseMutationResult<typeof name>
|
||
}
|
||
|
||
const directMutateFn = (
|
||
args: MArgs,
|
||
options?: MutateOptions<MData, unknown, MArgs, unknown>,
|
||
) => {
|
||
const observer = new MutationObserver<MData, unknown, MArgs, unknown>(
|
||
queryClient,
|
||
{
|
||
...options,
|
||
mutationKey: [...baseKey, ...(args ?? [])],
|
||
mutationFn: (variables) => mutation(...variables),
|
||
},
|
||
)
|
||
|
||
return observer.mutate(args ?? [])
|
||
}
|
||
|
||
;(endpoints as any)[name] = {
|
||
mutate: directMutateFn,
|
||
useMutation: useMut,
|
||
}
|
||
}
|
||
})
|
||
|
||
// Events
|
||
// We expect `config.events` to be something like:
|
||
// {
|
||
// onSomethingHappened: event<[], { some: 'data' }>(),
|
||
// onConversationDeleted: event<[conversationId: string], boolean>(),
|
||
// onUserRenamed: event<[userId: number], { id:number; newName: string }>(),
|
||
// }
|
||
//
|
||
// Where the user can subscribe to an argument-less event,
|
||
// e.g. `onSomethingHappened`, or an event with key arguments,
|
||
// e.g. `onConversationDeleted` which takes a `conversationId` as the key argument.
|
||
//
|
||
// That is different from the payload, which is the data that is sent
|
||
// when the event is emitted.
|
||
// e.g.
|
||
// useOnConversationDeleted(conversationId, (result) => {
|
||
// console.log(`Conversation ${conversationId} was deleted: ${result}`)
|
||
// })
|
||
//
|
||
// -or-
|
||
//
|
||
// useOnSomethingHappened((data) => {})
|
||
//
|
||
//
|
||
type EvAll = keyof NonNullable<typeof config.events>
|
||
type EvDefs = NonNullable<typeof config.events>
|
||
|
||
type KeyArgsOf<K extends EvAll> =
|
||
EvDefs[K] extends EventDef<infer A, any> ? A : never
|
||
type PayloadOf<K extends EvAll> =
|
||
EvDefs[K] extends EventDef<any, infer P extends any[]> ? P : never
|
||
|
||
type EventInternalData<T> = { payload: T; eventCount: number }
|
||
|
||
function emitEvent<K extends EvAll>(
|
||
eventName: K,
|
||
...argsAndPayload: [...KeyArgsOf<K>, PayloadOf<K>]
|
||
) {
|
||
const payload = argsAndPayload[argsAndPayload.length - 1] as PayloadOf<K>
|
||
const keyArgs = argsAndPayload.slice(
|
||
0,
|
||
argsAndPayload.length - 1,
|
||
) as KeyArgsOf<K>
|
||
queryClient.setQueryData<EventInternalData<PayloadOf<K>>>(
|
||
[rootKey, eventName, ...keyArgs],
|
||
(old) => ({ payload, eventCount: (old?.eventCount ?? 0) + 1 }),
|
||
)
|
||
}
|
||
|
||
type EventHooks = {
|
||
/**
|
||
* React hook to wrap useEffect with a subscription and unsubscription
|
||
* to the event, firing the provided handler, and resubscribing
|
||
* whenever the provided dependencies change.
|
||
*/
|
||
[K in EvAll as `use${Capitalize<string & K>}`]: (
|
||
handler?: (...result: PayloadOf<K>) => void,
|
||
deps?: React.DependencyList,
|
||
...keyArgs: KeyArgsOf<K>
|
||
) => void
|
||
} & {
|
||
/**
|
||
* React hook to get the latest payload for the event. Useful for one-off
|
||
* events without needing to add extra state to your UI component
|
||
*/
|
||
[K in EvAll as `useCurrent${Capitalize<string & K>}`]: (
|
||
...keyArgs: KeyArgsOf<K>
|
||
) => {
|
||
hasEmitted: boolean
|
||
/**
|
||
* The latest data for the event, or undefined if no data has been set
|
||
*/
|
||
data: PayloadOf<K> | undefined
|
||
}
|
||
} & {
|
||
/**
|
||
* Subscribe and unsubscribe to the event
|
||
*/
|
||
[K in EvAll as `subscribeTo${Capitalize<string & K>}`]: (
|
||
handler: (...result: PayloadOf<K>) => void,
|
||
...keyArgs: KeyArgsOf<K>
|
||
) => () => void
|
||
} & {
|
||
/**
|
||
* Clear event data so that useCurrentMyEvent will return undefined until
|
||
* the next time the event is emitted.
|
||
*/
|
||
[K in EvAll as `reset${Capitalize<string & K>}`]: (
|
||
...keyArgs: KeyArgsOf<K>
|
||
) => void
|
||
}
|
||
|
||
// Events implementation
|
||
|
||
const eventHooks = {} as EventHooks
|
||
|
||
;(Object.keys(config.events || {}) as Array<EvAll>).forEach((eventName) => {
|
||
const cap = `${(eventName as string)[0].toUpperCase()}${(eventName as string).slice(1)}`
|
||
const hookName = `use${cap}`
|
||
const hookNameUseCurrent = `useCurrent${cap}`
|
||
const subscribeName = `subscribeTo${cap}`
|
||
const handledName = `reset${cap}`
|
||
|
||
const keyBase = [rootKey, eventName]
|
||
|
||
// register the emitter for the event
|
||
// @ts-expect-error we need to fix the type of argsAndPayload
|
||
;(config.events as any)[eventName].registerEmitter((...args) =>
|
||
// @ts-expect-error
|
||
emitEvent(eventName, args),
|
||
)
|
||
|
||
// useCurrentMyEvent
|
||
;(eventHooks as any)[hookNameUseCurrent] = (...keyArgs: any[]) => {
|
||
const hookData = useQuery<EventInternalData<PayloadOf<typeof eventName>>>(
|
||
{
|
||
queryKey: [...keyBase, ...keyArgs],
|
||
enabled: false,
|
||
queryFn: () =>
|
||
Promise.reject(
|
||
new Error(
|
||
`${eventName as string} is an event, not a query and should not try to fetch data`,
|
||
),
|
||
),
|
||
},
|
||
queryClient,
|
||
)
|
||
|
||
return {
|
||
hasEmitted: hookData.isFetched,
|
||
data: hookData.data?.payload,
|
||
}
|
||
}
|
||
|
||
// subscribeToMyEvent
|
||
;(eventHooks as any)[subscribeName] = (
|
||
handler: (...result: PayloadOf<typeof eventName>) => {},
|
||
...keyArgs: any[]
|
||
) => {
|
||
const observer = new QueryObserver<
|
||
EventInternalData<PayloadOf<typeof eventName>>
|
||
>(queryClient, {
|
||
queryKey: [...keyBase, ...keyArgs],
|
||
enabled: false,
|
||
queryFn: () =>
|
||
Promise.reject(
|
||
new Error(
|
||
`${eventName as string} is an event, not a query and should not try to fetch data`,
|
||
),
|
||
),
|
||
})
|
||
|
||
const unsubscribe = observer.subscribe((result) => {
|
||
if (result.data !== undefined) {
|
||
handler(...result.data.payload)
|
||
} else {
|
||
// We would only get here if there is never any payload type for
|
||
// this event.
|
||
// @ts-expect-error no args
|
||
handler()
|
||
}
|
||
})
|
||
|
||
return unsubscribe
|
||
}
|
||
|
||
// useMyEvent
|
||
;(eventHooks as any)[hookName] = (
|
||
handler: (...result: PayloadOf<typeof eventName>) => {},
|
||
deps: React.DependencyList,
|
||
...keyArgs: any[]
|
||
) => {
|
||
// Every time this function is called, we're probably going to get a new instance
|
||
// of the handler, but we don't want to depend on it so that we re-subscribe
|
||
// every time the handler changes. We also don't want to call an old version
|
||
// of the handler when the event fires. Storing in a ref solves both requirements.
|
||
const handlerRef = React.useRef(handler)
|
||
handlerRef.current = handler
|
||
React.useEffect(() => {
|
||
const unsubscribe = (eventHooks as any)[subscribeName](
|
||
handlerRef.current,
|
||
...keyArgs,
|
||
)
|
||
return () => {
|
||
unsubscribe()
|
||
}
|
||
}, deps)
|
||
}
|
||
|
||
// Remove any emitted data for this event
|
||
;(eventHooks as any)[handledName] = (...keyArgs: any[]) => {
|
||
queryClient.resetQueries({
|
||
queryKey: [...keyBase, ...keyArgs],
|
||
exact: true,
|
||
})
|
||
}
|
||
})
|
||
|
||
type MutationKeys = {
|
||
[K in keyof RawEndpoints]: RawEndpoints[K] extends MutationEndpointDefinition<
|
||
any,
|
||
any
|
||
>
|
||
? K
|
||
: never
|
||
}[keyof RawEndpoints]
|
||
& string
|
||
|
||
type QueryKeys = {
|
||
[K in keyof RawEndpoints]: RawEndpoints[K] extends QueryEndpointDefinition<
|
||
any,
|
||
any
|
||
>
|
||
? K
|
||
: never
|
||
}[keyof RawEndpoints]
|
||
& string
|
||
|
||
// Build convenience root access to endpoints
|
||
type RootEndpointMethods =
|
||
// Queries
|
||
{
|
||
// Named query React hook
|
||
[K in QueryKeys as `use${Capitalize<string & K>}`]: (
|
||
...args: ArgsOf<K>
|
||
) => UseQueryResult<K>
|
||
} & {
|
||
// Direct access to endpoint to get non-hook current data, invalidate the query, etc.
|
||
[K in QueryKeys]: APIEndpoints[K]
|
||
} & {
|
||
// Queries with placeholder data always have data, so we can have a
|
||
// named data convenience hook if components don't need progress state.
|
||
[K in ValidKey as RawEndpoints[K] extends PlaceholderQueryEndpointDefinition<
|
||
any,
|
||
any
|
||
>
|
||
? `use${Capitalize<string & K>}Data`
|
||
: never]: (...args: ArgsOf<K>) => DataOf<K>
|
||
} & {
|
||
// Mutations
|
||
// useDoSomething: endpoints.doSomething.useMutation
|
||
[K in MutationKeys as `use${Capitalize<string & K>}`]: (
|
||
options?: APIUseMutationOptions<K>,
|
||
) => UseMutationResult<K>
|
||
} & {
|
||
// doSomething: endpoints.doSomething.mutate
|
||
[K in MutationKeys]: Extract<APIEndpoints[K], { mutate: any }>['mutate']
|
||
}
|
||
|
||
const rootEndpointProperties = {} as RootEndpointMethods
|
||
|
||
;(Object.keys(config.endpoints) as Array<keyof RawEndpoints>).forEach(
|
||
(name) => {
|
||
const capitalized = `${(name as string)[0].toUpperCase()}${(name as string).slice(1)}`
|
||
const hookName =
|
||
`use${capitalized}` as `use${Capitalize<string & typeof name>}`
|
||
|
||
if ('query' in config.endpoints[name]) {
|
||
// @ts-expect-error: generated hook name
|
||
rootEndpointProperties[hookName] = (endpoints as any)[name].useQuery
|
||
|
||
// @ts-expect-error
|
||
rootEndpointProperties[name] = (endpoints as any)[name]
|
||
|
||
if ('placeholderData' in config.endpoints[name]) {
|
||
// @ts-expect-error: generated hook name
|
||
rootEndpointProperties[`use${capitalized}Data`] = (
|
||
...args: any[]
|
||
) => {
|
||
return (endpoints as any)[name].useQuery(...args).data
|
||
}
|
||
}
|
||
}
|
||
|
||
if ('mutation' in config.endpoints[name]) {
|
||
// @ts-expect-error: we know that `endpoints[name].useMutation` matches the signature
|
||
rootEndpointProperties[hookName] = (endpoints as any)[name].useMutation
|
||
// @ts-expect-error
|
||
rootEndpointProperties[name] = (endpoints as any)[name].mutate
|
||
}
|
||
},
|
||
)
|
||
|
||
const actions = config.actions as any as APIActions
|
||
|
||
// Convenient access to query endpoint child functions
|
||
|
||
const api = {
|
||
emitEvent,
|
||
...actions,
|
||
...rootEndpointProperties,
|
||
...eventHooks,
|
||
/**
|
||
* Invalidates all queries for this API instance, causing them to refetch
|
||
* if currently subscribed. Useful for Storybook/tests when any control
|
||
* changes and you want all data to refresh from mock functions.
|
||
*/
|
||
invalidateAll: () => {
|
||
queryClient.invalidateQueries({
|
||
queryKey: [rootKey],
|
||
exact: false,
|
||
})
|
||
},
|
||
close: () => {
|
||
// Cancel all pending queries for this API key
|
||
queryClient.cancelQueries({ queryKey: [rootKey], exact: false })
|
||
// Queries will be gc with no subscribers but we can remove them immediately
|
||
queryClient.removeQueries({ queryKey: [rootKey], exact: false })
|
||
},
|
||
}
|
||
|
||
return api
|
||
}
|