Skip to content

Pausing mid-edit no longer discards the Cluster Strength - #376

Merged
lstein merged 5 commits into
masterfrom
lstein/fix/mid-typing-cluster-strength
Aug 21, 2026
Merged

Pausing mid-edit no longer discards the Cluster Strength#376
lstein merged 5 commits into
masterfrom
lstein/fix/mid-typing-cluster-strength

Conversation

@lstein

@lstein lstein commented Aug 19, 2026

Copy link
Copy Markdown
Owner

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:

"0.4" -> value="0.4"
"0."  -> value=""      <- indistinguishable from a cleared field
"-"   -> value=""
"1e"  -> value=""

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:

album tuned to 0.4; user selects the field and retypes
  "0"   -> parseable, save armed
  "0."  -> value becomes "", i.e. "clear it"
  ...user hesitates for one second...
  -> POST /set_umap_eps/ {eps: null}
  -> the album is back on a derived strength, "auto" badge and all

The existing guard cannot catch this. if (eps !== null && Number.isNaN(eps)) is dead code for a number input: the sanitized value is "", so readSpinnerEps returns null, never NaN.

The fix

validity.badInput is 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 .value alone cannot do.

Two smaller things in the same handler:

  • The pending save is dropped before every early return, not after them. Otherwise a save armed by an earlier keystroke still fires a second later carrying a number the field no longer shows.
  • A value below the spinner's own min is not saved. The server floors anything under MIN_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 exceed max, 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 albumIndexUpdated listener 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 late get_umap_eps reply 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.
  • Blur does not release the hold while unparseable text is still showing. Blur fires on a click anywhere else and on an alt-tab, and the half-typed text stays in the field. The hold ends when the text does.
  • A failed save is no longer indistinguishable from a successful one. Neither the HTTP status nor a rejection was checked, so the map was redrawn at a strength the album does not have — most often against the 403 from require_no_lock, i.e. while the album is being indexed, which is exactly when someone is tuning it.
  • A refused value is no longer sent either. Typing 0.005 armed 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.
  • A save landing on a closed window rendered Plotly into a hidden container and consumed the dataChanged flag, so re-opening showed a zero-size plot and refetched nothing.
  • A saved strength belongs to the album it was typed in. The save read state.album when 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.)
  • A stored strength the map cannot cluster with is marked rather than silently presented as fine — versions before this one could store 0, -2 or 0.005, and the config file is hand-editable.

Tests

tests/frontend/umap-eps-debounce.test.js (new, 17 tests) and tests/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 min attribute to MIN_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 sets badInput (verified — see typeUnparseable, which stubs it). These tests pin this module's logic; that a browser really sets badInput for 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:

🤖 Generated with Claude Code

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
lstein force-pushed the lstein/fix/mid-typing-cluster-strength branch from 0afca9a to 2d779f2 Compare August 20, 2026 00:18
@lstein lstein changed the title Pausing mid-edit no longer overwrites the Cluster Strength Pausing mid-edit no longer discards the Cluster Strength Aug 20, 2026
lstein and others added 4 commits August 20, 2026 23:00
…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>
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.

1 participant