Skip to content

Commit bf3cc65

Browse files
fix(core,nextjs-router,remix-router): parse deeply nested conditional filters with syncWithLocation (#7245)
Co-authored-by: Alican Erdurmaz <alicanerdurmaz@gmail.com>
1 parent 28c5287 commit bf3cc65

10 files changed

Lines changed: 119 additions & 10 deletions

File tree

.changeset/perfect-knives-call.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
"@refinedev/core": patch
3+
"@refinedev/nextjs-router": patch
4+
"@refinedev/remix-router": patch
5+
"@refinedev/react-router": patch
6+
---
7+
8+
fix: correctly parse deeply nested conditional filters from URL with syncWithLocation
9+
10+
Increased `qs.parse` depth from default 5 to 10 to support deeply nested conditional filters (e.g., `or -> and -> {field, operator, value}`). Previously, nested filter properties were incorrectly parsed as bracket notation keys (`[field]`, `[operator]`, `[value]`) after page reload when using `syncWithLocation: true`.

packages/core/src/definitions/table/index.spec.ts

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -636,4 +636,59 @@ describe("definitions/table", () => {
636636
}),
637637
).toEqual("");
638638
});
639+
640+
it("parseTableParams should handle deeply nested conditional filters", () => {
641+
const filters: CrudFilter[] = [
642+
{
643+
operator: "or",
644+
value: [
645+
{
646+
operator: "and",
647+
value: [
648+
{ field: "isDismissed", operator: "eq", value: true },
649+
{ field: "participation.id", operator: "nnull", value: "" },
650+
],
651+
},
652+
{
653+
operator: "and",
654+
value: [{ field: "isDismissed", operator: "eq", value: false }],
655+
},
656+
],
657+
},
658+
];
659+
660+
const url = stringifyTableParams({
661+
pagination: { currentPage: 1, pageSize: 10 },
662+
sorters: [],
663+
filters,
664+
});
665+
666+
const { parsedFilters } = parseTableParams(`?${url}`);
667+
668+
expect(parsedFilters).toHaveLength(1);
669+
expect(parsedFilters[0]).toHaveProperty("operator", "or");
670+
expect(parsedFilters[0]).toHaveProperty("value");
671+
672+
const orValue = (parsedFilters[0] as any).value;
673+
expect(orValue).toHaveLength(2);
674+
675+
expect(orValue[0]).toHaveProperty("operator", "and");
676+
expect(orValue[0].value).toHaveLength(2);
677+
expect(orValue[0].value[0]).toHaveProperty("field", "isDismissed");
678+
expect(orValue[0].value[0]).toHaveProperty("operator", "eq");
679+
expect(orValue[0].value[0]).toHaveProperty("value", "true");
680+
expect(orValue[0].value[1]).toHaveProperty("field", "participation.id");
681+
expect(orValue[0].value[1]).toHaveProperty("operator", "nnull");
682+
683+
expect(orValue[1]).toHaveProperty("operator", "and");
684+
expect(orValue[1].value).toHaveLength(1);
685+
expect(orValue[1].value[0]).toHaveProperty("field", "isDismissed");
686+
expect(orValue[1].value[0]).toHaveProperty("operator", "eq");
687+
expect(orValue[1].value[0]).toHaveProperty("value", "false");
688+
689+
const stringified = JSON.stringify(parsedFilters);
690+
expect(stringified).not.toContain("[field]");
691+
expect(stringified).not.toContain("[operator]");
692+
expect(stringified).not.toContain("[value]");
693+
});
639694
});

packages/core/src/definitions/table/index.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,17 @@ import type {
1010
SortOrder,
1111
} from "../../contexts/data/types";
1212

