Skip to content

fix(useHold): suppress trailing repeats and deliver onRelease after a hold - #49

Open
chiefcll wants to merge 10 commits into
mainfrom
fix/use-hold-key-suppression
Open

fix(useHold): suppress trailing repeats and deliver onRelease after a hold#49
chiefcll wants to merge 10 commits into
mainfrom
fix/use-hold-key-suppression

Conversation

@chiefcll

@chiefcll chiefcll commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

Addresses five issues found while migrating a webOS/Tizen/Android TV app off the deprecated keyHoldOptions / userKeyHoldMap path onto useHold.

The main bug

A hold action typically moves focus while the key is still physically down — hold OK to open a context menu. Two things then go wrong, and neither can be fixed by the element that owns the hold, because once focus moves it is no longer in the focus path:

  1. The platform's trailing auto-repeat key-downs propagate down the new focus path and fire whatever just took focus.
  2. The key-up goes there too, so releaseHold never runs and onRelease silently never fires — contradicting the documented contract.

Both are regressions from the legacy userKeyHoldMap path, which got the same protection for free by returning early from every key-down for a hold-mapped key (focusManager.ts handleKeyEvents).

Fix

Since both are propagation concerns, the latch lives in the focus manager, which still owns propagation after focus leaves the element:

suppressKeyUntilRelease(keyOrEvent, onRelease?)
releaseKeySuppression(keyOrEvent)
  • Drops auto-repeat key-downs for the key until it is released.
  • The release callback is delivered wherever focus has moved — that is what fixes onRelease.
  • Suppression lifts on key-up or on the next non-repeat key-down, so a swallowed key-up (webOS) cannot wedge a key permanently.
  • Non-repeat key-downs are never suppressed, so a real press always gets through.

useHold registers the latch itself when its hold fires. Double-firing is avoided by ordering: the keyup branch lifts suppression before propagating, so if the element is still focused, releaseHold finds holdFired === false and no-ops.

Both helpers are exported from @solidtv/solid/primitives for hold logic written outside the primitive.

Also in this PR

holdRequiresRepeat (default true, no behavior change). Hold detection reads e.repeat, so on a platform delivering neither key-up nor auto-repeat, a genuine long press resolved as a tap and a hold gesture was unreachable — the opposite of what keyHoldOptions did, which fired the hold purely on a timer and never consulted e.repeat. Setting this to false restores that semantics. An early key-up still resolves as a tap either way.

Callback context. onHold / onEnter / onRelease were () => void, so every call site had to stash KeyHandler arguments in a closure and wrap startHold. They now receive (e?, target?, handlerElm?), matching KeyHandler's shape. onHold and timer-resolved onEnter / onRelease replay the context captured from the originating key-down, since they fire from a timer with no event of their own.

Types. The return was inferred as ((e?: KeyboardEvent) => boolean)[] — a plain array — while the JSDoc claimed a tuple. Now explicitly [HoldHandler, HoldHandler]. The docs also omitted the KeyboardEvent parameter entirely; it is now stated, with a warning that a wrapper which drops the event yields a primitive that can never detect a hold — silently, and typically only on device.

Docs: startHold stops propagation. It returns true unconditionally, which ends the bubble phase, so ancestor handlers for that key never run. This is structural, not incidental: whether the press was a tap is not known until key-up or until the timer fires, by which point the propagation pass is over, so the deferred tap cannot be handed back. Documented as a caveat with the parent-walk workaround, rather than adding a propagation-resume API.

Reviewer notes

  • The suppression fix depends on startHold receiving the event (if (e) suppressKeyUntilRelease(e, fireRelease)). A wrapper that drops the event now loses hold detection and suppression together. Called out in the JSDoc and docs — happy to add a __DEV__ warn if you'd rather it fail loudly.
  • releaseKeySuppression has no in-tree caller; it exists as the documented pair to suppressKeyUntilRelease for consumers.
  • Default behavior is unchanged for anyone whose hold does not move focus.

Verification

  • npm run tsc clean.
  • npm test — 164/164 across 15 files.
  • npm run lint — 0 errors, 152 warnings, identical to the pre-change baseline (the 2 warnings in focusManager.ts are pre-existing, at line 200, unrelated).
  • New tests/keySuppression.test.tsx drives real keydown/keyup through useFocusManager against a focused tree: repeats dropped while latched, key-up lifting, fresh-keydown lifting when key-up never arrives, non-repeat presses never suppressed.
  • tests/useHold.spec.ts — 9 added, covering holdRequiresRepeat both ways, latch registration, latch-delivered onRelease, no-double-fire, and context propagation to each callback.

Not verified on real hardware — the reporter's original repro was LG webOS with a physical remote.

🤖 Generated with Claude Code

chiefcll and others added 3 commits August 2, 2026 20:20
… hold

A hold action typically moves focus while the key is still physically down
(hold OK to open a context menu). Two things then went wrong, and neither
could be fixed by the element owning the hold, since it is no longer in the
focus path: the platform's trailing auto-repeat key-downs propagated down the
*new* focus path and fired whatever just took focus, and the key-up went there
too, so onRelease never ran.

