diff --git a/packages/ra-core/src/controller/list/useListParams.spec.tsx b/packages/ra-core/src/controller/list/useListParams.spec.tsx
index cc9c1c0b9dc..7d2d1f030d0 100644
--- a/packages/ra-core/src/controller/list/useListParams.spec.tsx
+++ b/packages/ra-core/src/controller/list/useListParams.spec.tsx
@@ -9,6 +9,7 @@ import { useStore } from '../../store/useStore';
import { useListParams, getQuery, getNumberOrDefault } from './useListParams';
import { SORT_DESC, SORT_ASC } from './queryReducer';
import { TestMemoryRouter } from '../../routing';
+import { memoryStore } from '../../store';
describe('useListParams', () => {
describe('getQuery', () => {
@@ -495,6 +496,71 @@ describe('useListParams', () => {
});
});
+ it('should synchronize location with store when sync is enabled', async () => {
+ let location;
+ let storeValue;
+ const StoreReader = () => {
+ const [value] = useStore('posts.listParams');
+ React.useEffect(() => {
+ storeValue = value;
+ }, [value]);
+ return null;
+ };
+ render(
+ {
+ location = l;
+ }}
+ >
+
+
+
+
+
+ );
+
+ await waitFor(() => {
+ expect(storeValue).toEqual({
+ sort: 'id',
+ order: 'ASC',
+ page: 10,
+ perPage: 10,
+ filter: {},
+ });
+ });
+
+ await waitFor(() => {
+ expect(location).toEqual(
+ expect.objectContaining({
+ hash: '',
+ key: expect.any(String),
+ state: null,
+ pathname: '/',
+ search:
+ '?' +
+ stringify({
+ filter: JSON.stringify({}),
+ sort: 'id',
+ order: 'ASC',
+ page: 10,
+ perPage: 10,
+ }),
+ })
+ );
+ });
+ });
+
it('should not synchronize parameters with location and store when sync is not enabled', async () => {
let location;
let storeValue;
@@ -540,6 +606,171 @@ describe('useListParams', () => {
expect(storeValue).toBeUndefined();
});
+ it('should not synchronize location with store if the location already contains parameters', async () => {
+ let location;
+ let storeValue;
+ const StoreReader = () => {
+ const [value] = useStore('posts.listParams');
+ React.useEffect(() => {
+ storeValue = value;
+ }, [value]);
+ return null;
+ };
+ render(
+ {
+ location = l;
+ }}
+ >
+
+
+
+
+
+ );
+
+ await waitFor(() => {
+ expect(storeValue).toEqual({
+ sort: 'id',
+ order: 'ASC',
+ page: 10,
+ perPage: 10,
+ filter: {},
+ });
+ });
+
+ await waitFor(() => {
+ expect(location).toEqual(
+ expect.objectContaining({
+ hash: '',
+ key: expect.any(String),
+ state: null,
+ pathname: '/',
+ search:
+ '?' +
+ stringify({
+ filter: JSON.stringify({}),
+ sort: 'id',
+ order: 'ASC',
+ page: 5,
+ perPage: 10,
+ }),
+ })
+ );
+ });
+ });
+
+ it('should not synchronize location with store if the store parameters are the defaults', async () => {
+ let location;
+ render(
+ {
+ location = l;
+ }}
+ >
+
+
+
+
+ );
+
+ // Let React do its thing
+ await new Promise(resolve => setTimeout(resolve, 0));
+
+ await waitFor(() => {
+ expect(location).toEqual(
+ expect.objectContaining({
+ hash: '',
+ key: expect.any(String),
+ state: null,
+ pathname: '/',
+ search: '',
+ })
+ );
+ });
+ });
+
+ it('should not synchronize location with store when sync is not enabled', async () => {
+ let location;
+ let storeValue;
+ const StoreReader = () => {
+ const [value] = useStore('posts.listParams');
+ React.useEffect(() => {
+ storeValue = value;
+ }, [value]);
+ return null;
+ };
+ render(
+ {
+ location = l;
+ }}
+ >
+
+
+
+
+
+ );
+
+ await waitFor(() => {
+ expect(storeValue).toEqual({
+ sort: 'id',
+ order: 'ASC',
+ page: 10,
+ perPage: 10,
+ filter: {},
+ });
+ });
+
+ await waitFor(() => {
+ expect(location).toEqual(
+ expect.objectContaining({
+ hash: '',
+ key: expect.any(String),
+ state: null,
+ pathname: '/',
+ search: '',
+ })
+ );
+ });
+ });
+
it('should synchronize parameters with store when sync is not enabled and storeKey is passed', async () => {
let storeValue;
const Component = ({
diff --git a/packages/ra-core/src/controller/list/useListParams.ts b/packages/ra-core/src/controller/list/useListParams.ts
index eca5484aed5..1ae50586515 100644
--- a/packages/ra-core/src/controller/list/useListParams.ts
+++ b/packages/ra-core/src/controller/list/useListParams.ts
@@ -1,6 +1,7 @@
import { useCallback, useMemo, useEffect, useState, useRef } from 'react';
import { parse, stringify } from 'query-string';
import lodashDebounce from 'lodash/debounce.js';
+import isEqual from 'lodash/isEqual.js';
import { useNavigate, useLocation } from 'react-router-dom';
import { useStore } from '../../store';
@@ -133,6 +134,62 @@ export const useListParams = ({
}
}, [location.search]); // eslint-disable-line
+ const currentStoreKey = useRef(storeKey);
+ // if the location includes params (for example from a link like
+ // the categories products on the demo), we need to persist them in the
+ // store as well so that we don't lose them after a redirection back
+ // to the list
+ useEffect(
+ () => {
+ // If the storeKey has changed, ignore the first effect call. This avoids conflicts between lists sharing
+ // the same resource but different storeKeys.
+ if (currentStoreKey.current !== storeKey) {
+ // storeKey has changed
+ currentStoreKey.current = storeKey;
+ return;
+ }
+ if (disableSyncWithLocation) {
+ return;
+ }
+ const defaultParams = {
+ filter: filterDefaultValues || {},
+ page: 1,
+ perPage,
+ sort: sort.field,
+ order: sort.order,
+ };
+
+ if (
+ // The location params are not empty (we don't want to override them if provided)
+ Object.keys(queryFromLocation).length > 0 ||
+ // or the stored params are different from the location params
+ isEqual(query, queryFromLocation) ||
+ // or the stored params are not different from the default params (to keep the URL simple when possible)
+ isEqual(query, defaultParams)
+ ) {
+ return;
+ }
+ navigate({
+ search: `?${stringify({
+ ...query,
+ filter: JSON.stringify(query.filter),
+ displayedFilters: JSON.stringify(query.displayedFilters),
+ })}`,
+ });
+ },
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ [
+ navigate,
+ disableSyncWithLocation,
+ filterDefaultValues,
+ perPage,
+ sort,
+ query,
+ location.search,
+ params,
+ ]
+ );
+
const changeParams = useCallback(
action => {
// do not change params if the component is already unmounted