fix(useHold): suppress trailing repeats and deliver onRelease after a hold - #49
fix(useHold): suppress trailing repeats and deliver onRelease after a hold#49chiefcll wants to merge 10 commits into
Conversation
… 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>
Follow-up:
|
| 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 tscclean;npm test170/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 stricttrue. - 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
… it doesn't" This reverts commit 4a858c0.
`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>
Addresses five issues found while migrating a webOS/Tizen/Android TV app off the deprecated
keyHoldOptions/userKeyHoldMappath ontouseHold.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:
releaseHoldnever runs andonReleasesilently never fires — contradicting the documented contract.Both are regressions from the legacy
userKeyHoldMappath, which got the same protection for free by returning early from every key-down for a hold-mapped key (focusManager.tshandleKeyEvents).Fix
Since both are propagation concerns, the latch lives in the focus manager, which still owns propagation after focus leaves the element:
onRelease.useHoldregisters 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,releaseHoldfindsholdFired === falseand no-ops.Both helpers are exported from
@solidtv/solid/primitivesfor hold logic written outside the primitive.Also in this PR
holdRequiresRepeat(defaulttrue, no behavior change). Hold detection readse.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 whatkeyHoldOptionsdid, which fired the hold purely on a timer and never consultede.repeat. Setting this tofalserestores that semantics. An early key-up still resolves as a tap either way.Callback context.
onHold/onEnter/onReleasewere() => void, so every call site had to stashKeyHandlerarguments in a closure and wrapstartHold. They now receive(e?, target?, handlerElm?), matchingKeyHandler's shape.onHoldand timer-resolvedonEnter/onReleasereplay 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 theKeyboardEventparameter 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:
startHoldstops propagation. It returnstrueunconditionally, 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
startHoldreceiving 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.releaseKeySuppressionhas no in-tree caller; it exists as the documented pair tosuppressKeyUntilReleasefor consumers.Verification
npm run tscclean.npm test— 164/164 across 15 files.npm run lint— 0 errors, 152 warnings, identical to the pre-change baseline (the 2 warnings infocusManager.tsare pre-existing, at line 200, unrelated).tests/keySuppression.test.tsxdrives realkeydown/keyupthroughuseFocusManageragainst 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, coveringholdRequiresRepeatboth ways, latch registration, latch-deliveredonRelease, 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