From 1a4a5220ef2732b828b3cde12276af0a5794581b Mon Sep 17 00:00:00 2001 From: Chris Lorenzo Date: Fri, 19 Jun 2026 14:34:52 -0400 Subject: [PATCH 1/2] fix(useHold): resolve taps without depending on key-up On TV platforms that don't reliably emit a key-up for the OK button (notably LG webOS), the only tap path lived in releaseHold gated on the key-up / onEnterRelease event, so rail cards routed through useHold never opened. The hold-to-watchlist gesture was unaffected because it fires from the key-down timer. Resolve tap vs hold from the timer and auto-repeat instead of key-up: - key-down starts the hold timer - an auto-repeat key-down (e.repeat) marks the key held -> timer fires onHold - key-up before the timer fires onEnter immediately (no latency where key-up is delivered) - if neither key-up nor auto-repeat arrives, the timer resolves to a tap -> onEnter, keeping taps working on webOS A fresh key-down also resets stale state so a hold whose key-up was swallowed doesn't wedge the next press. performOnEnterImmediately is preserved. Adds tests covering tap, webOS tap, hold, no-double-fire, recovery, and performOnEnterImmediately. Co-Authored-By: Claude Opus 4.8 --- src/primitives/useHold.ts | 85 ++++++++++++++++++++++++++++------- tests/useHold.spec.ts | 93 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 162 insertions(+), 16 deletions(-) create mode 100644 tests/useHold.spec.ts diff --git a/src/primitives/useHold.ts b/src/primitives/useHold.ts index d4c811e..be3f873 100644 --- a/src/primitives/useHold.ts +++ b/src/primitives/useHold.ts @@ -9,13 +9,30 @@ export type UseHoldProps = { }; /** + * Distinguishes a tap from a press-and-hold for a single key, without depending + * on the key-up event. This matters on TV platforms (notably LG webOS) where the + * OK button does not reliably emit a key-up, so any tap logic gated on key-up + * (`onEnterRelease`) would never run and the card would never open. + * + * How a press resolves: + * - key-down starts the hold timer. + * - an auto-repeat key-down before the timer fires marks the key as still held; + * when the timer fires it resolves to a hold → `onHold`. + * - if key-up arrives before the timer, it's a tap → `onEnter` (fires + * immediately, no latency, on platforms that deliver key-up). + * - if neither key-up nor auto-repeat arrives, the timer resolves to a tap → + * `onEnter` after `holdThreshold` ms. This is the key-up-independent path that + * keeps taps working on webOS, at the cost of ~`holdThreshold` ms latency. + * + * `performOnEnterImmediately` keeps the legacy behavior of firing `onEnter` on + * key-down; a long-press then fires both `onEnter` and `onHold`. + * * @example * const [holdRight, releaseRight] = useHold({ * onHold: handleHoldRight, * onEnter: handleOnRight, * onRelease: handleReleaseHold, * holdThreshold: 200, - * performOnEnterImmediately: true * }); * * * * @param {UseHoldProps} props - The properties for configuring the hold behavior. - * @returns {[() => boolean, () => boolean]} A tuple containing `startHold` and `releaseHold` functions. + * @returns {[(e?: KeyboardEvent) => boolean, () => boolean]} A tuple containing `startHold` and `releaseHold` functions. */ export function useHold(props: UseHoldProps) { - const holdThreshold = createMemo(() => props.holdThreshold ?? 500); + const holdThreshold = createMemo(() => props.holdThreshold ?? 550); const performOnEnterImmediately = createMemo( () => props.performOnEnterImmediately ?? false, ); let holdTimeout = -1; - let wasHeld = false; + let enterFired = false; // onEnter already fired for this press + let holdFired = false; // onHold already fired for this press + let repeated = false; // an auto-repeat key-down was seen (key still held) + + const reset = () => { + if (holdTimeout !== -1) { + clearTimeout(holdTimeout); + holdTimeout = -1; + } + enterFired = false; + holdFired = false; + repeated = false; + }; + + const startHold = (e?: KeyboardEvent) => { + // Auto-repeat key-down: the key is still held. Record it so the timer + // resolves to a hold even if the key-up event never arrives (webOS). + if (e?.repeat) { + repeated = true; + return true; + } + + // Fresh key-down begins a new press. Reset first so a previous press whose + // key-up was never delivered doesn't leave us wedged for this one. + reset(); - const startHold = () => { - if (holdTimeout === -1) { - if (performOnEnterImmediately()) { + if (performOnEnterImmediately()) { + enterFired = true; + props.onEnter(); + } + + holdTimeout = setTimeout(() => { + holdTimeout = -1; + if (repeated) { + // Held past the threshold → hold gesture. + holdFired = true; + props.onHold(); + } else if (!enterFired) { + // No key-up and no auto-repeat arrived: resolve as a tap so the + // primary action still fires on remotes that swallow key-up. + enterFired = true; props.onEnter(); } - holdTimeout = setTimeout(() => { - wasHeld = true; - props.onHold(); - }, holdThreshold()) as unknown as number; - } + }, holdThreshold()) as unknown as number; + return true; }; const releaseHold = () => { if (holdTimeout !== -1) { + // Released before the threshold → tap. Fires immediately where key-up is + // delivered, avoiding the timer latency. clearTimeout(holdTimeout); holdTimeout = -1; - if (!wasHeld) { - if (!performOnEnterImmediately()) props.onEnter(); - return; + if (!enterFired) { + enterFired = true; + props.onEnter(); } + } else if (holdFired) { props.onRelease?.(); - wasHeld = false; } + reset(); return true; }; diff --git a/tests/useHold.spec.ts b/tests/useHold.spec.ts new file mode 100644 index 0000000..7c11625 --- /dev/null +++ b/tests/useHold.spec.ts @@ -0,0 +1,93 @@ +import { createRoot } from 'solid-js'; +import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { useHold } from '../src/primitives/useHold.ts'; + +const downRepeat = { repeat: true } as KeyboardEvent; + +function setup(props: Partial[0]> = {}) { + const onEnter = vi.fn(); + const onHold = vi.fn(); + const onRelease = vi.fn(); + let api!: ReturnType; + const dispose = createRoot((d) => { + api = useHold({ onEnter, onHold, onRelease, holdThreshold: 200, ...props }); + return d; + }); + const [startHold, releaseHold] = api; + return { startHold, releaseHold, onEnter, onHold, onRelease, dispose }; +} + +describe('useHold', () => { + beforeEach(() => vi.useFakeTimers()); + afterEach(() => vi.useRealTimers()); + + it('fires onEnter immediately on key-up before the threshold (tap)', () => { + const { startHold, releaseHold, onEnter, onHold, dispose } = setup(); + startHold(); + releaseHold(); + expect(onEnter).toHaveBeenCalledTimes(1); + vi.advanceTimersByTime(500); + expect(onHold).not.toHaveBeenCalled(); + dispose(); + }); + + it('fires onEnter via the timer when key-up never arrives (webOS tap)', () => { + const { startHold, onEnter, onHold, dispose } = setup(); + startHold(); // no releaseHold — key-up swallowed + expect(onEnter).not.toHaveBeenCalled(); + vi.advanceTimersByTime(200); + expect(onEnter).toHaveBeenCalledTimes(1); + expect(onHold).not.toHaveBeenCalled(); + dispose(); + }); + + it('fires onHold (not onEnter) when held with auto-repeat', () => { + const { startHold, releaseHold, onEnter, onHold, onRelease, dispose } = + setup(); + startHold(); + startHold(downRepeat); // auto-repeat → key still held + vi.advanceTimersByTime(200); + expect(onHold).toHaveBeenCalledTimes(1); + expect(onEnter).not.toHaveBeenCalled(); + releaseHold(); + expect(onRelease).toHaveBeenCalledTimes(1); + dispose(); + }); + + it('does not double-fire when key-up arrives after a hold', () => { + const { startHold, releaseHold, onEnter, onHold, dispose } = setup(); + startHold(); + startHold(downRepeat); + vi.advanceTimersByTime(200); + releaseHold(); + expect(onHold).toHaveBeenCalledTimes(1); + expect(onEnter).not.toHaveBeenCalled(); + dispose(); + }); + + it('recovers on the next press after a key-up-less hold', () => { + const { startHold, releaseHold, onEnter, onHold, dispose } = setup(); + // First press: held, no key-up ever delivered. + startHold(); + startHold(downRepeat); + vi.advanceTimersByTime(200); + expect(onHold).toHaveBeenCalledTimes(1); + // Second press: a fresh tap with key-up must still work. + startHold(); + releaseHold(); + expect(onEnter).toHaveBeenCalledTimes(1); + expect(onHold).toHaveBeenCalledTimes(1); + dispose(); + }); + + it('performOnEnterImmediately fires onEnter on key-down', () => { + const { startHold, releaseHold, onEnter, dispose } = setup({ + performOnEnterImmediately: true, + }); + startHold(); + expect(onEnter).toHaveBeenCalledTimes(1); + releaseHold(); + expect(onEnter).toHaveBeenCalledTimes(1); // not double-fired + dispose(); + }); +}); From 0094050398599ee0c56f5102f8de7bc0afb1bb2c Mon Sep 17 00:00:00 2001 From: Chris Lorenzo Date: Fri, 19 Jun 2026 14:42:42 -0400 Subject: [PATCH 2/2] default back to 500 --- src/primitives/useHold.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/primitives/useHold.ts b/src/primitives/useHold.ts index be3f873..947ddce 100644 --- a/src/primitives/useHold.ts +++ b/src/primitives/useHold.ts @@ -45,7 +45,7 @@ export type UseHoldProps = { */ export function useHold(props: UseHoldProps) { - const holdThreshold = createMemo(() => props.holdThreshold ?? 550); + const holdThreshold = createMemo(() => props.holdThreshold ?? 500); const performOnEnterImmediately = createMemo( () => props.performOnEnterImmediately ?? false, );