diff --git a/packages/ra-core/src/dataProvider/useCreate.spec.tsx b/packages/ra-core/src/dataProvider/useCreate.spec.tsx index 5dd522e0390..ae76ad97208 100644 --- a/packages/ra-core/src/dataProvider/useCreate.spec.tsx +++ b/packages/ra-core/src/dataProvider/useCreate.spec.tsx @@ -1,5 +1,5 @@ import * as React from 'react'; -import { render, waitFor, screen } from '@testing-library/react'; +import { render, waitFor, screen, fireEvent } from '@testing-library/react'; import expect from 'expect'; import { RaRecord } from '../types'; @@ -7,6 +7,7 @@ import { testDataProvider } from './testDataProvider'; import { useCreate } from './useCreate'; import { useGetList } from './useGetList'; import { CoreAdminContext } from '../core'; +import { Basic } from './useCreate.stories'; import { ErrorCase as ErrorCasePessimistic, SuccessCase as SuccessCasePessimistic, @@ -334,6 +335,23 @@ describe('useCreate', () => { expect(screen.queryByText('mutating')).toBeNull(); }); }); + it('allows to control the mutation mode', async () => { + jest.spyOn(console, 'error').mockImplementation(() => {}); + render(); + // Create a post in pessimistic mode + fireEvent.click(await screen.findByText('Create post')); + await screen.findByText('Hello World 2'); + fireEvent.click(await screen.findByText('undoable')); + fireEvent.click(await screen.findByText('Increment id')); + await screen.findByText('nothing yet'); + // Create a post in undoable mode + fireEvent.click(await screen.findByText('Create post')); + // Check the optimistic result + await screen.findByText('Hello World 3'); + // As we haven't confirmed the undoable mutation, refetching the post should return nothing + fireEvent.click(await screen.findByText('Refetch')); + await screen.findByText('nothing yet'); + }); }); describe('middlewares', () => { diff --git a/packages/ra-core/src/dataProvider/useCreate.stories.tsx b/packages/ra-core/src/dataProvider/useCreate.stories.tsx new file mode 100644 index 00000000000..ae61500ee74 --- /dev/null +++ b/packages/ra-core/src/dataProvider/useCreate.stories.tsx @@ -0,0 +1,123 @@ +import * as React from 'react'; +import { useState } from 'react'; +import { QueryClient, useIsMutating } from '@tanstack/react-query'; + +import { CoreAdminContext } from '../core'; +import { useCreate } from './useCreate'; +import { useGetOne } from './useGetOne'; +import { MutationMode } from '../types'; + +export default { title: 'ra-core/dataProvider/useCreate' }; + +export const Basic = ({ timeout = 1000 }: { timeout?: number }) => { + const posts = [{ id: 1, title: 'Hello', author: 'John Doe' }]; + const dataProvider = { + getOne: (resource, params) => { + return new Promise((resolve, reject) => { + const data = posts.find(p => p.id === params.id); + setTimeout(() => { + if (!data) { + reject(new Error('nothing yet')); + } + resolve({ data }); + }, timeout); + }); + }, + create: (resource, params) => { + return new Promise(resolve => { + setTimeout(() => { + posts.push(params.data); + resolve({ data: params.data }); + }, timeout); + }); + }, + } as any; + return ( + + + + ); +}; + +Basic.args = { + timeout: 1000, +}; + +Basic.argTypes = { + timeout: { + control: { + type: 'number', + }, + }, +}; + +const SuccessCore = () => { + const isMutating = useIsMutating(); + const [success, setSuccess] = useState(); + const [id, setId] = useState(2); + const [mutationMode, setMutationMode] = + useState('pessimistic'); + const { data, error, refetch } = useGetOne('posts', { id }); + const [create, { isPending }] = useCreate( + 'posts', + {}, + { + mutationMode, + onSuccess: () => { + setSuccess('success'); + }, + } + ); + const handleClick = () => { + create('posts', { + data: { id, title: `Hello World ${id}` }, + }); + }; + return ( + <> + {error ? ( +

{error.message}

+ ) : ( +
+
id
+
{data?.id}
+
title
+
{data?.title}
+
author
+
{data?.author}
+
+ )} +
+ +   + + + + + +
+ {success &&
{success}
} + {isMutating !== 0 &&
mutating
} + + ); +}; diff --git a/packages/ra-core/src/dataProvider/useCreate.ts b/packages/ra-core/src/dataProvider/useCreate.ts index 462e36e2ace..5b33b935d6c 100644 --- a/packages/ra-core/src/dataProvider/useCreate.ts +++ b/packages/ra-core/src/dataProvider/useCreate.ts @@ -1,4 +1,4 @@ -import { useMemo, useRef } from 'react'; +import { useEffect, useMemo, useRef } from 'react'; import { useMutation, UseMutationOptions, @@ -96,7 +96,11 @@ export const useCreate = < getMutateWithMiddlewares, ...mutationOptions } = options; + const mode = useRef(mutationMode); + useEffect(() => { + mode.current = mutationMode; + }, [mutationMode]); const paramsRef = useRef>>>(params); diff --git a/packages/ra-core/src/dataProvider/useDelete.spec.tsx b/packages/ra-core/src/dataProvider/useDelete.spec.tsx index e9a35997fa0..ab88f8d3242 100644 --- a/packages/ra-core/src/dataProvider/useDelete.spec.tsx +++ b/packages/ra-core/src/dataProvider/useDelete.spec.tsx @@ -1,5 +1,5 @@ import * as React from 'react'; -import { screen, render, waitFor } from '@testing-library/react'; +import { screen, render, waitFor, fireEvent } from '@testing-library/react'; import expect from 'expect'; import { CoreAdminContext } from '../core'; @@ -20,6 +20,7 @@ import { SuccessCase as SuccessCaseUndoable, } from './useDelete.undoable.stories'; import { QueryClient } from '@tanstack/react-query'; +import { Basic } from './useDelete.stories'; describe('useDelete', () => { it('returns a callback that can be used with deleteOne arguments', async () => { @@ -449,6 +450,31 @@ describe('useDelete', () => { { timeout: 4000 } ); }); + it('allows to control the mutation mode', async () => { + jest.spyOn(console, 'error').mockImplementation(() => {}); + render(); + // Delete the first post in pessimistic mode + await screen.findByText('Hello'); + await screen.findByText('World'); + fireEvent.click(await screen.findByText('Delete post')); + await screen.findByText('World'); + // Wait for the post to be deleted + await waitFor(() => { + expect(screen.queryByText('Hello')).toBeNull(); + }); + + fireEvent.click(await screen.findByText('undoable')); + fireEvent.click(await screen.findByText('Increment id')); + // Delete the second post in undoable mode + fireEvent.click(await screen.findByText('Delete post')); + // Check the optimistic result + await waitFor(() => { + expect(screen.queryByText('World')).toBeNull(); + }); + // As we haven't confirmed the undoable mutation, refetching the post should return nothing + fireEvent.click(await screen.findByText('Refetch')); + await screen.findByText('World'); + }); }); describe('query cache', () => { diff --git a/packages/ra-core/src/dataProvider/useDelete.stories.tsx b/packages/ra-core/src/dataProvider/useDelete.stories.tsx new file mode 100644 index 00000000000..b67bf763762 --- /dev/null +++ b/packages/ra-core/src/dataProvider/useDelete.stories.tsx @@ -0,0 +1,93 @@ +import * as React from 'react'; +import { useState } from 'react'; +import { QueryClient, useIsMutating } from '@tanstack/react-query'; + +import { CoreAdminContext } from '../core'; +import { useDelete } from './useDelete'; +import { useGetList } from './useGetList'; +import { MutationMode } from '../types'; + +export default { title: 'ra-core/dataProvider/useDelete' }; + +export const Basic = ({ timeout = 1000 }: { timeout?: number }) => { + const posts = [ + { id: 1, title: 'Hello' }, + { id: 2, title: 'World' }, + ]; + const dataProvider = { + getList: (resource, params) => { + console.log('getList', resource, params); + return Promise.resolve({ + data: posts, + total: posts.length, + }); + }, + delete: (resource, params) => { + console.log('delete', resource, params); + return new Promise(resolve => { + setTimeout(() => { + const index = posts.findIndex(p => p.id === params.id); + const deletedPost = posts.splice(index, 1); + resolve({ data: deletedPost }); + }, timeout); + }); + }, + } as any; + return ( + + + + ); +}; + +const SuccessCore = () => { + const isMutating = useIsMutating(); + const [success, setSuccess] = useState(); + const { data, refetch } = useGetList('posts'); + const [id, setId] = useState(1); + const [mutationMode, setMutationMode] = + useState('pessimistic'); + const [deleteOne, { isPending }] = useDelete( + 'posts', + {}, + { + mutationMode, + onSuccess: () => setSuccess('success'), + } + ); + const handleClick = () => { + deleteOne('posts', { + id, + previousData: { id, title: 'Hello' }, + }); + }; + return ( + <> +
    {data?.map(post =>
  • {post.title}
  • )}
+
+ +   + + + + + +
+ {success &&
{success}
} + {isMutating !== 0 &&
mutating
} + + ); +}; diff --git a/packages/ra-core/src/dataProvider/useDelete.ts b/packages/ra-core/src/dataProvider/useDelete.ts index 25cfd6a9133..58e9cff755e 100644 --- a/packages/ra-core/src/dataProvider/useDelete.ts +++ b/packages/ra-core/src/dataProvider/useDelete.ts @@ -1,4 +1,4 @@ -import { useMemo, useRef } from 'react'; +import { useEffect, useMemo, useRef } from 'react'; import { useMutation, useQueryClient, @@ -93,6 +93,9 @@ export const useDelete = < const { id, previousData } = params; const { mutationMode = 'pessimistic', ...mutationOptions } = options; const mode = useRef(mutationMode); + useEffect(() => { + mode.current = mutationMode; + }, [mutationMode]); const paramsRef = useRef>>(params); const snapshot = useRef([]); const hasCallTimeOnError = useRef(false); diff --git a/packages/ra-core/src/dataProvider/useDeleteMany.spec.tsx b/packages/ra-core/src/dataProvider/useDeleteMany.spec.tsx index 92117121330..edda50305b0 100644 --- a/packages/ra-core/src/dataProvider/useDeleteMany.spec.tsx +++ b/packages/ra-core/src/dataProvider/useDeleteMany.spec.tsx @@ -1,11 +1,12 @@ import * as React from 'react'; -import { waitFor, render } from '@testing-library/react'; +import { waitFor, render, screen, fireEvent } from '@testing-library/react'; import expect from 'expect'; import { CoreAdminContext } from '../core'; import { testDataProvider } from './testDataProvider'; import { useDeleteMany } from './useDeleteMany'; import { QueryClient } from '@tanstack/react-query'; +import { Basic } from './useDeleteMany.stories'; describe('useDeleteMany', () => { it('returns a callback that can be used with update arguments', async () => { @@ -263,4 +264,36 @@ describe('useDeleteMany', () => { }); }); }); + + it('allows to control the mutation mode', async () => { + jest.spyOn(console, 'log').mockImplementation(() => {}); + jest.spyOn(console, 'error').mockImplementation(() => {}); + render(); + await screen.findByText('Hello World 1'); + await screen.findByText('Hello World 2'); + await screen.findByText('Hello World 3'); + await screen.findByText('Hello World 4'); + + // Delete the first 2 posts in pessimistic mode + fireEvent.click(await screen.findByText('Delete posts')); + // Wait for the post to be deleted + await waitFor(() => { + expect(screen.queryByText('Hello World 1')).toBeNull(); + expect(screen.queryByText('Hello World 2')).toBeNull(); + }); + + fireEvent.click(await screen.findByText('undoable')); + fireEvent.click(await screen.findByText('Increment id')); + // Delete the 2 next posts in undoable mode + fireEvent.click(await screen.findByText('Delete posts')); + // Check the optimistic result + await waitFor(() => { + expect(screen.queryByText('Hello World 3')).toBeNull(); + expect(screen.queryByText('Hello World 4')).toBeNull(); + }); + // As we haven't confirmed the undoable mutation, refetching the post should return nothing + fireEvent.click(await screen.findByText('Refetch')); + await screen.findByText('Hello World 3'); + await screen.findByText('Hello World 4'); + }); }); diff --git a/packages/ra-core/src/dataProvider/useDeleteMany.stories.tsx b/packages/ra-core/src/dataProvider/useDeleteMany.stories.tsx new file mode 100644 index 00000000000..882ccaa8243 --- /dev/null +++ b/packages/ra-core/src/dataProvider/useDeleteMany.stories.tsx @@ -0,0 +1,92 @@ +import * as React from 'react'; +import { useState } from 'react'; +import { QueryClient, useIsMutating } from '@tanstack/react-query'; + +import { CoreAdminContext } from '../core'; +import { useGetList } from './useGetList'; +import { MutationMode } from '../types'; +import { useDeleteMany } from './useDeleteMany'; + +export default { title: 'ra-core/dataProvider/useDeleteMany' }; + +export const Basic = ({ timeout = 1000 }: { timeout?: number }) => { + const posts = [ + { id: 1, title: 'Hello World 1' }, + { id: 2, title: 'Hello World 2' }, + { id: 3, title: 'Hello World 3' }, + { id: 4, title: 'Hello World 4' }, + ]; + const dataProvider = { + getList: (resource, params) => { + console.log('getList', resource, params); + return Promise.resolve({ + data: posts, + total: posts.length, + }); + }, + deleteMany: (resource, params) => { + console.log('deleteMany', resource, params); + return new Promise(resolve => { + setTimeout(() => { + for (const id of params.ids) { + const index = posts.findIndex(p => p.id === id); + posts.splice(index, 1); + } + resolve({ data: params.ids }); + }, timeout); + }); + }, + } as any; + return ( + + + + ); +}; + +const SuccessCore = () => { + const isMutating = useIsMutating(); + const [success, setSuccess] = useState(); + const { data, refetch } = useGetList('posts'); + const [id, setId] = useState(1); + const [mutationMode, setMutationMode] = + useState('pessimistic'); + const [deleteMany, { isPending }] = useDeleteMany('posts', undefined, { + mutationMode, + onSuccess: () => setSuccess('success'), + }); + const handleClick = () => { + deleteMany('posts', { + ids: [id, id + 1], + }); + }; + return ( + <> +
    {data?.map(post =>
  • {post.title}
  • )}
+
+ +   + + + + + +
+ {success &&
{success}
} + {isMutating !== 0 &&
mutating
} + + ); +}; diff --git a/packages/ra-core/src/dataProvider/useDeleteMany.ts b/packages/ra-core/src/dataProvider/useDeleteMany.ts index dceb34cb667..4b557df9045 100644 --- a/packages/ra-core/src/dataProvider/useDeleteMany.ts +++ b/packages/ra-core/src/dataProvider/useDeleteMany.ts @@ -1,4 +1,4 @@ -import { useMemo, useRef } from 'react'; +import { useEffect, useMemo, useRef } from 'react'; import { useMutation, useQueryClient, @@ -93,6 +93,9 @@ export const useDeleteMany = < const { ids } = params; const { mutationMode = 'pessimistic', ...mutationOptions } = options; const mode = useRef(mutationMode); + useEffect(() => { + mode.current = mutationMode; + }, [mutationMode]); const paramsRef = useRef>>({}); const snapshot = useRef([]); const hasCallTimeOnError = useRef(false); diff --git a/packages/ra-core/src/dataProvider/useUpdate.spec.tsx b/packages/ra-core/src/dataProvider/useUpdate.spec.tsx index 73ea6d7c786..7add708a55e 100644 --- a/packages/ra-core/src/dataProvider/useUpdate.spec.tsx +++ b/packages/ra-core/src/dataProvider/useUpdate.spec.tsx @@ -1,5 +1,11 @@ import * as React from 'react'; -import { act, render, screen, waitFor } from '@testing-library/react'; +import { + act, + fireEvent, + render, + screen, + waitFor, +} from '@testing-library/react'; import expect from 'expect'; import { CoreAdminContext } from '../core'; @@ -25,6 +31,7 @@ import { WithMiddlewaresError as WithMiddlewaresErrorUndoable, } from './useUpdate.undoable.stories'; import { QueryClient } from '@tanstack/react-query'; +import { Basic } from './useUpdate.stories'; describe('useUpdate', () => { describe('mutate', () => { @@ -368,6 +375,23 @@ describe('useUpdate', () => { expect(screen.queryByText('mutating')).toBeNull(); }); }); + it('allows to control the mutation mode', async () => { + jest.spyOn(console, 'error').mockImplementation(() => {}); + render(); + await screen.findByText('Hello'); + // Update the post in pessimistic mode + fireEvent.click(await screen.findByText('Update title')); + await screen.findByText('Hello World 0'); + fireEvent.click(await screen.findByText('undoable')); + fireEvent.click(await screen.findByText('Increment counter')); + // Update a post in undoable mode + fireEvent.click(await screen.findByText('Update title')); + // Check the optimistic result + await screen.findByText('Hello World 1'); + // As we haven't confirmed the undoable mutation, refetching the post should return nothing + fireEvent.click(await screen.findByText('Refetch')); + await screen.findByText('Hello World 0'); + }); }); describe('query cache', () => { it('updates getList query cache when dataProvider promise resolves', async () => { diff --git a/packages/ra-core/src/dataProvider/useUpdate.stories.tsx b/packages/ra-core/src/dataProvider/useUpdate.stories.tsx new file mode 100644 index 00000000000..69b8a39441f --- /dev/null +++ b/packages/ra-core/src/dataProvider/useUpdate.stories.tsx @@ -0,0 +1,370 @@ +import * as React from 'react'; +import { useState } from 'react'; +import { QueryClient, useIsMutating } from '@tanstack/react-query'; + +import { CoreAdminContext } from '../core'; +import { useUpdate } from './useUpdate'; +import { useGetOne } from './useGetOne'; +import { MutationMode } from '..'; + +export default { title: 'ra-core/dataProvider/useUpdate' }; + +export const Basic = ({ timeout = 1000 }) => { + const posts = [{ id: 1, title: 'Hello', author: 'John Doe' }]; + const dataProvider = { + getOne: (resource, params) => { + return Promise.resolve({ + data: posts.find(p => p.id === params.id), + }); + }, + update: (resource, params) => { + return new Promise(resolve => { + setTimeout(() => { + const post = posts.find(p => p.id === params.id); + if (post) { + post.title = params.data.title; + } + resolve({ data: post }); + }, timeout); + }); + }, + } as any; + return ( + + + + ); +}; + +const SuccessCore = () => { + const isMutating = useIsMutating(); + const [success, setSuccess] = useState(); + const { data, refetch } = useGetOne('posts', { id: 1 }); + const [counter, setCounter] = useState(0); + const [mutationMode, setMutationMode] = + useState('pessimistic'); + const [update, { isPending }] = useUpdate('posts', undefined, { + mutationMode, + onSuccess: () => setSuccess('success'), + }); + const handleClick = () => { + update('posts', { + id: 1, + data: { title: `Hello World ${counter}` }, + }); + }; + return ( + <> +
+
title
+
{data?.title}
+
author
+
{data?.author}
+
+
+ +   + + + + + +
+ {success &&
{success}
} + {isMutating !== 0 &&
mutating
} + + ); +}; + +export const UndefinedValues = () => { + const data = { id: 1, title: 'Hello' }; + const dataProvider = { + getOne: async () => ({ data }), + update: () => new Promise(() => {}), // never resolve to see only optimistic update + } as any; + return ( + + + + ); +}; + +const UndefinedValuesCore = () => { + const { data } = useGetOne('posts', { id: 1 }); + const [update, { isPending }] = useUpdate(); + const handleClick = () => { + update( + 'posts', + { id: 1, data: { id: undefined, title: 'world' } }, + { mutationMode: 'optimistic' } + ); + }; + return ( + <> +
{JSON.stringify(data)}
+
+ +
+ + ); +}; + +export const ErrorCase = ({ timeout = 1000 }) => { + const posts = [{ id: 1, title: 'Hello', author: 'John Doe' }]; + const dataProvider = { + getOne: (resource, params) => { + return Promise.resolve({ + data: posts.find(p => p.id === params.id), + }); + }, + update: () => { + return new Promise((resolve, reject) => { + setTimeout(() => { + reject(new Error('something went wrong')); + }, timeout); + }); + }, + } as any; + return ( + + + + ); +}; + +const ErrorCore = () => { + const isMutating = useIsMutating(); + const [success, setSuccess] = useState(); + const [error, setError] = useState(); + const { data, refetch } = useGetOne('posts', { id: 1 }); + const [update, { isPending }] = useUpdate(); + const handleClick = () => { + update( + 'posts', + { + id: 1, + data: { title: 'Hello World' }, + }, + { + mutationMode: 'optimistic', + onSuccess: () => setSuccess('success'), + onError: e => { + setError(e); + setSuccess(''); + }, + } + ); + }; + return ( + <> +
+
title
+
{data?.title}
+
author
+
{data?.author}
+
+
+ +   + +
+ {success &&
{success}
} + {error &&
{error.message}
} + {isMutating !== 0 &&
mutating
} + + ); +}; + +export const WithMiddlewaresSuccess = ({ timeout = 1000 }) => { + const posts = [{ id: 1, title: 'Hello', author: 'John Doe' }]; + const dataProvider = { + getOne: (resource, params) => { + return Promise.resolve({ + data: posts.find(p => p.id === params.id), + }); + }, + update: (resource, params) => { + return new Promise(resolve => { + setTimeout(() => { + const post = posts.find(p => p.id === params.id); + if (post) { + post.title = params.data.title; + } + resolve({ data: post }); + }, timeout); + }); + }, + } as any; + return ( + + + + ); +}; + +const WithMiddlewaresSuccessCore = () => { + const isMutating = useIsMutating(); + const [success, setSuccess] = useState(); + const { data, refetch } = useGetOne('posts', { id: 1 }); + const [update, { isPending }] = useUpdate( + 'posts', + { + id: 1, + data: { title: 'Hello World' }, + }, + { + mutationMode: 'optimistic', + // @ts-ignore + getMutateWithMiddlewares: mutate => async (resource, params) => { + return mutate(resource, { + ...params, + data: { title: `${params.data.title} from middleware` }, + }); + }, + } + ); + const handleClick = () => { + update( + 'posts', + { + id: 1, + data: { title: 'Hello World' }, + }, + { + onSuccess: () => setSuccess('success'), + } + ); + }; + return ( + <> +
+
title
+
{data?.title}
+
author
+
{data?.author}
+
+
+ +   + +
+ {success &&
{success}
} + {isMutating !== 0 &&
mutating
} + + ); +}; + +export const WithMiddlewaresError = ({ timeout = 1000 }) => { + const posts = [{ id: 1, title: 'Hello', author: 'John Doe' }]; + const dataProvider = { + getOne: (resource, params) => { + return Promise.resolve({ + data: posts.find(p => p.id === params.id), + }); + }, + update: () => { + return new Promise((resolve, reject) => { + setTimeout(() => { + reject(new Error('something went wrong')); + }, timeout); + }); + }, + } as any; + return ( + + + + ); +}; + +const WithMiddlewaresErrorCore = () => { + const isMutating = useIsMutating(); + const [success, setSuccess] = useState(); + const [error, setError] = useState(); + const { data, refetch } = useGetOne('posts', { id: 1 }); + const [update, { isPending }] = useUpdate( + 'posts', + { + id: 1, + data: { title: 'Hello World' }, + }, + { + mutationMode: 'optimistic', + // @ts-ignore + mutateWithMiddlewares: mutate => async (resource, params) => { + return mutate(resource, { + ...params, + data: { title: `${params.data.title} from middleware` }, + }); + }, + } + ); + const handleClick = () => { + setError(undefined); + update( + 'posts', + { + id: 1, + data: { title: 'Hello World' }, + }, + { + onSuccess: () => setSuccess('success'), + onError: e => { + setError(e); + setSuccess(''); + }, + } + ); + }; + return ( + <> +
+
title
+
{data?.title}
+
author
+
{data?.author}
+
+
+ +   + +
+ {error &&
{error.message}
} + {success &&
{success}
} + {isMutating !== 0 &&
mutating
} + + ); +}; diff --git a/packages/ra-core/src/dataProvider/useUpdate.ts b/packages/ra-core/src/dataProvider/useUpdate.ts index 9e3e53c21e8..3914eb1bca3 100644 --- a/packages/ra-core/src/dataProvider/useUpdate.ts +++ b/packages/ra-core/src/dataProvider/useUpdate.ts @@ -1,4 +1,4 @@ -import { useMemo, useRef } from 'react'; +import { useEffect, useMemo, useRef } from 'react'; import { useMutation, useQueryClient, @@ -99,6 +99,9 @@ export const useUpdate = ( ...mutationOptions } = options; const mode = useRef(mutationMode); + useEffect(() => { + mode.current = mutationMode; + }, [mutationMode]); const paramsRef = useRef>>(params); const snapshot = useRef([]); // Ref that stores the mutation with middlewares to avoid losing them if the calling component is unmounted