From 8c7bc011c718e837ee506d7538d4b07e80673cdd Mon Sep 17 00:00:00 2001 From: Chris Lorenzo Date: Sun, 19 Jul 2026 19:10:26 -0400 Subject: [PATCH] fix(useMouse): force key/keyCode on synthetic events for Chrome <51 (webOS 3.x) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On Chrome < 51 (LG webOS 3.x runs Chrome 38) the KeyboardEvent constructor ignores the key/keyCode/which init members, so the synthetic Enter/ArrowUp/ArrowDown events dispatched by useMouse arrived with key '' / keyCode 0 and the focusManager silently dropped them — every Magic Remote click and scroll did nothing. Detect when the constructor dropped the init members and force the values onto the event instance with Object.defineProperty getters. Modern engines keep the plain constructor path unchanged. Co-Authored-By: Claude Fable 5 --- src/primitives/useMouse.ts | 14 +++- tests/useMouse.test.tsx | 160 +++++++++++++++++++++++++++++++++++++ 2 files changed, 173 insertions(+), 1 deletion(-) diff --git a/src/primitives/useMouse.ts b/src/primitives/useMouse.ts index c1a243d..e3d5e24 100644 --- a/src/primitives/useMouse.ts +++ b/src/primitives/useMouse.ts @@ -51,7 +51,7 @@ function createKeyboardEvent( keyCode: number, eventName: string = 'keydown', ): KeyboardEvent { - return new KeyboardEvent(eventName, { + const event = new KeyboardEvent(eventName, { key, keyCode, which: keyCode, @@ -61,6 +61,18 @@ function createKeyboardEvent( metaKey: false, bubbles: true, }); + + // Chrome < 51 (LG webOS 3.x runs Chrome 38) ignores the key/keyCode/which + // members of KeyboardEventInit, so the event arrives with key '' / keyCode 0 + // and the focusManager drops it. Force the values onto the instance so Magic + // Remote clicks and scrolls still map to keys on those devices. + if (event.key !== key || event.keyCode !== keyCode) { + Object.defineProperty(event, 'key', { get: () => key }); + Object.defineProperty(event, 'keyCode', { get: () => keyCode }); + Object.defineProperty(event, 'which', { get: () => keyCode }); + } + + return event; } const [cursorVisible, setCursorVisible] = createSignal(false); diff --git a/tests/useMouse.test.tsx b/tests/useMouse.test.tsx index fb3fd74..5660974 100644 --- a/tests/useMouse.test.tsx +++ b/tests/useMouse.test.tsx @@ -1,6 +1,7 @@ import * as v from 'vitest'; import * as lng from '@solidtv/solid'; import { useMouse } from '../src/primitives/useMouse.js'; +import { useFocusManager } from '../src/core/focusManager.js'; import { renderer } from './setup.js'; const dispatchClick = (x: number, y: number) => @@ -414,3 +415,162 @@ v.describe('useMouse', () => { dispose(); }); }); + +v.describe('synthetic keyboard events (legacy KeyboardEvent constructors)', () => { + // Simulates Chrome < 51 (LG webOS 3.x), where the KeyboardEvent constructor + // exists but silently drops the key/keyCode/which members of the init dict. + class LegacyKeyboardEvent extends KeyboardEvent { + constructor(type: string, init?: KeyboardEventInit) { + super(type, { bubbles: init?.bubbles, cancelable: init?.cancelable }); + } + } + + const captureKeydown = () => { + const events: KeyboardEvent[] = []; + const listener = (e: Event) => events.push(e as KeyboardEvent); + document.addEventListener('keydown', listener); + return { + events, + stop: () => document.removeEventListener('keydown', listener), + }; + }; + + v.afterEach(() => { + v.vi.unstubAllGlobals(); + }); + + v.test('modern engine: click dispatches Enter via the constructor path (no defineProperty)', async () => { + let app!: lng.ElementNode; + let btn!: lng.ElementNode; + + const dispose = renderer.render(() => { + const root = ( + + {}} + /> + + ); + useMouse(app, 5); + return root; + }); + + await wait(20); + btn.setFocus(); + await wait(20); + v.assert.equal(lng.activeElement(), btn); + + // attach after the waits so stray delayed dispatches from prior tests + // have flushed + const capture = captureKeydown(); + dispatchClick(150, 250); + await wait(20); // synthetic Enter is dispatched on a 1ms timeout + + v.assert.equal(capture.events.length, 1, 'one Enter keydown dispatched'); + const evt = capture.events[0]!; + v.assert.equal(evt.key, 'Enter'); + v.assert.equal(evt.keyCode, 13); + v.assert.equal(evt.which, 13); + v.assert.isUndefined( + Object.getOwnPropertyDescriptor(evt, 'key'), + 'constructor honored init members, so no own-property override', + ); + v.assert.isUndefined(Object.getOwnPropertyDescriptor(evt, 'keyCode')); + + capture.stop(); + dispose(); + }); + + v.test('legacy constructor: click still reports Enter/13 and reaches onEnter via the focus manager', async () => { + v.vi.stubGlobal('KeyboardEvent', LegacyKeyboardEvent); + + let app!: lng.ElementNode; + let btn!: lng.ElementNode; + const onEnter = v.vi.fn(); + + const dispose = renderer.render(() => { + const root = ( + + + + ); + useFocusManager(); + useMouse(app, 5); + return root; + }); + + await wait(20); + btn.setFocus(); + await wait(20); + v.assert.equal(lng.activeElement(), btn); + + const capture = captureKeydown(); + dispatchClick(150, 250); + await wait(20); + + v.assert.equal(capture.events.length, 1, 'one Enter keydown dispatched'); + const evt = capture.events[0]!; + v.assert.equal(evt.key, 'Enter', 'key forced onto instance'); + v.assert.equal(evt.keyCode, 13, 'keyCode forced onto instance'); + v.assert.equal(evt.which, 13, 'which forced onto instance'); + v.assert.equal( + onEnter.mock.calls.length, + 1, + 'focus manager mapped the synthetic Enter to onEnter', + ); + + capture.stop(); + dispose(); + }); + + v.test('legacy constructor: wheel scroll still reports ArrowUp/38 and ArrowDown/40', async () => { + v.vi.stubGlobal('KeyboardEvent', LegacyKeyboardEvent); + + let app!: lng.ElementNode; + + const dispose = renderer.render(() => { + const root = ( + + {}} /> + + ); + useMouse(app, 5); + return root; + }); + + await wait(20); + const capture = captureKeydown(); + window.dispatchEvent(new WheelEvent('wheel', { deltaY: 100 })); + await wait(300); // module-level scroll handler is throttled at 250ms + + v.assert.equal(capture.events.length, 1); + v.assert.equal(capture.events[0]!.key, 'ArrowDown'); + v.assert.equal(capture.events[0]!.keyCode, 40); + v.assert.equal(capture.events[0]!.which, 40); + + window.dispatchEvent(new WheelEvent('wheel', { deltaY: -100 })); + await wait(300); + + v.assert.equal(capture.events.length, 2); + v.assert.equal(capture.events[1]!.key, 'ArrowUp'); + v.assert.equal(capture.events[1]!.keyCode, 38); + v.assert.equal(capture.events[1]!.which, 38); + + // let the trailing keyup timeout flush before other tests run + await wait(300); + capture.stop(); + dispose(); + }); +});