From 4dd34fcd8fffc49ce919c821051d47b022e214d9 Mon Sep 17 00:00:00 2001 From: Gildas Garcia <1122076+djhi@users.noreply.github.com> Date: Wed, 5 Nov 2025 11:54:46 +0100 Subject: [PATCH 1/9] Improve lists URL management --- .../controller/list/useListParams.spec.tsx | 123 ++++++++++++++++++ .../src/controller/list/useListParams.ts | 20 +++ 2 files changed, 143 insertions(+) diff --git a/packages/ra-core/src/controller/list/useListParams.spec.tsx b/packages/ra-core/src/controller/list/useListParams.spec.tsx index cc9c1c0b9dc..7d8b9245d38 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,63 @@ describe('useListParams', () => { expect(storeValue).toBeUndefined(); }); + 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..7d3cdf949bf 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,25 @@ export const useListParams = ({ } }, [location.search]); // eslint-disable-line + // 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 (disableSyncWithLocation) { + return; + } + if (!isEqual(query, queryFromLocation)) { + navigate({ + search: `?${stringify({ + ...query, + filter: JSON.stringify(query.filter), + displayedFilters: JSON.stringify(query.displayedFilters), + })}`, + }); + } + }, [disableSyncWithLocation, query, location.search]); // eslint-disable-line + const changeParams = useCallback( action => { // do not change params if the component is already unmounted From 4e70e17904acf78232fd09f8a3c052b62658a087 Mon Sep 17 00:00:00 2001 From: Gildas Garcia <1122076+djhi@users.noreply.github.com> Date: Wed, 5 Nov 2025 12:01:38 +0100 Subject: [PATCH 2/9] Don't update location if it has parameters --- .../controller/list/useListParams.spec.tsx | 78 +++++++++++++++++++ .../src/controller/list/useListParams.ts | 5 +- 2 files changed, 82 insertions(+), 1 deletion(-) diff --git a/packages/ra-core/src/controller/list/useListParams.spec.tsx b/packages/ra-core/src/controller/list/useListParams.spec.tsx index 7d8b9245d38..317182f1804 100644 --- a/packages/ra-core/src/controller/list/useListParams.spec.tsx +++ b/packages/ra-core/src/controller/list/useListParams.spec.tsx @@ -606,6 +606,84 @@ 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 when sync is not enabled', async () => { let location; let storeValue; diff --git a/packages/ra-core/src/controller/list/useListParams.ts b/packages/ra-core/src/controller/list/useListParams.ts index 7d3cdf949bf..4cdf302e0f5 100644 --- a/packages/ra-core/src/controller/list/useListParams.ts +++ b/packages/ra-core/src/controller/list/useListParams.ts @@ -142,7 +142,10 @@ export const useListParams = ({ if (disableSyncWithLocation) { return; } - if (!isEqual(query, queryFromLocation)) { + if ( + !isEqual(query, queryFromLocation) && + Object.keys(queryFromLocation).length === 0 + ) { navigate({ search: `?${stringify({ ...query, From 59a4bd2549262356b4f6309cc336a61bb27117de Mon Sep 17 00:00:00 2001 From: Gildas Garcia <1122076+djhi@users.noreply.github.com> Date: Wed, 5 Nov 2025 14:19:36 +0100 Subject: [PATCH 3/9] Improve useListParams to not update the URL when params are the default --- .../src/controller/list/useListParams.ts | 44 ++++++++++++++----- 1 file changed, 34 insertions(+), 10 deletions(-) diff --git a/packages/ra-core/src/controller/list/useListParams.ts b/packages/ra-core/src/controller/list/useListParams.ts index 4cdf302e0f5..91ff348a34a 100644 --- a/packages/ra-core/src/controller/list/useListParams.ts +++ b/packages/ra-core/src/controller/list/useListParams.ts @@ -138,14 +138,28 @@ export const useListParams = ({ // 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 (disableSyncWithLocation) { - return; - } - if ( - !isEqual(query, queryFromLocation) && - Object.keys(queryFromLocation).length === 0 - ) { + useEffect( + () => { + 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, @@ -153,8 +167,18 @@ export const useListParams = ({ displayedFilters: JSON.stringify(query.displayedFilters), })}`, }); - } - }, [disableSyncWithLocation, query, location.search]); // eslint-disable-line + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [ + navigate, + disableSyncWithLocation, + filterDefaultValues, + perPage, + sort, + query, + location.search, + ] + ); const changeParams = useCallback( action => { From 467446f0afa0445ca4fc84f3ec6d3700e3965d37 Mon Sep 17 00:00:00 2001 From: Gildas Garcia <1122076+djhi@users.noreply.github.com> Date: Wed, 5 Nov 2025 14:29:57 +0100 Subject: [PATCH 4/9] Add a test for default params handling --- .../controller/list/useListParams.spec.tsx | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/packages/ra-core/src/controller/list/useListParams.spec.tsx b/packages/ra-core/src/controller/list/useListParams.spec.tsx index 317182f1804..7d2d1f030d0 100644 --- a/packages/ra-core/src/controller/list/useListParams.spec.tsx +++ b/packages/ra-core/src/controller/list/useListParams.spec.tsx @@ -684,6 +684,36 @@ describe('useListParams', () => { }); }); + 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; From 6bf1e23f6c9409f3c605a05ddb5e7f1983de6003 Mon Sep 17 00:00:00 2001 From: Gildas Garcia <1122076+djhi@users.noreply.github.com> Date: Wed, 5 Nov 2025 14:55:10 +0100 Subject: [PATCH 5/9] Fix issues with same resource, different store keys lists --- packages/ra-core/src/controller/list/useListParams.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/packages/ra-core/src/controller/list/useListParams.ts b/packages/ra-core/src/controller/list/useListParams.ts index 91ff348a34a..1ae50586515 100644 --- a/packages/ra-core/src/controller/list/useListParams.ts +++ b/packages/ra-core/src/controller/list/useListParams.ts @@ -134,12 +134,20 @@ 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; } @@ -150,6 +158,7 @@ export const useListParams = ({ 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 || @@ -177,6 +186,7 @@ export const useListParams = ({ sort, query, location.search, + params, ] ); From 550161520e3ed872a8ce08e59eee0c81746e9d1c Mon Sep 17 00:00:00 2001 From: Gildas Garcia <1122076+djhi@users.noreply.github.com> Date: Wed, 10 Dec 2025 17:53:33 +0100 Subject: [PATCH 6/9] Make sure we don't add two histories entries after a redirect --- .../src/controller/list/useListParams.ts | 21 ++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/packages/ra-core/src/controller/list/useListParams.ts b/packages/ra-core/src/controller/list/useListParams.ts index 1ae50586515..6afc0ca49d9 100644 --- a/packages/ra-core/src/controller/list/useListParams.ts +++ b/packages/ra-core/src/controller/list/useListParams.ts @@ -169,13 +169,20 @@ export const useListParams = ({ ) { return; } - navigate({ - search: `?${stringify({ - ...query, - filter: JSON.stringify(query.filter), - displayedFilters: JSON.stringify(query.displayedFilters), - })}`, - }); + navigate( + { + search: `?${stringify({ + ...query, + filter: JSON.stringify(query.filter), + displayedFilters: JSON.stringify( + query.displayedFilters + ), + })}`, + }, + { + replace: true, + } + ); }, // eslint-disable-next-line react-hooks/exhaustive-deps [ From fa0b913f717c114f80f0d0e9640264958eaedbcb Mon Sep 17 00:00:00 2001 From: React-Admin CI Date: Thu, 29 Jan 2026 09:31:33 +0100 Subject: [PATCH 7/9] Apply review comments --- .../controller/list/useListParams.spec.tsx | 88 +++++++++---- .../src/controller/list/useListParams.ts | 117 +++++++++--------- 2 files changed, 118 insertions(+), 87 deletions(-) diff --git a/packages/ra-core/src/controller/list/useListParams.spec.tsx b/packages/ra-core/src/controller/list/useListParams.spec.tsx index 7d2d1f030d0..7ff63b87435 100644 --- a/packages/ra-core/src/controller/list/useListParams.spec.tsx +++ b/packages/ra-core/src/controller/list/useListParams.spec.tsx @@ -6,7 +6,12 @@ import { CoreAdminContext } from '../../core'; import { testDataProvider } from '../../dataProvider'; import { useStore } from '../../store/useStore'; -import { useListParams, getQuery, getNumberOrDefault } from './useListParams'; +import { + useListParams, + getQuery, + getNumberOrDefault, + ListParamsOptions, +} from './useListParams'; import { SORT_DESC, SORT_ASC } from './queryReducer'; import { TestMemoryRouter } from '../../routing'; import { memoryStore } from '../../store'; @@ -361,11 +366,16 @@ describe('useListParams', () => { }); }); describe('useListParams', () => { - const Component = ({ disableSyncWithLocation = false }) => { - const [{ page }, { setPage }] = useListParams({ - resource: 'posts', - disableSyncWithLocation, - }); + const Component = ({ + disableSyncWithLocation = false, + ...options + }: Partial) => { + const [{ page, perPage, sort, order, filter }, { setPage }] = + useListParams({ + resource: 'posts', + disableSyncWithLocation, + ...options, + }); const handleClick = () => { setPage(10); @@ -374,6 +384,10 @@ describe('useListParams', () => { return ( <>

