Skip to content

Commit adabc29

Browse files
authored
Avoid race condition when setting initial BoxesView filters through URL (#2430)
1 parent f8ec334 commit adabc29

File tree

5 files changed

+86
-32
lines changed

5 files changed

+86
-32
lines changed

front/src/hooks/useTableConfig.ts

Lines changed: 49 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,18 @@ export const useTableConfig = ({
132132
}
133133
return urlFilters;
134134
}, [searchParams]);
135+
136+
// Compute the initial filters if there is no saved config
137+
const initialColumnFilters = useMemo(() => {
138+
if (!syncFiltersAndUrlParams) return defaultTableConfig.columnFilters;
139+
140+
// If URL params exist, merge them into defaults (URL wins)
141+
const hasUrlParams = URL_FILTER_CONFIG.some(({ urlParam }) => searchParams.get(urlParam));
142+
return hasUrlParams
143+
? createInitialColumnFilters(defaultTableConfig.columnFilters, urlFilters)
144+
: defaultTableConfig.columnFilters;
145+
}, [defaultTableConfig.columnFilters, syncFiltersAndUrlParams, searchParams, urlFilters]);
146+
135147
const tableConfigsState = useReactiveVar(tableConfigsVar);
136148

137149
const isInitialMount = useRef(true);
@@ -144,31 +156,45 @@ export const useTableConfig = ({
144156
[searchParams, setSearchParams],
145157
);
146158

147-
// Sync default filters to URL on first load if no URL parameters present
159+
/* Initial mount logic (one-time):
160+
*
161+
* - We compute `initialColumnFilters` earlier so callers can synchronously read
162+
* URL-applied filters from the getters during render (avoids the race where
163+
* react-table/queries initialize with defaults before the effect runs).
164+
*
165+
* - Here in the effect we persist a table config only if there is no existing
166+
* saved config for the key. This avoids stomping user-saved settings on first load.
167+
*
168+
* - If URL params exist, we persist the URL-merged filters (URL wins over defaults).
169+
* If the URL is empty and there's no saved config, we persist the default filters
170+
* (and write them into the URL via the existing updateUrl logic).
171+
*
172+
* - Important: we avoid performing any synchronous state mutation during render.
173+
* The getters return the precomputed values and this effect performs the side
174+
* effect of persisting them after mount. This pattern prevents the race that
175+
* previously caused initial queries to use the default "InStock" filter even
176+
* when the URL requested e.g. "Donated".
177+
*/
148178
useEffect(() => {
149179
if (isInitialMount.current && syncFiltersAndUrlParams) {
150180
const hasUrlParams = URL_FILTER_CONFIG.some(({ urlParam }) => searchParams.get(urlParam));
151181

152-
if (!hasUrlParams) {
153-
const currentConfig = tableConfigsState.get(tableConfigKey);
154-
if (currentConfig?.columnFilters) {
155-
updateUrl(currentConfig.columnFilters);
156-
}
157-
} else {
158-
// Create initial column filters, prioritizing URL parameters
159-
const initialColumnFilters = createInitialColumnFilters(
160-
defaultTableConfig.columnFilters,
161-
urlFilters,
162-
);
163-
182+
const existingConfig = tableConfigsState.get(tableConfigKey);
183+
if (!existingConfig) {
184+
const initialFiltersToPersist = hasUrlParams
185+
? initialColumnFilters
186+
: defaultTableConfig.columnFilters;
164187
const tableConfig: ITableConfig = {
165188
globalFilter: defaultTableConfig.globalFilter,
166-
columnFilters: initialColumnFilters,
189+
columnFilters: initialFiltersToPersist,
167190
sortBy: defaultTableConfig.sortBy,
168191
hiddenColumns: defaultTableConfig.hiddenColumns,
169192
};
170193
tableConfigsState.set(tableConfigKey, tableConfig);
171194
tableConfigsVar(tableConfigsState);
195+
} else if (!hasUrlParams) {
196+
// If URL is empty, write the default filters into the URL
197+
updateUrl(existingConfig.columnFilters);
172198
}
173199

174200
isInitialMount.current = false;
@@ -181,24 +207,29 @@ export const useTableConfig = ({
181207
updateUrl,
182208
defaultTableConfig,
183209
urlFilters,
210+
initialColumnFilters,
184211
]);
185212

186213
// Note: URL sync happens via setColumnFilters when filters change through UI
187214

188215
function getGlobalFilter() {
189-
return (tableConfigsState.get(tableConfigKey) || defaultTableConfig).globalFilter;
216+
const cfg = tableConfigsState.get(tableConfigKey);
217+
return cfg?.globalFilter ?? defaultTableConfig.globalFilter;
190218
}
191219

192220
function getColumnFilters() {
193-
return (tableConfigsState.get(tableConfigKey) || defaultTableConfig).columnFilters;
221+
const cfg = tableConfigsState.get(tableConfigKey);
222+
return cfg?.columnFilters ?? initialColumnFilters;
194223
}
195224

196225
function getSortBy() {
197-
return (tableConfigsState.get(tableConfigKey) || defaultTableConfig).sortBy;
226+
const cfg = tableConfigsState.get(tableConfigKey);
227+
return cfg?.sortBy ?? defaultTableConfig.sortBy;
198228
}
199229

200230
function getHiddenColumns() {
201-
return (tableConfigsState.get(tableConfigKey) || defaultTableConfig).hiddenColumns;
231+
const cfg = tableConfigsState.get(tableConfigKey);
232+
return cfg?.hiddenColumns ?? defaultTableConfig.hiddenColumns;
202233
}
203234

204235
function setGlobalFilter(globalFilter: string | undefined) {

front/src/views/Boxes/BoxesView.test.tsx

Lines changed: 12 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import { ErrorBoundary } from "@sentry/react";
66
import { AlertWithoutAction } from "components/Alerts";
77
import { TableSkeleton } from "components/Skeletons";
88
import { Suspense } from "react";
9-
import { cache } from "queries/cache";
9+
import { cache, tableConfigsVar } from "queries/cache";
1010
import Boxes, { ACTION_OPTIONS_FOR_BOXESVIEW_QUERY, BOXES_FOR_BOXESVIEW_QUERY } from "./BoxesView";
1111
import { FakeGraphQLError, FakeGraphQLNetworkError } from "mocks/functions";
1212
import {
@@ -26,6 +26,8 @@ const mockedUseAuth0 = vi.mocked(useAuth0);
2626

2727
beforeEach(() => {
2828
mockAuthenticatedUser(mockedUseAuth0, "[email protected]");
29+
void cache.reset();
30+
tableConfigsVar(new Map());
2931
});
3032

3133
const jotaiAtoms = [
@@ -47,7 +49,7 @@ const boxesQuery = ({
4749
query: BOXES_FOR_BOXESVIEW_QUERY,
4850
variables: {
4951
baseId: "2",
50-
filterInput: { states: state2 ? [state, state2] : [state] },
52+
filterInput: state === "ALL" ? {} : { states: state2 ? [state, state2] : [state] },
5153
paginationInput,
5254
},
5355
},
@@ -537,7 +539,7 @@ describe("4.8.1 - Initial load of Page", () => {
537539
</ErrorBoundary>,
538540
{
539541
routePath: "/bases/:baseId/boxes",
540-
initialUrl: "/bases/2/boxes",
542+
initialUrl: "/bases/2/boxes?state_ids=1",
541543
mocks: [
542544
boxesQuery({ state: "Scrap", paginationInput: 20 }),
543545
boxesQuery({ state: "Donated", paginationInput: 20 }),
@@ -593,7 +595,7 @@ describe("4.8.1 - Initial load of Page", () => {
593595
</ErrorBoundary>,
594596
{
595597
routePath: "/bases/:baseId/boxes",
596-
initialUrl: "/bases/2/boxes",
598+
initialUrl: "/bases/2/boxes?state_ids=1",
597599
mocks,
598600
cache,
599601
addTypename: true,
@@ -626,7 +628,7 @@ describe("4.8.1 - Initial load of Page", () => {
626628
</ErrorBoundary>,
627629
{
628630
routePath: "/bases/:baseId/boxes",
629-
initialUrl: "/bases/2/boxes",
631+
initialUrl: "/bases/2/boxes?state_ids=1",
630632
mocks: [
631633
boxesQuery({ state: "Scrap", paginationInput: 20 }),
632634
boxesQuery({ state: "Donated", paginationInput: 20 }),
@@ -660,7 +662,7 @@ describe("4.8.2 - Selecting rows and performing bulk actions", () => {
660662
</ErrorBoundary>,
661663
{
662664
routePath: "/bases/:baseId/boxes",
663-
initialUrl: "/bases/2/boxes",
665+
initialUrl: "/bases/2/boxes?state_ids=1",
664666
mocks: [
665667
boxesQuery({ state: "Scrap", paginationInput: 20 }),
666668
boxesQuery({ state: "Donated", paginationInput: 20 }),
@@ -760,7 +762,7 @@ describe("4.8.2 - Selecting rows and performing bulk actions", () => {
760762
</ErrorBoundary>,
761763
{
762764
routePath: "/bases/:baseId/boxes",
763-
initialUrl: "/bases/2/boxes",
765+
initialUrl: "/bases/2/boxes?state_ids=1",
764766
mocks: [
765767
boxesQuery({ state: "Scrap", paginationInput: 20 }),
766768
boxesQuery({ state: "Donated", paginationInput: 20 }),
@@ -873,10 +875,11 @@ describe("4.8.3 - URL Parameter Sync for Filters", () => {
873875
routePath: "/bases/:baseId/boxes",
874876
initialUrl: "/bases/2/boxes?state_ids=999", // Invalid state ID
875877
mocks: [
878+
boxesQuery({ state: "ALL", paginationInput: 20 }),
879+
boxesQuery({ state: "ALL" }),
876880
boxesQuery({ state: "Scrap", paginationInput: 20 }),
877881
boxesQuery({ state: "Donated", paginationInput: 20 }),
878882
boxesQuery({ paginationInput: 20 }),
879-
boxesQuery({}),
880883
actionsQuery,
881884
],
882885
cache,
@@ -947,7 +950,7 @@ describe("4.8.3 - URL Parameter Sync for Filters", () => {
947950
mocks: [
948951
boxesQuery({ state: "Scrap", paginationInput: 20 }),
949952
boxesQuery({ state: "Donated", paginationInput: 20 }),
950-
boxesQuery({ paginationInput: 20 }),
953+
boxesQuery({ state: "InStock", state2: "Donated", paginationInput: 20 }),
951954
boxesQuery({ state: "InStock", state2: "Donated" }),
952955
actionsQuery,
953956
],

front/src/views/Boxes/BoxesView.tsx

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ import { selectedBaseIdAtom } from "stores/globalPreferenceStore";
4343
import { DateCell, ProductWithSPCheckmarkCell } from "components/Table/Cells";
4444
import { BoxState } from "queries/types";
4545
import BoxesTable from "./components/BoxesTable";
46+
import { boxStateIds } from "utils/constants"; // added import to map state names -> ids
4647

4748
// TODO: Implement Pagination and Filtering
4849
export const BOXES_QUERY_ELEMENT_FIELD_FRAGMENT = graphql(
@@ -160,7 +161,7 @@ function Boxes({
160161
const tableConfig = useTableConfig({
161162
tableConfigKey,
162163
defaultTableConfig: {
163-
columnFilters: [{ id: "state", value: ["1"] }], // for InStock (see boxStateIds)
164+
columnFilters: [],
164165
sortBy: [{ id: "lastModified", desc: true }],
165166
hiddenColumns: [
166167
"qrLabel",
@@ -199,10 +200,24 @@ function Boxes({
199200
return;
200201
}
201202

202-
// Only on very initial mount, query 20 boxes of the most used other than InStock state to
203-
// preload the data into Apollo cache.
204-
const states = ["Donated", "Scrap"] satisfies Partial<BoxState>[];
203+
// Only on very initial mount, query 20 boxes of the most used states to preload the data into
204+
// Apollo cache.
205+
// But skip preloading a state if the current table config already requests it via filters.
206+
// e.g. if tableConfig.getColumnFilters() already contains the id for "Donated" (boxStateIds.Donated),
207+
// do not query Donated here.
208+
const states = ["InStock", "Donated", "Scrap"] satisfies Partial<BoxState>[];
209+
210+
// Read the current state filter values (these are state IDs like "5", "6" etc.)
211+
const stateFilterValues: string[] =
212+
(tableConfig.getColumnFilters().find((f) => f.id === "state")?.value as string[]) ?? [];
213+
205214
for (const state of states) {
215+
const stateId = boxStateIds[state];
216+
// If the table is already filtered to this state ID, skip preloading it.
217+
if (stateId && stateFilterValues.includes(stateId)) {
218+
continue;
219+
}
220+
206221
apolloClient.query({
207222
query: BOXES_FOR_BOXESVIEW_QUERY,
208223
variables: {

front/src/views/Boxes/BoxesViewActions.test.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -172,6 +172,7 @@ const mockedUseAuth0 = vi.mocked(useAuth0);
172172

173173
beforeEach(() => {
174174
mockAuthenticatedUser(mockedUseAuth0, "[email protected]");
175+
void cache.reset();
175176
tableConfigsVar(new Map());
176177
});
177178

@@ -410,6 +411,7 @@ const boxesViewActionsTests = [
410411
boxesQuery({}),
411412
boxesQuery({ state: "Donated", stateFilter: ["Donated"] }),
412413
boxesQuery({ state: "Scrap", stateFilter: ["Scrap"] }),
414+
boxesQuery({ state: "MarkedForShipment", stateFilter: ["MarkedForShipment"] }),
413415
boxesQuery({
414416
state: "MarkedForShipment",
415417
stateFilter: ["MarkedForShipment"],
@@ -497,6 +499,7 @@ const boxesViewActionsTests = [
497499
boxesQuery({}),
498500
boxesQuery({ state: "Donated", stateFilter: ["Donated"] }),
499501
boxesQuery({ state: "Scrap", stateFilter: ["Scrap"] }),
502+
boxesQuery({ state: "MarkedForShipment", stateFilter: ["MarkedForShipment"] }),
500503
boxesQuery({
501504
state: "MarkedForShipment",
502505
shipmentDetail: shipmentDetail1(),
@@ -522,6 +525,7 @@ const boxesViewActionsTests = [
522525
boxesQuery({}),
523526
boxesQuery({ state: "Donated", stateFilter: ["Donated"] }),
524527
boxesQuery({ state: "Scrap", stateFilter: ["Scrap"] }),
528+
boxesQuery({ state: "MarkedForShipment", stateFilter: ["MarkedForShipment"] }),
525529
boxesQuery({
526530
state: "MarkedForShipment",
527531
shipmentDetail: shipmentDetail1(),
@@ -545,6 +549,7 @@ const boxesViewActionsTests = [
545549
boxesQuery({}),
546550
boxesQuery({ state: "Donated", stateFilter: ["Donated"] }),
547551
boxesQuery({ state: "Scrap", stateFilter: ["Scrap"] }),
552+
boxesQuery({ state: "MarkedForShipment", stateFilter: ["MarkedForShipment"] }),
548553
boxesQuery({
549554
state: "MarkedForShipment",
550555
shipmentDetail: shipmentDetail1(),

front/src/views/QrReader/QrReaderMultiBoxAssignTags.test.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ const mockTagsQuery = ({
5858
? null
5959
: {
6060
shipments: [generateMockShipmentMinimal({ state: "Preparing", iAmSource: true })],
61-
base: { locations, tags: tagsArray },
61+
base: { locations, id: "1", tags: tagsArray },
6262
},
6363
errors: graphQlError ? [new FakeGraphQLError()] : undefined,
6464
},

0 commit comments

Comments
 (0)