Replace fuzzy-search with fuzzysort, and load the song search on demand - #628
Merged
Conversation
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
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
noahm
commented
Aug 11, 2026
noahm
left a comment
Owner
Author
There was a problem hiding this comment.
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
enabled auto-merge
August 13, 2026 21:49
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Swaps
fuzzy-search3.2.1 forfuzzysort4.0.1, then splits the search UI out of the main bundle so the larger library doesn't cost anything at startup.Why
fuzzy-searchscores a match by the span between the first and last matched character, so a scattered subsequence inside a short title outranks an exact prefix. Searchingmaxin 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@typespackage.Search quality
Both columns are the real omnibar, driven in headless Chromium against the two production builds, DDR World (1,451 songs), same config:
maxnaokimaxpranoiaRecall is unchanged. The call site passes
threshold: 0, which keeps every subsequence match the old library found — including the transposedpranoia— and only reorders them. At fuzzysort's defaultthreshold: 0.5that typo returns nothing, so the explicit zero is load-bearing.fuzzysort also applies NFKD normalization and strips diacritics, which is what fixes
maxand 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_nablaset). 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.SongSearchrenders anOmnibarthat 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 wayeligible-charts,drawn-set, andcontrols-draweralready 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 aWeakMapkeyed 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.main.jsrawmain.jsgzipfuzzysort 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
SongSearchonpocketPickPendingForPlayer— rendering it only while a pick is pending — puts the Suspense boundary around the component that owns the overlay animation. Blueprint'sOverlay2wraps its children in aTransitionGroupwithappear={true}, so opening still faded in, but closing set the flag to0and unmounted the whole overlay beforeCSSTransitioncould run its exit, and the omnibar vanished instantly instead of fading out.Sampling
.bp6-omnibaropacity every animation frame, against the pre-fuzzysort build:So
SongSearchis instead mounted, closed, when a card's action menu opens, andisOpendrives 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.Overlay2renders 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 intoresult.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
wr1against "Winners Round 1" that's three scattered bold letters where the old highlighter bolded nothing. Correct, but visually different.scripts/needed the search, and none do.Verification
tsc --noemit,oxlint, andoxfmt --checkall 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.