Skip to content

Replace fuzzy-search with fuzzysort, and load the song search on demand - #628

Merged
noahm merged 7 commits into
mainfrom
claude/fuzzy-search-fuzzysort-eval-2y19n5
Aug 13, 2026
Merged

Replace fuzzy-search with fuzzysort, and load the song search on demand#628
noahm merged 7 commits into
mainfrom
claude/fuzzy-search-fuzzysort-eval-2y19n5

Conversation

@noahm

@noahm noahm commented Aug 11, 2026

Copy link
Copy Markdown
Owner

Swaps fuzzy-search 3.2.1 for fuzzysort 4.0.1, then splits the search UI out of the main bundle so the larger library doesn't cost anything at startup.

Why

fuzzy-search scores a match by the span between the first and last matched character, so a scattered subsequence inside a short title outranks an exact prefix. Searching max in DDR World put ARACHNE at the top, ahead of every song actually named MAX. It also has no Unicode normalization, so full-width input returns nothing — a real problem on this song database. The package last shipped in February 2020 and needs a separate @types package.

Search quality

Both columns are the real omnibar, driven in headless Chromium against the two production builds, DDR World (1,451 songs), same config:

query before after
max ARACHNE, Fascination MAXX, MAX 300 ΔMAX, MAX 300, MAX 360
naoki 1998, B4U, B4U ("VOLTAGE" Special) BRILLIANT 2U ("STREAM"), BURNIN' THE FLOOR, BRILLIANT 2U
max no results ΔMAX, MAX 300, MAX 360
pranoia PARANOiA, PARANOiA (kskst mix), PARANOiA ETERNAL PARANOiA, PARANOiA ETERNAL, PARANOiA Rebirth

Recall is unchanged. The call site passes threshold: 0, which keeps every subsequence match the old library found — including the transposed pranoia — and only reorders them. At fuzzysort's default threshold: 0.5 that typo returns nothing, so the explicit zero is load-bearing.

fuzzysort also applies NFKD normalization and strips diacritics, which is what fixes max and helps with the typographic quotes and full-width tildes that are all over these titles.

Search latency drops from 1.79 ms to 0.26 ms per keystroke (2.23 ms → 0.37 ms on the 2,279-song sdvx_nabla set). Nobody was complaining about 2 ms; it's headroom for larger imported data sets.

Bundle size

fuzzysort is a bigger library — 17.6 KB minified vs ~1.5 KB — so dropped in as a straight swap it added 7.2 KB gzipped to main.js. The later commits get that back.

SongSearch renders an Omnibar that is closed until someone starts a pocket pick, but every song card mounted it, so it sat in the main bundle. It's now lazy-loaded the way eligible-charts, drawn-set, and controls-drawer already are.

Splitting the component alone wasn't enough: the store imported fuzzysort to build the search index, which anchored the library to the main bundle regardless. The index is derived from gameData, which the store already holds, so it moved next to the search UI behind a WeakMap keyed on the loaded data set. That also removes a piece of redundant derived state from the store. Indexing costs ~40 ms and the omnibar now unmounts between uses, so the cache keeps that off every open.

asset before after Δ
main.js raw 502,250 500,387 −1,863
main.js gzip 162,963 162,236 −727

fuzzysort ends up in its own 20.8 KB / 8.1 KB gzipped chunk, which webpack shares between the search UI and round-label. It is not fetched on initial page load; it arrives when the first drawing renders.

Where the lazy boundary goes

Worth calling out for review, since the first attempt got this wrong. Gating the lazy SongSearch on pocketPickPendingForPlayer — rendering it only while a pick is pending — puts the Suspense boundary around the component that owns the overlay animation. Blueprint's Overlay2 wraps its children in a TransitionGroup with appear={true}, so opening still faded in, but closing set the flag to 0 and unmounted the whole overlay before CSSTransition could run its exit, and the omnibar vanished instantly instead of fading out.

Sampling .bp6-omnibar opacity every animation frame, against the pre-fuzzysort build:

baseline gated on the pick mounted on menu open
enter 11 opacity steps, starts immediately 11 steps, starts ~270 ms late 11 steps, starts immediately
exit 11 opacity steps 0 frames — instant 11 steps

So SongSearch is instead mounted, closed, when a card's action menu opens, and isOpen drives the overlay as before. The chunk still loads only on demand, and it doubles as the prefetch — the menu always opens before a player is picked. Overlay2 renders nothing until its first open, so mounting it closed costs no DOM. Verified stable across repeated open/close cycles.

Code structure

Four source files plus one new module, and a net reduction. The nicest part is round-label.tsx, where ~35 lines of hand-rolled highlighting — split the query on whitespace, escape regex metacharacters, run a global regex, splice <strong> tags — collapse into result.highlight(). It's also more correct: it highlights the characters the predicate actually matched rather than a separate word-based approximation.

Worth knowing before merging

  • Results reorder. Anyone with muscle memory for a half-typed query will find that row has moved. The new order is better on every query tested, but it's a behavior change during live tournament use, so it's worth landing between events rather than the morning of one.
  • Round-label highlighting is now subsequence-based. For wr1 against "Winners Round 1" that's three scattered bold letters where the old highlighter bolded nothing. Correct, but visually different.
  • fuzzysort v4 is ESM-only. Webpack 5 handles it without configuration; it would only matter if a CommonJS script under scripts/ needed the search, and none do.

Verification

