Skip to content

Commit

Permalink
feat: mutate filter (#1989)
Browse files Browse the repository at this point in the history
* feat: mutate filter

* tweak typing

* add test

* tweak typings

* chore: remove useless overload (#1993)

* fix typing

* chore: change internalMutate type (#1997)

* chore: change internalMutate type

* chore: useoverload for internal mutate

* store keys in global state

* add type truthy key

* polish truthy key

* dont remove key

* add clear test case, fix empty key

* store key in cache

* use values

* fix cache test

* pass original argument to the filter

* fix types

* fix types

* fix failed tests; use useSyncExternalStore and memorized selector

* move function declaration

* fix infinite use cases

* fix lint error

Co-authored-by: Yixuan Xu <yixuanxu94@outlook.com>
Co-authored-by: Shu Ding <g@shud.in>
  • Loading branch information
3 people committed Jun 26, 2022
1 parent 518ce25 commit ec42fed
Show file tree
Hide file tree
Showing 14 changed files with 522 additions and 210 deletions.
17 changes: 7 additions & 10 deletions _internal/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -204,15 +204,13 @@ export type MutatorWrapper<Fn> = Fn extends (
export type Mutator<Data = any> = MutatorWrapper<MutatorFn<Data>>

export interface ScopedMutator<Data = any> {
/** This is used for bound mutator */
(
key: Key,
data?: Data | Promise<Data> | MutatorCallback<Data>,
<T = Data>(
matcher: (key?: Arguments) => boolean,
data?: T | Promise<T> | MutatorCallback<T>,
opts?: boolean | MutatorOptions<Data>
): Promise<Data | undefined>
/** This is used for global mutator */
<T = any>(
key: Key,
): Promise<Array<T | undefined>>
<T = Data>(
key: Arguments,
data?: T | Promise<T> | MutatorCallback<T>,
opts?: boolean | MutatorOptions<Data>
): Promise<T | undefined>
Expand All @@ -223,8 +221,6 @@ export type KeyedMutator<Data> = (
opts?: boolean | MutatorOptions<Data>
) => Promise<Data | undefined>

// Public types

export type SWRConfiguration<
Data = any,
Error = any,
Expand Down Expand Up @@ -267,6 +263,7 @@ export type RevalidateCallback = <K extends RevalidateEvent>(
) => RevalidateCallbackReturnType[K]

export interface Cache<Data = any> {
keys(): IterableIterator<string>
get(key: Key): State<Data> | undefined
set(key: Key, value: State<Data>): void
delete(key: Key): void
Expand Down
7 changes: 3 additions & 4 deletions _internal/utils/cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ export const initCache = <Data = any>(
// If there's no global state bound to the provider, create a new one with the
// new mutate function.
const EVENT_REVALIDATORS = {}

const mutate = internalMutate.bind(
UNDEFINED,
provider
Expand All @@ -58,16 +59,14 @@ export const initCache = <Data = any>(
subscriptions[key] = subs

subs.push(callback)
return () => {
subs.splice(subs.indexOf(callback), 1)
}
return () => subs.splice(subs.indexOf(callback), 1)
}
const setter = (key: string, value: any, prev: any) => {
provider.set(key, value)
const subs = subscriptions[key]
if (subs) {
for (let i = subs.length; i--; ) {
subs[i](value, prev)
subs[i](prev, value)
}
}
}
Expand Down
11 changes: 7 additions & 4 deletions _internal/utils/helper.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { SWRGlobalState } from './global-state'
import { Key, Cache, State, GlobalState } from '../types'
import type { Key, Cache, State, GlobalState } from '../types'

const EMPTY_CACHE = {}
export const noop = () => {}
Expand All @@ -13,9 +13,12 @@ export const UNDEFINED = (/*#__NOINLINE__*/ noop()) as undefined
export const OBJECT = Object

export const isUndefined = (v: any): v is undefined => v === UNDEFINED
export const isFunction = (v: any): v is Function => typeof v == 'function'
export const isEmptyCache = (v: any): boolean => v === EMPTY_CACHE
export const mergeObjects = (a: any, b: any) => OBJECT.assign({}, a, b)
export const isFunction = <
T extends (...args: any[]) => any = (...args: any[]) => any
>(
v: unknown
): v is T => typeof v == 'function'
export const mergeObjects = (a: any, b?: any) => OBJECT.assign({}, a, b)

const STR_UNDEFINED = 'undefined'

Expand Down
257 changes: 145 additions & 112 deletions _internal/utils/mutate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,22 +4,41 @@ import { SWRGlobalState } from './global-state'
import { getTimestamp } from './timestamp'
import * as revalidateEvents from '../constants'
import {
Key,
Cache,
MutatorCallback,
MutatorOptions,
GlobalState,
State
State,
Arguments,
Key
} from '../types'

export const internalMutate = async <Data>(
type KeyFilter = (key?: Arguments) => boolean
type MutateState<Data> = State<Data, any> & {
// The previously committed data.
_c?: Data
}

export async function internalMutate<Data>(
cache: Cache,
_key: KeyFilter,
_data?: Data | Promise<Data | undefined> | MutatorCallback<Data>,
_opts?: boolean | MutatorOptions<Data>
): Promise<Array<Data | undefined>>
export async function internalMutate<Data>(
cache: Cache,
_key: Arguments,
_data?: Data | Promise<Data | undefined> | MutatorCallback<Data>,
_opts?: boolean | MutatorOptions<Data>
): Promise<Data | undefined>
export async function internalMutate<Data>(
...args: [
Cache,
Key,
undefined | Data | Promise<Data | undefined> | MutatorCallback<Data>,
undefined | boolean | MutatorOptions<Data>
cache: Cache,
_key: KeyFilter | Arguments,
_data?: Data | Promise<Data | undefined> | MutatorCallback<Data>,
_opts?: boolean | MutatorOptions<Data>
]
): Promise<Data | undefined> => {
): Promise<any> {
const [cache, _key, _data, _opts] = args

// When passing as a boolean, it's explicitly used to disable/enable
Expand All @@ -35,130 +54,144 @@ export const internalMutate = async <Data>(
const revalidate = options.revalidate !== false
const rollbackOnError = options.rollbackOnError !== false

// Serialize key
const [key] = serialize(_key)
if (!key) return

const [get, set] = createCacheHelper<
Data,
State<Data, any> & {
// The previously committed data.
_c?: Data
}
>(cache, key)
const [EVENT_REVALIDATORS, MUTATION, FETCH] = SWRGlobalState.get(
cache
) as GlobalState

const revalidators = EVENT_REVALIDATORS[key]
const startRevalidate = () => {
if (revalidate) {
// Invalidate the key by deleting the concurrent request markers so new
// requests will not be deduped.
delete FETCH[key]
if (revalidators && revalidators[0]) {
return revalidators[0](revalidateEvents.MUTATE_EVENT).then(
() => get().data
)
// If the second argument is a key filter, return the mutation results for all
// filtered keys.
if (isFunction(_key)) {
const keyFilter = _key
const matchedKeys: Key[] = []
for (const key of cache.keys()) {
if (
// Skip the speical useSWRInfinite keys.
!key.startsWith('$inf$') &&
keyFilter((cache.get(key) as { _k: Arguments })._k)
) {
matchedKeys.push(key)
}
}
return get().data
return Promise.all(matchedKeys.map(mutateByKey))
}

// If there is no new data provided, revalidate the key with current state.
if (args.length < 3) {
// Revalidate and broadcast state.
return startRevalidate()
}
return mutateByKey(_key)

async function mutateByKey(_k: Key): Promise<Data | undefined> {
// Serialize key
const [key] = serialize(_k)
if (!key) return
const [get, set] = createCacheHelper<Data, MutateState<Data>>(cache, key)
const [EVENT_REVALIDATORS, MUTATION, FETCH] = SWRGlobalState.get(
cache
) as GlobalState

const revalidators = EVENT_REVALIDATORS[key]
const startRevalidate = () => {
if (revalidate) {
// Invalidate the key by deleting the concurrent request markers so new
// requests will not be deduped.
delete FETCH[key]
if (revalidators && revalidators[0]) {
return revalidators[0](revalidateEvents.MUTATE_EVENT).then(
() => get().data
)
}
}
return get().data
}

let data: any = _data
let error: unknown
// If there is no new data provided, revalidate the key with current state.
if (args.length < 3) {
// Revalidate and broadcast state.
return startRevalidate()
}

// Update global timestamps.
const beforeMutationTs = getTimestamp()
MUTATION[key] = [beforeMutationTs, 0]
let data: any = _data
let error: unknown

const hasOptimisticData = !isUndefined(optimisticData)
const state = get()
// Update global timestamps.
const beforeMutationTs = getTimestamp()
MUTATION[key] = [beforeMutationTs, 0]

// `displayedData` is the current value on screen. It could be the optimistic value
// that is going to be overridden by a `committedData`, or get reverted back.
// `committedData` is the validated value that comes from a fetch or mutation.
const displayedData = state.data
const committedData = isUndefined(state._c) ? displayedData : state._c
const hasOptimisticData = !isUndefined(optimisticData)
const state = get()

// Do optimistic data update.
if (hasOptimisticData) {
optimisticData = isFunction(optimisticData)
? optimisticData(committedData)
: optimisticData
// `displayedData` is the current value on screen. It could be the optimistic value
// that is going to be overridden by a `committedData`, or get reverted back.
// `committedData` is the validated value that comes from a fetch or mutation.
const displayedData = state.data
const committedData = isUndefined(state._c) ? displayedData : state._c

// When we set optimistic data, backup the current committedData data in `_c`.
set({ data: optimisticData, _c: committedData })
}
// Do optimistic data update.
if (hasOptimisticData) {
optimisticData = isFunction(optimisticData)
? optimisticData(committedData)
: optimisticData

if (isFunction(data)) {
// `data` is a function, call it passing current cache value.
try {
data = (data as MutatorCallback<Data>)(committedData)
} catch (err) {
// If it throws an error synchronously, we shouldn't update the cache.
error = err
// When we set optimistic data, backup the current committedData data in `_c`.
set({ data: optimisticData, _c: committedData })
}
}

// `data` is a promise/thenable, resolve the final data first.
if (data && isFunction((data as Promise<Data>).then)) {
// This means that the mutation is async, we need to check timestamps to
// avoid race conditions.
data = await (data as Promise<Data>).catch(err => {
error = err
})

// Check if other mutations have occurred since we've started this mutation.
// If there's a race we don't update cache or broadcast the change,
// just return the data.
if (beforeMutationTs !== MUTATION[key][0]) {
if (error) throw error
return data
} else if (error && hasOptimisticData && rollbackOnError) {
// Rollback. Always populate the cache in this case but without
// transforming the data.
populateCache = true
data = committedData

// Reset data to be the latest committed data, and clear the `_c` value.
set({ data, _c: UNDEFINED })
if (isFunction(data)) {
// `data` is a function, call it passing current cache value.
try {
data = (data as MutatorCallback<Data>)(committedData)
} catch (err) {
// If it throws an error synchronously, we shouldn't update the cache.
error = err
}
}
}

// If we should write back the cache after request.
if (populateCache) {
if (!error) {
// Transform the result into data.
if (isFunction(populateCache)) {
data = populateCache(data, committedData)
// `data` is a promise/thenable, resolve the final data first.
if (data && isFunction((data as Promise<Data>).then)) {
// This means that the mutation is async, we need to check timestamps to
// avoid race conditions.
data = await (data as Promise<Data>).catch(err => {
error = err
})

// Check if other mutations have occurred since we've started this mutation.
// If there's a race we don't update cache or broadcast the change,
// just return the data.
if (beforeMutationTs !== MUTATION[key][0]) {
if (error) throw error
return data
} else if (error && hasOptimisticData && rollbackOnError) {
// Rollback. Always populate the cache in this case but without
// transforming the data.
populateCache = true
data = committedData

// Reset data to be the latest committed data, and clear the `_c` value.
set({ data, _c: UNDEFINED })
}

// Only update cached data if there's no error. Data can be `undefined` here.
set({ data, _c: UNDEFINED })
}

// Always update error and original data here.
set({ error })
}
// If we should write back the cache after request.
if (populateCache) {
if (!error) {
// Transform the result into data.
if (isFunction(populateCache)) {
data = populateCache(data, committedData)
}

// Reset the timestamp to mark the mutation has ended.
MUTATION[key][1] = getTimestamp()
// Only update cached data if there's no error. Data can be `undefined` here.
set({ data, _c: UNDEFINED })
}

// Update existing SWR Hooks' internal states:
const res = await startRevalidate()
// Always update error and original data here.
set({ error })
}

// Reset the timestamp to mark the mutation has ended.
MUTATION[key][1] = getTimestamp()

// The mutation and revalidation are ended, we can clear it since the data is
// not an optimistic value anymore.
set({ _c: UNDEFINED })
// Update existing SWR Hooks' internal states:
const res = await startRevalidate()

// Throw error or return data
if (error) throw error
return populateCache ? res : data
// The mutation and revalidation are ended, we can clear it since the data is
// not an optimistic value anymore.
set({ _c: UNDEFINED })

// Throw error or return data
if (error) throw error
return populateCache ? res : data
}
}

0 comments on commit ec42fed

Please sign in to comment.