Implement optimistic updates

* Remove flickering fixes for optimistic updates

* Add optimistic updates to Task.js

* First round of code review feedback

* Remove react query's additional options from hook

* Futher review feedback and code docs

* Fix and document error handling for optimistic updates

* Further document optimistic update mechanisms

* Update optimistic update docs

* Improve comment for useAction

* Further improve comments in actions/index

* Heavily comment actions/index

* Fix capitalization in action/index

* Add types to actions/index

* Fix jsdoc types in actions/index

* Comment testing sleeps

* Comment sleep function in todoApp/ext/actions

* Properly handle type errors in actions/index

* Fix formatting in actions/index

* Move updateFn change to optimistic update instantiation

* Extract common functionality in actions/index

* Fix formatting in actions/index

* Add jsdoc types to actions/index

* Revise jsdoc in actions/index

* Address PR comments

* Update e2e tests

* Mention optimistic updates in cache invalidation
This commit is contained in:
Filip Sodić 2022-09-21 17:40:06 +02:00 committed by GitHub
parent b965dfeb30
commit 4a94c9851e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
24 changed files with 1459 additions and 80 deletions

View File

@ -5,4 +5,3 @@ export default createAction(
'{= actionRoute =}',
{=& entitiesArray =},
)

View File

@ -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]
}

View File

@ -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)))
}

View File

@ -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

View File

@ -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"
],
[
[

View File

@ -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]
}

View File

@ -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)))
}

View File

@ -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

View File

@ -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"
],
[
[

View File

@ -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]
}

View File

@ -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)))
}

View File

@ -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

View File

@ -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"
],
[
[

View File

@ -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]
}

View File

@ -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)))
}

View File

@ -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

View File

@ -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"
],
[
[

View File

@ -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]
}

View File

@ -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)))
}

View File

@ -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)
}
}

View File

@ -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 } },

View File

@ -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 <div>Task with id {taskId} does not exist.</div>
if (error) return <div>Error occurred! {error}</div>
async function toggleIsDone() {
try {
updateTaskIsDone({ id: task.id, isDone: !task.isDone })
updateTaskIsDoneOptimistically({ id: task.id, isDone: !task.isDone })
} catch (err) {
window.alert("Error: " + err.message)
console.log(err)
}
}

View File

@ -29,8 +29,8 @@ genOperations :: AppSpec -> Generator [FileDraft]
genOperations spec =
genQueries spec
<++> genActions spec
<++> return [C.mkTmplFd $ C.asTmplFile [relfile|src/operations/index.js|]]
<++> Resources.genResources spec
<++> return [C.mkSrcTmplFd [relfile|operations/index.js|]]
genQueries :: AppSpec -> Generator [FileDraft]
genQueries spec =
@ -43,7 +43,10 @@ genQueries spec =
genActions :: AppSpec -> Generator [FileDraft]
genActions spec =
mapM (genAction spec) (AS.getActions spec)
<++> return [C.mkSrcTmplFd [relfile|actions/core.js|]]
<++> return
[ C.mkSrcTmplFd [relfile|actions/index.js|],
C.mkSrcTmplFd [relfile|actions/core.js|]
]
genQuery :: AppSpec -> (String, AS.Query.Query) -> Generator FileDraft
genQuery _ (queryName, query) = return $ C.mkTmplFdWithDstAndData tmplFile dstFile (Just tmplData)

View File

