-
-
Notifications
You must be signed in to change notification settings - Fork 26
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
Showing
8 changed files
with
464 additions
and
7 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 |
---|---|---|
|
@@ -3,7 +3,7 @@ | |
"packageManager": "[email protected]", | ||
"version": "0.0.0", | ||
"type": "module", | ||
"description": "", | ||
"description": "The smart data fetching layer for Pinia", | ||
"publishConfig": { | ||
"access": "public" | ||
}, | ||
|
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,149 @@ | ||
import { defineStore } from 'pinia' | ||
import { shallowReactive } from 'vue' | ||
import type { | ||
UseQueryOptionsWithDefaults, | ||
UseDataFetchingQueryEntry, | ||
UseQueryKey, | ||
} from './use-query' | ||
|
||
export const useDataFetchingStore = defineStore('PiniaColada', () => { | ||
/** | ||
* - These are reactive because they are needed for SSR | ||
* - They are split into multiple stores to better handle reactivity | ||
* - With `shallowReactive()` we only observe the first level of the object, which is enough here as the user only | ||
* gets read-only access to the data | ||
*/ | ||
const dataRegistry = shallowReactive(new Map<UseQueryKey, unknown>()) | ||
const errorRegistry = shallowReactive(new Map<UseQueryKey, any>()) | ||
const isFetchingRegistry = shallowReactive(new Map<UseQueryKey, boolean>()) | ||
|
||
// no reactive on this one as it's only used internally and is not needed for hydration | ||
const queryEntriesRegistry = new Map< | ||
UseQueryKey, | ||
UseDataFetchingQueryEntry<unknown, unknown> | ||
>() | ||
|
||
function ensureEntry<TResult = unknown, TError = Error>( | ||
key: UseQueryKey, | ||
{ fetcher, initialValue, cacheTime }: UseQueryOptionsWithDefaults<TResult> | ||
): UseDataFetchingQueryEntry<TResult, TError> { | ||
// ensure the data | ||
console.log('⚙️ Ensuring entry', key) | ||
if (!dataRegistry.has(key)) { | ||
dataRegistry.set(key, initialValue?.() ?? undefined) | ||
errorRegistry.set(key, null) | ||
isFetchingRegistry.set(key, false) | ||
} | ||
|
||
// we need to repopulate the entry registry separately from data and errors | ||
if (!queryEntriesRegistry.has(key)) { | ||
const entry: UseDataFetchingQueryEntry<TResult, TError> = { | ||
data: () => dataRegistry.get(key) as TResult, | ||
error: () => errorRegistry.get(key) as TError, | ||
// FIXME: not reactive | ||
isPending: () => !entry.previous, | ||
isFetching: () => isFetchingRegistry.get(key)!, | ||
pending: null, | ||
previous: null, | ||
async fetch(): Promise<TResult> { | ||
if (!entry.previous || isExpired(entry.previous.when, cacheTime)) { | ||
if (entry.previous) { | ||
console.log( | ||
`⬇️ fetching "${String(key)}". expired ${entry.previous | ||
?.when} / ${cacheTime}` | ||
) | ||
} | ||
await (entry.pending?.refreshCall ?? entry.refresh()) | ||
} | ||
|
||
return entry.data()! | ||
}, | ||
async refresh() { | ||
console.log('🔄 refreshing', key) | ||
// when if there an ongoing request | ||
if (entry.pending) { | ||
console.log(' -> skipped!') | ||
return entry.pending.refreshCall | ||
} | ||
isFetchingRegistry.set(key, true) | ||
errorRegistry.set(key, null) | ||
const nextPrevious = { | ||
when: 0, | ||
data: undefined as TResult | undefined, | ||
error: null as TError | null, | ||
} satisfies UseDataFetchingQueryEntry['previous'] | ||
|
||
entry.pending = { | ||
refreshCall: fetcher() | ||
.then((data) => { | ||
nextPrevious.data = data | ||
dataRegistry.set(key, data) | ||
}) | ||
.catch((error) => { | ||
nextPrevious.error = error | ||
errorRegistry.set(key, error) | ||
throw error | ||
}) | ||
.finally(() => { | ||
entry.pending = null | ||
nextPrevious.when = Date.now() | ||
entry.previous = nextPrevious | ||
isFetchingRegistry.set(key, false) | ||
}), | ||
when: Date.now(), | ||
} | ||
|
||
return entry.pending.refreshCall | ||
}, | ||
} | ||
queryEntriesRegistry.set(key, entry) | ||
} | ||
|
||
const entry = queryEntriesRegistry.get(key)! | ||
// automatically try to refresh the data if it's expired | ||
entry.fetch() | ||
|
||
return entry as UseDataFetchingQueryEntry<TResult, TError> | ||
} | ||
|
||
/** | ||
* Invalidates a query entry, forcing a refetch of the data if `refresh` is true | ||
* | ||
* @param key - the key of the query to invalidate | ||
* @param refresh - whether to force a refresh of the data | ||
*/ | ||
function invalidateEntry(key: string, refresh = false) { | ||
if (!queryEntriesRegistry.has(key)) { | ||
console.warn( | ||
`⚠️ trying to invalidate "${key}" but it's not in the registry` | ||
) | ||
return | ||
} | ||
const entry = queryEntriesRegistry.get(key)! | ||
|
||
if (entry.previous) { | ||
// will force a fetch next time | ||
entry.previous.when = 0 | ||
} | ||
|
||
if (refresh) { | ||
// reset any pending request | ||
entry.pending = null | ||
// force refresh | ||
entry.refresh() | ||
} | ||
} | ||
|
||
return { | ||
dataRegistry, | ||
errorRegistry, | ||
isLoadingRegistry: isFetchingRegistry, | ||
|
||
ensureEntry, | ||
invalidateEntry, | ||
} | ||
}) | ||
|
||
function isExpired(lastRefresh: number, cacheTime: number): boolean { | ||
return lastRefresh + cacheTime < Date.now() | ||
} |
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 |
---|---|---|
@@ -1 +1,17 @@ | ||
export const test = 0 | ||
export { | ||
useMutation, | ||
type UseMutationReturn, | ||
type UseMutationsOptions, | ||
} from './use-mutation' | ||
|
||
export { | ||
USE_QUERY_DEFAULTS, | ||
useQuery, | ||
type UseDataFetchingQueryEntry, | ||
type UseQueryKey, | ||
type UseQueryOptions, | ||
type UseQueryOptionsWithDefaults, | ||
type UseQueryReturn, | ||
} from './use-query' | ||
|
||
export { useDataFetchingStore } from './data-fetching-store' |
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,108 @@ | ||
import { computed, ref, type ComputedRef, shallowRef } from 'vue' | ||
import { useDataFetchingStore } from './data-fetching-store' | ||
|
||
type _MutatorKeys<TParams extends readonly any[], TResult> = readonly ( | ||
| string | ||
| ((context: { variables: TParams; result: TResult }) => string) | ||
)[] | ||
|
||
export interface UseMutationsOptions< | ||
TResult = unknown, | ||
TParams extends readonly unknown[] = readonly [], | ||
> { | ||
/** | ||
* The key of the mutation. If the mutation is successful, it will invalidate the query with the same key and refetch it | ||
*/ | ||
mutator: (...args: TParams) => Promise<TResult> | ||
keys?: _MutatorKeys<TParams, TResult> | ||
} | ||
// export const USE_MUTATIONS_DEFAULTS = {} satisfies Partial<UseMutationsOptions> | ||
|
||
export interface UseMutationReturn< | ||
TResult = unknown, | ||
TParams extends readonly unknown[] = readonly [], | ||
TError = Error, | ||
> { | ||
data: ComputedRef<TResult | undefined> | ||
error: ComputedRef<TError | null> | ||
isPending: ComputedRef<boolean> | ||
|
||
mutate: (...params: TParams) => Promise<TResult> | ||
reset: () => void | ||
} | ||
|
||
export function useMutation< | ||
TResult, | ||
TParams extends readonly unknown[], | ||
TError = Error, | ||
>( | ||
options: UseMutationsOptions<TResult, TParams> | ||
): UseMutationReturn<TResult, TParams, TError> { | ||
console.log(options) | ||
const store = useDataFetchingStore() | ||
|
||
const isPending = ref(false) | ||
const data = shallowRef<TResult>() | ||
const error = shallowRef<TError | null>(null) | ||
|
||
// a pending promise allows us to discard previous ongoing requests | ||
let pendingPromise: Promise<TResult> | null = null | ||
function mutate(...args: TParams) { | ||
isPending.value = true | ||
error.value = null | ||
|
||
const promise = (pendingPromise = options | ||
.mutator(...args) | ||
.then((_data) => { | ||
if (pendingPromise === promise) { | ||
data.value = _data | ||
if (options.keys) { | ||
for (const key of options.keys) { | ||
store.invalidateEntry( | ||
typeof key === 'string' | ||
? key | ||
: key({ variables: args, result: _data }), | ||
true | ||
) | ||
} | ||
} | ||
} | ||
return _data | ||
}) | ||
.catch((_error) => { | ||
if (pendingPromise === promise) { | ||
error.value = _error | ||
} | ||
throw _error | ||
}) | ||
.finally(() => { | ||
if (pendingPromise === promise) { | ||
isPending.value = false | ||
} | ||
})) | ||
|
||
return promise | ||
} | ||
|
||
function reset() { | ||
data.value = undefined | ||
error.value = null | ||
} | ||
|
||
const mutationReturn = { | ||
data: computed(() => data.value), | ||
isPending: computed(() => isPending.value), | ||
error: computed(() => error.value), | ||
mutate, | ||
reset, | ||
} satisfies UseMutationReturn<TResult, TParams, TError> | ||
|
||
return mutationReturn | ||
} | ||
|
||
// useMutation({ | ||
// async mutator(one: string, other?: number) { | ||
// return { one, other: other || 0 } | ||
// }, | ||
// keys: ['register', ({ variables: [one], result }) => `register:${one}` + result.one], | ||
// }) |
Oops, something went wrong.