From 503f52e500f51d8d0ef05ff80da236455e173c4c Mon Sep 17 00:00:00 2001 From: Will Eastcott Date: Thu, 24 Sep 2026 23:22:20 +0100 Subject: [PATCH] fix: Create an ElementInput in Application created the mouse and touch devices but no ElementInput, the object that turns input into events on element components. Without it, elements with useInput received no events, buttons never reacted, and scroll views and scrollbars could not be dragged. The debug engine only warns about it. Create one when the application is created, before the mouse and touch devices, so its listeners run first and stopPropagation() in a UI event handler also keeps the event from app.mouse and app.touch. The test setup's mock Application now passes the element input through, so the new test can check it. Co-Authored-By: Claude Opus 5.5 --- .changeset/application-element-input.md | 5 +++++ packages/lib/src/Application.test.tsx | 26 ++++++++++++++++++++++++- packages/lib/src/Application.tsx | 8 +++++++- packages/lib/test/setup.ts | 9 +++++---- 4 files changed, 42 insertions(+), 6 deletions(-) create mode 100644 .changeset/application-element-input.md diff --git a/.changeset/application-element-input.md b/.changeset/application-element-input.md new file mode 100644 index 00000000..8c2e3f56 --- /dev/null +++ b/.changeset/application-element-input.md @@ -0,0 +1,5 @@ +--- +"@playcanvas/react": patch +--- + +`` now creates an `ElementInput`, so elements with `useInput`, buttons, scroll views and scrollbars respond to the mouse, touch and XR input sources. It is created before the mouse and touch devices, so calling `stopPropagation()` in a UI event handler also keeps the event from `app.mouse` and `app.touch`. diff --git a/packages/lib/src/Application.test.tsx b/packages/lib/src/Application.test.tsx index 2e354657..f540bc66 100644 --- a/packages/lib/src/Application.test.tsx +++ b/packages/lib/src/Application.test.tsx @@ -1,16 +1,40 @@ -import { render, screen } from '@testing-library/react'; +import { render, screen, waitFor } from '@testing-library/react'; +import type { Application as PlayCanvasApplication } from 'playcanvas'; import { describe, it, expect, vi, beforeEach } from 'vitest'; import '@testing-library/jest-dom'; // eslint-disable-next-line import-x/order -- preserve the existing side-effect import order import React from 'react'; import { Application, ApplicationWithoutCanvas } from './Application.tsx'; +import { useApp } from './hooks/index.ts'; describe('Application', () => { beforeEach(() => { vi.clearAllMocks(); }); + // Without an ElementInput, elements with useInput, buttons and scroll views get no input + it('creates an ElementInput for the UI', async () => { + const onApp = vi.fn(); + const CaptureApp = () => { + const app = useApp(); + React.useEffect(() => onApp(app), [app]); + return null; + }; + + render( + + + + ); + + await waitFor(() => expect(onApp).toHaveBeenCalled()); + + const app = onApp.mock.calls[0][0] as PlayCanvasApplication; + expect(app.elementInput).toBeTruthy(); + expect(app.elementInput!.app).toBe(app); + }); + it('The Application component renders with default props', () => { render(); diff --git a/packages/lib/src/Application.tsx b/packages/lib/src/Application.tsx index a499c732..f9b8a151 100644 --- a/packages/lib/src/Application.tsx +++ b/packages/lib/src/Application.tsx @@ -5,6 +5,7 @@ import { FILLMODE_KEEP_ASPECT, RESOLUTION_AUTO, Application as PlayCanvasApplication, + ElementInput, Mouse, TouchDevice, RESOLUTION_FIXED, @@ -159,8 +160,13 @@ export const ApplicationWithoutCanvas: FC = (prop return; } - // Proceed with normal PlayCanvas init + // Proceed with normal PlayCanvas init. The ElementInput gives and + // the button, scroll view and scrollbar components their input. It is created before + // the mouse and touch devices so that its listeners run first, which lets a UI event + // handler that calls stopPropagation() keep the event from them too. + const elementInput = new ElementInput(canvas); const pcApp = new PlayCanvasApplication(canvas, { + elementInput, mouse: new Mouse(canvas), touch: new TouchDevice(canvas), graphicsDevice: dev diff --git a/packages/lib/test/setup.ts b/packages/lib/test/setup.ts index 2d306385..4a65feab 100644 --- a/packages/lib/test/setup.ts +++ b/packages/lib/test/setup.ts @@ -42,12 +42,14 @@ afterEach(() => { vi.clearAllMocks(); }); -// Create test application with spies -const createTestApp = (actual: typeof playcanvas) => { +// Create test application with spies. The element input the component creates is passed through, +// so that tests can check what it gives the UI. +const createTestApp = (actual: typeof playcanvas, options?: { elementInput?: playcanvas.ElementInput }) => { const canvas = document.createElement('canvas'); const app = new actual.Application(canvas, { graphicsDevice: new actual.NullGraphicsDevice(canvas), + elementInput: options?.elementInput, touch: new actual.TouchDevice(canvas), mouse: new actual.Mouse(canvas) }); @@ -75,8 +77,7 @@ vi.mock('playcanvas', async () => { const actual = await vi.importActual('playcanvas'); const MockApplication = vi.fn(function MockApplication(this: unknown, ...args: unknown[]) { - void args; - return createTestApp(actual); + return createTestApp(actual, args[1] as { elementInput?: playcanvas.ElementInput } | undefined); }); return {