@ -239,7 +239,7 @@ We'll leave this option aside for now. You can read more about it [here](#using-
Wasp Queries and their implementations don't need to (but can) have the same name, so we will keep the names different to avoid confusion.
With that in mind, this is how you might declare the Queries that use the implementations from the previous step:
```c title="main.wasp"
```c title="pages/main.wasp"
// ...
// Again, it most likely makes sense to name the Wasp Query after
@ -301,8 +301,6 @@ import { useQuery } from '@wasp/queries'
import fetchAllTasks from '@wasp/queries/fetchAllTasks'
import fetchFilteredTasks from '@wasp/queries/fetchFilteredTasks'
const MainPage = () => {
const {
data: allTasks,
@ -316,12 +314,25 @@ const MainPage = () => {
return (
<div>
<p>All tasks: { JSON.stringify(allTasks || error1) }</p>
<p>Finished tasks: { JSON.stringify(doneTasks || error2) }</p>
<h2>All Tasks</h2>
{allTasks ? allTasks.map(task => <Task key={task.id} {...task}/>) : error1}
<h2>Finished Tasks</h2>
{doneTasks ? doneTasks.map(task => <Task key={task.id} {...task}/>) : error2}
</div>
)
}
const Task = ({ description, isDone }) => {
return (
<div>
<p><strong>Description: </strong>{ description }</p>
<p><strong>Is done: </strong>{ isDone ? 'Yes' : 'No' }</p>
</div>
)
}
export default MainPage
```
@ -363,8 +374,6 @@ query fetchFilteredTasks {
Wasp will inject the specified Entity into the Query's `context` argument, giving you access to the Entity's Prisma API:
```js title="ext/queries.js"
// ...
export const getAllTasks = async (args, context) => {
return context.entities.Task.findMany({})
}
@ -383,7 +392,8 @@ The object `context.entities.Task` exposes `prisma.task` from [Prisma's CRUD API
Actions are very similar to Queries. So similar, in fact, we will only list the differences:
1. They can (and most often should) modify the server's state, while Queries are only allowed to read it.
2. Since Actions don't need to be reactive, Wasp doesn't provide a React hook for them (like `useQuery` for Queries) - you just call them directly.
2. Actions don't need to be reactive so you can call them directly. Still, Wasp does provide a `useAction` React hook for adding extra behavior to the Action (e.g., optimistic updates).
Read more about the [`useAction` hook](#the-useaction-hook) below.
3. `action` declarations in Wasp are mostly identical to `query` declarations. The only difference is in the declaration's name.
Here's an implementation of a simple Action:
@ -412,8 +422,113 @@ import sayHi from '@wasp/actions/sayHi'
sayHi()
```
Here's an example on how you might define a less contrived Action.
```js title=actions.js
// ...
export const updateTaskIsDone = ({ id, isDone }, context) => {
return context.entities.Task.update({
where: { id },
data: { isDone }
})
}
```
```c title=main.wasp
action updateTaskIsDone {
fn: import { updateTaskIsDone } from "@ext/actions.js",
entities: [Task]
}
```
More differences and Action/Query specific features will come in future versions of Wasp.
And here is how you might use it:
```js {4,18} title=pages/Task.js
import React from 'react'
import { useQuery } from '@wasp/queries'
import fetchTask from '@wasp/queries/fetchTask'
import updateTaskIsDone from '@wasp/actions/updateTaskIsDone'
const TaskPage = ({ id }) => {
const { data: task } = useQuery(fetchTask, { id })
if (!task) {
return <h1>"Loading"</h1>
}
const { description, isDone } = task
return (
<div>
<p><strong>Description: </strong>{description}</p>
<p><strong>Is done: </strong>{isDone ? 'Yes' : 'No'}</p>
<button onClick={() => updateTaskIsDone({ id, isDone: !isDone })}>
Mark as {task.isDone ? 'undone' : 'done'}
</button>
</div>
)
}
```
#### The `useAction` hook
When using Actions in components, you can enhance them with the help of the `useAction` hook. This hook comes bundled with Wasp and decorates Wasp Actions.
In other words, the hook returns a function whose API matches the original Action while also doing something extra under the hood (depending on how you configure it).
The `useAction` hook accepts two arguments:
- `actionFn` (required) - The Wasp Action (i.e., the client-side query function generated by Wasp based on a query declaration) you wish to enhance.
- `actionOptions` (optional) - An object configuring the extra features you want to add to the given Action. While this argument is technically optional, there is no point in using the `useAction` hook without providing it (it would be the same as using the Action directly). The Action options object supports the following fields:
- `optimisticUpdates` (optional) - An array of objects where each object defines an [optimistic update](https://stackoverflow.com/a/33009713) to perform on the query cache. To define an optimistic update, you must specify the following properties:
- `getQuerySpecifier` (required) - A function returning the query specifier (i.e., a value used to address the query you want to update). A query specifier is an array specifying the query function and arguments. For example, to optimistically update the query used with `useQuery(fetchFilteredTasks, {isDone: true }]`, your `getQuerySpecifier` function would have to return the array `[fetchFilteredTasks, { isDone: true}]`. Wasp will forward the argument you pass into the decorated Action to this function (i.e., you can use the properties of the added/change item to address the query).
- `updateQuery` (required) - The function used to perform the optimistic update. Wasp will call it with the following arguments:
- `item` - The argument you pass into the decorated Action.
- `oldData` - The currently cached value for the query identified by the specifier.
Here's an example showing how to configure the Action from the previous example to perform an optimistic update:
```jsx {3,9,10,11,12,13,14,15,16,27} title=pages/Task.js
import React from 'react'
import { useQuery } from '@wasp/queries'
import { useAction } from '@wasp/actions'
import fetchTask from '@wasp/queries/fetchTask'
import updateTaskIsDone from '@wasp/actions/updateTaskIsDone'
const TaskPage = ({ id }) => {
const { data: task } = useQuery(fetchTask, { id })
const updateTaskIsDoneOptimistically = useAction(updateTaskIsDone, {
optimisticUpdates: [
{
getQuerySpecifier: ({ id }) => [fetchTask, { id }],
updateQuery: ({ isDone }, oldData) => ({ ...oldData, isDone })
}
]
})
if (!task) {
return <h1>"Loading"</h1>
}
const { description, isDone } = task
return (
<div>
<p><strong>Description: </strong>{description}</p>
<p><strong>Is done: </strong>{isDone ? 'Yes' : 'No'}</p>
<button onClick={() => updateTaskIsDoneOptimistically({ id, isDone: !isDone })}>
Mark as {task.isDone ? 'undone' : 'done'}
</button>
<div>
<Link to="/">Back to main page</Link>
</div>
</div>
)
}
export default TaskPage
```
The `useAction` hook currently only supports specifying optimistic updates. You can expect more features in future versions of Wasp.
Wasp's optimistic update API is deliberately small and focuses exclusively on updating Query caches (as that's the most common use case). You might need an API that offers more options or a higher level of control. If that's the case, instead of using Wasp's `useAction` hook, you can use _react-query_'s `useMutation` hook and directly work with [their low-level API](https://tanstack.com/query/v4/docs/guides/optimistic-updates?from=reactQueryV3&original=https://react-query-v3.tanstack.com/guides/optimistic-updates).
If you decide to use _react-query_'s API directly, you will need access to the Query's cache key. Wasp internally uses this key but abstracts it from the programmer. Still, you can easily obtain it by accessing the `queryCacheKey` property on a Query:
```js
import { fetchTasks } from '@wasp/queries'
const queryKey = fetchTasks.queryCacheKey
```
### Cache Invalidation
One of the trickiest parts of managing a web app's state is making sure the data returned by the queries is up to date.
@ -430,6 +545,8 @@ In practice, this means that Wasp keeps the queries "fresh" without requiring yo
On the other hand, this kind of automatic cache invalidation can become wasteful (some updates might not be necessary) and will only work for Entities. If that's an issue, you can use the mechanisms provided by _react-query_ for now, and expect more direct support in Wasp for handling those use cases in a nice, elegant way.
If you wish to optimistically set cache values after perfomring an action, you can do so using [optimistic updates](https://stackoverflow.com/a/33009713). Configure them using Wasp's [useAction hook](#the-useaction-hook). This is currently the only manual cache invalidation mechanism Wasps supports natively. For everything else, you can always rely on _react-query_.
### Prisma Error Helpers
In your Operations, you may wish to handle general Prisma errors with HTTP-friendly responses. We have exposed two helper functions, `isPrismaError`, and `prismaErrorToHttpError`, for this purpose. As of now, we convert two specific Prisma errors (which we will continue to expand), with the rest being `500`. See the [source here](https://github.com/wasp-lang/wasp/blob/main/waspc/e2e-test/test-outputs/waspMigrate-golden/waspMigrate/.wasp/out/server/src/utils.js).
@ -861,7 +978,7 @@ import useAuth from '@wasp/auth/useAuth.js'
```
##### Example of usage:
```js title="ext/MainPage.js"
```js title="ext/pages/MainPage.js"
import React from 'react'
import { Link } from 'react-router-dom'