From f0d9602eb8c6e6dd95fd670ef0cf20288a892359 Mon Sep 17 00:00:00 2001 From: Will Eastcott Date: Fri, 25 Sep 2026 23:07:30 +0100 Subject: [PATCH] fix: Create a Keyboard in Application created the mouse and touch devices but no keyboard, and `keyboard` is excluded from its props, so app.keyboard was always null and scripts and hooks that read it threw. Create one attached to the window, as the Editor launcher and do. It is created with the application, after the check for a mount that was torn down while the graphics device was being created, and app.destroy() detaches it. Unmounting, and the first mount that StrictMode discards, therefore leave no keyboard attached. Co-Authored-By: Claude Opus 5.5 --- .changeset/application-keyboard.md | 5 ++ .../lib/src/Application.keyboard.test.tsx | 77 +++++++++++++++++++ packages/lib/src/Application.tsx | 4 + 3 files changed, 86 insertions(+) create mode 100644 .changeset/application-keyboard.md create mode 100644 packages/lib/src/Application.keyboard.test.tsx diff --git a/.changeset/application-keyboard.md b/.changeset/application-keyboard.md new file mode 100644 index 00000000..3210e903 --- /dev/null +++ b/.changeset/application-keyboard.md @@ -0,0 +1,5 @@ +--- +"@playcanvas/react": patch +--- + +`` now creates a `Keyboard` attached to the window, so `app.keyboard` is no longer `null` and scripts and hooks can read key state, for example `app.keyboard.isPressed(KEY_RIGHT)`. It is detached when the application is destroyed on unmount. diff --git a/packages/lib/src/Application.keyboard.test.tsx b/packages/lib/src/Application.keyboard.test.tsx new file mode 100644 index 00000000..57f6a297 --- /dev/null +++ b/packages/lib/src/Application.keyboard.test.tsx @@ -0,0 +1,77 @@ +import { fireEvent, render, waitFor } from '@testing-library/react'; +import type { RenderOptions } from '@testing-library/react'; +import type { Application as PlayCanvasApplication } from 'playcanvas'; +import { KEY_RIGHT, Keyboard } from 'playcanvas'; +import { useEffect } from 'react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { Application } from './Application.tsx'; +import { useApp } from './hooks/index.ts'; + +// These tests run against the real engine. The mock in test/setup.ts builds a stand-in application +// with its own input devices, so it never sees the keyboard that creates. +vi.unmock('playcanvas'); + +// Renders an application and resolves with the engine application once it has been created +const renderApplication = async (options?: RenderOptions) => { + const onApp = vi.fn(); + const CaptureApp = () => { + const app = useApp(); + useEffect(() => onApp(app), [app]); + return null; + }; + + const result = render( + + + , + options + ); + + await waitFor(() => expect(onApp).toHaveBeenCalled()); + + return { ...result, app: onApp.mock.calls[0][0] as PlayCanvasApplication }; +}; + +describe('Application keyboard', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + // Scripts and hooks read app.keyboard, for example to push a rigid body while a key is held + it('gives the application a keyboard that listens on the window', async () => { + const { app } = await renderApplication(); + + expect(app.keyboard).toBeInstanceOf(Keyboard); + + fireEvent.keyDown(window, { keyCode: KEY_RIGHT }); + expect(app.keyboard!.isPressed(KEY_RIGHT)).toBe(true); + + fireEvent.keyUp(window, { keyCode: KEY_RIGHT }); + expect(app.keyboard!.isPressed(KEY_RIGHT)).toBe(false); + }); + + it('leaves one keyboard attached in StrictMode and detaches it on unmount', async () => { + const attach = vi.spyOn(Keyboard.prototype, 'attach'); + const detach = vi.spyOn(Keyboard.prototype, 'detach'); + const attached = () => attach.mock.contexts.filter((keyboard) => !detach.mock.contexts.includes(keyboard)); + + // StrictMode mounts, unmounts and remounts. The first mount is either abandoned before it + // creates an application (as here, where act() flushes the remount first) or its application + // is created and then destroyed (as in a browser). Either way, one keyboard stays attached. + const { app, unmount } = await renderApplication({ reactStrictMode: true }); + const keyboard = app.keyboard!; + + expect(attached()).toHaveLength(1); + expect(attached()[0]).toBe(keyboard); + + unmount(); + + expect(attached()).toHaveLength(0); + expect(app.keyboard).toBeNull(); + + // Key presses no longer reach it + fireEvent.keyDown(window, { keyCode: KEY_RIGHT }); + expect(keyboard.isPressed(KEY_RIGHT)).toBe(false); + }); +}); diff --git a/packages/lib/src/Application.tsx b/packages/lib/src/Application.tsx index a499c732..a52ea0af 100644 --- a/packages/lib/src/Application.tsx +++ b/packages/lib/src/Application.tsx @@ -7,6 +7,7 @@ import { Application as PlayCanvasApplication, Mouse, TouchDevice, + Keyboard, RESOLUTION_FIXED, DEVICETYPE_WEBGL2, DEVICETYPE_WEBGPU, @@ -163,6 +164,9 @@ export const ApplicationWithoutCanvas: FC = (prop const pcApp = new PlayCanvasApplication(canvas, { mouse: new Mouse(canvas), touch: new TouchDevice(canvas), + // On the window, as in the Editor and , so key presses register without the + // canvas having focus. Destroying the app detaches it. + keyboard: new Keyboard(window), graphicsDevice: dev }); pcApp.start();