-
-
Notifications
You must be signed in to change notification settings - Fork 29
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
1 parent
797b1ae
commit 88d5f7f
Showing
3 changed files
with
565 additions
and
54 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,215 @@ | ||
import { type Ref, type ShallowRef, getCurrentScope, shallowReactive, shallowRef, toValue } from 'vue' | ||
import type { AsyncStatus } from './data-state' | ||
import { type EntryNodeKey, TreeMapNode } from './tree-map' | ||
import type { UseMutationOptions, _ReduceContext } from './use-mutation' | ||
import { defineStore } from 'pinia' | ||
import type { ErrorDefault } from './types-extension' | ||
import { useQueryCache } from './query-store' | ||
import { stringifyFlatObject } from './utils' | ||
|
||
/** | ||
* A query entry in the cache. | ||
*/ | ||
export interface UseMutationEntry<TResult = unknown, TVars = unknown, TError = unknown, TContext extends Record<any, any> | void | null = void> { | ||
/** | ||
* The state of the query. Contains the data, error and status. | ||
*/ | ||
// NOTE: use `DataStatus`? | ||
status: Ref<'pending' | 'success' | 'error'> | ||
|
||
/** | ||
* The status of the query. | ||
*/ | ||
asyncStatus: Ref<AsyncStatus> | ||
|
||
/** | ||
* The serialized key associated with this query entry. | ||
*/ | ||
key: EntryNodeKey[] | ||
|
||
// /** | ||
// * Components and effects scopes that use this query entry. | ||
// */ | ||
// deps: Set<EffectScope | ComponentInternalInstance> | ||
|
||
pendingCall?: symbol | ||
// pending: null | { | ||
// abortController: AbortController | ||
// refreshCall: Promise<DataState<TResult, TError>> | ||
// when: number | ||
// } | ||
|
||
/** | ||
* Options used to create the query. They can be undefined during hydration but are needed for fetching. This is why | ||
* `store.ensure()` sets this property. Note these options might be shared by multiple query entries when the key is | ||
* dynamic. | ||
*/ | ||
|
||
options: UseMutationOptions<TResult, TVars, TError, TContext> | null | ||
// TODO: ideally shouldn't be null, there should be different kind of types | ||
|
||
data: ShallowRef<TResult | undefined> | ||
|
||
error: ShallowRef<TError | null> | ||
|
||
// /** | ||
// * Whether the query is currently being used by a Component or EffectScope (e.g. a store). | ||
// */ | ||
// readonly active: boolean | ||
} | ||
|
||
export interface UseMutationOptionsWithKey<TResult, TError> extends UseMutationOptions<TResult, TError> { | ||
key: EntryNodeKey[] | ||
} | ||
|
||
/** | ||
* Creates a new mutation entry. | ||
* | ||
* @internal | ||
* @param key - key of the entry | ||
*/ | ||
export function createMutationEntry<TResult = unknown, TError = ErrorDefault>( | ||
key?: EntryNodeKey[], | ||
): UseMutationEntry<TResult, TError> { | ||
return { | ||
key: key!, | ||
status: shallowRef('pending'), | ||
asyncStatus: shallowRef<AsyncStatus>('idle'), | ||
// TODO? | ||
// deps: new Set(), | ||
options: null, | ||
error: shallowRef(null), | ||
data: shallowRef(), | ||
// TODO? | ||
// get active() { | ||
// return this.deps.size > 0 | ||
// }, | ||
} | ||
} | ||
|
||
/** | ||
* The id of the store used for queries. | ||
* @internal | ||
*/ | ||
export const MUTATION_STORE_ID = '_pc_mutation' | ||
|
||
export const useMutationCache = defineStore(MUTATION_STORE_ID, ({ action }) => { | ||
// We have two versions of the cache, one that track changes and another that doesn't so the actions can be used | ||
// inside computed properties | ||
const cachesRaw = new TreeMapNode<UseMutationEntry<unknown, unknown>>() | ||
const caches = shallowReactive(cachesRaw) | ||
const queryCache = useQueryCache() | ||
|
||
// this allows use to attach reactive effects to the scope later on | ||
const scope = getCurrentScope()! | ||
|
||
/** | ||
* Ensures a query entry is present in the cache. If it's not, it creates a new one. The resulting entry is required | ||
* to call other methods like {@link fetch}, {@link refresh}, or {@link invalidate}. | ||
* | ||
* @param key - the key of the query | ||
*/ | ||
const ensure = action( | ||
<TResult = unknown, TError = ErrorDefault>( | ||
options: UseMutationOptionsWithKey<TResult, TError>, | ||
): UseMutationEntry<TResult, TError> => { | ||
const key = toValue(options.key).map(stringifyFlatObject) | ||
|
||
// ensure the state | ||
// console.log('⚙️ Ensuring entry', key) | ||
let entry = cachesRaw.get(key) | ||
if (!entry) { | ||
cachesRaw.set( | ||
key, | ||
(entry = scope.run(() => | ||
createMutationEntry(key), | ||
)!), | ||
) | ||
entry = cachesRaw.get(key) | ||
} | ||
|
||
// during HMR, the options might change, so it's better to always update them | ||
// @ts-expect-error: options generics | ||
entry.options = options | ||
|
||
// TODO | ||
// if this query was defined within a defineQuery call, add it to the list | ||
// currentDefineQueryEntry?.[0].push(entry) | ||
|
||
return entry as UseMutationEntry<TResult, TError> | ||
}, | ||
) | ||
|
||
const mutateAsync = action( | ||
async <TResult, TVars, TError, TContext extends Record<any, any> | void | null = void>(entry: UseMutationEntry<TResult, TVars, TError, TContext>, vars: TVars) => { | ||
entry.asyncStatus.value = 'loading' | ||
|
||
// TODO: AbortSignal that is aborted when the mutation is called again so we can throw in pending | ||
let currentData: TResult | undefined | ||
let currentError: TError | undefined | ||
let context!: _ReduceContext<TContext> | ||
|
||
// TODO: a closer implementation to the one of the query store | ||
const currentCall = (entry.pendingCall = Symbol()) | ||
try { | ||
// NOTE: the cast makes it easier to write without extra code. It's safe because { ...null, ...undefined } works and TContext must be a Record<any, any> | ||
context = (await entry.options?.onMutate?.(vars)) as _ReduceContext<TContext> | ||
|
||
// TODO: handle the case where `entry.options` is null | ||
currentData = await entry.options!.mutation( | ||
vars, | ||
context as TContext, | ||
) | ||
await entry.options?.onSuccess?.({ data: currentData, vars, ...context }) | ||
|
||
if (entry.pendingCall === currentCall) { | ||
entry.data.value = currentData | ||
entry.error.value = null | ||
entry.status.value = 'success' | ||
|
||
// TODO: move to plugin | ||
if (entry.options && entry.options.keys) { | ||
const keys | ||
= typeof entry.options.keys === 'function' | ||
? entry.options.keys(currentData, vars) | ||
: entry.options.keys | ||
for (const entry of keys.flatMap((key) => | ||
queryCache.getEntries({ key, exact: true }), | ||
)) { | ||
// TODO: find a way to pass a source of the invalidation, could be a symbol associated with the mutation, the parameters | ||
queryCache.invalidate(entry) | ||
// auto refresh of the active queries | ||
if (entry.active) { | ||
queryCache.fetch(entry) | ||
} | ||
} | ||
} | ||
} | ||
} catch (newError: any) { | ||
currentError = newError | ||
await entry.options?.onError?.({ error: newError, vars, ...context }) | ||
if (entry.pendingCall === currentCall) { | ||
entry.error.value = newError | ||
entry.status.value = 'error' | ||
} | ||
throw newError | ||
} finally { | ||
entry.asyncStatus.value = 'idle' | ||
await entry.options?.onSettled?.({ | ||
data: currentData, | ||
error: currentError, | ||
vars, | ||
...context, | ||
}) | ||
} | ||
|
||
return currentData | ||
}, | ||
) | ||
|
||
return { | ||
caches, | ||
ensure, | ||
mutateAsync, | ||
} | ||
}) |
Oops, something went wrong.