13+
/**
14+
* Depth limit for `qs.parse`. Deeply nested conditional filters
15+
* (e.g. `or -> and -> { field, operator, value }`) require at least 7 levels.
16+
* We use 10 to leave comfortable headroom.
17+
*/
18+
export const QS_PARSE_DEPTH = 10;
19+
1320
export const parseTableParams = (url: string) => {
1421
const { currentPage, pageSize, sorters, sorter, filters } = qs.parse(
1522
url.substring(1), // remove first ? character
23+
{ depth: QS_PARSE_DEPTH },
1624
);
1725

1826
return {
@@ -44,6 +52,9 @@ export const stringifyTableParams = (params: {
4452
filters: CrudFilter[];
4553
[key: string]: any;
4654
}): string => {
55+
// Note: qs.stringify has no depth limit by default, so it correctly
56+
// serialises deeply nested filters without extra configuration.
57+
// The matching qs.parse call uses QS_PARSE_DEPTH to deserialise them.
4758
const options: IStringifyOptions = {
4859
skipNulls: true,
4960
arrayFormat: "indices",

packages/core/src/index.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ export {
99
getDefaultSortOrder,
1010
parseTableParams,
1111
parseTableParamsFromQuery,
12+
QS_PARSE_DEPTH,
1213
setInitialFilters,
1314
setInitialSorters,
1415
stringifyTableParams,

packages/nextjs-router/src/app/bindings.tsx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {
44
ResourceContext,
55
matchResourceFromRoute,
66
type ParseResponse,
7+
QS_PARSE_DEPTH,
78
} from "@refinedev/core";
89
import { useRouter, usePathname, useSearchParams } from "next/navigation";
910
import NextLink from "next/link";
@@ -48,6 +49,7 @@ export const routerProvider: RouterProvider = {
4849
...(keepQuery
4950
? qs.parse(searchParamsObj.toString(), {
5051
ignoreQueryPrefix: true,
52+
depth: QS_PARSE_DEPTH,
5153
})
5254
: {}),
5355
...query,
@@ -109,7 +111,10 @@ export const routerProvider: RouterProvider = {
109111

110112
const parsedParams = React.useMemo(() => {
111113
const searchParams = searchParamsObj.toString();
112-
return qs.parse(searchParams, { ignoreQueryPrefix: true });
114+
return qs.parse(searchParams, {
115+
ignoreQueryPrefix: true,
116+
depth: QS_PARSE_DEPTH,
117+
});
113118
}, [searchParamsObj]);
114119

115120
const fn = React.useCallback(() => {

packages/nextjs-router/src/common/parse-table-params.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
11
import qs from "qs";
2-
import type { ParsedParams } from "@refinedev/core";
2+
import { type ParsedParams, QS_PARSE_DEPTH } from "@refinedev/core";
33

44
const parseTableParams = (search: string) => {
5-
const parsed: ParsedParams = qs.parse(search, { ignoreQueryPrefix: true });
5+
const parsed: ParsedParams = qs.parse(search, {
6+
ignoreQueryPrefix: true,
7+
depth: QS_PARSE_DEPTH,
8+
});
69

710
const tableReady = {
811
...parsed,

packages/nextjs-router/src/pages/bindings.tsx

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {
44
ResourceContext,
55
matchResourceFromRoute,
66
type ParseResponse,
7+
QS_PARSE_DEPTH,
78
} from "@refinedev/core";
89
import { useRouter } from "next/router";
910
import NextLink from "next/link";
@@ -46,6 +47,7 @@ export const routerProvider: RouterProvider = {
4647
...(keepQuery
4748
? qs.parse(pathname.split("?")[1], {
4849
ignoreQueryPrefix: true,
50+
depth: QS_PARSE_DEPTH,
4951
})
5052
: {}),
5153
...query,
@@ -110,12 +112,16 @@ export const routerProvider: RouterProvider = {
110112

111113
const parsedParams = React.useMemo(() => {
112114
const searchParams = pathname.split("?")[1];
113-
return qs.parse(searchParams, { ignoreQueryPrefix: true });
115+
return qs.parse(searchParams, {
116+
ignoreQueryPrefix: true,
117+
depth: QS_PARSE_DEPTH,
118+
});
114119
}, [pathname]);
115120

116121
const fn = React.useCallback(() => {
117122
const parsedQuery = qs.parse(query as Record<string, string>, {
118123
ignoreQueryPrefix: true,
124+
depth: QS_PARSE_DEPTH,
119125
});
120126
const combinedParams = {
121127
...inferredParams,

packages/react-router/src/bindings.tsx

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
type RouterProvider,
66
matchResourceFromRoute,
77
ResourceContext,
8+
QS_PARSE_DEPTH,
89
} from "@refinedev/core";
910
import { useCallback, useContext } from "react";
1011
import qs from "qs";
@@ -42,7 +43,11 @@ export const routerProvider: RouterProvider = {
4243
const urlQuery = {
4344
...(keepQuery &&
4445
existingSearch &&
45-
qs.parse(existingSearch, { ignoreQueryPrefix: true })),
46+
qs.parse(existingSearch, {
47+
ignoreQueryPrefix: true,
48+
depth: QS_PARSE_DEPTH,
49+
})),
50+
4651
...query,
4752
};
4853

@@ -106,7 +111,10 @@ export const routerProvider: RouterProvider = {
106111
}
107112

108113
const fn = useCallback(() => {
109-
const parsedSearch = qs.parse(search, { ignoreQueryPrefix: true });
114+
const parsedSearch = qs.parse(search, {
115+
ignoreQueryPrefix: true,
116+
depth: QS_PARSE_DEPTH,
117+
});
110118

111119
const combinedParams = {
112120
...params,

packages/remix-router/src/bindings.tsx

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {
44
ResourceContext,
55
matchResourceFromRoute,
66
type ParseResponse,
7+
QS_PARSE_DEPTH,
78
} from "@refinedev/core";
89
import { useParams, useLocation, useNavigate, Link } from "@remix-run/react";
910
import qs from "qs";
@@ -36,7 +37,10 @@ export const routerProvider: RouterProvider = {
3637
const urlQuery = {
3738
...(keepQuery &&
3839
existingSearch &&
39-
qs.parse(existingSearch, { ignoreQueryPrefix: true })),
40+
qs.parse(existingSearch, {
41+
ignoreQueryPrefix: true,
42+
depth: QS_PARSE_DEPTH,
43+
})),
4044
...query,
4145
};
4246

@@ -98,7 +102,10 @@ export const routerProvider: RouterProvider = {
98102
const inferredId = inferredParams.id;
99103

100104
const fn = useCallback(() => {
101-
const parsedSearch = qs.parse(search, { ignoreQueryPrefix: true });
105+
const parsedSearch = qs.parse(search, {
106+
ignoreQueryPrefix: true,
107+
depth: QS_PARSE_DEPTH,
108+
});
102109

103110
const combinedParams = {
104111
...inferredParams,

packages/remix-router/src/parse-table-params.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
11
import qs from "qs";
2-
import type { ParsedParams } from "@refinedev/core";
2+
import { type ParsedParams, QS_PARSE_DEPTH } from "@refinedev/core";
33

44
export const parseTableParams = (search: string) => {
5-
const parsed: ParsedParams = qs.parse(search, { ignoreQueryPrefix: true });
5+
const parsed: ParsedParams = qs.parse(search, {
6+
ignoreQueryPrefix: true,
7+
depth: QS_PARSE_DEPTH,
8+
});
69

710
const tableReady = {
811
...parsed,

0 commit comments

Comments
 (0)