Both are regressions from the legacy `userKeyHoldMap` path, which got the same
protection for free by returning early from every key-down for a hold-mapped
key.

Add a suppression latch to the focus manager, which owns propagation:
`suppressKeyUntilRelease(keyOrEvent, onRelease?)` drops auto-repeat key-downs
for a key and delivers a callback when the key is finally released, wherever
focus has moved. Suppression lifts on key-up or on the next non-repeat
key-down, so a swallowed key-up (webOS) cannot wedge a key; non-repeat
key-downs are never suppressed. useHold latches when its hold fires.

Also:

- Add `holdRequiresRepeat` (default true, preserving current behavior). Hold
  detection reads `e.repeat`, so on a platform delivering neither key-up nor
  auto-repeat a long press resolved as a tap and a hold was unreachable —
  the opposite of what `keyHoldOptions` did. Setting it false resolves that
  ambiguous case as a hold.
- Pass the originating KeyboardEvent and elements through to onHold, onEnter
  and onRelease, so call sites no longer stash KeyHandler args in a closure.
- Annotate the return as a tuple rather than letting it infer an array, and
  document the KeyboardEvent parameter that hold detection depends on.
- Document that startHold ends the bubble phase, so ancestor handlers for the
  key do not run and the deferred tap cannot be handed back to them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
webOS emits no OS-level `repeat === true` for the Back key, so requiring an
auto-repeat to confirm a hold made Back holds unreachable there. Whether a key
repeats is a per-key property, not a per-platform one — webOS emits repeat for
OK but not Back, and swallows key-up for OK but not Back — so a static flag per
useHold instance was the wrong shape.

Make `holdRequiresRepeat` three-state, defaulting to a new `'auto'` that infers
per key. The discriminator is key-up delivery, which is the only thing that
separates "still held" from "already released, key-up swallowed" at the
threshold:

- a key seen delivering key-up must still be down if none arrived, so no
  auto-repeat is needed and the hold resolves by timer (webOS Back);
- a key that swallows key-up is indistinguishable either way, so an auto-repeat
  is still required and a repeat-less press stays a tap (webOS OK).

The focus manager records key-up delivery per key as it observes it, exposed as
`keyDeliversKeyUp` / `noteKeyUpDelivered`. Inference needs one prior press of a
key, so the first press of a session resolves as a tap; `holdRequiresRepeat:
false` skips the warm-up and always resolves by timer for a key already known to
lack auto-repeat. `true` keeps the strict repeat-only behavior.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@chiefcll

chiefcll commented Aug 3, 2026

Copy link
Copy Markdown
Contributor Author

Follow-up: holdRequiresRepeat is now three-state, defaulting to 'auto'

Reported against the first commit: webOS emits no OS-level repeat === true for the Back key, so requiring an auto-repeat to confirm a hold makes a Back hold unreachable there.

The static holdRequiresRepeat: false flag from the first commit did unblock it, but it was the wrong shape. Repeat delivery is a per-key property, not a per-platform one — on webOS, OK emits auto-repeat but swallows key-up, and Back is the exact reverse. A single boolean per useHold instance forces the app to know and hard-code that matrix.

'auto' (new default)

The discriminator is key-up delivery, which is the only thing that separates "still held" from "already released, key-up swallowed" at the threshold:

  • Key delivers key-up (webOS Back) — reaching the threshold without one means it is genuinely still down. No auto-repeat needed; resolve by timer → onHold.
  • Key swallows key-up (webOS OK) — a finished press is indistinguishable from one still held, so only an auto-repeat can confirm a hold. Without one → onEnter, preserving the tap path the primitive was written for.

The focus manager records key-up delivery per key as it observes it, exposed as keyDeliversKeyUp / noteKeyUpDelivered. useHold also records from releaseHold, so 'auto' still works behind a custom focus manager.

Value Behavior
'auto' (default) Use auto-repeat where the key needs it, fall back to timer where not
false Always resolve by timer — the legacy keyHoldOptions semantics
true Require an auto-repeat; never resolve a repeat-less press as a hold

Caveat worth reviewing

'auto' infers from keys observed earlier in the session, so the first press of a given key has no evidence and resolves as a tap. For Back on webOS that means the first hold of a session is missed unless it is stated explicitly:

const [holdBack, releaseBack] = useHold({
  onHold: exitApp,
  onEnter: goBack,
  holdThreshold: 1000,
  holdRequiresRepeat: false, // resolve by timer from the very first press
});

<view onBack={holdBack} onCaptureBackRelease={releaseBack} />;

I kept inference as the default rather than making false the default, since false would regress webOS OK — a short OK press whose key-up is swallowed would fire onHold instead of onEnter. If you would rather not carry the warm-up caveat at all, the alternative is to drop 'auto' and require an explicit value per key; happy to switch.

An early key-up still resolves as a tap under every setting — this only governs the no-key-up and no-repeat case.