page: {page}

+

perPage: {perPage}

+

sort: {sort}

+

order: {order}

+

filter: {JSON.stringify(filter)}

); @@ -608,14 +622,6 @@ describe('useListParams', () => { 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( { }, })} > - - + ); - await waitFor(() => { - expect(storeValue).toEqual({ - sort: 'id', - order: 'ASC', - page: 10, - perPage: 10, - filter: {}, - }); - }); - await waitFor(() => { expect(location).toEqual( expect.objectContaining({ @@ -693,7 +688,40 @@ describe('useListParams', () => { }} > - + + + + ); + + // 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 if the store parameters are the custom defaults provided to the hook', async () => { + let location; + render( + { + location = l; + }} + > + + ); @@ -701,6 +729,12 @@ describe('useListParams', () => { // Let React do its thing await new Promise(resolve => setTimeout(resolve, 0)); + // The list is using the default set on the component + await screen.findByText('perPage: 5'); + await screen.findByText('sort: title'); + await screen.findByText('order: DESC'); + + // The location is the default for the list (no query parameters) await waitFor(() => { expect(location).toEqual( expect.objectContaining({ diff --git a/packages/ra-core/src/controller/list/useListParams.ts b/packages/ra-core/src/controller/list/useListParams.ts index 8755a98e76f..7e428ef636f 100644 --- a/packages/ra-core/src/controller/list/useListParams.ts +++ b/packages/ra-core/src/controller/list/useListParams.ts @@ -107,9 +107,10 @@ export const useListParams = ({ disableSyncWithLocation, ]; - const queryFromLocation = disableSyncWithLocation - ? {} - : parseQueryFromLocation(location); + const queryFromLocation = useMemo( + () => (disableSyncWithLocation ? {} : parseQueryFromLocation(location)), + [location, disableSyncWithLocation] + ); const query = useMemo( () => @@ -138,63 +139,59 @@ export const useListParams = ({ // 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 - ), - })}`, - }, - { - replace: true, - } - ); - }, - // eslint-disable-next-line react-hooks/exhaustive-deps - [ - navigate, - disableSyncWithLocation, - filterDefaultValues, + 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, - query, - location.search, - params, - ] - ); + 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 the same as the location params + isEqual(query, queryFromLocation) || + // or the stored params are the same as 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), + })}`, + }, + { + replace: true, + } + ); + }, [ + navigate, + disableSyncWithLocation, + filterDefaultValues, + perPage, + sort, + query, + queryFromLocation, + location.search, + params, + storeKey, + ]); const changeParams = useCallback( action => { @@ -340,7 +337,7 @@ const parseObject = (query, field) => { if (query[field] && typeof query[field] === 'string') { try { query[field] = JSON.parse(query[field]); - } catch (err) { + } catch { delete query[field]; } } From e19040eee81b9e91807f9f8b3ec89c717f9a79c4 Mon Sep 17 00:00:00 2001 From: React-Admin CI Date: Wed, 4 Feb 2026 11:04:46 +0100 Subject: [PATCH 8/9] Ensure default `displayedFilters` are ignored too --- packages/ra-core/src/controller/list/useListParams.ts | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/packages/ra-core/src/controller/list/useListParams.ts b/packages/ra-core/src/controller/list/useListParams.ts index 7e428ef636f..21247941953 100644 --- a/packages/ra-core/src/controller/list/useListParams.ts +++ b/packages/ra-core/src/controller/list/useListParams.ts @@ -135,10 +135,6 @@ 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. @@ -151,6 +147,7 @@ export const useListParams = ({ return; } const defaultParams = { + displayedFilters: {}, filter: filterDefaultValues || {}, page: 1, perPage, @@ -168,6 +165,7 @@ export const useListParams = ({ ) { return; } + console.log({ query, queryFromLocation, defaultParams }); navigate( { search: `?${stringify({ From cdba26c2bbd6969575e5444d522b2240b0ff7aaa Mon Sep 17 00:00:00 2001 From: React-Admin CI Date: Wed, 4 Feb 2026 11:51:00 +0100 Subject: [PATCH 9/9] Fix useListParams --- packages/ra-core/src/controller/list/useListParams.ts | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/packages/ra-core/src/controller/list/useListParams.ts b/packages/ra-core/src/controller/list/useListParams.ts index 21247941953..3f92cbbd7d8 100644 --- a/packages/ra-core/src/controller/list/useListParams.ts +++ b/packages/ra-core/src/controller/list/useListParams.ts @@ -147,7 +147,6 @@ export const useListParams = ({ return; } const defaultParams = { - displayedFilters: {}, filter: filterDefaultValues || {}, page: 1, perPage, @@ -155,17 +154,21 @@ export const useListParams = ({ order: sort.order, }; + const { + displayedFilters: _displayedFilters, + ...queryWithoutDisplayedFilters + } = query; + 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 the same as the location params - isEqual(query, queryFromLocation) || + isEqual(queryWithoutDisplayedFilters, queryFromLocation) || // or the stored params are the same as the default params (to keep the URL simple when possible) - isEqual(query, defaultParams) + isEqual(queryWithoutDisplayedFilters, defaultParams) ) { return; } - console.log({ query, queryFromLocation, defaultParams }); navigate( { search: `?${stringify({