1- /* eslint-disable react-hooks/exhaustive-deps */
21import { stringify } from "qs" ;
3- import { useEffect , useMemo , useState } from "react" ;
2+ import { useCallback , useEffect , useMemo , useRef , useState } from "react" ;
43import useRouter from "use-react-router" ;
54
6- import { InitialAttributesAPIState } from "../API/initialState/attributes/useInitialAttributesState" ;
7- import { InitialCollectionAPIState } from "../API/initialState/collections/useInitialCollectionsState" ;
8- import { InitialGiftCardsAPIState } from "../API/initialState/giftCards/useInitialGiftCardsState" ;
9- import { InitialOrderAPIState } from "../API/initialState/orders/useInitialOrderState" ;
10- import { InitialPageAPIState } from "../API/initialState/page/useInitialPageState" ;
11- import { InitialProductAPIState } from "../API/initialState/product/useProductInitialAPIState" ;
12- import { InitialProductTypesAPIState } from "../API/initialState/productTypes/useInitialProdutTypesState" ;
13- import { InitialStaffMembersAPIState } from "../API/initialState/staffMembers/useInitialStaffMemebersState" ;
14- import { InitialVoucherAPIState } from "../API/initialState/vouchers/useInitialVouchersState" ;
155import { FilterContainer , FilterElement } from "../FilterElement" ;
166import { FilterValueProvider } from "../FilterValueProvider" ;
177import { FilterProviderType , InitialAPIState } from "../types" ;
188import { TokenArray } from "./TokenArray" ;
19- import {
20- AttributesFetchingParams ,
21- CollectionFetchingParams ,
22- FetchingParams ,
23- getEmptyFetchingPrams ,
24- GiftCardsFetchingParams ,
25- OrderFetchingParams ,
26- PageFetchingParams ,
27- ProductTypesFetchingParams ,
28- StaffMembersFetchingParams ,
29- VoucherFetchingParams ,
30- } from "./TokenArray/fetchingParams" ;
9+ import { getEmptyFetchingPrams } from "./TokenArray/fetchingParams" ;
3110import { prepareStructure } from "./utils" ;
3211
12+ /*
13+ * Race condition fix for URL ↔ React state synchronization.
14+ *
15+ * Problem: Filter value has two sources:
16+ * 1. Rehydration: computed from URL params + fetched GraphQL data
17+ * 2. User action: when user selects a filter, we update URL and set value directly
18+ *
19+ * When user selects a filter:
20+ * - persist() calls router.history.replace() which changes the URL
21+ * - URL change causes tokenizedUrl/rehydratedValue to recompute
22+ * - But GraphQL data is stale (from before the selection)
23+ * - So rehydratedValue overwrites user's selection with old data
24+ *
25+ * Solution: UserOverride stores user's selection + URL snapshot.
26+ * When computing final value, we check if URL still matches the snapshot.
27+ * If yes → user just set this, use their value.
28+ * If no → URL changed externally, use rehydrated value.
29+ */
30+ interface UserOverride {
31+ urlSnapshot : string ;
32+ value : FilterContainer ;
33+ }
34+
3335export const useUrlValueProvider = (
3436 locationSearch : string ,
3537 type : FilterProviderType ,
3638 initialState ?: InitialAPIState ,
3739) : FilterValueProvider => {
3840 const router = useRouter ( ) ;
3941 const params = new URLSearchParams ( locationSearch ) ;
40- const [ value , setValue ] = useState < FilterContainer > ( [ ] ) ;
42+ const [ userOverride , setUserOverride ] = useState < UserOverride | null > ( null ) ;
4143 const activeTab = params . get ( "activeTab" ) ;
4244 const query = params . get ( "query" ) ;
4345 const before = params . get ( "before" ) ;
@@ -50,115 +52,119 @@ export const useUrlValueProvider = (
5052 params . delete ( "before" ) ;
5153 params . delete ( "after" ) ;
5254
53- const tokenizedUrl = useMemo ( ( ) => new TokenArray ( params . toString ( ) ) , [ params . toString ( ) ] ) ;
55+ const filterQueryString = params . toString ( ) ;
56+ const tokenizedUrl = useMemo ( ( ) => new TokenArray ( filterQueryString ) , [ filterQueryString ] ) ;
5457 const paramsFromType = getEmptyFetchingPrams ( type ) ;
5558 const fetchingParams = paramsFromType
5659 ? tokenizedUrl . getFetchingParams ( paramsFromType , type )
5760 : null ;
5861
62+ const data = initialState ?. data ;
63+ const loading = initialState ?. loading ?? false ;
64+
65+ // Store fetchQueries in a ref to avoid triggering useEffect when initialState changes
66+ // but the function reference is the same (common with hooks returning stable callbacks).
67+ // Type assertion needed because InitialAPIState is a union type where each variant
68+ // has fetchQueries accepting different params. The correlation between `type` and
69+ // `initialState` ensures the correct params are passed, but TS can't narrow the union.
70+ const fetchQueriesRef = useRef < ( ( params : typeof fetchingParams ) => Promise < void > ) | null > ( null ) ;
71+
5972 useEffect ( ( ) => {
60- if ( initialState ) {
61- switch ( type ) {
62- case "product" :
63- ( initialState as InitialProductAPIState ) . fetchQueries ( fetchingParams as FetchingParams ) ;
64- break ;
65- case "order" :
66- ( initialState as InitialOrderAPIState ) . fetchQueries (
67- fetchingParams as OrderFetchingParams ,
68- ) ;
69- break ;
70- case "voucher" :
71- ( initialState as InitialVoucherAPIState ) . fetchQueries (
72- fetchingParams as VoucherFetchingParams ,
73- ) ;
74- break ;
75- case "page" :
76- ( initialState as InitialPageAPIState ) . fetchQueries ( fetchingParams as PageFetchingParams ) ;
77- break ;
78- case "gift-cards" :
79- ( initialState as InitialGiftCardsAPIState ) . fetchQueries (
80- fetchingParams as GiftCardsFetchingParams ,
81- ) ;
82- break ;
83- case "collection" :
84- ( initialState as InitialCollectionAPIState ) . fetchQueries (
85- fetchingParams as CollectionFetchingParams ,
86- ) ;
87- break ;
88- case "product-types" :
89- ( initialState as InitialProductTypesAPIState ) . fetchQueries (
90- fetchingParams as ProductTypesFetchingParams ,
91- ) ;
92- break ;
93- case "staff-members" :
94- ( initialState as InitialStaffMembersAPIState ) . fetchQueries (
95- fetchingParams as StaffMembersFetchingParams ,
96- ) ;
97- break ;
98- case "attributes" :
99- ( initialState as InitialAttributesAPIState ) . fetchQueries (
100- fetchingParams as AttributesFetchingParams ,
101- ) ;
102- break ;
103- }
104- }
105- } , [ locationSearch ] ) ;
73+ fetchQueriesRef . current = initialState ?. fetchQueries as
74+ | ( ( params : typeof fetchingParams ) => Promise < void > )
75+ | null ;
76+ } , [ initialState ?. fetchQueries ] ) ;
10677
10778 useEffect ( ( ) => {
108- if ( ! initialState ) return ;
79+ if ( ! fetchQueriesRef . current || ! fetchingParams ) return ;
10980
110- const { data, loading } = initialState ;
81+ fetchQueriesRef . current ( fetchingParams ) ;
82+ } , [ fetchingParams ] ) ;
11183
112- if ( loading ) return ;
84+ const rehydratedValue = useMemo ( ( ) : FilterContainer => {
85+ if ( initialState ) {
86+ if ( loading || ! data ) {
87+ return [ ] ;
88+ }
11389
114- setValue ( tokenizedUrl . asFilterValuesFromResponse ( data ) ) ;
115- // Only run after fetching the initial data; otherwise, a race condition may occur.
116- } , [ initialState ?. data , initialState ?. loading ] ) ;
90+ return tokenizedUrl . asFilterValuesFromResponse ( data ) ;
91+ }
11792
118- useEffect ( ( ) => {
119- if ( initialState ) return ;
93+ return tokenizedUrl . asFilterValueFromEmpty ( ) ;
94+ } , [ initialState , loading , data , tokenizedUrl ] ) ;
12095
121- setValue ( tokenizedUrl . asFilterValueFromEmpty ( ) ) ;
122- } , [ locationSearch , tokenizedUrl , initialState ] ) ;
96+ const value = useMemo ( ( ) : FilterContainer => {
97+ if ( userOverride && userOverride . urlSnapshot === filterQueryString ) {
98+ return userOverride . value ;
99+ }
123100
124- const persist = ( filterValue : FilterContainer ) => {
125- router . history . replace ( {
126- pathname : router . location . pathname ,
127- search : stringify ( {
128- ...prepareStructure ( filterValue ) ,
129- ...{ activeTab : activeTab || undefined } ,
130- ...{ query : query || undefined } ,
131- ...{ before : before || undefined } ,
132- ...{ after : after || undefined } ,
133- } ) ,
134- } ) ;
135- setValue ( filterValue ) ;
136- } ;
101+ return rehydratedValue ;
102+ } , [ userOverride , filterQueryString , rehydratedValue ] ) ;
137103
138- const clear = ( ) => {
104+ const persist = useCallback (
105+ ( filterValue : FilterContainer ) => {
106+ const newParams = {
107+ ...prepareStructure ( filterValue ) ,
108+ ...( activeTab ? { activeTab } : { } ) ,
109+ ...( query ? { query } : { } ) ,
110+ ...( before ? { before } : { } ) ,
111+ ...( after ? { after } : { } ) ,
112+ } ;
113+
114+ router . history . replace ( {
115+ pathname : router . location . pathname ,
116+ search : stringify ( newParams ) ,
117+ } ) ;
118+
119+ const filterStructureParams = prepareStructure ( filterValue ) ;
120+ const newUrlSnapshot = Object . entries ( filterStructureParams )
121+ . map ( ( [ k , v ] ) => `${ k } =${ encodeURIComponent ( String ( v ) ) } ` )
122+ . join ( "&" ) ;
123+
124+ setUserOverride ( {
125+ urlSnapshot : newUrlSnapshot ,
126+ value : filterValue ,
127+ } ) ;
128+ } ,
129+ [ router , activeTab , query , before , after ] ,
130+ ) ;
131+
132+ const clear = useCallback ( ( ) => {
139133 router . history . replace ( {
140134 pathname : router . location . pathname ,
141135 } ) ;
142- setValue ( [ ] ) ;
143- } ;
144-
145- const isPersisted = ( element : FilterElement ) => {
146- return value . some ( p => FilterElement . isFilterElement ( p ) && p . equals ( element ) ) ;
147- } ;
148-
149- const getTokenByName = ( name : string ) => {
150- return tokenizedUrl . asFlatArray ( ) . find ( token => token . name === name ) ;
151- } ;
152-
153- const count = value . filter ( v => typeof v !== "string" ) . length ;
154-
155- return {
156- value,
157- loading : initialState ?. loading || false ,
158- persist,
159- clear,
160- isPersisted,
161- getTokenByName,
162- count,
163- } ;
136+ setUserOverride ( {
137+ urlSnapshot : "" ,
138+ value : [ ] ,
139+ } ) ;
140+ } , [ router ] ) ;
141+
142+ const isPersisted = useCallback (
143+ ( element : FilterElement ) => {
144+ return value . some ( p => FilterElement . isFilterElement ( p ) && p . equals ( element ) ) ;
145+ } ,
146+ [ value ] ,
147+ ) ;
148+
149+ const getTokenByName = useCallback (
150+ ( name : string ) => {
151+ return tokenizedUrl . asFlatArray ( ) . find ( token => token . name === name ) ;
152+ } ,
153+ [ tokenizedUrl ] ,
154+ ) ;
155+
156+ const count = useMemo ( ( ) => value . filter ( v => typeof v !== "string" ) . length , [ value ] ) ;
157+
158+ return useMemo (
159+ ( ) => ( {
160+ value,
161+ loading : initialState ?. loading || false ,
162+ persist,
163+ clear,
164+ isPersisted,
165+ getTokenByName,
166+ count,
167+ } ) ,
168+ [ value , initialState ?. loading , persist , clear , isPersisted , getTokenByName , count ] ,
169+ ) ;
164170} ;
0 commit comments