Repository: playcanvas/engine (2026-07-02T08:49:01Z)

Issue:
NullGraphicsDevice throws in constructor with mock canvas since v2.20.0 (getBoundingClientRect regression from #8950)
### Description

Since v2.20.0, constructing any `GraphicsDevice` (including `NullGraphicsDevice`) throws if the supplied canvas does not implement `getBoundingClientRect()`:

```
TypeError: this.canvas.getBoundingClientRect is not a function
    at NullGraphicsDevice.updateClientRect
    at new GraphicsDevice
    at new NullGraphicsDevice
```

#8950 (first released in v2.20.0) moved the `updateClientRect()` call into the base `GraphicsDevice` constructor so that `clientRect` is valid before the first frame (fixing #8932). `updateClientRect()` calls `this.canvas.getBoundingClientRect()` unconditionally, which only exists on real DOM elements.

`NullGraphicsDevice` is the documented path for headless / server-side use, where consumers typically pass a mock canvas object. For example, `@playcanvas/react` passes `{ id: 'pc-react-mock-canvas' }` to `NullGraphicsDevice` for SSR — and does so at module scope, so with engine ≥ 2.20.0 merely importing `@playcanvas/react` in Node crashes.

### Impact

* Broke the developer.playcanvas.com Docusaurus build: static site generation failed for all 639 doc pages once the site upgraded to playcanvas 2.20.4. The site is temporarily pinned to `~2.19.7` (playcanvas/developer-site#1074) until this is fixed.
* Any headless/SSR consumer constructing a `NullGraphicsDevice` with a non-DOM canvas is affected.

### Steps to Reproduce

In Node (no DOM):

```js
import { NullGraphicsDevice } from 'playcanvas';

const mockCanvas = { id: 'mock' };
new NullGraphicsDevice(mockCanvas); // throws in >= 2.20.0, works in 2.19.7
```

### Suggested fix

Guard the constructor-time initialization so it degrades gracefully when the canvas lacks `getBoundingClientRect` (e.g. `typeof this.canvas.getBoundingClientRect === 'function'` in `updateClientRect()`, or skip/override for the null backend), while preserving the intent of #8950 — a valid `clientRect` before the first device update for real backends.

There is a related hardening issue on the consumer side: playcanvas/react's SSR mock canvas could also stub this method (issue to follow on that repo).

🤖 Generated with [Claude Code](https://claude.com/claude-code)



Discussion:

Patch:
diff --git a/src/platform/graphics/graphics-device.js b/src/platform/graphics/graphics-device.js
index bb4e2d137a0..57bcd75d127 100644
--- a/src/platform/graphics/graphics-device.js
+++ b/src/platform/graphics/graphics-device.js
@@ -1256,14 +1256,16 @@ class GraphicsDevice extends EventHandler {
     }
 
     updateClientRect() {
-        if (platform.worker) {
-            // Web Workers don't do page layout, so getBoundingClientRect is not available
-            this.clientRect.width = this.canvas.width;
-            this.clientRect.height = this.canvas.height;
-        } else {
+        if (typeof this.canvas.getBoundingClientRect === 'function') {
             const rect = this.canvas.getBoundingClientRect();
             this.clientRect.width = rect.width;
             this.clientRect.height = rect.height;
+        } else {
+            // getBoundingClientRect is not available on OffscreenCanvas (Web Workers don't do
+            // page layout) or on mock canvases used with NullGraphicsDevice in headless
+            // environments
+            this.clientRect.width = this.canvas.width ?? 0;
+            this.clientRect.height = this.canvas.height ?? 0;
         }
     }
 


Test patch:
diff --git a/test/platform/graphics/graphics-device.test.mjs b/test/platform/graphics/graphics-device.test.mjs
new file mode 100644
index 00000000000..1da5201cce1
--- /dev/null
+++ b/test/platform/graphics/graphics-device.test.mjs
@@ -0,0 +1,44 @@
+import { expect } from 'chai';
+
+import { NullGraphicsDevice } from '../../../src/platform/graphics/null/null-graphics-device.js';
+import { jsdomSetup, jsdomTeardown } from '../../jsdom.mjs';
+
+describe('GraphicsDevice', function () {
+
+    describe('#constructor', function () {
+
+        it('does not throw with a mock canvas lacking getBoundingClientRect', function () {
+            const device = new NullGraphicsDevice({ id: 'mock' });
+            expect(device.clientRect.width).to.equal(0);
+            expect(device.clientRect.height).to.equal(0);
+            device.destroy();
+        });
+
+        it('initializes clientRect from mock canvas width and height', function () {
+            const device = new NullGraphicsDevice({ width: 300, height: 150 });
+            expect(device.clientRect.width).to.equal(300);
+            expect(device.clientRect.height).to.equal(150);
+            device.destroy();
+        });
+
+        describe('with a DOM canvas', function () {
+
+            beforeEach(function () {
+                jsdomSetup();
+            });
+
+            afterEach(function () {
+                jsdomTeardown();
+            });
+
+            it('initializes clientRect using getBoundingClientRect', function () {
+                const canvas = document.createElement('canvas');
+                canvas.getBoundingClientRect = () => ({ width: 640, height: 480 });
+                const device = new NullGraphicsDevice(canvas);
+                expect(device.clientRect.width).to.equal(640);
+                expect(device.clientRect.height).to.equal(480);
+                device.destroy();
+            });
+        });
+    });
+});