Verification

  • npm run tsc clean; npm test 170/170 across 15 files; lint at the 152-warning baseline.
  • 6 tests added for 'auto' (repeat-swallowing key, key-up-delivering key, early key-up, per-key tracking in one session, no-event case) plus one for strict true.
  • Integration test asserts the focus manager's registry populates from real key-ups and stays per-key.

Note this branch also picked up your Release 1.4.1-0 commit while I was working; the new commit sits on top of it.

🤖 Generated with Claude Code

chiefcll and others added 7 commits August 2, 2026 21:07
`key` is not a stable identity for a physical key across key-down and key-up.
webOS reports Back's key-down as { key: 'GoBack', keyCode: 461 } and its key-up
as { key: 'Unidentified', keyCode: 461 } — the same physical key under two
names, sharing only the keyCode.

The suppression latch keyed on `e.key || e.keyCode`, so a hold on Back
registered under 'GoBack' and the key-up looked up 'Unidentified'. It never
matched: the key stayed suppressed until the next fresh key-down, and the
latch's onRelease never fired.

Identify a key by every name its event carries, and treat two events as the
same key if any identity matches. A suppression is indexed under each of its
identities and lifted through any of them. 'Unidentified' is excluded as an
identity, since it names no particular key and would conflate every key that
reports it.

Tests cover the logged webOS Back sequence verbatim, including a full
useHold-driven tap / hold / release through the real focus manager.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
BREAKING CHANGE: `useFocusManager`'s second parameter (`keyHoldOptions` /
`userKeyHoldMap` / `holdThreshold`) is removed, along with the `onKeyHold`
fallback handler and the `on${Key}Hold` handlers it dispatched. Hold gestures
are handled by the `useHold` primitive.

The global path delayed every key-down for a hold-mapped key across the whole
app, whether or not the focused element cared about holds, and it keyed its
pending timeout on `e.key` — which webOS does not report consistently across
key-down and key-up, so a short tap on Back could still fire BackHold. `useHold`
is scoped to the element that owns the gesture and has none of that.

Removed:

- `useFocusManager(keyMap, keyHoldOptions)` second parameter
- `KeyHoldOptions`, `KeyHoldMap`, `DefaultKeyHoldMap` types
- `onKeyHold` from `FocusNode`, and `EventHandlers<KeyHoldMap>` from `NodeProps`
  (which is what supplied `onEnterHold` and friends)
- the `isHold` argument threaded through `propagateKeyPress`/`runBubblePhase`

Migration: move a `userKeyHoldMap` entry onto the element that owns the gesture.

  // before
  useFocusManager(keyMap, { userKeyHoldMap: { EnterHold: 'Enter' }, holdThreshold: 1000 })
  <view onEnterHold={openMenu} onEnter={openTile} />

  // after
  const [holdEnter, releaseEnter] = useHold({
    onHold: openMenu, onEnter: openTile, holdThreshold: 1000,
  })
  <view onEnter={holdEnter} onEnterRelease={releaseEnter} />

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…port a hold

Doc audit against the source turned up several inaccuracies:

- `useFocusManager` and `focusPath` are exported from `@solidtv/solid/primitives`,
  not `@solidtv/solid`, and `useFocusManager` returns nothing — the docs showed
  `const focusPath = useFocusManager(...)` imported from the main entry.
- Capture handlers are `onCapture${Key}` / `onCaptureKey`; the docs named them
  `capture${key}` / `captureKey`.
- The bubble phase is a single interleaved walk — `onKeyPress` on an element is
  tried before its parent's `on${Key}` — not a second pass over the whole tree.
- The key handler signature omitted the capture-phase `mappedEvent` argument and
  claimed a `boolean` return rather than `boolean | void`. `onKeyPress` takes the
  mapped event name as its second argument, which was undocumented.
- The listed default key map included numeric keyCodes that are not mapped by
  default and omitted `l: 'Last'`. Since nothing maps keyCodes out of the box,
  that mattered for devices reporting keys by code.
- Key release skipped the capture phase; `onCaptureKeyRelease` is the only
  catch-all for a release.
- `printFocusHistory` / `getFocusHistory` were documented but exported from
  neither entry point. Export them from primitives alongside the rest of the
  focus API, and drop the claim that `printFocusHistory` is callable from the
  DevTools console — only `$f` is attached to `window`.

Also document that only Left/Right/Up/Down/Enter/Last have typed handler props;
`onBack` and friends are only accepted via ElementNode's index signature.

For useHold, replace the "platforms without auto-repeat" guidance, which assumed
a key that emits key-up on real release. Testing on an LG remote shows Back emits
key-down and key-up together at press time, so tap and hold are indistinguishable
and no setting recovers the gesture — `holdRequiresRepeat: false` cannot help,
because the immediate key-up cancels the timer before it fires. Document the
limitation, tabulate which signals do and don't permit hold detection, and give a
snippet for checking a key on real hardware.

Co-Authored-By: Claude Opus 5 <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