11/**
22 * Hook for managing filter state and URL synchronization.
33 *
4- * Encapsulates all filter-related state and callbacks used in App.tsx .
4+ * Uses persistent state from Layout context to survive navigation .
55 */
66
77import { useState , useCallback , useEffect , useRef } from 'react' ;
88
99import type { PlotImage , FilterCategory , ActiveFilters , FilterCounts } from '../types' ;
1010import { FILTER_CATEGORIES } from '../types' ;
1111import { API_URL , BATCH_SIZE } from '../constants' ;
12+ import { useHomeState } from '../components/Layout' ;
1213
1314/**
14- * Fisher-Yates shuffle algorithm .
15+ * Seeded random number generator (mulberry32) .
1516 */
16- function shuffleArray < T > ( array : T [ ] ) : T [ ] {
17+ function seededRandom ( seed : number ) : ( ) => number {
18+ return ( ) => {
19+ let t = ( seed += 0x6d2b79f5 ) ;
20+ t = Math . imul ( t ^ ( t >>> 15 ) , t | 1 ) ;
21+ t ^= t + Math . imul ( t ^ ( t >>> 7 ) , t | 61 ) ;
22+ return ( ( t ^ ( t >>> 14 ) ) >>> 0 ) / 4294967296 ;
23+ } ;
24+ }
25+
26+ /**
27+ * Fisher-Yates shuffle algorithm with optional seed for deterministic results.
28+ */
29+ function shuffleArray < T > ( array : T [ ] , seed ?: number ) : T [ ] {
1730 const shuffled = [ ...array ] ;
31+ const random = seed !== undefined ? seededRandom ( seed ) : Math . random ;
1832 for ( let i = shuffled . length - 1 ; i > 0 ; i -- ) {
19- const j = Math . floor ( Math . random ( ) * ( i + 1 ) ) ;
33+ const j = Math . floor ( random ( ) * ( i + 1 ) ) ;
2034 [ shuffled [ i ] , shuffled [ j ] ] = [ shuffled [ j ] , shuffled [ i ] ] ;
2135 }
2236 return shuffled ;
2337}
2438
39+ /**
40+ * Generate a hash from filter state for deterministic shuffle.
41+ */
42+ function hashFilters ( filters : ActiveFilters ) : number {
43+ const str = JSON . stringify ( filters ) ;
44+ let hash = 0 ;
45+ for ( let i = 0 ; i < str . length ; i ++ ) {
46+ const char = str . charCodeAt ( i ) ;
47+ hash = ( hash << 5 ) - hash + char ;
48+ hash = hash & hash ;
49+ }
50+ return Math . abs ( hash ) ;
51+ }
52+
2553/**
2654 * Parse URL params into ActiveFilters.
2755 * URL format: ?lib=matplotlib&lib=seaborn (AND) or ?lib=matplotlib,seaborn (OR within group)
@@ -106,19 +134,35 @@ export function useFilterState({
106134 onTrackPageview,
107135 onTrackEvent,
108136} : UseFilterStateOptions ) : UseFilterStateReturn {
109- // Filter state - initialize from URL params immediately
110- const [ activeFilters , setActiveFilters ] = useState < ActiveFilters > ( ( ) => parseUrlFilters ( ) ) ;
111- const [ filterCounts , setFilterCounts ] = useState < FilterCounts | null > ( null ) ;
112- const [ globalCounts , setGlobalCounts ] = useState < FilterCounts | null > ( null ) ;
113- const [ orCounts , setOrCounts ] = useState < Record < string , number > [ ] > ( [ ] ) ;
137+ const { homeStateRef, setHomeState } = useHomeState ( ) ;
138+
139+ // Initialize from persistent state (ref) or URL params (all using lazy initializers)
140+ const [ activeFilters , setActiveFilters ] = useState < ActiveFilters > ( ( ) =>
141+ homeStateRef . current . initialized ? homeStateRef . current . activeFilters : parseUrlFilters ( )
142+ ) ;
143+ const [ filterCounts , setFilterCounts ] = useState < FilterCounts | null > ( ( ) =>
144+ homeStateRef . current . initialized ? homeStateRef . current . filterCounts : null
145+ ) ;
146+ const [ globalCounts , setGlobalCounts ] = useState < FilterCounts | null > ( ( ) =>
147+ homeStateRef . current . initialized ? homeStateRef . current . globalCounts : null
148+ ) ;
149+ const [ orCounts , setOrCounts ] = useState < Record < string , number > [ ] > ( ( ) =>
150+ homeStateRef . current . initialized ? homeStateRef . current . orCounts : [ ]
151+ ) ;
114152
115- // Image state
116- const [ allImages , setAllImages ] = useState < PlotImage [ ] > ( [ ] ) ;
117- const [ displayedImages , setDisplayedImages ] = useState < PlotImage [ ] > ( [ ] ) ;
118- const [ hasMore , setHasMore ] = useState ( false ) ;
153+ // Image state - restore from persistent state if available
154+ const [ allImages , setAllImages ] = useState < PlotImage [ ] > ( ( ) =>
155+ homeStateRef . current . initialized ? homeStateRef . current . allImages : [ ]
156+ ) ;
157+ const [ displayedImages , setDisplayedImages ] = useState < PlotImage [ ] > ( ( ) =>
158+ homeStateRef . current . initialized ? homeStateRef . current . displayedImages : [ ]
159+ ) ;
160+ const [ hasMore , setHasMore ] = useState ( ( ) =>
161+ homeStateRef . current . initialized ? homeStateRef . current . hasMore : false
162+ ) ;
119163
120164 // UI state
121- const [ loading , setLoading ] = useState ( true ) ;
165+ const [ loading , setLoading ] = useState ( ( ) => ! homeStateRef . current . initialized ) ;
122166 const [ error , setError ] = useState < string > ( '' ) ;
123167 const [ randomAnimation , setRandomAnimation ] = useState < {
124168 index : number ;
@@ -130,6 +174,23 @@ export function useFilterState({
130174 const activeFiltersRef = useRef ( activeFilters ) ;
131175 activeFiltersRef . current = activeFilters ;
132176
177+ // Sync state changes back to persistent context
178+ useEffect ( ( ) => {
179+ if ( allImages . length > 0 || displayedImages . length > 0 ) {
180+ setHomeState ( ( prev ) => ( {
181+ ...prev ,
182+ allImages,
183+ displayedImages,
184+ activeFilters,
185+ filterCounts,
186+ globalCounts,
187+ orCounts,
188+ hasMore,
189+ initialized : true ,
190+ } ) ) ;
191+ }
192+ } , [ allImages , displayedImages , activeFilters , filterCounts , globalCounts , orCounts , hasMore , setHomeState ] ) ;
193+
133194 // Add a new filter group (creates new chip - AND with other groups)
134195 const handleAddFilter = useCallback ( ( category : FilterCategory , value : string ) => {
135196 setActiveFilters ( ( prev ) => [ ...prev , { category, values : [ value ] } ] ) ;
@@ -234,8 +295,23 @@ export function useFilterState({
234295 onTrackPageview ( ) ;
235296 } , [ activeFilters , onTrackPageview ] ) ;
236297
298+ // Track if we should skip initial fetch (restored from persistent state)
299+ const initializedRef = useRef ( homeStateRef . current . initialized ) ;
300+ const filtersMatchRef = useRef (
301+ homeStateRef . current . initialized && JSON . stringify ( homeStateRef . current . activeFilters ) === JSON . stringify ( activeFilters )
302+ ) ;
303+
237304 // Load filtered images when filters change
238305 useEffect ( ( ) => {
306+ // Skip fetch on first mount if restored from persistent state with same filters
307+ if ( initializedRef . current && filtersMatchRef . current ) {
308+ initializedRef . current = false ;
309+ filtersMatchRef . current = false ;
310+ return ;
311+ }
312+ initializedRef . current = false ;
313+ filtersMatchRef . current = false ;
314+
239315 const abortController = new AbortController ( ) ;
240316
241317 const fetchFilteredImages = async ( ) => {
@@ -265,9 +341,12 @@ export function useFilterState({
265341 setGlobalCounts ( data . globalCounts || data . counts ) ;
266342 setOrCounts ( data . orCounts || [ ] ) ;
267343
268- // Shuffle and set images
269- const shuffled = shuffleArray < PlotImage > ( data . images || [ ] ) ;
344+ // Shuffle with deterministic seed based on filters
345+ const seed = hashFilters ( activeFilters ) ;
346+ const shuffled = shuffleArray < PlotImage > ( data . images || [ ] , seed ) ;
270347 setAllImages ( shuffled ) ;
348+
349+ // Initial display count
271350 setDisplayedImages ( shuffled . slice ( 0 , BATCH_SIZE ) ) ;
272351 setHasMore ( shuffled . length > BATCH_SIZE ) ;
273352 } catch ( err ) {
0 commit comments