Skip to content

Commit d571390

Browse files
committed
fix(workers): match Fuse ordering and reject non-cloneable options
FuseWorker previously merged shard results and sorted by `score ?? 0`, which broke parity with Fuse in three ways: 1. With `includeScore: false`, score was never returned from the shards, so the merge step had nothing to sort on and the order was effectively shard-concat order. 2. Equal-score results across shards collapsed in shard-concat order instead of Fuse's `(score, idx)` global tie-break. 3. With `shouldSort: false`, results came back in shard-concat order rather than global collection order — visible after `add()` (which round-robins across shards) and `setCollection()`. The worker is now initialized with `includeScore: true` so the main thread always receives the score it needs to tie-break, then strips it from the final result if the caller didn't ask for it. The merge sort mirrors Fuse's default `(score, idx)` comparator on the global refIndex, and `shouldSort: false` re-sorts by global refIndex. Also reject function-valued options (`sortFn`, top-level `getFn`, `keys[].getFn`) at construction time. Functions aren't structured- cloneable, so they used to crash with an opaque `DataCloneError` on the first `search()` call. The new error names the offending option. Plan 007.
1 parent 4ad5999 commit d571390

5 files changed

Lines changed: 330 additions & 7 deletions

File tree

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ const results = await fuse.search('query')
4040
fuse.terminate()
4141
```
4242

43-
Same options, same results as `Fuse` — just async. See the [Web Workers docs](https://fusejs.io/web-workers.html) for the interactive demo and full API.
43+
Same options and results as `Fuse` — just async. Function-valued options (`sortFn`, `getFn`, `keys[].getFn`) aren't supported because functions can't be transferred to a worker; everything else carries over. See the [Web Workers docs](https://fusejs.io/web-workers.html) for the interactive demo and full API.
4444

4545
## Installation
4646

docs/web-workers.md

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ const results = await fuse.search('javascript')
5858
fuse.terminate()
5959
```
6060

61-
Same options and same results as `Fuse` — the only difference is that `search()` returns a Promise.
61+
Almost the same API as `Fuse`, with a few caveats covered in [Differences from Fuse](#differences-from-fuse) below — the headline one is that `search()` returns a Promise.
6262

6363
## How It Works
6464

@@ -161,9 +161,23 @@ Workers add overhead from data serialization (`postMessage`) and worker startup.
161161
| `remove(predicate)` | Supported | Use `setCollection()` instead |
162162
| `getIndex()` | Supported | Not available |
163163
| Result object references | Same as input docs | Copies (structured clone) |
164-
| Custom `sortFn` | Supported | Not available (sorts by score) |
164+
| Custom `sortFn` | Supported | Throws at construction |
165+
| Custom `getFn` (top-level) | Supported | Throws at construction |
166+
| Custom `keys[].getFn` | Supported | Throws at construction |
165167
| Cleanup required | No | Call `terminate()` |
166168
169+
#### Unsupported options
170+
171+
`FuseWorker` rejects function-valued options at construction time. Functions can't be transferred to a worker via `postMessage` (they aren't structured-cloneable), so `FuseWorker` throws an explicit error instead of failing later with an opaque `DataCloneError`.
172+
173+
The unsupported options are:
174+
175+
- **`sortFn`** — `FuseWorker` always sorts results by Fuse's default `(score, refIndex)` tie-break. If you need a custom sort, run `Fuse` on the main thread or sort the returned array yourself.
176+
- **`getFn`** (top-level) — Fall back to dotted key paths (`'a.b.c'`) or array paths (`['a', 'b', 'c']`).
177+
- **`keys[].getFn`** — Same as above. Use a string or array path on the key.
178+
179+
Default ordering is preserved: `FuseWorker` returns the same order as `Fuse` for the same inputs (with or without `includeScore`), and `shouldSort: false` returns results in global collection order.
180+
167181
## Worker URL
168182
169183
`FuseWorker` needs to load a separate script file inside each Web Worker. By default, it finds this file automatically — you don't need to do anything.

