Files
brave-core/components/common/api/create_interface_api.ts
T
Pete Miller 5cc0360a83 [createInterfaceApi] typescript allows mutations to have array params (#36581)
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
2026-05-20 14:17:36 -07:00

970 lines
33 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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 tupletype `Args` (an array of keyargument 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 keyarg (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 parametertuple 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
}