From a3df93b8b14333d0e19db69a1a5542503d0cca6b Mon Sep 17 00:00:00 2001 From: abstrakt Date: Tue, 4 Aug 2026 14:02:20 +0100 Subject: [PATCH 1/3] fix: make @playcanvas/react importable without a DOM (SSR/SSG) Engine 2.20.0-2.20.4 calls getBoundingClientRect() on the canvas from the GraphicsDevice constructor. getNullApplication() runs at module scope with a bare-object mock canvas, so merely importing the package crashed in Node (Docusaurus SSG, Next.js builds, etc). - Stub getBoundingClientRect (and width/height) on the mock canvas so it answers layout probes without a DOM - Add a node-environment vitest project (no jsdom, no playcanvas mocks) with regression tests: package entry imports cleanly, and the mock canvas survives GraphicsDevice.updateClientRect() - Fix a latent import extension typo in gltf/index.ts (use-entity.ts -> use-entity.tsx) that strict Node resolution rejects Fixes #335 --- packages/lib/src/gltf/index.ts | 2 +- .../lib/src/utils/validation.node.test.ts | 28 +++++++++++++++ packages/lib/src/utils/validation.ts | 12 ++++++- packages/lib/vitest.config.ts | 35 ++++++++++++++----- 4 files changed, 67 insertions(+), 10 deletions(-) create mode 100644 packages/lib/src/utils/validation.node.test.ts diff --git a/packages/lib/src/gltf/index.ts b/packages/lib/src/gltf/index.ts index 074a44d9..145b9f86 100644 --- a/packages/lib/src/gltf/index.ts +++ b/packages/lib/src/gltf/index.ts @@ -6,7 +6,7 @@ export type { GltfProps } from './components/Gltf.tsx'; export { Modify } from './components/Modify.tsx'; // Hooks -export { useEntity } from './hooks/use-entity.ts'; +export { useEntity } from './hooks/use-entity.tsx'; // Types export type { diff --git a/packages/lib/src/utils/validation.node.test.ts b/packages/lib/src/utils/validation.node.test.ts new file mode 100644 index 00000000..030f411e --- /dev/null +++ b/packages/lib/src/utils/validation.node.test.ts @@ -0,0 +1,28 @@ +import { describe, it, expect } from 'vitest'; + +/** + * Regression tests for https://github.com/playcanvas/react/issues/335 + * + * `getNullApplication()` runs at module scope, so importing the package must + * succeed in a DOM-less Node process (SSR / static site generation). Engine + * 2.20.0–2.20.4 crashed at import time because the GraphicsDevice constructor + * called `getBoundingClientRect()` on the mock canvas. + * + * These tests run in a plain `node` environment (see vitest.config.ts) with no + * jsdom and no playcanvas mocks — the same conditions as an SSG build. + */ +describe('DOM-less import (SSR)', () => { + it('imports the package entry point without a DOM', async () => { + await expect(import('../index.ts')).resolves.toBeDefined(); + }); + + it('mock canvas answers the engine\'s layout probe', async () => { + const { getNullApplication } = await import('./validation.ts'); + const app = getNullApplication(); + + // The same call the GraphicsDevice constructor makes in engine 2.20.0+. + expect(() => app.graphicsDevice.updateClientRect()).not.toThrow(); + expect(app.graphicsDevice.clientRect.width).toBe(0); + expect(app.graphicsDevice.clientRect.height).toBe(0); + }); +}); diff --git a/packages/lib/src/utils/validation.ts b/packages/lib/src/utils/validation.ts index bceb55fc..9ff9c8b3 100644 --- a/packages/lib/src/utils/validation.ts +++ b/packages/lib/src/utils/validation.ts @@ -549,7 +549,17 @@ export function createComponentDefinition( * @returns A mock application that is used to render the application without a canvas. */ export function getNullApplication() { - const mockCanvas = { id: 'pc-react-mock-canvas' }; + const mockCanvas = { + id: 'pc-react-mock-canvas', + width: 0, + height: 0, + // The GraphicsDevice constructor may probe the canvas for layout + // (engine 2.20.0+ calls getBoundingClientRect). This module is evaluated + // during SSR/SSG where no DOM exists, so the mock must answer without one. + getBoundingClientRect: () => ({ + x: 0, y: 0, width: 0, height: 0, top: 0, right: 0, bottom: 0, left: 0 + }) + }; // @ts-expect-error - Mock canvas is not a real canvas return new Application(mockCanvas, { graphicsDevice: new NullGraphicsDevice(mockCanvas) }); } diff --git a/packages/lib/vitest.config.ts b/packages/lib/vitest.config.ts index ad0f6e31..b8a766f3 100644 --- a/packages/lib/vitest.config.ts +++ b/packages/lib/vitest.config.ts @@ -1,18 +1,15 @@ -import { defineConfig } from 'vitest/config'; +import { defineConfig, configDefaults } from 'vitest/config'; import react from '@vitejs/plugin-react'; export default defineConfig({ plugins: [react()], test: { - environment: 'jsdom', - globals: true, - setupFiles: ['./test/setup.ts'], - include: ['src/**/*.test.{ts,tsx}'], coverage: { provider: 'v8', reporter: ['text', 'json', 'html'], exclude: [ 'test/**', + 'src/**/*.test.ts', 'src/**/*.test.tsx', 'src/**/*.d.ts' ], @@ -22,7 +19,29 @@ export default defineConfig({ branches: 80, statements: 80 } - } - , + }, + projects: [ + { + extends: true, + test: { + name: 'jsdom', + environment: 'jsdom', + globals: true, + setupFiles: ['./test/setup.ts'], + include: ['src/**/*.test.{ts,tsx}'], + exclude: [...configDefaults.exclude, 'src/**/*.node.test.ts'] + } + }, + { + // SSR regression tests — a plain Node environment with no DOM and no + // playcanvas mocks, mirroring what SSG frameworks do at build time. + extends: true, + test: { + name: 'node', + environment: 'node', + include: ['src/**/*.node.test.ts'] + } + } + ] } -}); \ No newline at end of file +}); From bb4f6cb7b524423ef849b4e1312d35e1d84b31d7 Mon Sep 17 00:00:00 2001 From: abstrakt Date: Tue, 4 Aug 2026 14:02:46 +0100 Subject: [PATCH 2/3] chore: add changeset --- .changeset/ssr-mock-canvas-client-rect.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/ssr-mock-canvas-client-rect.md diff --git a/.changeset/ssr-mock-canvas-client-rect.md b/.changeset/ssr-mock-canvas-client-rect.md new file mode 100644 index 00000000..f3088c3e --- /dev/null +++ b/.changeset/ssr-mock-canvas-client-rect.md @@ -0,0 +1,5 @@ +--- +"@playcanvas/react": patch +--- + +Fix import crash in DOM-less environments (SSR/SSG) with engine 2.20.0–2.20.4: the module-scope null application's mock canvas now stubs `getBoundingClientRect`, which the `GraphicsDevice` constructor probes in those engine versions. From 254d4c75bf0ad5d045feb8943b9d23fb065de3b2 Mon Sep 17 00:00:00 2001 From: abstrakt Date: Tue, 4 Aug 2026 14:21:17 +0100 Subject: [PATCH 3/3] docs: pin exact broken engine versions and link engine fix PR in comment --- packages/lib/src/utils/validation.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/lib/src/utils/validation.ts b/packages/lib/src/utils/validation.ts index 9ff9c8b3..4a1dc22f 100644 --- a/packages/lib/src/utils/validation.ts +++ b/packages/lib/src/utils/validation.ts @@ -553,9 +553,11 @@ export function getNullApplication() { id: 'pc-react-mock-canvas', width: 0, height: 0, - // The GraphicsDevice constructor may probe the canvas for layout - // (engine 2.20.0+ calls getBoundingClientRect). This module is evaluated - // during SSR/SSG where no DOM exists, so the mock must answer without one. + // Engine 2.20.0–2.20.4 calls getBoundingClientRect() unconditionally from + // the GraphicsDevice constructor; 2.21.0 made it optional again + // (https://github.com/playcanvas/engine/pull/9000). This module is evaluated + // during SSR/SSG where no DOM exists, so the mock must answer the probe + // itself to keep the whole peer range (^2.11.8) importable in Node. getBoundingClientRect: () => ({ x: 0, y: 0, width: 0, height: 0, top: 0, right: 0, bottom: 0, left: 0 })