Pausing mid-edit no longer discards the Cluster Strength - #376
Merged
Conversation
An empty Cluster Strength field is a deliberate signal — it means "go back to a derived strength". The trap is that `<input type="number">` reports an empty value for anything it cannot parse *yet*: "0.", "-", "1e". So an empty field alone cannot be read as the user asking for a derived value, and pausing for a second while retyping looked exactly like it, throwing away the number the album was tuned to. `validity.badInput` separates the two: the browser sets it only while the input holds text it could not turn into a number. `Number.isNaN` cannot do this job — the sanitized value is "", never "NaN" — so the guard that was there was dead code for this element. Two smaller things in the same handler: * The pending save is now dropped before *every* early return rather than after them. Otherwise a save armed by an earlier keystroke still fires a second later, carrying a number the field no longer shows. * A non-positive number is not saved at all. DBSCAN refuses one, so `resolve_cluster_eps` floors it and the map ends up clustered at something other than the number the spinner and the cluster-info modal report. Note on the tests: jsdom sanitizes an unparseable value to "" but never sets `badInput`, so the flag is stubbed. These tests pin this module's logic; that a browser really sets `badInput` for a half-typed number is a platform guarantee, not something the suite proves. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
lstein
force-pushed
the
lstein/fix/mid-typing-cluster-strength
branch
from
August 20, 2026 00:18
0afca9a to
2d779f2
Compare
…o it Review follow-ups on the badInput fix. The debounce handle was doing double duty as "an edit is pending", which the albumIndexUpdated listener reads before re-resolving the strength. Refusing to arm a save for half-typed text removed the only evidence that an edit was in progress, so a re-index landing while the user was part-way through a number overwrote the field under the cursor — the same loss the badInput guard exists to prevent, one step narrower. `epsEditPending` now says it directly: set on every keystroke ahead of the early returns, cleared when the save lands and on blur, so abandoned text cannot disable re-resolving for the rest of the session. Clearing the field asks the server to derive a strength, and on a large album that answer can be a minute coming. The user is free to change their mind in that minute and type a number, which saves and redraws on its own — and then the late reply put the derived value and the "auto" badge back over an album that was storing theirs. `refreshResolvedEps` now pins the edit sequence the way it already pins the album, and the debounced save passes the sequence it started with so a keystroke during the POST counts too. Also: * The floor is read from the spinner's own `min` rather than hardcoded as `> 0`, so a value the server would silently raise to MIN_CLUSTER_EPS is refused as well. The ceiling is deliberately left alone: a derived strength for a small album can legitimately exceed `max`, and enforcing it would leave those albums untunable. * A refused keystroke now marks the field and says why. The handler answers one by doing nothing, so without a mark it looked identical to a save. Marked from JS rather than `:invalid`, which would also fire on those legitimately-over-`max` derived values. * Dropped the NaN check in fetchUmapData that the previous commit showed was unreachable, and corrected readSpinnerEps's comment to match. * The debounce tests run on fake timers: five real 1.15s waits cost more wall clock than the whole rest of the frontend suite (9.4s -> 3.0s). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
All five reproduced against the branch before fixing, and each fix is pinned
by a test that fails without it.
* Blur released the guard while the unparseable text was still in the field.
Blur is not the user saying they are done — it fires on a click anywhere
else and on an alt-tab, and the browser goes on showing "0." afterwards —
so a re-index landing in that window still took the number out from under
them. The guard is now held until the text is dealt with. The cost is one
skipped display refresh, not a wrong map: an unparseable field sends no
cluster_eps at all, so the album's own strength applies.
* A save in the air left nothing behind for the guard to see. The debounce
handle is dropped the moment the save starts, and a save armed before a
blur clears the flag too, so a re-index during the round trip re-resolved
from the server that was still waiting for the POST — putting the pre-save
value and the "auto" badge back over the number just stored. Counted now.
* A failed save was indistinguishable from a successful one. It ignored both
the HTTP status and any rejection, so the map was redrawn at a strength the
album does not have — most often against the 403 from require_no_lock,
which is to say while the album is being indexed, which is exactly when
someone is tuning it. It now marks the field, leaves the map alone, and
clears the edit flag either way: stranded on the error path, that flag
disabled every re-resolve for the rest of the session.
* A refused value was still sent to /umap_data. Typing 0.005 armed no save,
but the next redraw asked the map for it and the server floored it to
MIN_CLUSTER_EPS — the exact divergence refusing to store it prevents. An
unusable field now sends no cluster_eps.
* A save landing after the window was closed rendered Plotly into a hidden
container and consumed the dataChanged flag, so re-opening showed a
zero-size plot and refetched nothing. The flag is set and the redraw left
to the next open.
Tests: the validity stub built the wrong object — spreading a ValidityState
yields {}, since every flag is a prototype getter — so it would have green-lit
a real bug the moment umap.js read a second flag. It now copies the flags and
stays installed, because a browser does not stop reporting badInput between
keystrokes. Cross-test cleanup of the module's edit flags moved into
beforeEach, where a failing test cannot skip it. And a wedge test written in
the debounce file passed with its fix reverted: that file re-imports umap.js
per test and the stale modules' window listeners answer the dispatch too. It
lives in umap-reindex-refresh.test.js now, which imports once, and the trap is
documented in the debounce file's header.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Second adversarial pass, all three reproduced first. * The debounced save read `state.album` when the timer fired, so switching albums inside the one-second window handed the number to whichever album was on screen a second later: album B silently acquired a Cluster Strength typed for album A, persisted to the config, with nothing on screen saying so until B's map was next opened. The album is pinned when the save is armed, and everything the save does to the screen afterwards is skipped if the user has moved on. (Pre-existing, but in the handler this branch rewrites.) * Half-typed text holds the edit guard across a blur, on purpose — but the hold has to end when the text does. Re-opening the map refills the field from the server, and the guard stayed set for a value that was no longer there, disabling every re-resolve for the rest of the session. applyResolvedEps ends the edit it overwrites. * An unmarked field is this module asserting the number in it is in effect. For a *stored* strength below the floor that is false — the spinner refuses those now, but versions before it did not, and the config is hand-editable — so applyResolvedEps marks one rather than clearing the mark unconditionally. A derived value is never marked however small: floored or not, it is exactly what the map was clustered with. The spinner's `min` and the server's MIN_CLUSTER_EPS are now pinned to each other by a test. The frontend reads its floor off the attribute rather than hardcoding a number, so the two could only drift silently — into either accepting a value the map then clusters at something else, or refusing one the server would have honored. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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.
Rebased onto master after #370 landed, and re-scoped: #370 changed what an empty field means, which changes what this bug is.
The bug
An empty Cluster Strength field is now a deliberate signal — it means "go back to a derived strength". The trap is that
<input type="number">reports an empty value for anything it cannot parse yet:So an empty field alone cannot be read as the user asking for a derived value. Pausing for a second while retyping looks exactly like it, and throws away the number the album was tuned to:
The existing guard cannot catch this.
if (eps !== null && Number.isNaN(eps))is dead code for a number input: the sanitized value is"", soreadSpinnerEpsreturnsnull, neverNaN.The fix
validity.badInputis the discriminator. The browser sets it only while the input holds text it could not turn into a number, so it separates "the user cleared this" from "the browser cannot parse this yet" — which reading.valuealone cannot do.Two smaller things in the same handler:
minis not saved. The server floors anything underMIN_CLUSTER_EPS, so the map ends up clustered at something other than what the spinner and the cluster-info modal report. (The ceiling is deliberately not enforced: a derived strength for a small album can legitimately exceedmax, and refusing those would leave exactly those albums untunable.)What the review passes added
Refusing to arm a save for half-typed text removed the only evidence that an edit was in progress — the debounce handle, which the
albumIndexUpdatedlistener reads before re-resolving. Three passes of adversarial review (each finding reproduced before being fixed, each fix pinned by a test that fails without it) turned that into a guard that actually holds the field:epsEditPending/epsEditSeq/ an in-flight save count. The field is held while the user is part-way through it, while a save it armed is in the air, and against a lateget_umap_epsreply the user has since typed past — a derive can take a minute on a large album, long enough to change your mind, and the reply used to land on top of the number you had just stored, "auto" badge and all.require_no_lock, i.e. while the album is being indexed, which is exactly when someone is tuning it.0.005armed no save, but the next redraw still asked the map for it and the server floored it — the exact divergence refusing to store it prevents.dataChangedflag, so re-opening showed a zero-size plot and refetched nothing.state.albumwhen the timer fired, so switching albums inside the one-second window silently wrote the number into the other album's config. (Pre-existing; fixed here because it is this handler.)0,-2or0.005, and the config file is hand-editable.Tests
tests/frontend/umap-eps-debounce.test.js(new, 17 tests) andtests/frontend/umap-reindex-refresh.test.js(+5). 615 frontend tests pass, 728 backend, eslint/prettier/ruff clean.The debounce suite runs on fake timers: five real 1.15s waits cost more wall clock than the whole rest of the frontend suite.
A new backend test pins the spinner's
minattribute toMIN_CLUSTER_EPS— the frontend reads its floor off the element rather than hardcoding it, so the two could otherwise drift silently.Caveat on the main one, now closed. jsdom sanitizes an unparseable value to
""but never setsbadInput(verified — seetypeUnparseable, which stubs it). These tests pin this module's logic; that a browser really setsbadInputfor a half-typed number is a platform guarantee, not something this suite proves. Confirmed by hand in a real browser — pausing mid-number no longer discards the album's strength.Known, not fixed here
Three things the review turned up that are not this branch's to fix, now filed:
_shrink_eps_to_pair_budgetshrinks large typed values too, so a 40k-image album saved at2.0clusters at1.4while the spinner,/get_umap_epsand the cluster-info modal all report2.0. No client-side bound can prevent it; the server needs to report the eps it used.cluster_eps:nanreaches DBSCAN (500), andinfmakes_shrink_eps_to_pair_budgetloop forever on any album past ~7k images, wedging a thread-pool worker. Not reachable by typing, but reachable by URL or a hand-edited config. Belongs with Refuse a Cluster Strength that DBSCAN cannot run with #375.🤖 Generated with Claude Code