Skip to content

fix: make @playcanvas/react importable without a DOM (SSR/SSG) - #338

Merged
abstrakt8 merged 3 commits into
mainfrom
fix/ssr-mock-canvas-client-rect
Aug 4, 2026
Merged

fix: make @playcanvas/react importable without a DOM (SSR/SSG)#338
abstrakt8 merged 3 commits into
mainfrom
fix/ssr-mock-canvas-client-rect

Conversation

@abstrakt8

Copy link
Copy Markdown
Contributor

Fixes #335

Problem

getNullApplication() runs at module scope (validation.ts) and constructs an Application around a bare-object mock canvas. Engine 2.20.0–2.20.4 calls getBoundingClientRect() unconditionally from the GraphicsDevice constructor, so merely importing @playcanvas/react in Node crashes:

TypeError: this.canvas.getBoundingClientRect is not a function

This breaks any SSR/SSG build (the Docusaurus build of developer.playcanvas.com, Next.js next build, …). A 'use client' boundary doesn't help — frameworks still evaluate the module in Node to prerender the HTML shell.

The engine made the probe optional again in 2.21.0 (playcanvas/engine#9000), but our peer range is "playcanvas": "^2.11.8", which still includes the broken 2.20.x window.

Fix

  • Stub getBoundingClientRect() (zeroed rect) and width/height on the mock canvas so it answers layout probes without a DOM. This keeps the entire advertised peer range importable in Node. The mock canvas is only ever used by the null application, so this cannot affect runtime behavior.
  • Fix a latent import-extension typo flushed out by the new tests: gltf/index.ts imported ./hooks/use-entity.ts but the file is use-entity.tsx. Vite's lenient resolver hid it; strict Node ESM resolution rejects it.

Why not the issue's "lazy-initialize localApp" proposal: it doesn't help. Every component module (Light.tsx, Camera.tsx, …) calls createComponentDefinition() at module scope, which eagerly instantiates a mock component via getStaticNullApplication() to derive its prop schema. Laziness would only relocate the crash from validation.ts to the first component module. Making schema derivation lazy is a larger refactor that this fix makes unnecessary for now.

Why not bump the peer floor to ^2.21.0 instead: peer ranges are metadata, not enforcement — pnpm/yarn users on 2.20.x would just get an ignorable warning and still crash, while users happily on 2.12–2.19 would be pushed into engine upgrades they don't need. The stub keeps the advertised range actually true.

Tests

Added a second vitest project (node environment — no jsdom, no playcanvas mocks, same conditions as an SSG build) with two regression tests:

  1. the package entry point imports cleanly in bare Node;
  2. the mock canvas survives graphicsDevice.updateClientRect() — the exact probe engine 2.20+ runs at construction time.

Test 2 fails without the stub on every engine version (the probe has existed since long before 2.20), so the guard doesn't depend on which engine version CI happens to install.

Verification

Import of the packed tarball in plain Node (await import('@playcanvas/react')):

engine published 0.11.5 this PR
2.19.7
2.20.0 getBoundingClientRect is not a function
2.20.4 getBoundingClientRect is not a function
2.21.3

Also verified in a minimal Next.js 16 App Router app (server page → 'use client' scene with <Application>/<Entity>/<Camera>/<Light>/<Render>) on engine 2.20.4: next build fails with the published package and succeeds with this PR, and the built app hydrates and renders the scene in the browser with no console errors.

Possible follow-up (not in this PR): a small CI matrix running the node/SSR tests against the peer-range floor and latest engine, so future engine releases can't silently break the import contract again.

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
@changeset-bot

changeset-bot Bot commented Aug 4, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 254d4c7

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 2 packages
Name Type
@playcanvas/react Patch
@playcanvas/blocks Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@pkg-pr-new

pkg-pr-new Bot commented Aug 4, 2026

Copy link
Copy Markdown

Open in StackBlitz

npm i https://pkg.pr.new/@playcanvas/blocks@338
npm i https://pkg.pr.new/@playcanvas/react@338

commit: 254d4c7

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR fixes an SSR/SSG import-time crash in @playcanvas/react by hardening the module-scope “null application” mock canvas so it can survive PlayCanvas engine layout probes in DOM-less (Node) environments across the package’s advertised peer range.

Changes:

  • Stub getBoundingClientRect() (plus width/height) on the null app’s mock canvas to prevent GraphicsDevice constructor crashes in Node/SSR.
  • Add a dedicated Vitest “node” project (no jsdom) and regression tests to ensure the package imports cleanly and the probe path doesn’t throw.
  • Fix a strict-resolution path typo by updating the GLTF hook re-export to the correct .tsx extension, and add a changeset for the patch release.

Reviewed changes

Copilot reviewed 5 out of 5 changed files in this pull request and generated no comments.

Show a summary per file
File Description
packages/lib/vitest.config.ts Splits tests into jsdom and node projects to exercise SSR-like conditions.
packages/lib/src/utils/validation.ts Adds a DOM-less-safe mock canvas surface (getBoundingClientRect, width, height) for the null application.
packages/lib/src/utils/validation.node.test.ts Adds Node-environment regression tests for import safety and the engine’s client-rect probe.
packages/lib/src/gltf/index.ts Fixes the useEntity re-export to reference the actual .tsx source file.
.changeset/ssr-mock-canvas-client-rect.md Documents the SSR/SSG import crash fix as a patch changeset.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

@abstrakt8
abstrakt8 merged commit bb97cd6 into main Aug 4, 2026
7 checks passed
@abstrakt8
abstrakt8 deleted the fix/ssr-mock-canvas-client-rect branch August 4, 2026 14:47
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

SSR broken with engine >= 2.20.0: module-scope getNullApplication() throws (mock canvas lacks getBoundingClientRect)

2 participants