Contributing
The library is alpha and moving; issues and PRs are welcome. Everything below assumes a clone of pmndrs/react-three-jolt.
Setup
Node 22 and Yarn 4 (a Yarn 4 monorepo). With Corepack enabled you get the right Yarn automatically:
corepack enable
yarn install
yarn build
Layout
| path | |
|---|---|
packages/react-three-jolt | @react-three/jolt — components, hooks, systems |
packages/react-three-jolt/src/addons | @react-three/jolt/addons — input commands, helper components |
packages/react-three-jolt/src/controllers | @react-three/jolt/controllers — character, camera rig, vehicles |
apps/examples | the Vite demo app |
docs/ | this site (MDX) |
Inside the core package: components/ (the React layer), systems/ (the physics layer —
physics-system, body-system, body-state, constraint-system, shape-system, queries/),
hooks/, utils/, and raw.ts, which holds the one global reference to the Jolt WASM module.
The split matters: components own React lifecycle and props, systems own Jolt. A component
should not talk to Raw.module.
Running the examples
cd apps/examples
yarn dev
For HMR against your library changes, run a watch build in a second terminal:
cd packages/react-three-jolt
yarn build -w
Docs
This site is MDX in docs/, built by pmndrs/docs.
yarn docs # build and serve locally
yarn docs:build # static build into docs/out
Front matter is title, description and nav — one ordering number shared across every page.
DEVELOPMENT.md has the full conventions and the GitHub Pages setup.
Two rules for doc pages:
- Internal links are
/section/page#anchor, where the page isdocs/section/page.mdxand the anchor is the slug of a heading on it (lowercase, punctuation dropped, spaces to hyphens). A broken anchor renders as a link to the top of the page rather than an error, so check them. - Every
ts/tsxcode sample is expected to typecheck against the builtdist. The cheapest way to verify a sample is to drop it into a scratch file underapps/examples/srcand runtsc -p apps/examples. A sample that can't (a Next.js snippet, a partial JSX fragment) should be obviously partial.
Lint, format, test
yarn lint # biome check .
yarn format # biome format --write .
yarn test # vitest across the workspaces
Biome replaced ESLint + Prettier: 4-space indent, 100-column lines,
single quotes, no trailing commas. yarn lint runs in CI and must exit 0.
Do not run biome check --write (and never --unsafe) across files you didn't touch. Two
of its "safe" fixes break this codebase's build: rewriting @ts-ignore to @ts-expect-error
(many of those suppress errors that only appear on some type-narrowing paths) and splitting a
default React import into import type. Format only your own files:
yarn biome format --write <paths>. LINTING.md lists the rules currently downgraded to
warn and why.
Merging
Git merges duplicate methods silently. Two branches that each add a destroy(), a listener
array or a dispose() to the same class merge cleanly into a class with the method twice — no
conflict, and in JavaScript the last definition wins, so the first branch's teardown
disappears without a single error. Before you add a teardown method, a listener list or an
on* helper to an existing class, grep for one first; after a merge, grep the file again.
The same applies to the listener convention: emitter.on(type, fn) returns an unsubscribe and
nothing is ever removed by function identity. If you find yourself writing a
removeXListener(fn), there is almost certainly one already — and it is almost certainly
deprecated. PhysicsSystem.events.listenerCount(type) is the supported test hook for asserting
that a subscription was actually dropped.
Tests
Vitest 5 with happy-dom, and the real Jolt WASM module running in Node — the tests are not
mocked. Component tests use @react-three/test-renderer.
If you touch anything that allocates Jolt objects, write an allocation-counting test:
test/jolt-alloc.ts exposes installAllocTracker(Raw), which wraps the module's constructors
and destroy so a test can assert the live-object count is flat across many iterations. It
catches leaks (count grows) and double frees (count drifts negative) — neither of which throws on
its own. Note that the tracker swaps Raw.module's identity, which rebuilds the joltScratch
singletons once, so warm them before you start counting.
Commits and changesets
Conventional commits (feat:, fix:, chore:, docs:, refactor:).
Every change that affects a published package needs a changeset:
yarn change
Pick the packages, pick patch for fixes and minor for features, and describe what changed
for a user — the existing changesets in .changeset/ are long on purpose and are the
repository's real changelog. A GitHub Action opens the version-bump PR from them.
Docs-only and example-only changes don't need one.
CI
Every pull request runs .github/workflows/ci.yml on Node 22: yarn install --immutable,
yarn lint, yarn build, yarn test. Run all four locally before opening a PR. Dependabot
handles grouped weekly dependency bumps, excluding majors of three, react and
@react-three/* — those are deliberate, hand-verified upgrades.
Working on the physics layer
Three things to read before your first PR into systems/:
- Memory & lifecycle — ownership, by-value static temporaries, and the helpers that keep you out of trouble.
packages/react-three-jolt/docs/Memory.md— the original engine-level notes.- The Jolt docs — the JS API mirrors the C++ one, and
JoltJS.idlis the authoritative list of what is exposed to JavaScript.
The IDL is a declaration, not a promise about what Jolt actually calls. Measured against
jolt-physics 1.1.0, a CharacterVirtual stepping against bodies invokes six of
CharacterContactListenerJS's eleven declared callbacks (OnAdjustBodyVelocity,
OnContactValidate, OnContactAdded, OnContactPersisted, OnContactRemoved,
OnContactSolve); the five character-vs-character ones only fire once a
CharacterVsCharacterCollision is installed. Emscripten's hasOwnProperty check is lazy — one
per call site — so the unused ones need no stubs. If you are about to add a no-op callback
"because the IDL says so", write a test that fails when Jolt starts calling it instead
(packages/react-three-jolt/test/controllers/character-contact-listener.test.ts is the pattern).