Skip to content

Commit 4eb95ea

Browse files
authored
feat: persist search history by locale (#3231)
## What's the purpose of this pull request? Stores with multiple locales shared one search history. This PR stores history **per locale** so each language has its own list. ## How it works? - Same IndexedDB key `fs::searchHistory`, but value is now `Record<locale, History[]>` (e.g. `{ "en-US": [...], "pt-BR": [...] }`). - `useSearchHistory` uses `useSession().locale` to read/write only the current locale. Public API unchanged. - Legacy array format is migrated on first load to the new shape (array → `{ defaultLocale: [...] }`). ## How to test it? 1. Run the store (`pnpm dev`). 2. In locale A: search a few terms, focus search again → history appears. 3. Switch to locale B → history is empty (or only B’s terms). 4. Search in B, focus → only B’s terms. Switch back to A → A’s history still there. 5. (Optional) DevTools → Application → IndexedDB → `fs::searchHistory` → object with one key per locale. <img width="1763" height="975" alt="image" src="https://github.com/user-attachments/assets/abbb2d36-f8ba-48cf-aa74-c7f7e70bc362" /> ### Starters Deploy Preview https://brandless-cma5xay4001f6dn4xjwato8b4-mw8252v1z-vtex-fs.vercel.app/it-IT https://brandless-cma5xay4001f6dn4xjwato8b4-mw8252v1z-vtex-fs.vercel.app/pt-BR <!--- Add link to starter.store deploy preview for this branch. ---> ## References - [SFS-2890](https://vtex-dev.atlassian.net/jira/software/c/projects/SFS/boards/1051?selectedIssue=SFS-2890) [SFS-2890]: https://vtex-dev.atlassian.net/browse/SFS-2890?atlOrigin=eyJpIjoiNWRkNTljNzYxNjVmNDY3MDlhMDU5Y2ZhYzA5YTRkZjUiLCJwIjoiZ2l0aHViLWNvbS1KU1cifQ <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Search history is now locale-aware: each language/locale keeps its own history and changes are isolated per locale. * Legacy array-based histories are migrated automatically to the locale-based format. * **Behavior Improvements** * History entries are deduplicated, newest-first, and capped to a maximum size. * Clearing history removes only the active locale’s entries. * **Tests** * Comprehensive tests added for locale-aware history, migration, deduplication, limits, and clearing behavior. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
1 parent 75d6bbc commit 4eb95ea

File tree

2 files changed

+263
-7
lines changed

2 files changed

+263
-7
lines changed

packages/core/src/sdk/search/useSearchHistory.ts

Lines changed: 41 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,35 +1,69 @@
11
import { createStore } from '@faststore/sdk'
2-
2+
import { useEffect, useMemo } from 'react'
3+
import config from 'discovery.config'
34
import { useStore } from '../useStore'
5+
import { useSession } from '../session'
6+
7+
export type SearchHistoryByLocale = Record<string, History[]>
48

59
export const searchHistoryStore = createStore(
6-
[] as History[],
10+
{} as SearchHistoryByLocale,
711
`fs::searchHistory`
812
)
913

1014
const MAX_HISTORY_SIZE = 4
15+
const DEFAULT_LOCALE = config.localization.enabled
16+
? config.localization.defaultLocale
17+
: config.session.locale
1118

1219
export interface History {
1320
term: string
1421
path: string
1522
}
1623

24+
function migrateSearchHistory(value: unknown): SearchHistoryByLocale {
25+
if (value && typeof value === 'object' && !Array.isArray(value)) {
26+
return value as SearchHistoryByLocale
27+
}
28+
if (Array.isArray(value) && value.length > 0) {
29+
return { [DEFAULT_LOCALE]: value as History[] }
30+
}
31+
return {}
32+
}
33+
1734
export default function useSearchHistory(
1835
maxHistorySize: number = MAX_HISTORY_SIZE
1936
) {
20-
const searchHistory = useStore(searchHistoryStore)
37+
const locale = useSession()?.locale ?? DEFAULT_LOCALE
38+
const rawStore = useStore(searchHistoryStore)
39+
40+
const byLocale = useMemo(() => migrateSearchHistory(rawStore), [rawStore])
41+
42+
useEffect(() => {
43+
if (Array.isArray(rawStore)) {
44+
searchHistoryStore.set(migrateSearchHistory(rawStore))
45+
}
46+
}, [rawStore])
47+
48+
const searchHistory = byLocale[locale] ?? []
2149

2250
function addToSearchHistory(newHistory: History) {
2351
const set = new Set<string>()
24-
const newHistoryArray = [newHistory, ...searchHistory]
25-
.slice(0, maxHistorySize)
52+
const current = byLocale[locale] ?? []
53+
const newHistoryArray = [newHistory, ...current]
2654
.filter((item) => !set.has(item.term) && set.add(item.term), set)
55+
.slice(0, maxHistorySize)
2756

28-
searchHistoryStore.set(newHistoryArray)
57+
searchHistoryStore.set({
58+
...byLocale,
59+
[locale]: newHistoryArray,
60+
})
2961
}
3062

3163
function clearSearchHistory() {
32-
searchHistoryStore.set([])
64+
const next = { ...byLocale }
65+
delete next[locale]
66+
searchHistoryStore.set(Object.keys(next).length > 0 ? next : {})
3367
}
3468

3569
return {
Lines changed: 222 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,222 @@
1+
/**
2+
* @vitest-environment jsdom
3+
*/
4+
5+
import { act, renderHook } from '@testing-library/react'
6+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
7+
8+
vi.mock('discovery.config', () => ({
9+
__esModule: true,
10+
default: {
11+
localization: { enabled: false, defaultLocale: 'pt-BR' },
12+
session: { locale: 'en-US' },
13+
},
14+
}))
15+
16+
const mockUseSession = vi.hoisted(() => vi.fn(() => ({ locale: 'pt-BR' })))
17+
vi.mock('src/sdk/session', () => ({ useSession: mockUseSession }))
18+
19+
import {
20+
searchHistoryStore,
21+
type History,
22+
} from '../../../src/sdk/search/useSearchHistory'
23+
import useSearchHistory from '../../../src/sdk/search/useSearchHistory'
24+
25+
function makeHistory(term: string, path = `/s?q=${term}`) {
26+
return { term, path } satisfies History
27+
}
28+
29+
describe('useSearchHistory', () => {
30+
beforeEach(() => {
31+
searchHistoryStore.set({})
32+
mockUseSession.mockReturnValue({ locale: 'pt-BR' })
33+
})
34+
35+
afterEach(() => {
36+
vi.clearAllMocks()
37+
})
38+
39+
describe('searchHistory (read by locale)', () => {
40+
it('returns empty array when store is empty', () => {
41+
const { result } = renderHook(() => useSearchHistory())
42+
expect(result.current.searchHistory).toEqual([])
43+
})
44+
45+
it('returns history for current locale when store has data for that locale', () => {
46+
const items = [makeHistory('tenis'), makeHistory('camisa')]
47+
searchHistoryStore.set({ 'pt-BR': items })
48+
49+
const { result } = renderHook(() => useSearchHistory())
50+
expect(result.current.searchHistory).toEqual(items)
51+
})
52+
53+
it('returns empty array when store has data only for another locale', () => {
54+
searchHistoryStore.set({
55+
'en-US': [makeHistory('shoes')],
56+
})
57+
58+
const { result } = renderHook(() => useSearchHistory())
59+
expect(result.current.searchHistory).toEqual([])
60+
})
61+
62+
it('uses session locale and shows correct slice when locale changes', () => {
63+
searchHistoryStore.set({
64+
'pt-BR': [makeHistory('tenis')],
65+
'en-US': [makeHistory('shoes')],
66+
})
67+
68+
const { result, rerender } = renderHook(() => useSearchHistory())
69+
expect(result.current.searchHistory).toEqual([makeHistory('tenis')])
70+
71+
mockUseSession.mockReturnValue({ locale: 'en-US' })
72+
rerender()
73+
expect(result.current.searchHistory).toEqual([makeHistory('shoes')])
74+
})
75+
})
76+
77+
describe('addToSearchHistory', () => {
78+
it('adds item to current locale and persists', () => {
79+
const { result } = renderHook(() => useSearchHistory())
80+
81+
act(() => {
82+
result.current.addToSearchHistory(makeHistory('tenis'))
83+
})
84+
85+
expect(result.current.searchHistory).toHaveLength(1)
86+
expect(result.current.searchHistory[0].term).toBe('tenis')
87+
expect(searchHistoryStore.read()).toEqual({
88+
'pt-BR': [makeHistory('tenis')],
89+
})
90+
})
91+
92+
it('dedupes by term and moves to front', () => {
93+
searchHistoryStore.set({
94+
'pt-BR': [makeHistory('camisa'), makeHistory('tenis')],
95+
})
96+
const { result } = renderHook(() => useSearchHistory())
97+
98+
act(() => {
99+
result.current.addToSearchHistory(
100+
makeHistory('tenis', '/s?q=tenis&sort=score_desc')
101+
)
102+
})
103+
104+
expect(result.current.searchHistory.map((h) => h.term)).toEqual([
105+
'tenis',
106+
'camisa',
107+
])
108+
expect(result.current.searchHistory).toHaveLength(2)
109+
})
110+
111+
it('respects maxHistorySize (default 4)', () => {
112+
const { result } = renderHook(() => useSearchHistory())
113+
114+
act(() => result.current.addToSearchHistory(makeHistory('a')))
115+
act(() => result.current.addToSearchHistory(makeHistory('b')))
116+
act(() => result.current.addToSearchHistory(makeHistory('c')))
117+
act(() => result.current.addToSearchHistory(makeHistory('d')))
118+
act(() => result.current.addToSearchHistory(makeHistory('e')))
119+
120+
expect(result.current.searchHistory).toHaveLength(4)
121+
expect(result.current.searchHistory.map((h) => h.term)).toEqual([
122+
'e',
123+
'd',
124+
'c',
125+
'b',
126+
])
127+
})
128+
129+
it('can use custom maxHistorySize', () => {
130+
const { result } = renderHook(() => useSearchHistory(2))
131+
132+
act(() => result.current.addToSearchHistory(makeHistory('a')))
133+
act(() => result.current.addToSearchHistory(makeHistory('b')))
134+
act(() => result.current.addToSearchHistory(makeHistory('c')))
135+
136+
expect(result.current.searchHistory).toHaveLength(2)
137+
expect(result.current.searchHistory.map((h) => h.term)).toEqual([
138+
'c',
139+
'b',
140+
])
141+
})
142+
143+
it('does not affect other locales', () => {
144+
searchHistoryStore.set({
145+
'en-US': [makeHistory('shoes')],
146+
})
147+
const { result } = renderHook(() => useSearchHistory())
148+
149+
act(() => {
150+
result.current.addToSearchHistory(makeHistory('tenis'))
151+
})
152+
153+
expect(searchHistoryStore.read()).toEqual({
154+
'en-US': [makeHistory('shoes')],
155+
'pt-BR': [makeHistory('tenis')],
156+
})
157+
})
158+
})
159+
160+
describe('clearSearchHistory', () => {
161+
it('removes only current locale and leaves others', () => {
162+
searchHistoryStore.set({
163+
'pt-BR': [makeHistory('tenis')],
164+
'en-US': [makeHistory('shoes')],
165+
})
166+
const { result } = renderHook(() => useSearchHistory())
167+
168+
act(() => {
169+
result.current.clearSearchHistory()
170+
})
171+
172+
expect(result.current.searchHistory).toEqual([])
173+
expect(searchHistoryStore.read()).toEqual({
174+
'en-US': [makeHistory('shoes')],
175+
})
176+
})
177+
178+
it('sets store to empty object when clearing last locale', () => {
179+
searchHistoryStore.set({ 'pt-BR': [makeHistory('tenis')] })
180+
const { result } = renderHook(() => useSearchHistory())
181+
182+
act(() => {
183+
result.current.clearSearchHistory()
184+
})
185+
186+
expect(searchHistoryStore.read()).toEqual({})
187+
})
188+
})
189+
190+
describe('legacy format (array) migration', () => {
191+
it('normalizes array to Record with default locale and exposes as searchHistory when session matches default', () => {
192+
const legacy = [makeHistory('old1'), makeHistory('old2')]
193+
searchHistoryStore.set(legacy as unknown as Record<string, History[]>)
194+
mockUseSession.mockReturnValue({ locale: 'en-US' })
195+
196+
const { result } = renderHook(() => useSearchHistory())
197+
198+
expect(result.current.searchHistory).toHaveLength(2)
199+
expect(result.current.searchHistory.map((h) => h.term)).toEqual([
200+
'old1',
201+
'old2',
202+
])
203+
})
204+
205+
it('after migration persists Record so store no longer holds array', async () => {
206+
const legacy = [makeHistory('old1')]
207+
searchHistoryStore.set(legacy as unknown as Record<string, History[]>)
208+
mockUseSession.mockReturnValue({ locale: 'en-US' })
209+
210+
renderHook(() => useSearchHistory())
211+
212+
await act(async () => {
213+
await Promise.resolve()
214+
})
215+
216+
const stored = searchHistoryStore.read()
217+
expect(Array.isArray(stored)).toBe(false)
218+
expect(stored).toHaveProperty('en-US')
219+
expect((stored as Record<string, History[]>)['en-US']).toHaveLength(1)
220+
})
221+
})
222+
})

0 commit comments

Comments
 (0)