From 302cdfd6090e15f2159a9448c36db08f18c9507a Mon Sep 17 00:00:00 2001 From: Lex0865 Date: Sun, 28 Jun 2026 09:19:42 -0700 Subject: [PATCH] Replace replaceState with pushState for search filter changes Search store (src/app/store/searchStore.ts) now calls pushState for significant filter changes (query, difficulty, topics, sort, price, duration, instructors) creating history entries so the Back button restores previous search state. replaceState is retained only for transient pagination cursor updates. A popstate listener restores filter state from URL on back/forward navigation. All 14 tests pass. Closes #777 --- src/app/store/__tests__/searchStore.test.ts | 233 ++++++++++++++++++++ src/app/store/searchStore.ts | 131 +++++++++-- 2 files changed, 342 insertions(+), 22 deletions(-) create mode 100644 src/app/store/__tests__/searchStore.test.ts diff --git a/src/app/store/__tests__/searchStore.test.ts b/src/app/store/__tests__/searchStore.test.ts new file mode 100644 index 00000000..41e961db --- /dev/null +++ b/src/app/store/__tests__/searchStore.test.ts @@ -0,0 +1,233 @@ +import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'; +import { useSearchStore } from '../searchStore'; + +const localStorageMock = (() => { + let store: Record = {}; + return { + getItem: (key: string) => store[key] ?? null, + setItem: (key: string, value: string) => { + store[key] = value; + }, + removeItem: (key: string) => { + delete store[key]; + }, + clear: () => { + store = {}; + }, + }; +})(); + +vi.stubGlobal('localStorage', localStorageMock); + +const pushStateMock = vi.fn(); +const replaceStateMock = vi.fn(); +const originalLocation = window.location; + +beforeEach(() => { + localStorageMock.clear(); + useSearchStore.setState({ + searchQuery: '', + cursor: undefined, + difficulty: [], + duration: [0, 20], + topics: [], + instructors: [], + sortBy: 'relevance', + price: [0, 1000], + searchHistory: [], + }); + + Object.defineProperty(window, 'location', { + value: { ...originalLocation, pathname: '/search', search: '' }, + writable: true, + }); + + pushStateMock.mockClear(); + replaceStateMock.mockClear(); + vi.stubGlobal('history', { + pushState: pushStateMock, + replaceState: replaceStateMock, + state: {}, + }); +}); + +afterEach(() => { + Object.defineProperty(window, 'location', { + value: originalLocation, + writable: true, + }); +}); + +describe('searchStore URL sync', () => { + it('calls pushState when searchQuery changes (significant change)', () => { + const store = useSearchStore.getState(); + store.setSearchQuery('react'); + + expect(pushStateMock).toHaveBeenCalledTimes(1); + expect(pushStateMock).toHaveBeenCalledWith( + expect.objectContaining({ searchState: expect.objectContaining({ searchQuery: 'react' }) }), + '', + expect.stringContaining('q=react'), + ); + expect(replaceStateMock).not.toHaveBeenCalled(); + }); + + it('calls pushState when difficulty filter changes (significant change)', () => { + const store = useSearchStore.getState(); + store.setDifficulty(['beginner']); + + expect(pushStateMock).toHaveBeenCalledTimes(1); + expect(pushStateMock).toHaveBeenCalledWith( + expect.anything(), + '', + expect.stringContaining('difficulty=beginner'), + ); + }); + + it('calls pushState when sortBy changes (significant change)', () => { + const store = useSearchStore.getState(); + store.setSortBy('rating'); + + expect(pushStateMock).toHaveBeenCalledTimes(1); + expect(pushStateMock).toHaveBeenCalledWith( + expect.anything(), + '', + expect.stringContaining('sort=rating'), + ); + }); + + it('calls replaceState when cursor changes (minor/transient change)', () => { + const store = useSearchStore.getState(); + store.setCursor('abc123'); + + expect(replaceStateMock).toHaveBeenCalledWith( + expect.objectContaining({ searchState: expect.objectContaining({ cursor: 'abc123' }) }), + '', + expect.stringContaining('cursor=abc123'), + ); + expect(pushStateMock).not.toHaveBeenCalled(); + }); + + it('clears cursor on a new search query and uses pushState', () => { + useSearchStore.setState({ cursor: 'old-cursor' }); + const store = useSearchStore.getState(); + store.setSearchQuery('react'); + + expect(pushStateMock).toHaveBeenCalledWith( + expect.objectContaining({ + searchState: expect.not.objectContaining({ cursor: 'old-cursor' }), + }), + '', + expect.not.stringContaining('cursor'), + ); + const updatedCursor = useSearchStore.getState().cursor; + expect(updatedCursor).toBeUndefined(); + }); + + it('includes all active filters in the URL on setSearchQuery', () => { + useSearchStore.setState({ + difficulty: ['intermediate'], + topics: ['design'], + sortBy: 'newest', + }); + + const store = useSearchStore.getState(); + store.setSearchQuery('ux'); + + const urlArg = pushStateMock.mock.calls[0][2]; + expect(urlArg).toContain('q=ux'); + expect(urlArg).toContain('difficulty=intermediate'); + expect(urlArg).toContain('topics=design'); + expect(urlArg).toContain('sort=newest'); + }); + + it('does not include default values in the URL', () => { + const store = useSearchStore.getState(); + store.setSearchQuery('test'); + + const urlArg = pushStateMock.mock.calls[0][2]; + expect(urlArg).toContain('q=test'); + expect(urlArg).not.toContain('sort='); + expect(urlArg).not.toContain('price='); + expect(urlArg).not.toContain('duration='); + }); +}); + +describe('searchStore pushState state payload', () => { + it('includes full state snapshot in pushState', () => { + useSearchStore.setState({ + difficulty: ['beginner'], + topics: ['react'], + sortBy: 'rating', + }); + const store = useSearchStore.getState(); + store.setSearchQuery('hooks'); + + const stateArg = pushStateMock.mock.calls[0][0]; + expect(stateArg.searchState).toMatchObject({ + searchQuery: 'hooks', + difficulty: ['beginner'], + topics: ['react'], + sortBy: 'rating', + }); + }); +}); + +describe('searchStore updateFromUrl', () => { + it('parses search query from URL params', () => { + const params = new URLSearchParams('q=react'); + useSearchStore.getState().updateFromUrl(params); + expect(useSearchStore.getState().searchQuery).toBe('react'); + }); + + it('parses difficulty from URL params', () => { + const params = new URLSearchParams('difficulty=beginner,intermediate'); + useSearchStore.getState().updateFromUrl(params); + expect(useSearchStore.getState().difficulty).toEqual(['beginner', 'intermediate']); + }); + + it('parses cursor from URL params', () => { + const params = new URLSearchParams('cursor=abc123'); + useSearchStore.getState().updateFromUrl(params); + expect(useSearchStore.getState().cursor).toBe('abc123'); + }); + + it('parses cursor as empty string when present but empty', () => { + const params = new URLSearchParams('cursor='); + useSearchStore.getState().updateFromUrl(params); + expect(useSearchStore.getState().cursor).toBe(''); + }); + + it('restores full filter state from URL params', () => { + const params = new URLSearchParams( + 'q=design&difficulty=advanced&topics=ui,ux&sort=rating&duration=5,15&price=10,500', + ); + useSearchStore.getState().updateFromUrl(params); + const state = useSearchStore.getState(); + expect(state.searchQuery).toBe('design'); + expect(state.difficulty).toEqual(['advanced']); + expect(state.topics).toEqual(['ui', 'ux']); + expect(state.sortBy).toBe('rating'); + expect(state.duration).toEqual([5, 15]); + expect(state.price).toEqual([10, 500]); + }); + + it('resets fields to defaults when params are absent', () => { + useSearchStore.setState({ + searchQuery: 'old', + difficulty: ['advanced'], + topics: ['design'], + sortBy: 'rating', + cursor: 'abc', + }); + + const params = new URLSearchParams(''); + useSearchStore.getState().updateFromUrl(params); + const state = useSearchStore.getState(); + expect(state.searchQuery).toBe(''); + expect(state.difficulty).toEqual([]); + expect(state.topics).toEqual([]); + expect(state.sortBy).toBe('relevance'); + expect(state.cursor).toBeUndefined(); + }); +}); diff --git a/src/app/store/searchStore.ts b/src/app/store/searchStore.ts index 7f1a71e7..5b111219 100644 --- a/src/app/store/searchStore.ts +++ b/src/app/store/searchStore.ts @@ -21,6 +21,10 @@ interface FilterState { } interface SearchStore extends FilterState { + searchQuery: string; + cursor: string | undefined; + setSearchQuery: (query: string) => void; + setCursor: (cursor: string | undefined) => void; setDifficulty: (difficulty: Difficulty[]) => void; setDuration: (duration: [number, number]) => void; setTopics: (topics: string[]) => void; @@ -28,14 +32,14 @@ interface SearchStore extends FilterState { setSortBy: (sortBy: SortOption) => void; setPrice: (price: [number, number]) => void; clearFilters: () => void; - syncWithUrl: () => void; + syncWithUrl: (options?: { push?: boolean }) => void; updateFromUrl: (params: URLSearchParams) => void; searchHistory: string[]; addToSearchHistory: (term: string) => void; clearSearchHistory: () => void; } -const initialState: FilterState = { +const initialFilterState: FilterState = { difficulty: [], duration: [0, 20], topics: [], @@ -47,34 +51,80 @@ const initialState: FilterState = { export const useSearchStore = create()( persist( (set, get) => ({ - ...initialState, + ...initialFilterState, + searchQuery: '', + cursor: undefined, searchHistory: [], - setDifficulty: (difficulty) => set({ difficulty }), - setDuration: (duration) => set({ duration }), - setTopics: (topics) => set({ topics }), - setInstructors: (instructors) => set({ instructors }), - setSortBy: (sortBy) => set({ sortBy }), - setPrice: (price) => set({ price }), - clearFilters: () => set(initialState), + + setSearchQuery: (query) => { + set({ searchQuery: query, cursor: undefined }); + get().syncWithUrl({ push: true }); + }, + + setCursor: (cursor) => { + set({ cursor }); + get().syncWithUrl({ push: false }); + }, + + setDifficulty: (difficulty) => { + set({ difficulty, cursor: undefined }); + get().syncWithUrl({ push: true }); + }, + + setDuration: (duration) => { + set({ duration, cursor: undefined }); + get().syncWithUrl({ push: true }); + }, + + setTopics: (topics) => { + set({ topics, cursor: undefined }); + get().syncWithUrl({ push: true }); + }, + + setInstructors: (instructors) => { + set({ instructors, cursor: undefined }); + get().syncWithUrl({ push: true }); + }, + + setSortBy: (sortBy) => { + set({ sortBy, cursor: undefined }); + get().syncWithUrl({ push: true }); + }, + + setPrice: (price) => { + set({ price, cursor: undefined }); + get().syncWithUrl({ push: true }); + }, + + clearFilters: () => { + set({ ...initialFilterState, searchQuery: '', cursor: undefined }); + get().syncWithUrl({ push: true }); + }, + addToSearchHistory: (term: string) => { if (!term.trim()) return; const current = get().searchHistory; - const updated = [term, ...current.filter((item) => item !== term)].slice(0, 10); // Keep last 10 searches + const updated = [term, ...current.filter((item) => item !== term)].slice(0, 10); set({ searchHistory: updated }); }, + clearSearchHistory: () => set({ searchHistory: [] }), - syncWithUrl: () => { + + syncWithUrl: (options = { push: true }) => { const params = new URLSearchParams(window.location.search); const state = get(); + if (state.searchQuery) { + params.set('q', state.searchQuery); + } if (state.difficulty.length) { params.set('difficulty', state.difficulty.join(',')); } if ( - state.duration[0] !== initialState.duration[0] || - state.duration[1] !== initialState.duration[1] + state.duration[0] !== initialFilterState.duration[0] || + state.duration[1] !== initialFilterState.duration[1] ) { params.set('duration', `${state.duration[0]},${state.duration[1]}`); } @@ -84,26 +134,39 @@ export const useSearchStore = create()( if (state.instructors.length) { params.set('instructors', state.instructors.join(',')); } - if (state.sortBy !== initialState.sortBy) { + if (state.sortBy !== initialFilterState.sortBy) { params.set('sort', state.sortBy); } - if (state.price[0] !== initialState.price[0] || state.price[1] !== initialState.price[1]) { + if ( + state.price[0] !== initialFilterState.price[0] || + state.price[1] !== initialFilterState.price[1] + ) { params.set('price', `${state.price[0]},${state.price[1]}`); } + if (state.cursor) { + params.set('cursor', state.cursor); + } - window.history.replaceState( - {}, - '', - `${window.location.pathname}${params.toString() ? `?${params.toString()}` : ''}`, - ); + const url = `${window.location.pathname}${params.toString() ? `?${params.toString()}` : ''}`; + + if (options.push) { + window.history.pushState({ searchState: { ...state } }, '', url); + } else { + window.history.replaceState({ searchState: { ...state } }, '', url); + } }, + updateFromUrl: (params) => { - const newState = { ...initialState }; + const newState: Record = {}; + + newState.searchQuery = params.get('q') ?? ''; const difficulty = params.get('difficulty'); if (difficulty) { const validated = difficulty.split(',').map(sanitizeString).filter(isValidDifficulty); if (validated.length) newState.difficulty = validated; + } else { + newState.difficulty = []; } const duration = params.get('duration'); @@ -111,24 +174,32 @@ export const useSearchStore = create()( const parts = duration.split(',').map(Number); const validated = validateNumericRange(parts[0], parts[1], 0, 100); if (validated) newState.duration = validated; + } else { + newState.duration = [0, 20]; } const topics = params.get('topics'); if (topics) { const validated = validateStringArray(topics.split(',')); if (validated.length) newState.topics = validated; + } else { + newState.topics = []; } const instructors = params.get('instructors'); if (instructors) { const validated = validateStringArray(instructors.split(',')); if (validated.length) newState.instructors = validated; + } else { + newState.instructors = []; } const sort = params.get('sort'); if (sort) { const sanitized = sanitizeString(sort); if (isValidSortOption(sanitized)) newState.sortBy = sanitized; + } else { + newState.sortBy = 'relevance'; } const price = params.get('price'); @@ -136,6 +207,15 @@ export const useSearchStore = create()( const parts = price.split(',').map(Number); const validated = validateNumericRange(parts[0], parts[1], 0, 10000); if (validated) newState.price = validated; + } else { + newState.price = [0, 1000]; + } + + const cursor = params.get('cursor'); + if (cursor !== null) { + newState.cursor = cursor; + } else { + newState.cursor = undefined; } set(newState); @@ -146,3 +226,10 @@ export const useSearchStore = create()( }, ), ); + +if (typeof window !== 'undefined') { + window.addEventListener('popstate', () => { + const params = new URLSearchParams(window.location.search); + useSearchStore.getState().updateFromUrl(params); + }); +}