src/core/errorMessages.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,3 +16,13 @@ export const MISSING_KEY_PROPERTY = (name: string): string => `Missing ${name} p
1616

1717
export const INVALID_KEY_WEIGHT_VALUE = (key: string): string =>
1818
`Property 'weight' in key '${key}' must be a positive integer`
19+
20+
export const FUSE_WORKER_UNSUPPORTED_FN_OPTION = (option: string): string =>
21+
`FuseWorker does not support function-valued option '${option}': ` +
22+
`functions cannot be transferred to Web Workers via postMessage. ` +
23+
`Remove this option or fall back to Fuse.`
24+
25+
export const FUSE_MATCH_TOKEN_SEARCH_UNSUPPORTED =
26+
`Fuse.match does not support useTokenSearch: token search requires ` +
27+
`corpus-level statistics (df, fieldCount) that a one-off string ` +
28+
`comparison does not have. Use new Fuse(...).search(...) instead.`

src/workers/FuseWorker.ts

Lines changed: 66 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
/// <reference lib="dom" />
22

3+
import * as ErrorMsg from '../core/errorMessages'
34
import type {
45
IFuseOptions,
6+
FuseOptionKey,
57
FuseResult,
68
FuseSearchOptions,
79
Expression
@@ -52,12 +54,39 @@ export default class FuseWorker<T> {
5254
this._docs = docs.slice()
5355
this._options = options || {} as IFuseOptions<T>
5456
this._workerOptions = workerOptions || {}
57+
// Reject function-valued options eagerly. Without this check, postMessage
58+
// throws DataCloneError on first search() rather than at construction.
59+
FuseWorker._assertNoFunctionOptions(this._options)
5560
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
5661
// @ts-ignore -- import.meta.url is resolved by Rollup at build time
5762
this._workerUrl = this._workerOptions.workerUrl
5863
|| new URL('./fuse.worker.mjs', import.meta.url)
5964
}
6065

66+
private static _assertNoFunctionOptions<U>(options: IFuseOptions<U>): void {
67+
if (typeof (options as { sortFn?: unknown }).sortFn === 'function') {
68+
throw new Error(ErrorMsg.FUSE_WORKER_UNSUPPORTED_FN_OPTION('sortFn'))
69+
}
70+
if (typeof (options as { getFn?: unknown }).getFn === 'function') {
71+
throw new Error(ErrorMsg.FUSE_WORKER_UNSUPPORTED_FN_OPTION('getFn'))
72+
}
73+
const keys = options.keys
74+
if (Array.isArray(keys)) {
75+
for (let i = 0, len = keys.length; i < len; i += 1) {
76+
const key = keys[i] as FuseOptionKey<U>
77+
if (key && typeof key === 'object' && !Array.isArray(key)) {
78+
if (typeof (key as { getFn?: unknown }).getFn === 'function') {
79+
const name = (key as { name?: string | string[] }).name
80+
const label = Array.isArray(name) ? name.join('.') : (name ?? String(i))
81+
throw new Error(
82+
ErrorMsg.FUSE_WORKER_UNSUPPORTED_FN_OPTION(`keys[${label}].getFn`)
83+
)
84+
}
85+
}
86+
}
87+
}
88+
}
89+
6190
private _getNumWorkers(): number {
6291
return this._workerOptions.numWorkers || getDefaultWorkerCount()
6392
}
@@ -93,13 +122,21 @@ export default class FuseWorker<T> {
93122
return worker
94123
}
95124

125+
private _workerInitOptions(): IFuseOptions<T> {
126+
// Force includeScore so the main thread can do a global (score, idx)
127+
// tie-break across shards. Score is stripped from the final result if the
128+
// caller didn't ask for it.
129+
return { ...this._options, includeScore: true }
130+
}
131+
96132
private async _init(): Promise<void> {
97133
const numWorkers = this._getNumWorkers()
98134
const chunkSize = Math.ceil(this._docs.length / numWorkers)
99135

100136
this._shards = []
101137
this._addCursor = 0
102138

139+
const workerInitOptions = this._workerInitOptions()
103140
const initPromises: Promise<void>[] = []
104141
for (let i = 0; i < numWorkers; i++) {
105142
const start = i * chunkSize
@@ -110,7 +147,7 @@ export default class FuseWorker<T> {
110147

111148
const shard: Shard = { worker: this._spawnWorker(), globalIndices }
112149
this._shards.push(shard)
113-
initPromises.push(this._call(shard.worker, 'init', [chunk, this._options]))
150+
initPromises.push(this._call(shard.worker, 'init', [chunk, workerInitOptions]))
114151
}
115152

116153
await Promise.all(initPromises)
@@ -144,13 +181,38 @@ export default class FuseWorker<T> {
144181
}
145182
}
146183

147-
// Sort by score (lower is better)
148184
const shouldSort = this._options.shouldSort !== false
149185
if (shouldSort) {
150-
merged.sort((a, b) => (a.score ?? 0) - (b.score ?? 0))
186+
// Mirror Fuse's default sortFn: (score, idx) tie-break, but on the
187+
// GLOBAL refIndex so equal-score results across shards collapse into a
188+
// deterministic order matching single-thread Fuse.
189+
merged.sort((a, b) => {
190+
const sa = a.score ?? 0
191+
const sb = b.score ?? 0
192+
if (sa === sb) {
193+
return a.refIndex < b.refIndex ? -1 : 1
194+
}
195+
return sa < sb ? -1 : 1
196+
})
197+
} else {
198+
// Restore global collection order. Round-robin add() and
199+
// shard-concatenation order otherwise leak through.
200+
merged.sort((a, b) => a.refIndex - b.refIndex)
201+
}
202+
203+
// Workers always include score so the merge above can tie-break; strip it
204+
// here if the caller didn't ask for it. Rebuild the object instead of
205+
// `delete`-ing — `delete` deopts the shape and roughly doubles the
206+
// post-sort cost on large result sets.
207+
if (!this._options.includeScore) {
208+
for (let i = 0, len = merged.length; i < len; i += 1) {
209+
const r = merged[i]
210+
merged[i] = r.matches !== undefined
211+
? { item: r.item, refIndex: r.refIndex, matches: r.matches }
212+
: { item: r.item, refIndex: r.refIndex }
213+
}
151214
}
152215

153-
// Apply limit
154216
const limit = options?.limit
155217
if (limit && limit > 0) {
156218
return merged.slice(0, limit)

0 commit comments

Comments
 (0)