diff --git a/waspc/data/Generator/templates/react-app/src/actions/_action.js b/waspc/data/Generator/templates/react-app/src/actions/_action.js index aec1f0afb..1d768b2f6 100644 --- a/waspc/data/Generator/templates/react-app/src/actions/_action.js +++ b/waspc/data/Generator/templates/react-app/src/actions/_action.js @@ -5,4 +5,3 @@ export default createAction( '{= actionRoute =}', {=& entitiesArray =}, ) - diff --git a/waspc/data/Generator/templates/react-app/src/actions/index.js b/waspc/data/Generator/templates/react-app/src/actions/index.js new file mode 100644 index 000000000..52a4013e7 --- /dev/null +++ b/waspc/data/Generator/templates/react-app/src/actions/index.js @@ -0,0 +1,234 @@ +import { + useMutation, + useQueryClient, +} from 'react-query' + +export { configureQueryClient } from '../queryClient' + +/** + * An options object passed into the `useAction` hook and used to enhance the + * action with extra options. + * + * @typedef {Object} ActionOptions + * @property {PublicOptimisticUpdateDefinition[]} optimisticUpdates + */ + +/** + * A documented (public) way to define optimistic updates. + * + * @typedef {Object} PublicOptimisticUpdateDefinition + * @property {GetQuerySpecifier} querySpecifier + * @property {UpdateQuery} updateQuery + */ + +/** + * A function that takes an item and returns a Wasp Query specifier. + * + * @callback GetQuerySpecifier + * @param {T} item + * @returns {QuerySpecifier} + */ + +/** + * A function that takes an item and the previous state of the cache, and returns + * the desired (new) state of the cache. + * + * @callback UpdateQuery + * @param {T} item + * @param {T[]} oldData + * @returns {T[]} + */ + +/** + * A public query specifier used for addressing Wasp queries. See our docs for details: + * https://wasp-lang.dev/docs/language/features#the-useaction-hook. + * + * @typedef {any[]} QuerySpecifier + */ + +/** + * An internal (undocumented, private, desugared) way of defining optimistic updates. + * + * @typedef {Object} InternalOptimisticUpdateDefinition + * @property {GetQuerySpecifier} querySpecifier + * @property {UpdateQuery} updateQuery + */ + +/** + * An UpdateQuery function "instantiated" with a specific item. It only takes + * the current state of the cache and returns the desired (new) state of the + * cache. + * + * @callback SpecificUpdateQuery + * @param {any[]} oldData + */ + +/** + * A specific, "instantiated" optimistic update definition which contains a + * fully-constructed query key and a specific update function. + * + * @typedef {Object} SpecificOptimisticUpdateDefinition + * @property {QueryKey} queryKey + * @property {SpecificUpdateQuery} updateQuery +*/ + +/** + * An array React Query uses to address queries. See their docs for details: + * https://react-query-v3.tanstack.com/guides/query-keys#array-keys. + * + * @typedef {any[]} QueryKey + */ + + +/** + * A hook for adding extra behavior to a Wasp Action (e.g., optimistic updates). + * + * @param actionFn The Wasp Action you wish to enhance/decorate. + * @param {ActionOptions} actionOptions An options object for enhancing/decorating the given Action. + * @returns A decorated Action with added behavior but an unchanged API. + */ +export function useAction(actionFn, actionOptions) { + const queryClient = useQueryClient(); + + let options = {} + if (actionOptions?.optimisticUpdates) { + const optimisticUpdateDefinitions = actionOptions.optimisticUpdates.map(translateToInternalDefinition) + options = makeRqOptimisticUpdateOptions(queryClient, optimisticUpdateDefinitions) + } + + // NOTE: We decided to hide React Query's extra mutation features (e.g., + // isLoading, onSuccess and onError callbacks, synchronous mutate) and only + // expose a simple async function whose API matches the original Action. + // We did this to avoid cluttering the API with stuff we're not sure we need + // yet (e.g., isLoading), to postpone the action vs mutation dilemma, and to + // clearly separate our opinionated API from React Query's lower-level + // advanced API (which users can also use) + const mutation = useMutation(actionFn, options) + return (args) => mutation.mutateAsync(args) +} + +/** + * Translates/Desugars a public optimistic update definition object into a definition object our + * system uses internally. + * + * @param {PublicOptimisticUpdateDefinition} publicOptimisticUpdateDefinition An optimistic update definition + * object that's a part of the public API: https://wasp-lang.dev/docs/language/features#the-useaction-hook. + * @returns {InternalOptimisticUpdateDefinition} An internally-used optimistic update definition object. + */ +function translateToInternalDefinition(publicOptimisticUpdateDefinition) { + const { getQuerySpecifier, updateQuery } = publicOptimisticUpdateDefinition + + const definitionErrors = [] + if (typeof getQuerySpecifier !== 'function') { + definitionErrors.push('`getQuerySpecifier` is not a function.') + } + if (typeof updateQuery !== 'function') { + definitionErrors.push('`updateQuery` is not a function.') + } + if (definitionErrors.length) { + throw new TypeError(`Invalid optimistic update definition: ${definitionErrors.join(', ')}.`) + } + + return { + getQueryKey: (item) => getRqQueryKeyFromSpecifier(getQuerySpecifier(item)), + updateQuery, + } +} + +/** + * Given a ReactQuery query client and our internal definition of optimistic + * updates, this function constructs an object describing those same optimistic + * updates in a format we can pass into React Query's useMutation hook. In other + * words, it translates our optimistic updates definition into React Query's + * optimistic updates definition. Check their docs for details: + * https://tanstack.com/query/v4/docs/guides/optimistic-updates?from=reactQueryV3&original=https://react-query-v3.tanstack.com/guides/optimistic-updates + * + * @param {Object} queryClient The QueryClient instance used by React Query. + * @param {InternalOptimisticUpdateDefinition} optimisticUpdateDefinitions A list containing internal optimistic updates definition objects + * (i.e., a list where each object carries the instructions for performing particular optimistic update). + * @returns {Object} An object containing 'onMutate' and 'onError' functions corresponding to the given optimistic update + * definitions (check the docs linked above for details). + */ +function makeRqOptimisticUpdateOptions(queryClient, optimisticUpdateDefinitions) { + async function onMutate(item) { + const specificOptimisticUpdateDefinitions = optimisticUpdateDefinitions.map( + generalDefinition => getOptimisticUpdateDefinitionForSpecificItem(generalDefinition, item) + ) + + // Cancel any outgoing refetches (so they don't overwrite our optimistic update). + // Theoretically, we can be a bit faster. Instead of awaiting the + // cancellation of all queries, we could cancel and update them in parallel. + // However, awaiting cancellation hasn't yet proven to be a performance bottleneck. + await Promise.all(specificOptimisticUpdateDefinitions.map( + ({ queryKey }) => queryClient.cancelQueries(queryKey) + )) + + // We're using a Map to correctly serialize query keys that contain objects. + const previousData = new Map() + specificOptimisticUpdateDefinitions.forEach(({ queryKey, updateQuery }) => { + // Snapshot the currently cached value. + const previousDataForQuery = queryClient.getQueryData(queryKey) + + // Attempt to optimistically update the cache using the new value. + try { + queryClient.setQueryData(queryKey, updateQuery) + } catch (e) { + console.error("The `updateQuery` function threw an exception, skipping optimistic update:") + console.error(e) + } + + // Remember the snapshotted value to restore in case of an error. + previousData.set(queryKey, previousDataForQuery) + }) + + return { previousData } + } + + function onError(_err, _item, context) { + // All we do in case of an error is roll back all optimistic updates. We ensure + // not to do anything else because React Query rethrows the error. This allows + // the programmer to handle the error as they usually would (i.e., we want the + // error handling to work as it would if the programmer wasn't using optimistic + // updates). + context.previousData.forEach(async (data, queryKey) => { + await queryClient.cancelQueries(queryKey) + queryClient.setQueryData(queryKey, data) + }) + } + + return { + onMutate, + onError, + } +} + +/** + * Constructs the definition for optimistically updating a specific item. It + * uses a closure over the updated item to construct an item-specific query key + * (e.g., useful when the query key depends on an ID). + * + * @param {InternalOptimisticUpdateDefinition} optimisticUpdateDefinition The general, "uninstantiated" optimistic + * update definition with a function for constructing the query key. + * @param item The item triggering the Action/optimistic update (i.e., the argument passed to the Action). + * @returns {SpecificOptimisticUpdateDefinition} A specific optimistic update definition + * which corresponds to the provided definition and closes over the provided item. + */ +function getOptimisticUpdateDefinitionForSpecificItem(optimisticUpdateDefinition, item) { + const { getQueryKey, updateQuery } = optimisticUpdateDefinition + return { + queryKey: getQueryKey(item), + updateQuery: (old) => updateQuery(item, old) + } +} + +/** + * Translates a Wasp query specifier to a query cache key used by React Query. + * + * @param {QuerySpecifier} querySpecifier A query specifier that's a part of the public API: + * https://wasp-lang.dev/docs/language/features#the-useaction-hook. + * @returns {QueryKey} A cache key React Query internally uses for addressing queries. + */ +function getRqQueryKeyFromSpecifier(querySpecifier) { + const [queryFn, ...otherKeys] = querySpecifier + return [queryFn.queryCacheKey, ...otherKeys] +} diff --git a/waspc/data/Generator/templates/react-app/src/operations/resources.js b/waspc/data/Generator/templates/react-app/src/operations/resources.js index 60124ec9d..bee212ccb 100644 --- a/waspc/data/Generator/templates/react-app/src/operations/resources.js +++ b/waspc/data/Generator/templates/react-app/src/operations/resources.js @@ -1,7 +1,5 @@ -{{= {= =} =}} import { queryClientInitialized } from '../queryClient' - // Map where key is resource name and value is Set // containing query ids of all the queries that use // that resource. @@ -24,13 +22,6 @@ export function addResourcesUsedByQuery(queryCacheKey, resources) { } } -/** - * @param {string} resource - Resource name. - * @returns {string[]} Array of "query cache keys" of queries that use specified resource. - */ -export function getQueriesUsingResource(resource) { - return Array.from(resourceToQueryCacheKeys.get(resource) || []) -} /** * Invalidates all queries that are using specified resources. * @param {string[]} resources - Names of resources. @@ -38,9 +29,9 @@ export function getQueriesUsingResource(resource) { export async function invalidateQueriesUsing(resources) { const queryClient = await queryClientInitialized - const queryCacheKeysToInvalidate = new Set(resources.flatMap(getQueriesUsingResource)) - queryCacheKeysToInvalidate.forEach(queryCacheKey => - queryClient.invalidateQueries(queryCacheKey) + const queryCacheKeysToInvalidate = getQueriesUsingResources(resources) + queryCacheKeysToInvalidate.forEach( + queryCacheKey => queryClient.invalidateQueries(queryCacheKey) ) } @@ -59,3 +50,15 @@ export async function invalidateAndRemoveQueries() { // remains in the cache, casuing a potential privacy issue. queryClient.removeQueries() } + +/** + * @param {string} resource - Resource name. + * @returns {string[]} Array of "query cache keys" of queries that use specified resource. + */ +function getQueriesUsingResource(resource) { + return Array.from(resourceToQueryCacheKeys.get(resource) || []) +} + +function getQueriesUsingResources(resources) { + return Array.from(new Set(resources.flatMap(getQueriesUsingResource))) +} diff --git a/waspc/e2e-test/test-outputs/waspBuild-golden/files.manifest b/waspc/e2e-test/test-outputs/waspBuild-golden/files.manifest index 4b4772810..7c89b9b9a 100644 --- a/waspc/e2e-test/test-outputs/waspBuild-golden/files.manifest +++ b/waspc/e2e-test/test-outputs/waspBuild-golden/files.manifest @@ -32,6 +32,7 @@ waspBuild/.wasp/build/web-app/public/favicon.ico waspBuild/.wasp/build/web-app/public/index.html waspBuild/.wasp/build/web-app/public/manifest.json waspBuild/.wasp/build/web-app/src/actions/core.js +waspBuild/.wasp/build/web-app/src/actions/index.js waspBuild/.wasp/build/web-app/src/api.js waspBuild/.wasp/build/web-app/src/config.js waspBuild/.wasp/build/web-app/src/ext-src/Main.css diff --git a/waspc/e2e-test/test-outputs/waspBuild-golden/waspBuild/.wasp/build/.waspchecksums b/waspc/e2e-test/test-outputs/waspBuild-golden/waspBuild/.wasp/build/.waspchecksums index 71995eac9..c8981cd53 100644 --- a/waspc/e2e-test/test-outputs/waspBuild-golden/waspBuild/.wasp/build/.waspchecksums +++ b/waspc/e2e-test/test-outputs/waspBuild-golden/waspBuild/.wasp/build/.waspchecksums @@ -237,6 +237,13 @@ ], "0891b7607b5503ada42386f73476a46f774a224d8b606f79ef37e451b229d399" ], + [ + [ + "file", + "web-app/src/actions/index.js" + ], + "da1a32d134ba931e5dc97294db7368b52b9cf5f54a9c9eaefa21cc54470d040c" + ], [ [ "file", @@ -305,7 +312,7 @@ "file", "web-app/src/operations/resources.js" ], - "9bccf509e1a3e04e24444eacd69e0f093e590a9b85325bedb6f1d52ac85ead3c" + "1644218db3ca4f811e52271b5f65d3b274c160517af116d6c92acc7b273d73ec" ], [ [ diff --git a/waspc/e2e-test/test-outputs/waspBuild-golden/waspBuild/.wasp/build/web-app/src/actions/index.js b/waspc/e2e-test/test-outputs/waspBuild-golden/waspBuild/.wasp/build/web-app/src/actions/index.js new file mode 100644 index 000000000..52a4013e7 --- /dev/null +++ b/waspc/e2e-test/test-outputs/waspBuild-golden/waspBuild/.wasp/build/web-app/src/actions/index.js @@ -0,0 +1,234 @@ +import { + useMutation, + useQueryClient, +} from 'react-query' + +export { configureQueryClient } from '../queryClient' + +/** + * An options object passed into the `useAction` hook and used to enhance the + * action with extra options. + * + * @typedef {Object} ActionOptions + * @property {PublicOptimisticUpdateDefinition[]} optimisticUpdates + */ + +/** + * A documented (public) way to define optimistic updates. + * + * @typedef {Object} PublicOptimisticUpdateDefinition + * @property {GetQuerySpecifier} querySpecifier + * @property {UpdateQuery} updateQuery + */ + +/** + * A function that takes an item and returns a Wasp Query specifier. + * + * @callback GetQuerySpecifier + * @param {T} item + * @returns {QuerySpecifier} + */ + +/** + * A function that takes an item and the previous state of the cache, and returns + * the desired (new) state of the cache. + * + * @callback UpdateQuery + * @param {T} item + * @param {T[]} oldData + * @returns {T[]} + */ + +/** + * A public query specifier used for addressing Wasp queries. See our docs for details: + * https://wasp-lang.dev/docs/language/features#the-useaction-hook. + * + * @typedef {any[]} QuerySpecifier + */ + +/** + * An internal (undocumented, private, desugared) way of defining optimistic updates. + * + * @typedef {Object} InternalOptimisticUpdateDefinition + * @property {GetQuerySpecifier} querySpecifier + * @property {UpdateQuery} updateQuery + */ + +/** + * An UpdateQuery function "instantiated" with a specific item. It only takes + * the current state of the cache and returns the desired (new) state of the + * cache. + * + * @callback SpecificUpdateQuery + * @param {any[]} oldData + */ + +/** + * A specific, "instantiated" optimistic update definition which contains a + * fully-constructed query key and a specific update function. + * + * @typedef {Object} SpecificOptimisticUpdateDefinition + * @property {QueryKey} queryKey + * @property {SpecificUpdateQuery} updateQuery +*/ + +/** + * An array React Query uses to address queries. See their docs for details: + * https://react-query-v3.tanstack.com/guides/query-keys#array-keys. + * + * @typedef {any[]} QueryKey + */ + + +/** + * A hook for adding extra behavior to a Wasp Action (e.g., optimistic updates). + * + * @param actionFn The Wasp Action you wish to enhance/decorate. + * @param {ActionOptions} actionOptions An options object for enhancing/decorating the given Action. + * @returns A decorated Action with added behavior but an unchanged API. + */ +export function useAction(actionFn, actionOptions) { + const queryClient = useQueryClient(); + + let options = {} + if (actionOptions?.optimisticUpdates) { + const optimisticUpdateDefinitions = actionOptions.optimisticUpdates.map(translateToInternalDefinition) + options = makeRqOptimisticUpdateOptions(queryClient, optimisticUpdateDefinitions) + } + + // NOTE: We decided to hide React Query's extra mutation features (e.g., + // isLoading, onSuccess and onError callbacks, synchronous mutate) and only + // expose a simple async function whose API matches the original Action. + // We did this to avoid cluttering the API with stuff we're not sure we need + // yet (e.g., isLoading), to postpone the action vs mutation dilemma, and to + // clearly separate our opinionated API from React Query's lower-level + // advanced API (which users can also use) + const mutation = useMutation(actionFn, options) + return (args) => mutation.mutateAsync(args) +} + +/** + * Translates/Desugars a public optimistic update definition object into a definition object our + * system uses internally. + * + * @param {PublicOptimisticUpdateDefinition} publicOptimisticUpdateDefinition An optimistic update definition + * object that's a part of the public API: https://wasp-lang.dev/docs/language/features#the-useaction-hook. + * @returns {InternalOptimisticUpdateDefinition} An internally-used optimistic update definition object. + */ +function translateToInternalDefinition(publicOptimisticUpdateDefinition) { + const { getQuerySpecifier, updateQuery } = publicOptimisticUpdateDefinition + + const definitionErrors = [] + if (typeof getQuerySpecifier !== 'function') { + definitionErrors.push('`getQuerySpecifier` is not a function.') + } + if (typeof updateQuery !== 'function') { + definitionErrors.push('`updateQuery` is not a function.') + } + if (definitionErrors.length) { + throw new TypeError(`Invalid optimistic update definition: ${definitionErrors.join(', ')}.`) + } + + return { + getQueryKey: (item) => getRqQueryKeyFromSpecifier(getQuerySpecifier(item)), + updateQuery, + } +} + +/** + * Given a ReactQuery query client and our internal definition of optimistic + * updates, this function constructs an object describing those same optimistic + * updates in a format we can pass into React Query's useMutation hook. In other + * words, it translates our optimistic updates definition into React Query's + * optimistic updates definition. Check their docs for details: + * https://tanstack.com/query/v4/docs/guides/optimistic-updates?from=reactQueryV3&original=https://react-query-v3.tanstack.com/guides/optimistic-updates + * + * @param {Object} queryClient The QueryClient instance used by React Query. + * @param {InternalOptimisticUpdateDefinition} optimisticUpdateDefinitions A list containing internal optimistic updates definition objects + * (i.e., a list where each object carries the instructions for performing particular optimistic update). + * @returns {Object} An object containing 'onMutate' and 'onError' functions corresponding to the given optimistic update + * definitions (check the docs linked above for details). + */ +function makeRqOptimisticUpdateOptions(queryClient, optimisticUpdateDefinitions) { + async function onMutate(item) { + const specificOptimisticUpdateDefinitions = optimisticUpdateDefinitions.map( + generalDefinition => getOptimisticUpdateDefinitionForSpecificItem(generalDefinition, item) + ) + + // Cancel any outgoing refetches (so they don't overwrite our optimistic update). + // Theoretically, we can be a bit faster. Instead of awaiting the + // cancellation of all queries, we could cancel and update them in parallel. + // However, awaiting cancellation hasn't yet proven to be a performance bottleneck. + await Promise.all(specificOptimisticUpdateDefinitions.map( + ({ queryKey }) => queryClient.cancelQueries(queryKey) + )) + + // We're using a Map to correctly serialize query keys that contain objects. + const previousData = new Map() + specificOptimisticUpdateDefinitions.forEach(({ queryKey, updateQuery }) => { + // Snapshot the currently cached value. + const previousDataForQuery = queryClient.getQueryData(queryKey) + + // Attempt to optimistically update the cache using the new value. + try { + queryClient.setQueryData(queryKey, updateQuery) + } catch (e) { + console.error("The `updateQuery` function threw an exception, skipping optimistic update:") + console.error(e) + } + + // Remember the snapshotted value to restore in case of an error. + previousData.set(queryKey, previousDataForQuery) + }) + + return { previousData } + } + + function onError(_err, _item, context) { + // All we do in case of an error is roll back all optimistic updates. We ensure + // not to do anything else because React Query rethrows the error. This allows + // the programmer to handle the error as they usually would (i.e., we want the + // error handling to work as it would if the programmer wasn't using optimistic + // updates). + context.previousData.forEach(async (data, queryKey) => { + await queryClient.cancelQueries(queryKey) + queryClient.setQueryData(queryKey, data) + }) + } + + return { + onMutate, + onError, + } +} + +/** + * Constructs the definition for optimistically updating a specific item. It + * uses a closure over the updated item to construct an item-specific query key + * (e.g., useful when the query key depends on an ID). + * + * @param {InternalOptimisticUpdateDefinition} optimisticUpdateDefinition The general, "uninstantiated" optimistic + * update definition with a function for constructing the query key. + * @param item The item triggering the Action/optimistic update (i.e., the argument passed to the Action). + * @returns {SpecificOptimisticUpdateDefinition} A specific optimistic update definition + * which corresponds to the provided definition and closes over the provided item. + */ +function getOptimisticUpdateDefinitionForSpecificItem(optimisticUpdateDefinition, item) { + const { getQueryKey, updateQuery } = optimisticUpdateDefinition + return { + queryKey: getQueryKey(item), + updateQuery: (old) => updateQuery(item, old) + } +} + +/** + * Translates a Wasp query specifier to a query cache key used by React Query. + * + * @param {QuerySpecifier} querySpecifier A query specifier that's a part of the public API: + * https://wasp-lang.dev/docs/language/features#the-useaction-hook. + * @returns {QueryKey} A cache key React Query internally uses for addressing queries. + */ +function getRqQueryKeyFromSpecifier(querySpecifier) { + const [queryFn, ...otherKeys] = querySpecifier + return [queryFn.queryCacheKey, ...otherKeys] +} diff --git a/waspc/e2e-test/test-outputs/waspBuild-golden/waspBuild/.wasp/build/web-app/src/operations/resources.js b/waspc/e2e-test/test-outputs/waspBuild-golden/waspBuild/.wasp/build/web-app/src/operations/resources.js index cabb9265b..bee212ccb 100644 --- a/waspc/e2e-test/test-outputs/waspBuild-golden/waspBuild/.wasp/build/web-app/src/operations/resources.js +++ b/waspc/e2e-test/test-outputs/waspBuild-golden/waspBuild/.wasp/build/web-app/src/operations/resources.js @@ -1,6 +1,5 @@ import { queryClientInitialized } from '../queryClient' - // Map where key is resource name and value is Set // containing query ids of all the queries that use // that resource. @@ -23,13 +22,6 @@ export function addResourcesUsedByQuery(queryCacheKey, resources) { } } -/** - * @param {string} resource - Resource name. - * @returns {string[]} Array of "query cache keys" of queries that use specified resource. - */ -export function getQueriesUsingResource(resource) { - return Array.from(resourceToQueryCacheKeys.get(resource) || []) -} /** * Invalidates all queries that are using specified resources. * @param {string[]} resources - Names of resources. @@ -37,9 +29,9 @@ export function getQueriesUsingResource(resource) { export async function invalidateQueriesUsing(resources) { const queryClient = await queryClientInitialized - const queryCacheKeysToInvalidate = new Set(resources.flatMap(getQueriesUsingResource)) - queryCacheKeysToInvalidate.forEach(queryCacheKey => - queryClient.invalidateQueries(queryCacheKey) + const queryCacheKeysToInvalidate = getQueriesUsingResources(resources) + queryCacheKeysToInvalidate.forEach( + queryCacheKey => queryClient.invalidateQueries(queryCacheKey) ) } @@ -58,3 +50,15 @@ export async function invalidateAndRemoveQueries() { // remains in the cache, casuing a potential privacy issue. queryClient.removeQueries() } + +/** + * @param {string} resource - Resource name. + * @returns {string[]} Array of "query cache keys" of queries that use specified resource. + */ +function getQueriesUsingResource(resource) { + return Array.from(resourceToQueryCacheKeys.get(resource) || []) +} + +function getQueriesUsingResources(resources) { + return Array.from(new Set(resources.flatMap(getQueriesUsingResource))) +} diff --git a/waspc/e2e-test/test-outputs/waspCompile-golden/files.manifest b/waspc/e2e-test/test-outputs/waspCompile-golden/files.manifest index ac0e8940e..028b6b622 100644 --- a/waspc/e2e-test/test-outputs/waspCompile-golden/files.manifest +++ b/waspc/e2e-test/test-outputs/waspCompile-golden/files.manifest @@ -32,6 +32,7 @@ waspCompile/.wasp/out/web-app/public/favicon.ico waspCompile/.wasp/out/web-app/public/index.html waspCompile/.wasp/out/web-app/public/manifest.json waspCompile/.wasp/out/web-app/src/actions/core.js +waspCompile/.wasp/out/web-app/src/actions/index.js waspCompile/.wasp/out/web-app/src/api.js waspCompile/.wasp/out/web-app/src/config.js waspCompile/.wasp/out/web-app/src/ext-src/Main.css diff --git a/waspc/e2e-test/test-outputs/waspCompile-golden/waspCompile/.wasp/out/.waspchecksums b/waspc/e2e-test/test-outputs/waspCompile-golden/waspCompile/.wasp/out/.waspchecksums index 2f828a4b4..215a9a9d6 100644 --- a/waspc/e2e-test/test-outputs/waspCompile-golden/waspCompile/.wasp/out/.waspchecksums +++ b/waspc/e2e-test/test-outputs/waspCompile-golden/waspCompile/.wasp/out/.waspchecksums @@ -237,6 +237,13 @@ ], "0891b7607b5503ada42386f73476a46f774a224d8b606f79ef37e451b229d399" ], + [ + [ + "file", + "web-app/src/actions/index.js" + ], + "da1a32d134ba931e5dc97294db7368b52b9cf5f54a9c9eaefa21cc54470d040c" + ], [ [ "file", @@ -305,7 +312,7 @@ "file", "web-app/src/operations/resources.js" ], - "9bccf509e1a3e04e24444eacd69e0f093e590a9b85325bedb6f1d52ac85ead3c" + "1644218db3ca4f811e52271b5f65d3b274c160517af116d6c92acc7b273d73ec" ], [ [ diff --git a/waspc/e2e-test/test-outputs/waspCompile-golden/waspCompile/.wasp/out/web-app/src/actions/index.js b/waspc/e2e-test/test-outputs/waspCompile-golden/waspCompile/.wasp/out/web-app/src/actions/index.js new file mode 100644 index 000000000..52a4013e7 --- /dev/null +++ b/waspc/e2e-test/test-outputs/waspCompile-golden/waspCompile/.wasp/out/web-app/src/actions/index.js @@ -0,0 +1,234 @@ +import { + useMutation, + useQueryClient, +} from 'react-query' + +export { configureQueryClient } from '../queryClient' + +/** + * An options object passed into the `useAction` hook and used to enhance the + * action with extra options. + * + * @typedef {Object} ActionOptions + * @property {PublicOptimisticUpdateDefinition[]} optimisticUpdates + */ + +/** + * A documented (public) way to define optimistic updates. + * + * @typedef {Object} PublicOptimisticUpdateDefinition + * @property {GetQuerySpecifier} querySpecifier + * @property {UpdateQuery} updateQuery + */ + +/** + * A function that takes an item and returns a Wasp Query specifier. + * + * @callback GetQuerySpecifier + * @param {T} item + * @returns {QuerySpecifier} + */ + +/** + * A function that takes an item and the previous state of the cache, and returns + * the desired (new) state of the cache. + * + * @callback UpdateQuery + * @param {T} item + * @param {T[]} oldData + * @returns {T[]} + */ + +/** + * A public query specifier used for addressing Wasp queries. See our docs for details: + * https://wasp-lang.dev/docs/language/features#the-useaction-hook. + * + * @typedef {any[]} QuerySpecifier + */ + +/** + * An internal (undocumented, private, desugared) way of defining optimistic updates. + * + * @typedef {Object} InternalOptimisticUpdateDefinition + * @property {GetQuerySpecifier} querySpecifier + * @property {UpdateQuery} updateQuery + */ + +/** + * An UpdateQuery function "instantiated" with a specific item. It only takes + * the current state of the cache and returns the desired (new) state of the + * cache. + * + * @callback SpecificUpdateQuery + * @param {any[]} oldData + */ + +/** + * A specific, "instantiated" optimistic update definition which contains a + * fully-constructed query key and a specific update function. + * + * @typedef {Object} SpecificOptimisticUpdateDefinition + * @property {QueryKey} queryKey + * @property {SpecificUpdateQuery} updateQuery +*/ + +/** + * An array React Query uses to address queries. See their docs for details: + * https://react-query-v3.tanstack.com/guides/query-keys#array-keys. + * + * @typedef {any[]} QueryKey + */ + + +/** + * A hook for adding extra behavior to a Wasp Action (e.g., optimistic updates). + * + * @param actionFn The Wasp Action you wish to enhance/decorate. + * @param {ActionOptions} actionOptions An options object for enhancing/decorating the given Action. + * @returns A decorated Action with added behavior but an unchanged API. + */ +export function useAction(actionFn, actionOptions) { + const queryClient = useQueryClient(); + + let options = {} + if (actionOptions?.optimisticUpdates) { + const optimisticUpdateDefinitions = actionOptions.optimisticUpdates.map(translateToInternalDefinition) + options = makeRqOptimisticUpdateOptions(queryClient, optimisticUpdateDefinitions) + } + + // NOTE: We decided to hide React Query's extra mutation features (e.g., + // isLoading, onSuccess and onError callbacks, synchronous mutate) and only + // expose a simple async function whose API matches the original Action. + // We did this to avoid cluttering the API with stuff we're not sure we need + // yet (e.g., isLoading), to postpone the action vs mutation dilemma, and to + // clearly separate our opinionated API from React Query's lower-level + // advanced API (which users can also use) + const mutation = useMutation(actionFn, options) + return (args) => mutation.mutateAsync(args) +} + +/** + * Translates/Desugars a public optimistic update definition object into a definition object our + * system uses internally. + * + * @param {PublicOptimisticUpdateDefinition} publicOptimisticUpdateDefinition An optimistic update definition + * object that's a part of the public API: https://wasp-lang.dev/docs/language/features#the-useaction-hook. + * @returns {InternalOptimisticUpdateDefinition} An internally-used optimistic update definition object. + */ +function translateToInternalDefinition(publicOptimisticUpdateDefinition) { + const { getQuerySpecifier, updateQuery } = publicOptimisticUpdateDefinition + + const definitionErrors = [] + if (typeof getQuerySpecifier !== 'function') { + definitionErrors.push('`getQuerySpecifier` is not a function.') + } + if (typeof updateQuery !== 'function') { + definitionErrors.push('`updateQuery` is not a function.') + } + if (definitionErrors.length) { + throw new TypeError(`Invalid optimistic update definition: ${definitionErrors.join(', ')}.`) + } + + return { + getQueryKey: (item) => getRqQueryKeyFromSpecifier(getQuerySpecifier(item)), + updateQuery, + } +} + +/** + * Given a ReactQuery query client and our internal definition of optimistic + * updates, this function constructs an object describing those same optimistic + * updates in a format we can pass into React Query's useMutation hook. In other + * words, it translates our optimistic updates definition into React Query's + * optimistic updates definition. Check their docs for details: + * https://tanstack.com/query/v4/docs/guides/optimistic-updates?from=reactQueryV3&original=https://react-query-v3.tanstack.com/guides/optimistic-updates + * + * @param {Object} queryClient The QueryClient instance used by React Query. + * @param {InternalOptimisticUpdateDefinition} optimisticUpdateDefinitions A list containing internal optimistic updates definition objects + * (i.e., a list where each object carries the instructions for performing particular optimistic update). + * @returns {Object} An object containing 'onMutate' and 'onError' functions corresponding to the given optimistic update + * definitions (check the docs linked above for details). + */ +function makeRqOptimisticUpdateOptions(queryClient, optimisticUpdateDefinitions) { + async function onMutate(item) { + const specificOptimisticUpdateDefinitions = optimisticUpdateDefinitions.map( + generalDefinition => getOptimisticUpdateDefinitionForSpecificItem(generalDefinition, item) + ) + + // Cancel any outgoing refetches (so they don't overwrite our optimistic update). + // Theoretically, we can be a bit faster. Instead of awaiting the + // cancellation of all queries, we could cancel and update them in parallel. + // However, awaiting cancellation hasn't yet proven to be a performance bottleneck. + await Promise.all(specificOptimisticUpdateDefinitions.map( + ({ queryKey }) => queryClient.cancelQueries(queryKey) + )) + + // We're using a Map to correctly serialize query keys that contain objects. + const previousData = new Map() + specificOptimisticUpdateDefinitions.forEach(({ queryKey, updateQuery }) => { + // Snapshot the currently cached value. + const previousDataForQuery = queryClient.getQueryData(queryKey) + + // Attempt to optimistically update the cache using the new value. + try { + queryClient.setQueryData(queryKey, updateQuery) + } catch (e) { + console.error("The `updateQuery` function threw an exception, skipping optimistic update:") + console.error(e) + } + + // Remember the snapshotted value to restore in case of an error. + previousData.set(queryKey, previousDataForQuery) + }) + + return { previousData } + } + + function onError(_err, _item, context) { + // All we do in case of an error is roll back all optimistic updates. We ensure + // not to do anything else because React Query rethrows the error. This allows + // the programmer to handle the error as they usually would (i.e., we want the + // error handling to work as it would if the programmer wasn't using optimistic + // updates). + context.previousData.forEach(async (data, queryKey) => { + await queryClient.cancelQueries(queryKey) + queryClient.setQueryData(queryKey, data) + }) + } + + return { + onMutate, + onError, + } +} + +/** + * Constructs the definition for optimistically updating a specific item. It + * uses a closure over the updated item to construct an item-specific query key + * (e.g., useful when the query key depends on an ID). + * + * @param {InternalOptimisticUpdateDefinition} optimisticUpdateDefinition The general, "uninstantiated" optimistic + * update definition with a function for constructing the query key. + * @param item The item triggering the Action/optimistic update (i.e., the argument passed to the Action). + * @returns {SpecificOptimisticUpdateDefinition} A specific optimistic update definition + * which corresponds to the provided definition and closes over the provided item. + */ +function getOptimisticUpdateDefinitionForSpecificItem(optimisticUpdateDefinition, item) { + const { getQueryKey, updateQuery } = optimisticUpdateDefinition + return { + queryKey: getQueryKey(item), + updateQuery: (old) => updateQuery(item, old) + } +} + +/** + * Translates a Wasp query specifier to a query cache key used by React Query. + * + * @param {QuerySpecifier} querySpecifier A query specifier that's a part of the public API: + * https://wasp-lang.dev/docs/language/features#the-useaction-hook. + * @returns {QueryKey} A cache key React Query internally uses for addressing queries. + */ +function getRqQueryKeyFromSpecifier(querySpecifier) { + const [queryFn, ...otherKeys] = querySpecifier + return [queryFn.queryCacheKey, ...otherKeys] +} diff --git a/waspc/e2e-test/test-outputs/waspCompile-golden/waspCompile/.wasp/out/web-app/src/operations/resources.js b/waspc/e2e-test/test-outputs/waspCompile-golden/waspCompile/.wasp/out/web-app/src/operations/resources.js index cabb9265b..bee212ccb 100644 --- a/waspc/e2e-test/test-outputs/waspCompile-golden/waspCompile/.wasp/out/web-app/src/operations/resources.js +++ b/waspc/e2e-test/test-outputs/waspCompile-golden/waspCompile/.wasp/out/web-app/src/operations/resources.js @@ -1,6 +1,5 @@ import { queryClientInitialized } from '../queryClient' - // Map where key is resource name and value is Set // containing query ids of all the queries that use // that resource. @@ -23,13 +22,6 @@ export function addResourcesUsedByQuery(queryCacheKey, resources) { } } -/** - * @param {string} resource - Resource name. - * @returns {string[]} Array of "query cache keys" of queries that use specified resource. - */ -export function getQueriesUsingResource(resource) { - return Array.from(resourceToQueryCacheKeys.get(resource) || []) -} /** * Invalidates all queries that are using specified resources. * @param {string[]} resources - Names of resources. @@ -37,9 +29,9 @@ export function getQueriesUsingResource(resource) { export async function invalidateQueriesUsing(resources) { const queryClient = await queryClientInitialized - const queryCacheKeysToInvalidate = new Set(resources.flatMap(getQueriesUsingResource)) - queryCacheKeysToInvalidate.forEach(queryCacheKey => - queryClient.invalidateQueries(queryCacheKey) + const queryCacheKeysToInvalidate = getQueriesUsingResources(resources) + queryCacheKeysToInvalidate.forEach( + queryCacheKey => queryClient.invalidateQueries(queryCacheKey) ) } @@ -58,3 +50,15 @@ export async function invalidateAndRemoveQueries() { // remains in the cache, casuing a potential privacy issue. queryClient.removeQueries() } + +/** + * @param {string} resource - Resource name. + * @returns {string[]} Array of "query cache keys" of queries that use specified resource. + */ +function getQueriesUsingResource(resource) { + return Array.from(resourceToQueryCacheKeys.get(resource) || []) +} + +function getQueriesUsingResources(resources) { + return Array.from(new Set(resources.flatMap(getQueriesUsingResource))) +} diff --git a/waspc/e2e-test/test-outputs/waspJob-golden/files.manifest b/waspc/e2e-test/test-outputs/waspJob-golden/files.manifest index 4aaa1351d..054d85845 100644 --- a/waspc/e2e-test/test-outputs/waspJob-golden/files.manifest +++ b/waspc/e2e-test/test-outputs/waspJob-golden/files.manifest @@ -34,6 +34,7 @@ waspJob/.wasp/out/web-app/public/favicon.ico waspJob/.wasp/out/web-app/public/index.html waspJob/.wasp/out/web-app/public/manifest.json waspJob/.wasp/out/web-app/src/actions/core.js +waspJob/.wasp/out/web-app/src/actions/index.js waspJob/.wasp/out/web-app/src/api.js waspJob/.wasp/out/web-app/src/config.js waspJob/.wasp/out/web-app/src/ext-src/Main.css diff --git a/waspc/e2e-test/test-outputs/waspJob-golden/waspJob/.wasp/out/.waspchecksums b/waspc/e2e-test/test-outputs/waspJob-golden/waspJob/.wasp/out/.waspchecksums index f8984f665..ce7ec6325 100644 --- a/waspc/e2e-test/test-outputs/waspJob-golden/waspJob/.wasp/out/.waspchecksums +++ b/waspc/e2e-test/test-outputs/waspJob-golden/waspJob/.wasp/out/.waspchecksums @@ -251,6 +251,13 @@ ], "0891b7607b5503ada42386f73476a46f774a224d8b606f79ef37e451b229d399" ], + [ + [ + "file", + "web-app/src/actions/index.js" + ], + "da1a32d134ba931e5dc97294db7368b52b9cf5f54a9c9eaefa21cc54470d040c" + ], [ [ "file", @@ -326,7 +333,7 @@ "file", "web-app/src/operations/resources.js" ], - "9bccf509e1a3e04e24444eacd69e0f093e590a9b85325bedb6f1d52ac85ead3c" + "1644218db3ca4f811e52271b5f65d3b274c160517af116d6c92acc7b273d73ec" ], [ [ diff --git a/waspc/e2e-test/test-outputs/waspJob-golden/waspJob/.wasp/out/web-app/src/actions/index.js b/waspc/e2e-test/test-outputs/waspJob-golden/waspJob/.wasp/out/web-app/src/actions/index.js new file mode 100644 index 000000000..52a4013e7 --- /dev/null +++ b/waspc/e2e-test/test-outputs/waspJob-golden/waspJob/.wasp/out/web-app/src/actions/index.js @@ -0,0 +1,234 @@ +import { + useMutation, + useQueryClient, +} from 'react-query' + +export { configureQueryClient } from '../queryClient' + +/** + * An options object passed into the `useAction` hook and used to enhance the + * action with extra options. + * + * @typedef {Object} ActionOptions + * @property {PublicOptimisticUpdateDefinition[]} optimisticUpdates + */ + +/** + * A documented (public) way to define optimistic updates. + * + * @typedef {Object} PublicOptimisticUpdateDefinition + * @property {GetQuerySpecifier} querySpecifier + * @property {UpdateQuery} updateQuery + */ + +/** + * A function that takes an item and returns a Wasp Query specifier. + * + * @callback GetQuerySpecifier + * @param {T} item + * @returns {QuerySpecifier} + */ + +/** + * A function that takes an item and the previous state of the cache, and returns + * the desired (new) state of the cache. + * + * @callback UpdateQuery + * @param {T} item + * @param {T[]} oldData + * @returns {T[]} + */ + +/** + * A public query specifier used for addressing Wasp queries. See our docs for details: + * https://wasp-lang.dev/docs/language/features#the-useaction-hook. + * + * @typedef {any[]} QuerySpecifier + */ + +/** + * An internal (undocumented, private, desugared) way of defining optimistic updates. + * + * @typedef {Object} InternalOptimisticUpdateDefinition + * @property {GetQuerySpecifier} querySpecifier + * @property {UpdateQuery} updateQuery + */ + +/** + * An UpdateQuery function "instantiated" with a specific item. It only takes + * the current state of the cache and returns the desired (new) state of the + * cache. + * + * @callback SpecificUpdateQuery + * @param {any[]} oldData + */ + +/** + * A specific, "instantiated" optimistic update definition which contains a + * fully-constructed query key and a specific update function. + * + * @typedef {Object} SpecificOptimisticUpdateDefinition + * @property {QueryKey} queryKey + * @property {SpecificUpdateQuery} updateQuery +*/ + +/** + * An array React Query uses to address queries. See their docs for details: + * https://react-query-v3.tanstack.com/guides/query-keys#array-keys. + * + * @typedef {any[]} QueryKey + */ + + +/** + * A hook for adding extra behavior to a Wasp Action (e.g., optimistic updates). + * + * @param actionFn The Wasp Action you wish to enhance/decorate. + * @param {ActionOptions} actionOptions An options object for enhancing/decorating the given Action. + * @returns A decorated Action with added behavior but an unchanged API. + */ +export function useAction(actionFn, actionOptions) { + const queryClient = useQueryClient(); + + let options = {} + if (actionOptions?.optimisticUpdates) { + const optimisticUpdateDefinitions = actionOptions.optimisticUpdates.map(translateToInternalDefinition) + options = makeRqOptimisticUpdateOptions(queryClient, optimisticUpdateDefinitions) + } + + // NOTE: We decided to hide React Query's extra mutation features (e.g., + // isLoading, onSuccess and onError callbacks, synchronous mutate) and only + // expose a simple async function whose API matches the original Action. + // We did this to avoid cluttering the API with stuff we're not sure we need + // yet (e.g., isLoading), to postpone the action vs mutation dilemma, and to + // clearly separate our opinionated API from React Query's lower-level + // advanced API (which users can also use) + const mutation = useMutation(actionFn, options) + return (args) => mutation.mutateAsync(args) +} + +/** + * Translates/Desugars a public optimistic update definition object into a definition object our + * system uses internally. + * + * @param {PublicOptimisticUpdateDefinition} publicOptimisticUpdateDefinition An optimistic update definition + * object that's a part of the public API: https://wasp-lang.dev/docs/language/features#the-useaction-hook. + * @returns {InternalOptimisticUpdateDefinition} An internally-used optimistic update definition object. + */ +function translateToInternalDefinition(publicOptimisticUpdateDefinition) { + const { getQuerySpecifier, updateQuery } = publicOptimisticUpdateDefinition + + const definitionErrors = [] + if (typeof getQuerySpecifier !== 'function') { + definitionErrors.push('`getQuerySpecifier` is not a function.') + } + if (typeof updateQuery !== 'function') { + definitionErrors.push('`updateQuery` is not a function.') + } + if (definitionErrors.length) { + throw new TypeError(`Invalid optimistic update definition: ${definitionErrors.join(', ')}.`) + } + + return { + getQueryKey: (item) => getRqQueryKeyFromSpecifier(getQuerySpecifier(item)), + updateQuery, + } +} + +/** + * Given a ReactQuery query client and our internal definition of optimistic + * updates, this function constructs an object describing those same optimistic + * updates in a format we can pass into React Query's useMutation hook. In other + * words, it translates our optimistic updates definition into React Query's + * optimistic updates definition. Check their docs for details: + * https://tanstack.com/query/v4/docs/guides/optimistic-updates?from=reactQueryV3&original=https://react-query-v3.tanstack.com/guides/optimistic-updates + * + * @param {Object} queryClient The QueryClient instance used by React Query. + * @param {InternalOptimisticUpdateDefinition} optimisticUpdateDefinitions A list containing internal optimistic updates definition objects + * (i.e., a list where each object carries the instructions for performing particular optimistic update). + * @returns {Object} An object containing 'onMutate' and 'onError' functions corresponding to the given optimistic update + * definitions (check the docs linked above for details). + */ +function makeRqOptimisticUpdateOptions(queryClient, optimisticUpdateDefinitions) { + async function onMutate(item) { + const specificOptimisticUpdateDefinitions = optimisticUpdateDefinitions.map( + generalDefinition => getOptimisticUpdateDefinitionForSpecificItem(generalDefinition, item) + ) + + // Cancel any outgoing refetches (so they don't overwrite our optimistic update). + // Theoretically, we can be a bit faster. Instead of awaiting the + // cancellation of all queries, we could cancel and update them in parallel. + // However, awaiting cancellation hasn't yet proven to be a performance bottleneck. + await Promise.all(specificOptimisticUpdateDefinitions.map( + ({ queryKey }) => queryClient.cancelQueries(queryKey) + )) + + // We're using a Map to correctly serialize query keys that contain objects. + const previousData = new Map() + specificOptimisticUpdateDefinitions.forEach(({ queryKey, updateQuery }) => { + // Snapshot the currently cached value. + const previousDataForQuery = queryClient.getQueryData(queryKey) + + // Attempt to optimistically update the cache using the new value. + try { + queryClient.setQueryData(queryKey, updateQuery) + } catch (e) { + console.error("The `updateQuery` function threw an exception, skipping optimistic update:") + console.error(e) + } + + // Remember the snapshotted value to restore in case of an error. + previousData.set(queryKey, previousDataForQuery) + }) + + return { previousData } + } + + function onError(_err, _item, context) { + // All we do in case of an error is roll back all optimistic updates. We ensure + // not to do anything else because React Query rethrows the error. This allows + // the programmer to handle the error as they usually would (i.e., we want the + // error handling to work as it would if the programmer wasn't using optimistic + // updates). + context.previousData.forEach(async (data, queryKey) => { + await queryClient.cancelQueries(queryKey) + queryClient.setQueryData(queryKey, data) + }) + } + + return { + onMutate, + onError, + } +} + +/** + * Constructs the definition for optimistically updating a specific item. It + * uses a closure over the updated item to construct an item-specific query key + * (e.g., useful when the query key depends on an ID). + * + * @param {InternalOptimisticUpdateDefinition} optimisticUpdateDefinition The general, "uninstantiated" optimistic + * update definition with a function for constructing the query key. + * @param item The item triggering the Action/optimistic update (i.e., the argument passed to the Action). + * @returns {SpecificOptimisticUpdateDefinition} A specific optimistic update definition + * which corresponds to the provided definition and closes over the provided item. + */ +function getOptimisticUpdateDefinitionForSpecificItem(optimisticUpdateDefinition, item) { + const { getQueryKey, updateQuery } = optimisticUpdateDefinition + return { + queryKey: getQueryKey(item), + updateQuery: (old) => updateQuery(item, old) + } +} + +/** + * Translates a Wasp query specifier to a query cache key used by React Query. + * + * @param {QuerySpecifier} querySpecifier A query specifier that's a part of the public API: + * https://wasp-lang.dev/docs/language/features#the-useaction-hook. + * @returns {QueryKey} A cache key React Query internally uses for addressing queries. + */ +function getRqQueryKeyFromSpecifier(querySpecifier) { + const [queryFn, ...otherKeys] = querySpecifier + return [queryFn.queryCacheKey, ...otherKeys] +} diff --git a/waspc/e2e-test/test-outputs/waspJob-golden/waspJob/.wasp/out/web-app/src/operations/resources.js b/waspc/e2e-test/test-outputs/waspJob-golden/waspJob/.wasp/out/web-app/src/operations/resources.js index cabb9265b..bee212ccb 100644 --- a/waspc/e2e-test/test-outputs/waspJob-golden/waspJob/.wasp/out/web-app/src/operations/resources.js +++ b/waspc/e2e-test/test-outputs/waspJob-golden/waspJob/.wasp/out/web-app/src/operations/resources.js @@ -1,6 +1,5 @@ import { queryClientInitialized } from '../queryClient' - // Map where key is resource name and value is Set // containing query ids of all the queries that use // that resource. @@ -23,13 +22,6 @@ export function addResourcesUsedByQuery(queryCacheKey, resources) { } } -/** - * @param {string} resource - Resource name. - * @returns {string[]} Array of "query cache keys" of queries that use specified resource. - */ -export function getQueriesUsingResource(resource) { - return Array.from(resourceToQueryCacheKeys.get(resource) || []) -} /** * Invalidates all queries that are using specified resources. * @param {string[]} resources - Names of resources. @@ -37,9 +29,9 @@ export function getQueriesUsingResource(resource) { export async function invalidateQueriesUsing(resources) { const queryClient = await queryClientInitialized - const queryCacheKeysToInvalidate = new Set(resources.flatMap(getQueriesUsingResource)) - queryCacheKeysToInvalidate.forEach(queryCacheKey => - queryClient.invalidateQueries(queryCacheKey) + const queryCacheKeysToInvalidate = getQueriesUsingResources(resources) + queryCacheKeysToInvalidate.forEach( + queryCacheKey => queryClient.invalidateQueries(queryCacheKey) ) } @@ -58,3 +50,15 @@ export async function invalidateAndRemoveQueries() { // remains in the cache, casuing a potential privacy issue. queryClient.removeQueries() } + +/** + * @param {string} resource - Resource name. + * @returns {string[]} Array of "query cache keys" of queries that use specified resource. + */ +function getQueriesUsingResource(resource) { + return Array.from(resourceToQueryCacheKeys.get(resource) || []) +} + +function getQueriesUsingResources(resources) { + return Array.from(new Set(resources.flatMap(getQueriesUsingResource))) +} diff --git a/waspc/e2e-test/test-outputs/waspMigrate-golden/files.manifest b/waspc/e2e-test/test-outputs/waspMigrate-golden/files.manifest index 5e12dd881..6f7622cab 100644 --- a/waspc/e2e-test/test-outputs/waspMigrate-golden/files.manifest +++ b/waspc/e2e-test/test-outputs/waspMigrate-golden/files.manifest @@ -37,6 +37,7 @@ waspMigrate/.wasp/out/web-app/public/favicon.ico waspMigrate/.wasp/out/web-app/public/index.html waspMigrate/.wasp/out/web-app/public/manifest.json waspMigrate/.wasp/out/web-app/src/actions/core.js +waspMigrate/.wasp/out/web-app/src/actions/index.js waspMigrate/.wasp/out/web-app/src/api.js waspMigrate/.wasp/out/web-app/src/config.js waspMigrate/.wasp/out/web-app/src/ext-src/Main.css diff --git a/waspc/e2e-test/test-outputs/waspMigrate-golden/waspMigrate/.wasp/out/.waspchecksums b/waspc/e2e-test/test-outputs/waspMigrate-golden/waspMigrate/.wasp/out/.waspchecksums index 13575af8a..385b6a4c1 100644 --- a/waspc/e2e-test/test-outputs/waspMigrate-golden/waspMigrate/.wasp/out/.waspchecksums +++ b/waspc/e2e-test/test-outputs/waspMigrate-golden/waspMigrate/.wasp/out/.waspchecksums @@ -237,6 +237,13 @@ ], "0891b7607b5503ada42386f73476a46f774a224d8b606f79ef37e451b229d399" ], + [ + [ + "file", + "web-app/src/actions/index.js" + ], + "da1a32d134ba931e5dc97294db7368b52b9cf5f54a9c9eaefa21cc54470d040c" + ], [ [ "file", @@ -305,7 +312,7 @@ "file", "web-app/src/operations/resources.js" ], - "9bccf509e1a3e04e24444eacd69e0f093e590a9b85325bedb6f1d52ac85ead3c" + "1644218db3ca4f811e52271b5f65d3b274c160517af116d6c92acc7b273d73ec" ], [ [ diff --git a/waspc/e2e-test/test-outputs/waspMigrate-golden/waspMigrate/.wasp/out/web-app/src/actions/index.js b/waspc/e2e-test/test-outputs/waspMigrate-golden/waspMigrate/.wasp/out/web-app/src/actions/index.js new file mode 100644 index 000000000..52a4013e7 --- /dev/null +++ b/waspc/e2e-test/test-outputs/waspMigrate-golden/waspMigrate/.wasp/out/web-app/src/actions/index.js @@ -0,0 +1,234 @@ +import { + useMutation, + useQueryClient, +} from 'react-query' + +export { configureQueryClient } from '../queryClient' + +/** + * An options object passed into the `useAction` hook and used to enhance the + * action with extra options. + * + * @typedef {Object} ActionOptions + * @property {PublicOptimisticUpdateDefinition[]} optimisticUpdates + */ + +/** + * A documented (public) way to define optimistic updates. + * + * @typedef {Object} PublicOptimisticUpdateDefinition + * @property {GetQuerySpecifier} querySpecifier + * @property {UpdateQuery} updateQuery + */ + +/** + * A function that takes an item and returns a Wasp Query specifier. + * + * @callback GetQuerySpecifier + * @param {T} item + * @returns {QuerySpecifier} + */ + +/** + * A function that takes an item and the previous state of the cache, and returns + * the desired (new) state of the cache. + * + * @callback UpdateQuery + * @param {T} item + * @param {T[]} oldData + * @returns {T[]} + */ + +/** + * A public query specifier used for addressing Wasp queries. See our docs for details: + * https://wasp-lang.dev/docs/language/features#the-useaction-hook. + * + * @typedef {any[]} QuerySpecifier + */ + +/** + * An internal (undocumented, private, desugared) way of defining optimistic updates. + * + * @typedef {Object} InternalOptimisticUpdateDefinition + * @property {GetQuerySpecifier} querySpecifier + * @property {UpdateQuery} updateQuery + */ + +/** + * An UpdateQuery function "instantiated" with a specific item. It only takes + * the current state of the cache and returns the desired (new) state of the + * cache. + * + * @callback SpecificUpdateQuery + * @param {any[]} oldData + */ + +/** + * A specific, "instantiated" optimistic update definition which contains a + * fully-constructed query key and a specific update function. + * + * @typedef {Object} SpecificOptimisticUpdateDefinition + * @property {QueryKey} queryKey + * @property {SpecificUpdateQuery} updateQuery +*/ + +/** + * An array React Query uses to address queries. See their docs for details: + * https://react-query-v3.tanstack.com/guides/query-keys#array-keys. + * + * @typedef {any[]} QueryKey + */ + + +/** + * A hook for adding extra behavior to a Wasp Action (e.g., optimistic updates). + * + * @param actionFn The Wasp Action you wish to enhance/decorate. + * @param {ActionOptions} actionOptions An options object for enhancing/decorating the given Action. + * @returns A decorated Action with added behavior but an unchanged API. + */ +export function useAction(actionFn, actionOptions) { + const queryClient = useQueryClient(); + + let options = {} + if (actionOptions?.optimisticUpdates) { + const optimisticUpdateDefinitions = actionOptions.optimisticUpdates.map(translateToInternalDefinition) + options = makeRqOptimisticUpdateOptions(queryClient, optimisticUpdateDefinitions) + } + + // NOTE: We decided to hide React Query's extra mutation features (e.g., + // isLoading, onSuccess and onError callbacks, synchronous mutate) and only + // expose a simple async function whose API matches the original Action. + // We did this to avoid cluttering the API with stuff we're not sure we need + // yet (e.g., isLoading), to postpone the action vs mutation dilemma, and to + // clearly separate our opinionated API from React Query's lower-level + // advanced API (which users can also use) + const mutation = useMutation(actionFn, options) + return (args) => mutation.mutateAsync(args) +} + +/** + * Translates/Desugars a public optimistic update definition object into a definition object our + * system uses internally. + * + * @param {PublicOptimisticUpdateDefinition} publicOptimisticUpdateDefinition An optimistic update definition + * object that's a part of the public API: https://wasp-lang.dev/docs/language/features#the-useaction-hook. + * @returns {InternalOptimisticUpdateDefinition} An internally-used optimistic update definition object. + */ +function translateToInternalDefinition(publicOptimisticUpdateDefinition) { + const { getQuerySpecifier, updateQuery } = publicOptimisticUpdateDefinition + + const definitionErrors = [] + if (typeof getQuerySpecifier !== 'function') { + definitionErrors.push('`getQuerySpecifier` is not a function.') + } + if (typeof updateQuery !== 'function') { + definitionErrors.push('`updateQuery` is not a function.') + } + if (definitionErrors.length) { + throw new TypeError(`Invalid optimistic update definition: ${definitionErrors.join(', ')}.`) + } + + return { + getQueryKey: (item) => getRqQueryKeyFromSpecifier(getQuerySpecifier(item)), + updateQuery, + } +} + +/** + * Given a ReactQuery query client and our internal definition of optimistic + * updates, this function constructs an object describing those same optimistic + * updates in a format we can pass into React Query's useMutation hook. In other + * words, it translates our optimistic updates definition into React Query's + * optimistic updates definition. Check their docs for details: + * https://tanstack.com/query/v4/docs/guides/optimistic-updates?from=reactQueryV3&original=https://react-query-v3.tanstack.com/guides/optimistic-updates + * + * @param {Object} queryClient The QueryClient instance used by React Query. + * @param {InternalOptimisticUpdateDefinition} optimisticUpdateDefinitions A list containing internal optimistic updates definition objects + * (i.e., a list where each object carries the instructions for performing particular optimistic update). + * @returns {Object} An object containing 'onMutate' and 'onError' functions corresponding to the given optimistic update + * definitions (check the docs linked above for details). + */ +function makeRqOptimisticUpdateOptions(queryClient, optimisticUpdateDefinitions) { + async function onMutate(item) { + const specificOptimisticUpdateDefinitions = optimisticUpdateDefinitions.map( + generalDefinition => getOptimisticUpdateDefinitionForSpecificItem(generalDefinition, item) + ) + + // Cancel any outgoing refetches (so they don't overwrite our optimistic update). + // Theoretically, we can be a bit faster. Instead of awaiting the + // cancellation of all queries, we could cancel and update them in parallel. + // However, awaiting cancellation hasn't yet proven to be a performance bottleneck. + await Promise.all(specificOptimisticUpdateDefinitions.map( + ({ queryKey }) => queryClient.cancelQueries(queryKey) + )) + + // We're using a Map to correctly serialize query keys that contain objects. + const previousData = new Map() + specificOptimisticUpdateDefinitions.forEach(({ queryKey, updateQuery }) => { + // Snapshot the currently cached value. + const previousDataForQuery = queryClient.getQueryData(queryKey) + + // Attempt to optimistically update the cache using the new value. + try { + queryClient.setQueryData(queryKey, updateQuery) + } catch (e) { + console.error("The `updateQuery` function threw an exception, skipping optimistic update:") + console.error(e) + } + + // Remember the snapshotted value to restore in case of an error. + previousData.set(queryKey, previousDataForQuery) + }) + + return { previousData } + } + + function onError(_err, _item, context) { + // All we do in case of an error is roll back all optimistic updates. We ensure + // not to do anything else because React Query rethrows the error. This allows + // the programmer to handle the error as they usually would (i.e., we want the + // error handling to work as it would if the programmer wasn't using optimistic + // updates). + context.previousData.forEach(async (data, queryKey) => { + await queryClient.cancelQueries(queryKey) + queryClient.setQueryData(queryKey, data) + }) + } + + return { + onMutate, + onError, + } +} + +/** + * Constructs the definition for optimistically updating a specific item. It + * uses a closure over the updated item to construct an item-specific query key + * (e.g., useful when the query key depends on an ID). + * + * @param {InternalOptimisticUpdateDefinition} optimisticUpdateDefinition The general, "uninstantiated" optimistic + * update definition with a function for constructing the query key. + * @param item The item triggering the Action/optimistic update (i.e., the argument passed to the Action). + * @returns {SpecificOptimisticUpdateDefinition} A specific optimistic update definition + * which corresponds to the provided definition and closes over the provided item. + */ +function getOptimisticUpdateDefinitionForSpecificItem(optimisticUpdateDefinition, item) { + const { getQueryKey, updateQuery } = optimisticUpdateDefinition + return { + queryKey: getQueryKey(item), + updateQuery: (old) => updateQuery(item, old) + } +} + +/** + * Translates a Wasp query specifier to a query cache key used by React Query. + * + * @param {QuerySpecifier} querySpecifier A query specifier that's a part of the public API: + * https://wasp-lang.dev/docs/language/features#the-useaction-hook. + * @returns {QueryKey} A cache key React Query internally uses for addressing queries. + */ +function getRqQueryKeyFromSpecifier(querySpecifier) { + const [queryFn, ...otherKeys] = querySpecifier + return [queryFn.queryCacheKey, ...otherKeys] +} diff --git a/waspc/e2e-test/test-outputs/waspMigrate-golden/waspMigrate/.wasp/out/web-app/src/operations/resources.js b/waspc/e2e-test/test-outputs/waspMigrate-golden/waspMigrate/.wasp/out/web-app/src/operations/resources.js index cabb9265b..bee212ccb 100644 --- a/waspc/e2e-test/test-outputs/waspMigrate-golden/waspMigrate/.wasp/out/web-app/src/operations/resources.js +++ b/waspc/e2e-test/test-outputs/waspMigrate-golden/waspMigrate/.wasp/out/web-app/src/operations/resources.js @@ -1,6 +1,5 @@ import { queryClientInitialized } from '../queryClient' - // Map where key is resource name and value is Set // containing query ids of all the queries that use // that resource. @@ -23,13 +22,6 @@ export function addResourcesUsedByQuery(queryCacheKey, resources) { } } -/** - * @param {string} resource - Resource name. - * @returns {string[]} Array of "query cache keys" of queries that use specified resource. - */ -export function getQueriesUsingResource(resource) { - return Array.from(resourceToQueryCacheKeys.get(resource) || []) -} /** * Invalidates all queries that are using specified resources. * @param {string[]} resources - Names of resources. @@ -37,9 +29,9 @@ export function getQueriesUsingResource(resource) { export async function invalidateQueriesUsing(resources) { const queryClient = await queryClientInitialized - const queryCacheKeysToInvalidate = new Set(resources.flatMap(getQueriesUsingResource)) - queryCacheKeysToInvalidate.forEach(queryCacheKey => - queryClient.invalidateQueries(queryCacheKey) + const queryCacheKeysToInvalidate = getQueriesUsingResources(resources) + queryCacheKeysToInvalidate.forEach( + queryCacheKey => queryClient.invalidateQueries(queryCacheKey) ) } @@ -58,3 +50,15 @@ export async function invalidateAndRemoveQueries() { // remains in the cache, casuing a potential privacy issue. queryClient.removeQueries() } + +/** + * @param {string} resource - Resource name. + * @returns {string[]} Array of "query cache keys" of queries that use specified resource. + */ +function getQueriesUsingResource(resource) { + return Array.from(resourceToQueryCacheKeys.get(resource) || []) +} + +function getQueriesUsingResources(resources) { + return Array.from(new Set(resources.flatMap(getQueriesUsingResource))) +} diff --git a/waspc/examples/todoApp/ext/Todo.js b/waspc/examples/todoApp/ext/Todo.js index efbbaadbe..363bff764 100644 --- a/waspc/examples/todoApp/ext/Todo.js +++ b/waspc/examples/todoApp/ext/Todo.js @@ -11,6 +11,7 @@ import TableRow from '@material-ui/core/TableRow' import Checkbox from '@material-ui/core/Checkbox' import { useQuery } from '@wasp/queries' +import { useAction } from '@wasp/actions' import getTasks from '@wasp/queries/getTasks.js' import createTask from '@wasp/actions/createTask.js' import updateTaskIsDone from '@wasp/actions/updateTaskIsDone.js' @@ -59,7 +60,6 @@ const Footer = (props) => { await deleteCompletedTasks() } catch (err) { console.log(err) - window.alert('Error:' + err.message) } } @@ -98,15 +98,27 @@ const Tasks = (props) => { } const Task = (props) => { + const updateTaskIsDoneOptimistically = useAction(updateTaskIsDone, { + optimisticUpdates: [{ + getQuerySpecifier: () => [getTasks], + updateQuery: (updatedTask, oldTasks) => { + if (oldTasks === undefined) { + // cache is empty + return [updatedTask]; + } else { + return oldTasks.map(task => task.id == updatedTask.id ? { ...task, ...updatedTask } : task) + } + } + }] + }); const handleTaskIsDoneChange = async (event) => { const id = parseInt(event.target.id) const isDone = event.target.checked try { - await updateTaskIsDone({ id, isDone }) + await updateTaskIsDoneOptimistically({ id, isDone }) } catch (err) { console.log(err) - window.alert('Error:' + err.message) } } @@ -143,7 +155,6 @@ const NewTaskForm = (props) => { setDescription(defaultDescription) } catch (err) { console.log(err) - window.alert('Error:' + err.message) } } @@ -170,7 +181,6 @@ const ToggleAllTasksButton = (props) => { await toggleAllTasks() } catch (err) { console.log(err) - window.alert('Error:' + err.message) } } diff --git a/waspc/examples/todoApp/ext/actions.js b/waspc/examples/todoApp/ext/actions.js index ca143738e..1150ccfca 100644 --- a/waspc/examples/todoApp/ext/actions.js +++ b/waspc/examples/todoApp/ext/actions.js @@ -1,6 +1,7 @@ import HttpError from '@wasp/core/HttpError.js' import { getSomeResource } from './serverSetup.js' + export const createTask = async (task, context) => { if (!context.user) { throw new HttpError(401) @@ -25,6 +26,10 @@ export const updateTaskIsDone = async ({ id, isDone }, context) => { throw new HttpError(401) } + // Uncomment to test optimistic updates + // const sleep = (ms) => new Promise(res => setTimeout(res, ms)) + // await sleep(3000); + const Task = context.entities.Task return Task.updateMany({ where: { id, user: { id: context.user.id } }, diff --git a/waspc/examples/todoApp/ext/pages/Task.js b/waspc/examples/todoApp/ext/pages/Task.js index 78d42becd..e103a6c4f 100644 --- a/waspc/examples/todoApp/ext/pages/Task.js +++ b/waspc/examples/todoApp/ext/pages/Task.js @@ -2,21 +2,45 @@ import React from 'react' import { Link } from 'react-router-dom' import { useQuery } from '@wasp/queries' +import { useAction } from '@wasp/actions' import updateTaskIsDone from '@wasp/actions/updateTaskIsDone' import getTask from '@wasp/queries/getTask.js' +import getTasks from '@wasp/queries/getTasks.js' const Todo = (props) => { const taskId = parseInt(props.match.params.id) const { data: task, isFetching, error } = useQuery(getTask, { id: taskId }) + const updateTaskIsDoneOptimistically = useAction(updateTaskIsDone, { + optimisticUpdates: [ + { + getQuerySpecifier: () => [getTask, { id: taskId }], + // This query's cache should should never be emtpy + updateQuery: ({ isDone }, oldTask) => ({ ...oldTask, isDone }), + }, + { + getQuerySpecifier: () => [getTasks], + updateQuery: (updatedTask, oldTasks) => { + if (oldTasks === undefined) { + // cache is empty + return [updatedTask] + } else { + return oldTasks.map(task => + task.id === updatedTask.id ? { ...task, ...updatedTask } : task + ) + } + }, + } + ] + }) + if (!task) return
All tasks: { JSON.stringify(allTasks || error1) }
-Finished tasks: { JSON.stringify(doneTasks || error2) }
+Description: { description }
+Is done: { isDone ? 'Yes' : 'No' }
+Description: {description}
+Is done: {isDone ? 'Yes' : 'No'}
+ +Description: {description}
+Is done: {isDone ? 'Yes' : 'No'}
+ +