tsc --noemit, oxlint, and oxfmt --check all pass. Production builds compared asset-by-asset at each step. Builds were served and driven in headless Chromium — load DDR World, draw, open the omnibar, run five queries — with identical results throughout and no page errors. Separate traces confirm the fuzzysort chunk is absent from initial page load, and that the overlay transitions match baseline frame for frame.

claude added 2 commits August 11, 2026 18:09
fuzzy-search ranks by the span between the first and last matched
character, so a scattered subsequence in a short title outranks an exact
prefix match ("max" returned ARACHNE ahead of MAX 300). fuzzysort ranks
on contiguity and word boundaries and additionally applies NFKD
normalization, so full-width input like "max" now matches.

- store: hold an immutable fuzzysort.snapshot() over the song list
  instead of a FuzzySearch instance, rebuilt on each game data load
- song-search: use fuzzysort.go() with threshold 0 so recall stays
  identical to the old subsequence matching, only better ordered
- round-label: use fuzzysort.single() for the item predicate and
  result.highlight() for match highlighting, dropping the hand-rolled
  regex highlighter and its escaping helper

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EbuWkr599Z1YPZRr3o2w8g
The omnibar is closed until someone starts a pocket pick, but it was
mounted by every song card, which pulled fuzzysort and the search UI into
the main bundle. Lazy-load it the way eligible-charts and drawn-set
already are, and warm the chunk when a card's action menu opens so it is
in flight before a player is picked.

Splitting the component is only half of it: the store also imported
fuzzysort to build the search index, which anchored the library to the
main bundle regardless. The index is derived from gameData, which the
store already holds, so it moves next to the search UI behind a WeakMap
keyed on the loaded data set. Indexing costs ~40ms, and the omnibar now
unmounts between uses, so caching it there keeps that off every open.

main.js drops to 500,387 bytes raw / 162,236 gzipped, slightly under
where it sat before fuzzysort was introduced. fuzzysort lands in its own
20.8 KB shared chunk, fetched when the first drawing renders rather than
at startup.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EbuWkr599Z1YPZRr3o2w8g
@vercel

vercel Bot commented Aug 11, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
ddr-tools Ready Ready Preview Aug 13, 2026 9:52pm

@noahm noahm left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

note to self: manual review animate in and out for the omnisearch ui

Gating the lazily loaded SongSearch on pocketPickPendingForPlayer put the
Suspense boundary around the component that owns the overlay animation.
Blueprint's Overlay2 wraps its children in a TransitionGroup with
appear={true}, so opening still faded in, but closing set the flag to 0
and unmounted the whole overlay before CSSTransition could run its exit
-- the omnibar vanished instantly instead of fading out.

Mount SongSearch, closed, when a card's action menu opens instead, and go
back to passing isOpen. The chunk still loads only on demand, and the
overlay keeps the same lifecycle it had when it was eagerly mounted.
Overlay2 renders nothing until its first open, so mounting it closed
costs no DOM.

Measured per animation frame against the pre-fuzzysort build: enter and
exit now both fade across 11 distinct opacity steps, matching baseline,
and hold up over repeated open/close cycles.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EbuWkr599Z1YPZRr3o2w8g
The omnibar stays mounted between uses, so a query typed for one pocket
pick was still sitting in the box the next time it opened. Reset it on
close instead, via the overlay's onClosed callback rather than on the
isOpen change: an empty query matches every song, so clearing at the
start of the close would refill the list with unfiltered results behind
the fading overlay.

Frame sampling across the exit transition confirms the query and result
list hold steady until the overlay is gone, and that reopening lands in
the same state as a first open.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EbuWkr599Z1YPZRr3o2w8g
Blueprint's Omnibar defaults initialContent to null, so it already
rendered nothing before the first keystroke -- but we were still ranking
every song, running each one through songIsValid, and building a full
item list for the omnibar to discard. Bail out early instead, which also
states the intent in our own code rather than leaning on a Blueprint
default.

Per-keystroke latency is unchanged (53/49/46/46ms vs 48/52/38/63ms
before), so deferring the first index build to the first keystroke costs
nothing measurable.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EbuWkr599Z1YPZRr3o2w8g
All five search keys carried equal weight, so a short query matched a
short artist name as strongly as a song title. Searching "a" led with
songs by A応P, ASKA and aran while "A Brighter Day" sat at rank 111.

Demote a song when its best match came from an artist field. Two details
worth keeping:

- Scale fuzzysort's own merged score instead of recomputing one as a
  weighted max over the per-key scores. Its merge gives a bonus to songs
  matching several keys at once, and a plain max discards that -- on its
  own that regressed "dj taka" from Abyss / .59 / Frozen Ray to
  Quickening / Frozen Ray / LOGICAL DASH.
- Weight of 0.8 rather than something harsher. Artist search is a normal
  thing to do here, and a heavier hand promotes songs that merely name an
  artist in their title over that artist's actual songs.

"a" now leads with A, AI, AA, air, ÆTHER and lifts "A Brighter Day" to
59, while "dj taka", "max" and "paranoia" keep their existing ordering
apart from the remix that names dj TAKA in its title.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EbuWkr599Z1YPZRr3o2w8g
@noahm
noahm merged commit 33cf3df into main Aug 13, 2026
6 of 7 checks passed
@noahm
noahm deleted the claude/fuzzy-search-fuzzysort-eval-2y19n5 branch August 13, 2026 21:51
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants