Skip to content

Commit

Permalink
feat: Hook for remote mutations (vercel#1450)
Browse files Browse the repository at this point in the history
* (wip) initial impl.

* fix test

* (wip) fix deps

* initial implementation

* fix linter

* fix state reset

* avoid reset race condition

* fix race conditions

* code tweaks

* code tweaks

* return bound mutate

* apply review comments

* fix tsconfig

* type fixes

* fix lint errors

* code tweaks

* fix type error

* update types

* inline serialization result

* merge main and update argument api

* add tests

* fix tests

* update typing

* update state api

* change error condition
  • Loading branch information
shuding authored and himanshiLt committed Apr 26, 2022
1 parent ec83f43 commit 3797c65
Show file tree
Hide file tree
Showing 17 changed files with 1,159 additions and 46 deletions.
10 changes: 6 additions & 4 deletions examples/axios-typescript/libs/useRequest.ts
Expand Up @@ -24,10 +24,12 @@ export default function useRequest<Data = unknown, Error = unknown>(
request: GetRequest,
{ fallbackData, ...config }: Config<Data, Error> = {}
): Return<Data, Error> {
const { data: response, error, isValidating, mutate } = useSWR<
AxiosResponse<Data>,
AxiosError<Error>
>(
const {
data: response,
error,
isValidating,
mutate
} = useSWR<AxiosResponse<Data>, AxiosError<Error>>(
request && JSON.stringify(request),
/**
* NOTE: Typescript thinks `request` can be `null` here, but the fetcher
Expand Down
3 changes: 2 additions & 1 deletion jest.config.js
Expand Up @@ -6,7 +6,8 @@ module.exports = {
moduleNameMapper: {
'^swr$': '<rootDir>/src',
'^swr/infinite$': '<rootDir>/infinite/index.ts',
'^swr/immutable$': '<rootDir>/immutable/index.ts'
'^swr/immutable$': '<rootDir>/immutable/index.ts',
'^swr/mutation$': '<rootDir>/mutation/index.ts'
},
transform: {
'^.+\\.(t|j)sx?$': '@swc/jest'
Expand Down
122 changes: 122 additions & 0 deletions mutation/index.ts
@@ -0,0 +1,122 @@
import { useCallback, useRef } from 'react'
import useSWR, { useSWRConfig, Middleware, Key } from 'swr'

import { serialize } from '../src/utils/serialize'
import { useStateWithDeps } from '../src/utils/state'
import { withMiddleware } from '../src/utils/with-middleware'
import { useIsomorphicLayoutEffect } from '../src/utils/env'
import { UNDEFINED } from '../src/utils/helper'
import { getTimestamp } from '../src/utils/timestamp'

import {
SWRMutationConfiguration,
SWRMutationResponse,
SWRMutationHook,
MutationFetcher
} from './types'

const mutation =
<Data, Error>() =>
(
key: Key,
fetcher: MutationFetcher<Data>,
config: SWRMutationConfiguration<Data, Error> = {}
) => {
const { mutate } = useSWRConfig()

const keyRef = useRef(key)
// Ditch all mutation results that happened earlier than this timestamp.
const ditchMutationsUntilRef = useRef(0)

const [stateRef, stateDependencies, setState] = useStateWithDeps({
data: UNDEFINED,
error: UNDEFINED,
isMutating: false
})
const currentState = stateRef.current

const trigger = useCallback(
async (arg, opts?: SWRMutationConfiguration<Data, Error>) => {
const [serializedKey, resolvedKey] = serialize(keyRef.current)

if (!fetcher) {
throw new Error('Can’t trigger the mutation: missing fetcher.')
}
if (!serializedKey) {
throw new Error('Can’t trigger the mutation: key isn’t ready.')
}

// Disable cache population by default.
const options = Object.assign({ populateCache: false }, config, opts)

// Trigger a mutation, also track the timestamp. Any mutation that happened
// earlier this timestamp should be ignored.
const mutationStartedAt = getTimestamp()

ditchMutationsUntilRef.current = mutationStartedAt

setState({ isMutating: true })

try {
const data = await mutate<Data>(
serializedKey,
(fetcher as any)(resolvedKey, { arg }),
options
)

// If it's reset after the mutation, we don't broadcast any state change.
if (ditchMutationsUntilRef.current <= mutationStartedAt) {
setState({ data, isMutating: false })
options.onSuccess?.(data as Data, serializedKey, options)
}
return data
} catch (error) {
// If it's reset after the mutation, we don't broadcast any state change.
if (ditchMutationsUntilRef.current <= mutationStartedAt) {
setState({ error: error as Error, isMutating: false })
options.onError?.(error as Error, serializedKey, options)
throw error as Error
}
}
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[]
)

const reset = useCallback(() => {
ditchMutationsUntilRef.current = getTimestamp()
setState({ data: UNDEFINED, error: UNDEFINED, isMutating: false })
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])

useIsomorphicLayoutEffect(() => {
keyRef.current = key
})

// We don't return `mutate` here as it can be pretty confusing (e.g. people
// calling `mutate` but they actually mean `trigger`).
// And also, `mutate` relies on the useSWR hook to exist too.
return {
trigger,
reset,
get data() {
stateDependencies.data = true
return currentState.data
},
get error() {
stateDependencies.error = true
return currentState.error
},
get isMutating() {
stateDependencies.isMutating = true
return currentState.isMutating
}
}
}

export default withMiddleware(
useSWR,
mutation as unknown as Middleware
) as unknown as SWRMutationHook

export { SWRMutationConfiguration, SWRMutationResponse }
11 changes: 11 additions & 0 deletions mutation/package.json
@@ -0,0 +1,11 @@
{
"name": "swr-mutation",
"version": "0.0.1",
"main": "./dist/index.js",
"module": "./dist/index.esm.js",
"types": "./dist/mutation",
"peerDependencies": {
"swr": "*",
"react": "*"
}
}
8 changes: 8 additions & 0 deletions mutation/tsconfig.json
@@ -0,0 +1,8 @@
{
"extends": "../tsconfig.json",
"compilerOptions": {
"rootDir": "..",
"outDir": "./dist"
},
"include": ["./*.ts"]
}
71 changes: 71 additions & 0 deletions mutation/types.ts
@@ -0,0 +1,71 @@
import { SWRResponse, Key, MutatorOptions } from 'swr'

type FetcherResponse<Data> = Data | Promise<Data>

type FetcherOptions<ExtraArg = unknown> = Readonly<{
arg: ExtraArg
}>

export type MutationFetcher<
Data = unknown,
ExtraArg = unknown,
SWRKey extends Key = Key
> = SWRKey extends () => infer Arg | null | undefined | false
? (key: Arg, options: FetcherOptions<ExtraArg>) => FetcherResponse<Data>
: SWRKey extends null | undefined | false
? never
: SWRKey extends infer Arg
? (key: Arg, options: FetcherOptions<ExtraArg>) => FetcherResponse<Data>
: never

export type SWRMutationConfiguration<
Data,
Error,
ExtraArg = any,
SWRMutationKey extends Key = Key
> = MutatorOptions<Data> & {
fetcher?: MutationFetcher<Data, ExtraArg, SWRMutationKey>
onSuccess?: (
data: Data,
key: string,
config: Readonly<
SWRMutationConfiguration<Data, Error, SWRMutationKey, ExtraArg>
>
) => void
onError?: (
err: Error,
key: string,
config: Readonly<
SWRMutationConfiguration<Data, Error, SWRMutationKey, ExtraArg>
>
) => void
}

export interface SWRMutationResponse<
Data = any,
Error = any,
ExtraArg = any,
SWRMutationKey extends Key = Key
> extends Pick<SWRResponse<Data, Error>, 'data' | 'error'> {
isMutating: boolean
trigger: (
extraArgument?: ExtraArg,
options?: SWRMutationConfiguration<Data, Error, ExtraArg, SWRMutationKey>
) => Promise<Data | undefined>
reset: () => void
}

export type SWRMutationHook = <
Data = any,
Error = any,
SWRMutationKey extends Key = Key,
ExtraArg = any
>(
...args:
| readonly [SWRMutationKey, MutationFetcher<Data, ExtraArg, SWRMutationKey>]
| readonly [
SWRMutationKey,
MutationFetcher<Data, ExtraArg, SWRMutationKey>,
SWRMutationConfiguration<Data, Error, ExtraArg, SWRMutationKey>
]
) => SWRMutationResponse<Data, Error, ExtraArg, SWRMutationKey>
21 changes: 16 additions & 5 deletions package.json
Expand Up @@ -31,36 +31,47 @@
"module": "./immutable/dist/index.esm.js",
"require": "./immutable/dist/index.js",
"types": "./immutable/dist/immutable/index.d.ts"
},
"./mutation": {
"import": "./mutation/dist/index.mjs",
"module": "./mutation/dist/index.esm.js",
"require": "./mutation/dist/index.js",
"types": "./mutation/dist/mutation/index.d.ts"
}
},
"types": "./dist/index.d.ts",
"files": [
"dist/**",
"infinite/dist/**",
"immutable/dist/**",
"mutation/dist/**",
"infinite/package.json",
"immutable/package.json"
"immutable/package.json",
"mutation/package.json"
],
"repository": "github:vercel/swr",
"homepage": "https://swr.vercel.app",
"license": "MIT",
"scripts": {
"clean": "rimraf dist infinite/dist immutable/dist",
"build": "yarn build:core && yarn build:infinite && yarn build:immutable",
"watch": "npm-run-all -p watch:core watch:infinite watch:immutable",
"clean": "rimraf dist infinite/dist immutable/dist mutation/dist",
"build": "yarn build:core && yarn build:infinite && yarn build:immutable && yarn build:mutation",
"watch": "npm-run-all -p watch:core watch:infinite watch:immutable watch:mutation",
"watch:core": "yarn build:core -w",
"watch:infinite": "yarn build:infinite -w",
"watch:immutable": "yarn build:immutable -w",
"watch:mutation": "yarn build:mutation -w",
"build:core": "bunchee src/index.ts --no-sourcemap",
"build:infinite": "bunchee index.ts --cwd infinite --no-sourcemap",
"build:immutable": "bunchee index.ts --cwd immutable --no-sourcemap",
"build:mutation": "bunchee index.ts --cwd mutation --no-sourcemap",
"prepublishOnly": "yarn clean && yarn build",
"publish-beta": "yarn publish --tag beta",
"types:check": "tsc --noEmit --project tsconfig.check.json && tsc --noEmit -p test",
"format": "prettier --write ./**/*.{ts,tsx}",
"lint": "eslint . --ext .ts,.tsx --cache",
"lint:fix": "yarn lint --fix",
"test": "jest --coverage"
"coverage": "jest --coverage",
"test": "jest"
},
"husky": {
"hooks": {
Expand Down
5 changes: 5 additions & 0 deletions src/types.ts
Expand Up @@ -145,6 +145,11 @@ export type MutatorOptions<Data = any> = {
rollbackOnError?: boolean
}

export type MutatorConfig = {
revalidate?: boolean
populateCache?: boolean
}

export type Broadcaster<Data = any, Error = any> = (
cache: Cache<Data>,
key: string,
Expand Down
17 changes: 8 additions & 9 deletions src/use-swr.ts
Expand Up @@ -131,14 +131,12 @@ export const useSWRHandler = <Data = any, Error = any>(
}
const isValidating = resolveValidating()

const [stateRef, stateDependencies, setState] = useStateWithDeps<Data, Error>(
{
data,
error,
isValidating
},
unmountedRef
)
const currentState = {
data,
error,
isValidating
}
const [stateRef, stateDependencies, setState] = useStateWithDeps(currentState)

// The revalidation function is a carefully crafted wrapper of the original
// `fetcher`, to correctly handle the many edge cases.
Expand Down Expand Up @@ -379,10 +377,11 @@ export const useSWRHandler = <Data = any, Error = any>(
[]
)

// Always update fetcher and config refs.
// Always update fetcher, config and state refs.
useIsomorphicLayoutEffect(() => {
fetcherRef.current = fetcher
configRef.current = config
stateRef.current = currentState
})

// After mounted or key changed.
Expand Down
7 changes: 5 additions & 2 deletions src/utils/broadcast-state.ts
@@ -1,6 +1,7 @@
import { Broadcaster } from '../types'
import { SWRGlobalState, GlobalState } from './global-state'
import * as revalidateEvents from '../constants'
import { createCacheHelper } from './cache'

export const broadcastState: Broadcaster = (
cache,
Expand All @@ -15,6 +16,8 @@ export const broadcastState: Broadcaster = (
const revalidators = EVENT_REVALIDATORS[key]
const updaters = STATE_UPDATERS[key]

const [get] = createCacheHelper(cache, key)

// Cache was populated, update states of all hooks.
if (broadcast && updaters) {
for (let i = 0; i < updaters.length; ++i) {
Expand All @@ -30,10 +33,10 @@ export const broadcastState: Broadcaster = (

if (revalidators && revalidators[0]) {
return revalidators[0](revalidateEvents.MUTATE_EVENT).then(
() => cache.get(key).data
() => get().data
)
}
}

return cache.get(key).data
return get().data
}

0 comments on commit 3797c65

Please sign in to comment.