diff --git a/packages/plugins/swr/package.json b/packages/plugins/swr/package.json index 8ddab716b..f26fe5cb0 100644 --- a/packages/plugins/swr/package.json +++ b/packages/plugins/swr/package.json @@ -49,7 +49,7 @@ "nock": "^13.3.6", "react": "18.2.0", "rimraf": "^3.0.2", - "swr": "^2.0.3", + "swr": "^2.2.4", "ts-jest": "^29.0.5", "typescript": "^4.9.4" } diff --git a/packages/plugins/swr/src/generator.ts b/packages/plugins/swr/src/generator.ts index f20af7dea..bdea20e83 100644 --- a/packages/plugins/swr/src/generator.ts +++ b/packages/plugins/swr/src/generator.ts @@ -32,6 +32,8 @@ export async function generate(model: Model, options: PluginOptions, dmmf: DMMF. ); } + const legacyMutations = options.legacyMutations !== false; + const models = getDataModels(model); await generateModelMeta(project, models, { @@ -49,14 +51,20 @@ export async function generate(model: Model, options: PluginOptions, dmmf: DMMF. warnings.push(`Unable to find mapping for model ${dataModel.name}`); return; } - generateModelHooks(project, outDir, dataModel, mapping); + generateModelHooks(project, outDir, dataModel, mapping, legacyMutations); }); await saveProject(project); return warnings; } -function generateModelHooks(project: Project, outDir: string, model: DataModel, mapping: DMMF.ModelMapping) { +function generateModelHooks( + project: Project, + outDir: string, + model: DataModel, + mapping: DMMF.ModelMapping, + legacyMutations: boolean +) { const fileName = paramCase(model.name); const sf = project.createSourceFile(path.join(outDir, `${fileName}.ts`), undefined, { overwrite: true }); @@ -64,12 +72,12 @@ function generateModelHooks(project: Project, outDir: string, model: DataModel, const prismaImport = getPrismaClientImportSpec(model.$container, outDir); sf.addImportDeclaration({ - namedImports: ['Prisma', model.name], + namedImports: ['Prisma'], isTypeOnly: true, moduleSpecifier: prismaImport, }); sf.addStatements([ - `import { RequestHandlerContext, type GetNextArgs, type RequestOptions, type InfiniteRequestOptions, type PickEnumerable, type CheckSelect, useHooksContext } from '@zenstackhq/swr/runtime';`, + `import { type GetNextArgs, type QueryOptions, type InfiniteQueryOptions, type MutationOptions, type PickEnumerable, useHooksContext } from '@zenstackhq/swr/runtime';`, `import metadata from './__model_meta';`, `import * as request from '@zenstackhq/swr/runtime';`, ]); @@ -77,45 +85,42 @@ function generateModelHooks(project: Project, outDir: string, model: DataModel, const modelNameCap = upperCaseFirst(model.name); const prismaVersion = getPrismaVersion(); - const useMutation = sf.addFunction({ - name: `useMutate${model.name}`, - isExported: true, - statements: [ - 'const { endpoint, fetch, logging } = useHooksContext();', - `const mutate = request.useMutate('${model.name}', metadata, logging);`, - ], - }); + const useMutation = legacyMutations + ? sf.addFunction({ + name: `useMutate${model.name}`, + isExported: true, + statements: [ + 'const { endpoint, fetch } = useHooksContext();', + `const invalidate = request.useInvalidation('${model.name}', metadata);`, + ], + }) + : undefined; + const mutationFuncs: string[] = []; // create is somehow named "createOne" in the DMMF // eslint-disable-next-line @typescript-eslint/no-explicit-any if (mapping.create || (mapping as any).createOne) { const argsType = `Prisma.${model.name}CreateArgs`; - const inputType = `Prisma.SelectSubset`; - const returnType = `CheckSelect>`; - mutationFuncs.push( - generateMutation(useMutation, model, 'post', 'create', argsType, inputType, returnType, true) - ); + mutationFuncs.push(generateMutation(sf, useMutation, model, 'POST', 'create', argsType, false)); } // createMany if (mapping.createMany) { const argsType = `Prisma.${model.name}CreateManyArgs`; - const inputType = `Prisma.SelectSubset`; - const returnType = `Prisma.BatchPayload`; - mutationFuncs.push( - generateMutation(useMutation, model, 'post', 'createMany', argsType, inputType, returnType, false) - ); + mutationFuncs.push(generateMutation(sf, useMutation, model, 'POST', 'createMany', argsType, true)); } // findMany if (mapping.findMany) { const argsType = `Prisma.${model.name}FindManyArgs`; const inputType = `Prisma.SelectSubset`; - const returnType = `Array>`; + const returnElement = `Prisma.${model.name}GetPayload`; + const returnType = `Array<${returnElement}>`; + const optimisticReturn = `Array<${makeOptimistic(returnElement)}>`; // regular findMany - generateQueryHook(sf, model, 'findMany', argsType, inputType, returnType); + generateQueryHook(sf, model, 'findMany', argsType, inputType, optimisticReturn, undefined, false); // infinite findMany generateQueryHook(sf, model, 'findMany', argsType, inputType, returnType, undefined, true); @@ -125,16 +130,16 @@ function generateModelHooks(project: Project, outDir: string, model: DataModel, if (mapping.findUnique) { const argsType = `Prisma.${model.name}FindUniqueArgs`; const inputType = `Prisma.SelectSubset`; - const returnType = `Prisma.${model.name}GetPayload`; - generateQueryHook(sf, model, 'findUnique', argsType, inputType, returnType); + const returnType = makeOptimistic(`Prisma.${model.name}GetPayload`); + generateQueryHook(sf, model, 'findUnique', argsType, inputType, returnType, undefined, false); } // findFirst if (mapping.findFirst) { const argsType = `Prisma.${model.name}FindFirstArgs`; const inputType = `Prisma.SelectSubset`; - const returnType = `Prisma.${model.name}GetPayload`; - generateQueryHook(sf, model, 'findFirst', argsType, inputType, returnType); + const returnType = makeOptimistic(`Prisma.${model.name}GetPayload`); + generateQueryHook(sf, model, 'findFirst', argsType, inputType, returnType, undefined, false); } // update @@ -142,21 +147,13 @@ function generateModelHooks(project: Project, outDir: string, model: DataModel, // eslint-disable-next-line @typescript-eslint/no-explicit-any if (mapping.update || (mapping as any).updateOne) { const argsType = `Prisma.${model.name}UpdateArgs`; - const inputType = `Prisma.SelectSubset`; - const returnType = `Prisma.${model.name}GetPayload`; - mutationFuncs.push( - generateMutation(useMutation, model, 'put', 'update', argsType, inputType, returnType, true) - ); + mutationFuncs.push(generateMutation(sf, useMutation, model, 'PUT', 'update', argsType, false)); } // updateMany if (mapping.updateMany) { const argsType = `Prisma.${model.name}UpdateManyArgs`; - const inputType = `Prisma.SelectSubset`; - const returnType = `Prisma.BatchPayload`; - mutationFuncs.push( - generateMutation(useMutation, model, 'put', 'updateMany', argsType, inputType, returnType, false) - ); + mutationFuncs.push(generateMutation(sf, useMutation, model, 'PUT', 'updateMany', argsType, true)); } // upsert @@ -164,11 +161,7 @@ function generateModelHooks(project: Project, outDir: string, model: DataModel, // eslint-disable-next-line @typescript-eslint/no-explicit-any if (mapping.upsert || (mapping as any).upsertOne) { const argsType = `Prisma.${model.name}UpsertArgs`; - const inputType = `Prisma.SelectSubset`; - const returnType = `Prisma.${model.name}GetPayload`; - mutationFuncs.push( - generateMutation(useMutation, model, 'post', 'upsert', argsType, inputType, returnType, true) - ); + mutationFuncs.push(generateMutation(sf, useMutation, model, 'POST', 'upsert', argsType, false)); } // del @@ -176,21 +169,13 @@ function generateModelHooks(project: Project, outDir: string, model: DataModel, // eslint-disable-next-line @typescript-eslint/no-explicit-any if (mapping.delete || (mapping as any).deleteOne) { const argsType = `Prisma.${model.name}DeleteArgs`; - const inputType = `Prisma.SelectSubset`; - const returnType = `Prisma.${model.name}GetPayload`; - mutationFuncs.push( - generateMutation(useMutation, model, 'delete', 'delete', argsType, inputType, returnType, true) - ); + mutationFuncs.push(generateMutation(sf, useMutation, model, 'DELETE', 'delete', argsType, false)); } // deleteMany if (mapping.deleteMany) { const argsType = `Prisma.${model.name}DeleteManyArgs`; - const inputType = `Prisma.SelectSubset`; - const returnType = `Prisma.BatchPayload`; - mutationFuncs.push( - generateMutation(useMutation, model, 'delete', 'deleteMany', argsType, inputType, returnType, false) - ); + mutationFuncs.push(generateMutation(sf, useMutation, model, 'DELETE', 'deleteMany', argsType, true)); } // aggregate @@ -283,7 +268,11 @@ function generateModelHooks(project: Project, outDir: string, model: DataModel, generateQueryHook(sf, model, 'count', argsType, inputType, returnType); } - useMutation.addStatements(`return { ${mutationFuncs.join(', ')} };`); + useMutation?.addStatements(`return { ${mutationFuncs.join(', ')} };`); +} + +function makeOptimistic(returnType: string) { + return `${returnType} & { $optimistic?: boolean }`; } function generateIndex(project: Project, outDir: string, models: DataModel[]) { @@ -321,7 +310,7 @@ function generateQueryHook( } parameters.push({ name: 'options?', - type: infinite ? `InfiniteRequestOptions<${returnType}>` : `RequestOptions<${returnType}>`, + type: infinite ? `InfiniteQueryOptions<${returnType}>` : `QueryOptions<${returnType}>`, }); sf.addFunction({ @@ -332,40 +321,72 @@ function generateQueryHook( }) .addBody() .addStatements([ - 'const { endpoint, fetch } = useHooksContext();', !infinite - ? `return request.useGet<${returnType}>('${model.name}', '${operation}', endpoint, args, options, fetch);` - : `return request.useInfiniteGet<${inputType} | undefined, ${returnType}>('${model.name}', '${operation}', endpoint, getNextArgs, options, fetch);`, + ? `return request.useModelQuery('${model.name}', '${operation}', args, options);` + : `return request.useInfiniteModelQuery('${model.name}', '${operation}', getNextArgs, options);`, ]); } function generateMutation( - func: FunctionDeclaration, + sf: SourceFile, + useMutateModelFunc: FunctionDeclaration | undefined, model: DataModel, - method: 'post' | 'put' | 'patch' | 'delete', + method: 'POST' | 'PUT' | 'PATCH' | 'DELETE', operation: string, argsType: string, - inputType: string, - returnType: string, - checkReadBack: boolean + batchResult: boolean ) { + // non-batch mutations are subject to read-back check + const checkReadBack = !batchResult; + const genericReturnType = batchResult ? 'Prisma.BatchPayload' : `Prisma.${model.name}GetPayload | undefined`; + const returnType = batchResult ? 'Prisma.BatchPayload' : `Prisma.${model.name}GetPayload<${argsType}> | undefined`; + const genericInputType = `Prisma.SelectSubset`; + const modelRouteName = lowerCaseFirst(model.name); const funcName = `${operation}${model.name}`; - const fetcherFunc = method === 'delete' ? 'del' : method; - func.addFunction({ - name: funcName, - isAsync: true, - typeParameters: [`T extends ${argsType}`], + + if (useMutateModelFunc) { + // generate async mutation function (legacy) + const mutationFunc = useMutateModelFunc.addFunction({ + name: funcName, + isAsync: true, + typeParameters: [`T extends ${argsType}`], + parameters: [ + { + name: 'args', + type: genericInputType, + }, + ], + }); + mutationFunc.addJsDoc(`@deprecated Use \`use${upperCaseFirst(operation)}${model.name}\` hook instead.`); + mutationFunc + .addBody() + .addStatements([ + `return await request.mutationRequest<${returnType}, ${checkReadBack}>('${method}', \`\${endpoint}/${modelRouteName}/${operation}\`, args, invalidate, fetch, ${checkReadBack});`, + ]); + } + + // generate mutation hook + sf.addFunction({ + name: `use${upperCaseFirst(operation)}${model.name}`, + isExported: true, parameters: [ { - name: 'args', - type: inputType, + name: 'options?', + type: `MutationOptions<${returnType}, unknown, ${argsType}>`, }, ], }) .addBody() .addStatements([ - `return await request.${fetcherFunc}<${returnType}, ${checkReadBack}>(\`\${endpoint}/${modelRouteName}/${operation}\`, args, mutate, fetch, ${checkReadBack});`, + `const mutation = request.useModelMutation('${model.name}', '${method}', '${operation}', metadata, options, ${checkReadBack});`, + `return { + ...mutation, + trigger(args: ${genericInputType}) { + return mutation.trigger(args, options as any) as Promise<${genericReturnType}>; + } + };`, ]); + return funcName; } diff --git a/packages/plugins/swr/src/runtime/index.ts b/packages/plugins/swr/src/runtime/index.ts index d5fa577d3..994ea178b 100644 --- a/packages/plugins/swr/src/runtime/index.ts +++ b/packages/plugins/swr/src/runtime/index.ts @@ -1,12 +1,25 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import { deserialize, serialize } from '@zenstackhq/runtime/browser'; -import { getMutatedModels, getReadModels, type ModelMeta, type PrismaWriteActionType } from '@zenstackhq/runtime/cross'; +import { + applyMutation, + getMutatedModels, + getReadModels, + type ModelMeta, + type PrismaWriteActionType, +} from '@zenstackhq/runtime/cross'; import * as crossFetch from 'cross-fetch'; import { lowerCaseFirst } from 'lower-case-first'; import { createContext, useContext } from 'react'; -import type { Fetcher, MutatorCallback, MutatorOptions, SWRConfiguration, SWRResponse } from 'swr'; +import type { Cache, Fetcher, SWRConfiguration, SWRResponse } from 'swr'; import useSWR, { useSWRConfig } from 'swr'; -import useSWRInfinite, { SWRInfiniteConfiguration, SWRInfiniteFetcher, SWRInfiniteResponse } from 'swr/infinite'; +import { ScopedMutator } from 'swr/_internal'; +import useSWRInfinite, { + unstable_serialize, + type SWRInfiniteConfiguration, + type SWRInfiniteFetcher, + type SWRInfiniteResponse, +} from 'swr/infinite'; +import useSWRMutation, { type SWRMutationConfiguration } from 'swr/mutation'; export * from './prisma-types'; /** @@ -58,77 +71,123 @@ export function useHooksContext() { } /** - * Client request options for regular query. + * Regular query options. */ -export type RequestOptions = { +export type QueryOptions = { /** * Disable data fetching */ disabled?: boolean; /** + * @deprecated Use `fallbackData` instead + * * Equivalent to @see SWRConfiguration.fallbackData */ initialData?: Result; -} & SWRConfiguration>; + + /** + * Whether to enable automatic optimistic update. Defaults to `true`. + */ + optimisticUpdate?: boolean; +} & Omit>, 'fetcher'>; /** - * Client request options for infinite query. + * Infinite query options. */ -export type InfiniteRequestOptions = { +export type InfiniteQueryOptions = { /** * Disable data fetching */ disabled?: boolean; /** + * @deprecated Use `fallbackData` instead + * * Equivalent to @see SWRInfiniteConfiguration.fallbackData */ initialData?: Result[]; -} & SWRInfiniteConfiguration>; +} & Omit>, 'fetcher'>; + +const QUERY_KEY_PREFIX = 'zenstack:query'; +const MUTATION_KEY_PREFIX = 'zenstack:mutation'; + +type QueryKey = { + prefix: typeof QUERY_KEY_PREFIX; + model: string; + operation: string; + args?: unknown; + infinite?: boolean; + optimisticUpdate?: boolean; +}; -export const QUERY_KEY_PREFIX = 'zenstack'; +/** + * Mutation options. + */ +export type MutationOptions = { + /** + * Whether to automatically optimistic-update queries potentially impacted. Defaults to `false`. + */ + optimisticUpdate?: boolean; +} & Omit, 'fetcher'>; -type QueryKey = { prefix: typeof QUERY_KEY_PREFIX; model: string; operation: string; args: unknown }; +/** + * Computes query key for the given model, operation, query args, and options. + */ +export function getQueryKey( + model: string, + operation: string, + args?: unknown, + infinite?: boolean, + optimisticUpdate?: boolean +) { + return JSON.stringify({ + prefix: QUERY_KEY_PREFIX, + model, + operation, + args, + infinite: infinite === true, + optimisticUpdate: optimisticUpdate !== false, + }); +} -export function getQueryKey(model: string, operation: string, args?: unknown) { - return JSON.stringify({ prefix: QUERY_KEY_PREFIX, model, operation, args }); +function getMutationKey(model: string, operation: string) { + // use a random key since we don't have 1:1 mapping between mutation and query + // https://github.com/vercel/swr/discussions/2461#discussioncomment-5281784 + return JSON.stringify({ prefix: MUTATION_KEY_PREFIX, model, operation, r: Date.now() }); } -export function parseQueryKey(key: unknown) { - if (typeof key !== 'string') { - return undefined; - } - try { - const parsed = JSON.parse(key); - if (!parsed || parsed.prefix !== QUERY_KEY_PREFIX) { +function parseQueryKey(key: unknown): QueryKey | undefined { + let keyValue: any = key; + if (typeof key === 'string') { + try { + keyValue = JSON.parse(key); + } catch { return undefined; } - return parsed as QueryKey; - } catch { - return undefined; } + return keyValue?.prefix === QUERY_KEY_PREFIX ? (keyValue as QueryKey) : undefined; } /** - * Makes a GET request with SWR. + * Makes a model query with SWR. * - * @param url The request URL. + * @param model Model name + * @param operation Prisma operation (e.g, `findMany`) * @param args The request args object, which will be superjson-stringified and appended as "?q=" parameter * @param options Query options - * @param fetch Custom fetch function * @returns SWR response */ -// eslint-disable-next-line @typescript-eslint/no-explicit-any -export function useGet( +export function useModelQuery( model: string, operation: string, - endpoint: string, args?: unknown, - options?: RequestOptions, - fetch?: FetchFn + options?: QueryOptions ): SWRResponse { - const key = options?.disabled ? null : getQueryKey(model, operation, args); + const { endpoint, fetch } = useHooksContext(); + const key = options?.disabled + ? null + : getQueryKey(model, operation, args, false, options?.optimisticUpdate !== false); const url = makeUrl(`${endpoint}/${lowerCaseFirst(model)}/${operation}`, args); return useSWR(key, () => fetcher(url, undefined, fetch, false), { ...options, @@ -144,41 +203,41 @@ export type GetNextArgs = (pageIndex: number, previousPageData: Re /** * Makes an infinite GET request with SWR. * - * @param url The request URL. - * @param getNextArgs Function for computing the query args for a page. + * @param model Model name + * @param operation Prisma operation (e.g, `findMany`) + * @param getNextArgs Function for computing the query args for a page * @param options Query options - * @param fetch Custom fetch function * @returns SWR infinite query response */ -// eslint-disable-next-line @typescript-eslint/no-explicit-any -export function useInfiniteGet( +export function useInfiniteModelQuery( model: string, operation: string, - endpoint: string, getNextArgs: GetNextArgs, - options?: InfiniteRequestOptions, - fetch?: FetchFn + options?: InfiniteQueryOptions ): SWRInfiniteResponse { + const { endpoint, fetch } = useHooksContext(); + const getKey = (pageIndex: number, previousPageData: Result | null) => { if (options?.disabled) { return null; } const nextArgs = getNextArgs(pageIndex, previousPageData); return nextArgs !== null // null means reached the end - ? getQueryKey(model, operation, nextArgs) + ? getQueryKey(model, operation, nextArgs, true, false) : null; }; return useSWRInfinite( getKey, - (key) => { - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - const parsedKey = parseQueryKey(key)!; - const url = makeUrl( - `${endpoint}/${lowerCaseFirst(parsedKey.model)}/${parsedKey.operation}`, - parsedKey.args - ); - return fetcher(url, undefined, fetch, false); + (key: unknown) => { + const parsedKey = parseQueryKey(key); + if (parsedKey) { + const { model, operation, args } = parsedKey; + const url = makeUrl(`${endpoint}/${lowerCaseFirst(model)}/${operation}`, args); + return fetcher(url, undefined, fetch, false); + } else { + throw new Error('Invalid query key: ' + key); + } }, { ...options, @@ -187,110 +246,78 @@ export function useInfiniteGet( ); } -/** - * Makes a POST request. - * - * @param url The request URL. - * @param data The request data. - * @param mutate Mutator for invalidating cache. - */ -export async function post( - url: string, - data: unknown, - mutate: Mutator, - fetch?: FetchFn, - checkReadBack?: C -): Promise { - const r = await fetcher( - url, - { - method: 'POST', - headers: { - 'content-type': 'application/json', - }, - body: marshal(data), +export function useModelMutation( + model: string, + method: 'POST' | 'PUT' | 'DELETE', + operation: string, + modelMeta: ModelMeta, + options?: MutationOptions, + checkReadBack?: CheckReadBack +) { + const { endpoint, fetch, logging } = useHooksContext(); + const invalidate = useInvalidation(model, modelMeta); + const { cache, mutate } = useSWRConfig(); + + return useSWRMutation( + getMutationKey(model, operation), + (_key, { arg }: { arg: any }) => { + if (options?.optimisticUpdate) { + optimisticUpdate(model, operation, arg, modelMeta, cache, mutate, logging); + } + const url = `${endpoint}/${lowerCaseFirst(model)}/${operation}`; + return mutationRequest(method, url, arg, invalidate, fetch, checkReadBack); }, - fetch, - checkReadBack + options ); - mutate(getOperationFromUrl(url), data); - return r; } /** - * Makes a PUT request. + * Makes a mutation request. * - * @param url The request URL. - * @param data The request data. - * @param mutate Mutator for invalidating cache. + * @param url The request URL + * @param data The request data + * @param invalidate Function for invalidating a query */ -export async function put( +export async function mutationRequest( + method: 'POST' | 'PUT' | 'DELETE', url: string, data: unknown, - mutate: Mutator, + invalidate: Invalidator, fetch?: FetchFn, checkReadBack?: C ): Promise { + const reqUrl = method === 'DELETE' ? makeUrl(url, data) : url; const r = await fetcher( - url, + reqUrl, { - method: 'PUT', + method, headers: { 'content-type': 'application/json', }, - body: marshal(data), + body: data ? marshal(data) : undefined, }, fetch, checkReadBack ); - mutate(getOperationFromUrl(url), data); + await invalidate(getOperationFromUrl(url), data); return r; } -/** - * Makes a DELETE request. - * - * @param url The request URL. - * @param args The request args object, which will be superjson-stringified and appended as "?q=" parameter - * @param mutate Mutator for invalidating cache. - */ -export async function del( - url: string, - args: unknown, - mutate: Mutator, - fetch?: FetchFn, - checkReadBack?: C -): Promise { - const reqUrl = makeUrl(url, args); - const r = await fetcher( - reqUrl, - { - method: 'DELETE', - }, - fetch, - checkReadBack - ); - mutate(getOperationFromUrl(url), args); - return r; -} - -type Mutator = ( - operation: string, - data?: unknown | Promise | MutatorCallback, - opts?: boolean | MutatorOptions -) => Promise; +// function for invalidating queries related to mutation represented by its operation and args +type Invalidator = (operation: string, args?: unknown) => ReturnType; -export function useMutate(model: string, modelMeta: ModelMeta, logging?: boolean): Mutator { +export function useInvalidation(model: string, modelMeta: ModelMeta): Invalidator { // https://swr.vercel.app/docs/advanced/cache#mutate-multiple-keys-from-regex + const { logging } = useHooksContext(); const { cache, mutate } = useSWRConfig(); - return async (operation: string, args: unknown, opts?: boolean | MutatorOptions) => { + return async (operation: string, args: unknown) => { if (!(cache instanceof Map)) { throw new Error('mutate requires the cache provider to be a Map instance'); } const mutatedModels = await getMutatedModels(model, operation as PrismaWriteActionType, args, modelMeta); - const keys = Array.from(cache.keys()).filter((key) => { + const keys = Array.from(cache.keys()).filter((key: unknown) => { const parsedKey = parseQueryKey(key); if (!parsedKey) { return false; @@ -305,11 +332,19 @@ export function useMutate(model: string, modelMeta: ModelMeta, logging?: boolean }); } - const mutations = keys.map((key) => mutate(key, undefined, opts)); + const mutations = keys.map((key) => { + const parsedKey = parseQueryKey(key); + // FIX: special handling for infinite query keys, but still not working + // https://github.com/vercel/swr/discussions/2843 + return mutate(parsedKey?.infinite ? unstable_serialize(() => key) : key); + }); return Promise.all(mutations); }; } +/** + * Makes fetch request for queries and mutations. + */ export async function fetcher( url: string, options?: RequestInit, @@ -387,3 +422,71 @@ function getOperationFromUrl(url: string) { return r; } } + +async function optimisticUpdate( + mutationModel: string, + mutationOp: string, + mutationArgs: any, + modelMeta: ModelMeta, + cache: Cache, + mutator: ScopedMutator, + logging = false +) { + const optimisticPromises: Array> = []; + for (const key of cache.keys()) { + const parsedKey = parseQueryKey(key); + if (!parsedKey) { + continue; + } + + if (!parsedKey.optimisticUpdate) { + if (logging) { + console.log(`Skipping optimistic update for ${key} due to opt-out`); + } + continue; + } + + const cacheValue = cache.get(key); + if (!cacheValue) { + continue; + } + + if (cacheValue.error) { + if (logging) { + console.warn(`Skipping optimistic update for ${key} due to error:`, cacheValue.error); + } + continue; + } + + const mutatedData = await applyMutation( + parsedKey.model, + parsedKey.operation, + cacheValue.data, + mutationModel, + mutationOp as PrismaWriteActionType, + mutationArgs, + modelMeta, + logging + ); + + if (mutatedData !== undefined) { + // mutation applicable to this query, update cache + if (logging) { + console.log( + `Optimistically updating query ${JSON.stringify( + key + )} due to mutation "${mutationModel}.${mutationOp}"` + ); + } + optimisticPromises.push( + mutator(key, mutatedData, { + // don't trigger revalidation here since we will do it + // when the remote mutation succeeds + revalidate: false, + }) + ); + } + } + + return Promise.all(optimisticPromises); +} diff --git a/packages/plugins/swr/tests/react-hooks.test.tsx b/packages/plugins/swr/tests/react-hooks.test.tsx index 4fc70ae08..f2a6d9aea 100644 --- a/packages/plugins/swr/tests/react-hooks.test.tsx +++ b/packages/plugins/swr/tests/react-hooks.test.tsx @@ -10,8 +10,9 @@ import { renderHook, waitFor } from '@testing-library/react'; import { lowerCaseFirst } from 'lower-case-first'; import nock from 'nock'; import { useSWRConfig } from 'swr'; -import { useGet, getQueryKey, post, useMutate, put, del } from '../src/runtime'; +import { RequestHandlerContext, getQueryKey, mutationRequest, useModelQuery, useInvalidation } from '../src/runtime'; import { modelMeta } from './test-model-meta'; +import React from 'react'; const ENDPOINT = 'http://localhost/api/model'; @@ -23,6 +24,10 @@ function makeUrl(model: string, operation: string, args?: unknown) { return r; } +const wrapper = ({ children }: { children: React.ReactNode }) => ( + {children} +); + describe('SWR React Hooks Test', () => { beforeEach(() => { nock.cleanAll(); @@ -41,7 +46,7 @@ describe('SWR React Hooks Test', () => { }; }); - const { result } = renderHook(() => useGet('User', 'findUnique', ENDPOINT, queryArgs)); + const { result } = renderHook(() => useModelQuery('User', 'findUnique', queryArgs), { wrapper }); await waitFor(() => { expect(result.current.data).toMatchObject(data); @@ -65,7 +70,7 @@ describe('SWR React Hooks Test', () => { }) .persist(); - const { result } = renderHook(() => useGet('User', 'findMany', ENDPOINT)); + const { result } = renderHook(() => useModelQuery('User', 'findMany'), { wrapper }); await waitFor(() => { expect(result.current.data).toHaveLength(0); }); @@ -78,11 +83,16 @@ describe('SWR React Hooks Test', () => { return { data: data[0] }; }); - const { result: useMutateResult } = renderHook(() => useMutate('User', modelMeta, true)); + const { result: useMutateResult } = renderHook(() => useInvalidation('User', modelMeta)); await waitFor(async () => { const mutate = useMutateResult.current; - const r = await post(makeUrl('User', 'create', undefined), { data: { name: 'foo' } }, mutate); + const r = await mutationRequest( + 'POST', + makeUrl('User', 'create', undefined), + { data: { name: 'foo' } }, + mutate + ); console.log('Mutate result:', r); }); @@ -105,7 +115,7 @@ describe('SWR React Hooks Test', () => { }) .persist(); - const { result } = renderHook(() => useGet('User', 'findUnique', ENDPOINT, queryArgs)); + const { result } = renderHook(() => useModelQuery('User', 'findUnique', queryArgs), { wrapper }); await waitFor(() => { expect(result.current.data).toMatchObject({ name: 'foo' }); }); @@ -118,11 +128,16 @@ describe('SWR React Hooks Test', () => { return data; }); - const { result: useMutateResult } = renderHook(() => useMutate('User', modelMeta, true)); + const { result: useMutateResult } = renderHook(() => useInvalidation('User', modelMeta)); await waitFor(async () => { const mutate = useMutateResult.current; - const r = await put(makeUrl('User', 'update', undefined), { ...queryArgs, data: { name: 'bar' } }, mutate); + const r = await mutationRequest( + 'PUT', + makeUrl('User', 'update', undefined), + { ...queryArgs, data: { name: 'bar' } }, + mutate + ); console.log('Mutate result:', r); }); @@ -145,7 +160,7 @@ describe('SWR React Hooks Test', () => { }) .persist(); - const { result } = renderHook(() => useGet('User', 'findUnique', ENDPOINT, queryArgs)); + const { result } = renderHook(() => useModelQuery('User', 'findUnique', queryArgs), { wrapper }); await waitFor(() => { expect(result.current.data).toMatchObject(data); }); @@ -158,11 +173,12 @@ describe('SWR React Hooks Test', () => { return data; }); - const { result: useMutateResult } = renderHook(() => useMutate('Post', modelMeta, true)); + const { result: useMutateResult } = renderHook(() => useInvalidation('Post', modelMeta)); await waitFor(async () => { const mutate = useMutateResult.current; - const r = await put( + const r = await mutationRequest( + 'PUT', makeUrl('Post', 'update', undefined), { where: { id: '1' }, data: { name: 'post2' } }, mutate @@ -188,7 +204,7 @@ describe('SWR React Hooks Test', () => { }) .persist(); - const { result } = renderHook(() => useGet('Post', 'findMany', ENDPOINT)); + const { result } = renderHook(() => useModelQuery('Post', 'findMany'), { wrapper }); await waitFor(() => { expect(result.current.data).toMatchObject(data); }); @@ -201,11 +217,12 @@ describe('SWR React Hooks Test', () => { return data; }); - const { result: useMutateResult } = renderHook(() => useMutate('User', modelMeta, true)); + const { result: useMutateResult } = renderHook(() => useInvalidation('User', modelMeta)); await waitFor(async () => { const mutate = useMutateResult.current; - const r = await put( + const r = await mutationRequest( + 'PUT', makeUrl('User', 'update', undefined), { where: { id: '1' }, data: { posts: { create: { title: 'post2' } } } }, mutate @@ -240,7 +257,7 @@ describe('SWR React Hooks Test separate due to potential nock issue', () => { }) .persist(); - const { result } = renderHook(() => useGet('User', 'findUnique', ENDPOINT, queryArgs)); + const { result } = renderHook(() => useModelQuery('User', 'findUnique', queryArgs), { wrapper }); await waitFor(() => { expect(result.current.data).toMatchObject({ name: 'foo' }); }); @@ -252,10 +269,15 @@ describe('SWR React Hooks Test separate due to potential nock issue', () => { return { data: { id: '1', title: 'post1' } }; }); - const { result: useMutateResult } = renderHook(() => useMutate('Post', modelMeta, true)); + const { result: useMutateResult } = renderHook(() => useInvalidation('Post', modelMeta)); await waitFor(async () => { const mutate = useMutateResult.current; - const r = await post(makeUrl('Post', 'create', undefined), { data: { title: 'post1' } }, mutate); + const r = await mutationRequest( + 'POST', + makeUrl('Post', 'create', undefined), + { data: { title: 'post1' } }, + mutate + ); console.log('Mutate result:', r); // no refetch caused by invalidation expect(queryCount).toBe(1); @@ -273,7 +295,7 @@ describe('SWR React Hooks Test separate due to potential nock issue', () => { }) .persist(); - const { result } = renderHook(() => useGet('Post', 'findMany', ENDPOINT)); + const { result } = renderHook(() => useModelQuery('Post', 'findMany'), { wrapper }); await waitFor(() => { expect(result.current.data).toHaveLength(1); }); @@ -286,11 +308,11 @@ describe('SWR React Hooks Test separate due to potential nock issue', () => { return { data: { id: '1' } }; }); - const { result: useMutateResult } = renderHook(() => useMutate('User', modelMeta, true)); + const { result: useMutateResult } = renderHook(() => useInvalidation('User', modelMeta)); await waitFor(async () => { const mutate = useMutateResult.current; - await del(makeUrl('User', 'delete', undefined), { where: { id: '1' } }, mutate); + await mutationRequest('DELETE', makeUrl('User', 'delete', undefined), { where: { id: '1' } }, mutate); }); const { result: cacheResult } = renderHook(() => useSWRConfig()); diff --git a/packages/runtime/src/cross/mutator.ts b/packages/runtime/src/cross/mutator.ts index 5e19e1aeb..0dd66e6fb 100644 --- a/packages/runtime/src/cross/mutator.ts +++ b/packages/runtime/src/cross/mutator.ts @@ -141,12 +141,14 @@ function createMutate( idFields.forEach((f) => { if (insert[f.name] === undefined) { if (f.type === 'Int' || f.type === 'BigInt') { - const currMax = Math.max( - ...[...currentData].map((item) => { - const idv = parseInt(item[f.name]); - return isNaN(idv) ? 0 : idv; - }) - ); + const currMax = Array.isArray(currentData) + ? Math.max( + ...[...currentData].map((item) => { + const idv = parseInt(item[f.name]); + return isNaN(idv) ? 0 : idv; + }) + ) + : 0; insert[f.name] = currMax + 1; } else { insert[f.name] = uuid(); @@ -159,7 +161,7 @@ function createMutate( if (logging) { console.log(`Optimistic create for ${queryModel}:`, insert); } - return [insert, ...currentData]; + return [insert, ...(Array.isArray(currentData) ? currentData : [])]; } function updateMutate( diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2d5527a6d..9da09d8b5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -206,8 +206,8 @@ importers: specifier: ^3.0.2 version: 3.0.2 swr: - specifier: ^2.0.3 - version: 2.0.3(react@18.2.0) + specifier: ^2.2.4 + version: 2.2.4(react@18.2.0) ts-jest: specifier: ^29.0.5 version: 29.0.5(@babel/core@7.23.2)(esbuild@0.18.13)(jest@29.5.0)(typescript@4.9.4) @@ -14660,6 +14660,16 @@ packages: use-sync-external-store: 1.2.0(react@18.2.0) dev: true + /swr@2.2.4(react@18.2.0): + resolution: {integrity: sha512-njiZ/4RiIhoOlAaLYDqwz5qH/KZXVilRLvomrx83HjzCWTfa+InyfAjv05PSFxnmLzZkNO9ZfvgoqzAaEI4sGQ==} + peerDependencies: + react: ^16.11.0 || ^17.0.0 || ^18.0.0 + dependencies: + client-only: 0.0.1 + react: 18.2.0 + use-sync-external-store: 1.2.0(react@18.2.0) + dev: true + /symbol-tree@3.2.4: resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} dev: true