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. 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..4a1dc22f 100644 --- a/packages/lib/src/utils/validation.ts +++ b/packages/lib/src/utils/validation.ts @@ -549,7 +549,19 @@ 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, + // 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 + }) + }; // @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 +});