Skip to content

Commit fcf4228

Browse files
committed
fix(core): invalidate searcher cache on collection mutation
_lastSearcher is keyed only on the query string, but token-search bakes IDF weights at searcher construction. After setCollection, add, remove, or removeAt the cached searcher can return stale scoring for the same query. Adds _invalidateSearcherCache called from every mutator. remove only invalidates when something was actually removed.
1 parent ea9356d commit fcf4228

2 files changed

Lines changed: 94 additions & 0 deletions

File tree

src/core/index.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,8 @@ export default class Fuse<T> {
122122
analyzer
123123
)
124124
}
125+
126+
this._invalidateSearcherCache()
125127
}
126128

127129
add(doc: T): void {
@@ -145,6 +147,8 @@ export default class Fuse<T> {
145147
analyzer
146148
)
147149
}
150+
151+
this._invalidateSearcherCache()
148152
}
149153

150154
remove(predicate: (doc: T, idx: number) => boolean = () => false): T[] {
@@ -167,6 +171,8 @@ export default class Fuse<T> {
167171
const toRemove = new Set(indicesToRemove)
168172
this._docs = this._docs.filter((_, i) => !toRemove.has(i))
169173
this._myIndex.removeAll(indicesToRemove)
174+
175+
this._invalidateSearcherCache()
170176
}
171177

172178
return results
@@ -178,9 +184,15 @@ export default class Fuse<T> {
178184
}
179185
const doc = this._docs.splice(idx, 1)[0]
180186
this._myIndex.removeAt(idx)
187+
this._invalidateSearcherCache()
181188
return doc
182189
}
183190

191+
_invalidateSearcherCache(): void {
192+
this._lastQuery = null
193+
this._lastSearcher = null
194+
}
195+
184196
getIndex(): FuseIndex<T> {
185197
return this._myIndex
186198
}

test/cache-invalidation.test.js

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
// Verifies that _lastSearcher is invalidated on collection mutation.
2+
// Without the fix, token-search IDF weights stay frozen from construction,
3+
// so the same query against a mutated collection returns stale scoring.
4+
5+
import { describe, test, expect } from 'vitest'
6+
7+
process.env.EXTENDED_SEARCH_ENABLED = 'true'
8+
process.env.TOKEN_SEARCH_ENABLED = 'true'
9+
10+
const { default: Fuse } = await import('../src/entry')
11+
12+
describe('searcher cache invalidation on mutation', () => {
13+
const options = {
14+
keys: ['title'],
15+
useTokenSearch: true,
16+
includeScore: true
17+
}
18+
19+
test('setCollection invalidates _lastSearcher', () => {
20+
const fuse = new Fuse([{ title: 'apple' }, { title: 'banana' }], options)
21+
fuse.search('apple')
22+
expect(fuse._lastSearcher).not.toBeNull()
23+
24+
fuse.setCollection([{ title: 'cherry' }])
25+
expect(fuse._lastSearcher).toBeNull()
26+
expect(fuse._lastQuery).toBeNull()
27+
})
28+
29+
test('add invalidates _lastSearcher', () => {
30+
const fuse = new Fuse([{ title: 'apple' }], options)
31+
fuse.search('apple')
32+
expect(fuse._lastSearcher).not.toBeNull()
33+
34+
fuse.add({ title: 'banana' })
35+
expect(fuse._lastSearcher).toBeNull()
36+
})
37+
38+
test('remove invalidates _lastSearcher', () => {
39+
const fuse = new Fuse(
40+
[{ title: 'apple' }, { title: 'banana' }, { title: 'cherry' }],
41+
options
42+
)
43+
fuse.search('apple')
44+
expect(fuse._lastSearcher).not.toBeNull()
45+
46+
fuse.remove((doc) => doc.title === 'banana')
47+
expect(fuse._lastSearcher).toBeNull()
48+
})
49+
50+
test('remove with no matches does not invalidate', () => {
51+
const fuse = new Fuse([{ title: 'apple' }], options)
52+
fuse.search('apple')
53+
const cached = fuse._lastSearcher
54+
55+
fuse.remove((doc) => doc.title === 'zebra')
56+
expect(fuse._lastSearcher).toBe(cached)
57+
})
58+
59+
test('removeAt invalidates _lastSearcher', () => {
60+
const fuse = new Fuse([{ title: 'apple' }, { title: 'banana' }], options)
61+
fuse.search('apple')
62+
expect(fuse._lastSearcher).not.toBeNull()
63+
64+
fuse.removeAt(0)
65+
expect(fuse._lastSearcher).toBeNull()
66+
})
67+
68+
test('same query after add returns docs added post-construction', () => {
69+
const fuse = new Fuse([{ title: 'foo bar' }], options)
70+
71+
// Prime cache with this query
72+
const first = fuse.search('quux')
73+
expect(first.length).toBe(0)
74+
75+
fuse.add({ title: 'quux quux' })
76+
77+
// Without invalidation, the cached searcher would run against stale state
78+
const second = fuse.search('quux')
79+
expect(second.length).toBe(1)
80+
expect(second[0].item.title).toBe('quux quux')
81+
})
82+
})

0 commit comments

Comments
 (0)