Migration

Upgrading from the pre-2026 builds of @react-three/jolt.

The 2026 refresh moves the whole stack forward — React 19, react-three-fiber 10, three 0.186 and jolt-physics 1.1 — and fixes a long list of memory and lifecycle bugs along the way. This page is what changes for you.

Note

The library is still alpha and was never widely published, so this is a "how the code differs" guide rather than a formal breaking-change list. If something you used isn't mentioned, it probably didn't change.

1. Peer dependencies

All three packages now require:

packagebeforenow
react / react-dom18.2>=19.0.0 (r3f 10 is tested against 19.2.x)
@react-three/fiber8.16>=10.0.0-0
three0.163, undeclared>=0.185, now a declared peer
jolt-physics^0.22.0, a dependency>=1.1.0, now a peer
node—>=22

One of those needs action even if your code doesn't change:

  • jolt-physics is a peer now. It is no longer installed for you — add it to your app. In exchange you get to pick the build variant, and you can no longer end up with two copies of the WASM module in one bundle.

React 19 and r3f 10 have their own migration notes; if you are coming from r3f 8 you are making two major jumps at once.

2. jolt-physics 0.22 → 1.1.0

Two years of engine changes (Jolt C++ v5.6). What surfaces in this library's API:

  • World-space positions are Jolt.RVec3. BodyState.getPosition(true) returns an RVec3 rather than a Vec3. In the single-precision builds the two are interchangeable at runtime, but not in the types.
  • vec3.rjolt() is new — the RVec3 counterpart of vec3.jolt(). Use it wherever you feed a position back into Jolt.
  • generateJoltMatrix() returns an RMat44, which is what CollideShape and RShapeCast take. It also returns a real copy now instead of the binder's shared static temporary — the old value was silently rewritten by the next caller, and destroying it freed memory Jolt owned.
  • Shapecaster.shapecast is a Jolt.RShapeCast.

If you call Jolt directly, note two upstream changes that bit this library: BodyInterface.AddForce/AddTorque gained an EActivation parameter (0.24), and body user data went from 64-bit to 32-bit (0.39).

3. Breaking changes

Everything that can break a compiling app, by package.

@react-three/jolt

whatbeforenow
Contact handler signature(handle1, handle2, manifold, settings, count, context)one payload object. The old arguments handed out Jolt pointers that were freed before the handler could run
Per-body listener arraysBodyState.contactAddedListeners / contactRemovedListeners / contactPersistedListeners / activationListenersBodyState.events, an Emitter
The 900 ms contact debounceBodyState.contactThreshold, contactTimestampsgone. Enter/persist/exit come from Jolt's sub-shape pairs, which is what the debounce was compensating for
Collision sub-groupshard-coded 0/1/2 semanticsa GroupFilterTable. Every sub-group pair collides until you call disableCollision(a, b)
BodyEvents / WorldEvents typesin types.tsdeleted. They were unreachable and described signatures that were never dispatched. Use BodyEventMap / WorldEventMap
BodyState.getPosition(true)Jolt.Vec3Jolt.RVec3
vec3.jolt() / vec3.rjolt() / quat.jolt()returned their argument when it already was a Jolt objectalways return a new object the caller owns
Raw.joltInterfaces / PhysicsSystem.maxInterfacesa fixed three-world cache keyed by React's useId()gone. JoltModule.registerInterface/getInterface/releaseInterface/interfaceCount replace it, with no fixed cap — see Destroying a world
PhysicsSystem's constructor argumentthe interface cache keya debug label only. destroy(pid)'s argument is likewise ignored. Both still compile
jolt-physicsa dependencya peer — add it to your app
Console output~17 unconditional console.logsoff unless you call setDebug(true)

<RigidBody>'s onContactAdded / onContactRemoved / onContactPersisted props still work as deprecated aliases of onCollisionEnter / onCollisionExit / onCollisionPersist, and now receive the new payload — their declared type never matched what was actually dispatched, so nothing that worked before breaks.

@react-three/jolt/addons

whatbeforenow
useCommand return valuethe Command, from the first renderundefined on the first render, the command from the first effect onwards. The command is no longer created during render. Use it in an effect, or guard it
gamepad.jsa dependencyremoved; gamepads are polled in-house. GamepadInputEvent and its payload are unchanged
Commander.getSnapsotthe only spellingdeprecated in favour of getSnapshot

@react-three/jolt/controllers

whatbeforenow
<VehicleFourWheel>the vehicle componentdeprecated; forwards to <Vehicle type="fourWheel">. Its type string is replaced by <Vehicle type="twoWheel">
VehicleFourWheelManagerthe classrenamed FourWheelVehicleManager; old name kept as a deprecated alias
VehicleManagerTwoWheelsthe classrenamed TwoWheelVehicleManager; old name kept as a deprecated alias
Two-wheel default settingsmerged over your settingsmerged under them — a motorcycle built with a custom mass or custom wheels no longer silently gets the defaults back
VehicleManager.settingsanya fully resolved, typed ResolvedVehicleSettings
camera-controlsa dependencyremoved with the dead camera rig that used it

Manager source files were kebab-cased (vehicle-manager.ts, four-wheel-vehicle-manager.ts, two-wheel-vehicle-manager.ts, wheel-state.ts). If you were deep-importing them, import from the package root instead.

4. Deprecated but still working

Nothing in this list has been removed. Each one has a replacement that returns an unsubscribe, because removal by function identity can never match the inline arrows callers actually pass.

deprecateduse
BodyState.addContactListener(fn, type) / removeContactListener(fn)body.on(type, fn)
BodyState.addActivationListener(fn) / removeActivationListener(fn)body.onSleep(fn) / body.onWake(fn), which tell the two apart
PhysicsSystem.addPreStepListener(fn) / addPostStepListener(fn) / removeStepListener(fn)onBeforeStep(fn) / onAfterStep(fn), or the hooks
<RigidBody onContactAdded/onContactRemoved/onContactPersisted>onCollisionEnter / onCollisionExit / onCollisionPersist
CharacterControllerSystem.removeActionListener(fn)keep the handle addActionListener / on returns
Commander.getSnapsot()getSnapshot()
createMeshForShapecreateMeshFromShape (they were byte-identical; both names still work)
VehicleFourWheel, VehicleFourWheelProps, VehicleFourWheelManager, VehicleManagerTwoWheels, VehicleFourWheelSettings, VehicleTwoWheelSettingsthe Vehicle equivalents

addPreStepListener / addPostStepListener now return an unsubscribe where they used to return void, so that part is source compatible; removeStepListener(fn) and removeContactListener(fn) now remove every subscription made for that function, where the old else if chain removed it from only the first channel.

5. Removed

goneinstead
heightField/heightfieldManager.ts and its worker scaffold— (never referenced)
heightField/generators-save.ts, utils/heightmap.ts, utils/psrddnoise3.ts—
the camera-controls-based camera rig (camera-rig-system-camera-controls.ts)the default CameraRigManager
use-character-controller.ts (empty), tmp.ts—
the camera-controls runtime dependency of @react-three/jolt/controllers—
packages/react-three-jolt/types/gamepad.js.d.ts— (the dependency is gone)
apps/examples/src/jolt/ (vendored Jolt builds, ~1 GB)the jolt-physics package

6. New

@react-three/jolt

  • Events. onCollisionEnter / onCollisionPersist / onCollisionExit / onSensorEnter / onSensorExit / onIntersectionEnter / onIntersectionExit / onSleep / onWake / onContactValidate on <RigidBody> and <InstancedRigidBodies>; the same names plus onSettled and onActivityChange on <Physics>; bodyState.on(type, fn), physicsSystem.events, and the useBodyEvent / useWorldEvent hooks. New exported types: CollisionTarget, CollisionPayload, CollisionEnterPayload, CollisionExitPayload, SensorPayload, ActivationPayload, ValidatePayload, BodyEventMap, WorldEventMap, Unsubscribe, Emitter.
  • Step hooks. useBeforePhysicsStep / useAfterPhysicsStep, physicsSystem.onBeforeStep / onAfterStep. The step order is now defined: beforeStep → pending actions → Step() → queued events → afterStep, per substep.
  • Activity accounting. bodySystem.activeBodyCount, simulatedBodyCount, isSettled.
  • The shape pipeline. describeShape / generateShape / createShapeSettings / descriptorKey / stableKey / scaleShape / validScaleFor, the ShapeDescriptor union, and explicit descriptors for tapered capsules, cylinders and tapered cylinders.
  • Mutable compounds. <Shape dynamic>, addSubShape / removeSubShape / modifySubShape (free functions and BodyState methods), BodyState.notifyShapeChanged.
  • Object scaling. A scaled mesh below a described object produces a scaled descriptor; describeShape's applyObjectScale; offsetCenterOfMass.
  • Per-body collision groups. bodySystem.setGroupCollision / disableCollision / enableCollision / isCollisionEnabled / subGroupCount, BodyState.collisionGroup / collisionSubGroup, BodySystem.destroy().
  • useMouseRaycaster, and normal-oriented, pooled debug markers on Raycaster.
  • <Physics> props. timeStep, maxSubSteps, defaultShape, a working interpolate, and a bounded accumulator. Plus PhysicsSystem.accumulator, resetAccumulator(), invalidatePoseCache(), destroyed.
  • Memory helpers. withJolt() / withRJolt() / withQuat(), joltScratch, createShapeFromSettings() / releaseShape(), setDebug(), and the allocation-free BodyState.readPose() / getInterpolatedPose() / resetPoseCache().
  • dynamicMeshStrategy on the body options: a trimesh on a dynamic body becomes a convex hull rather than falling through the world (#112).
  • constraintSystem.constraints, removeConstraintsForBody(), removeAllConstraints(), and typed ConstraintType / ConstraintOptions / ConstraintTypeMap.
  • <Attractor> / useAttractor(). A point that pulls (or pushes) every dynamic body within range, with rapier's three falloff curves — a @react-three/rapier scene's attractors port across unchanged.
  • <Physics debug> collider renderer / <Debug>. One wireframe per body built from its real Jolt shape (so it shows what a dynamic trimesh actually fell back to), coloured by motion type, plus constraint lines and showContacts. Replaces the old per-body debug boolean.
  • Heightfield generation and surface materials. <Heightfield samples> / <Heightfield generator> build terrain synchronously with no image or loader; generateHeightfield, samplesFromGenerator, heightfieldToGeometry, validateHeightfieldSize, psrdnoise2 / simplex2 / fbm2. materials / materialIndex give individual quads their own friction/restitution via a new SurfaceMaterialTable.
  • Named collider components and <RigidBody colliders>. <CuboidCollider>, <BallCollider>, <CapsuleCollider>, <CylinderCollider>, <ConeCollider>, <ConvexHullCollider>, <TrimeshCollider>, <HeightfieldCollider> — rapier-compatible args (half extents) over <Shape>. <RigidBody colliders="cuboid" | "ball" | "hull" | "trimesh" | false> picks the auto shape rapier-style, or turns it off entirely.
  • Sub-shape identity on contacts. CollisionPayload.targetSubShape / otherSubShape ({ id, index, userData, descriptor }), and per-<Shape> userData / name / onCollisionEnter / onCollisionPersist / onCollisionExit / onSensorEnter / onSensorExit scoped to that sub-shape.
  • activateOnChange and matrixAutoUpdate. Opt out of a mover setter waking a sleeping body, or of three's own per-object matrix recompute in the frame sync.
  • Static bodies can be moved. position/rotation work on type="static" now; BodySystem.movedStatics drains once a frame so the object stays in sync — scenes whose statics never move pay nothing.
  • setKinematicTarget(position, rotation?) / clearKinematicTarget(). A sticky target re-applied at the top of every substep, so a driven platform converges on it however many substeps a frame runs, instead of moveKinematic's one-shot nudge lurching once per rendered frame.
  • A shared query base. QueryBase / CastQueryBase / HitBase — see Queries: shared base. No public property, method or constructor signature changed; worth knowing about because destroy() is now idempotent on every query type, not just ShapeCollider.
  • Real world teardown. PhysicsSystem.destroy() walks the whole world in dependency order instead of freeing the JoltInterface and stopping, <Physics> defers it past the React commit so children clean up against a live world, and registerDisposable() ties a non-body object's lifetime to the world. JoltModule (renamed from Raw, which is kept as an alias), getJoltModule() and free() are exported from the package root. See Destroying a world and Memory & lifecycle.
  • Unlimited concurrent worlds, gated by the real WASM heap instead of a fixed cap of three — see the heap check.

@react-three/jolt/addons

  • Gamepads. An in-house poller with CommanderOptions.gamepad (deadzone, axisThreshold, buttonThreshold), the standardGamepadButtons / gamepadButtonName / standardGamepadSticks helpers, hasGamepadSupport() and GamepadPoller. Connect/disconnect is handled, and a yanked controller releases whatever it was holding.
  • useLookCommand touch and gamepad — { mouse, touch, gamepad, sensitivity, invertY }.
  • <CommanderProvider> / CommanderContext to scope a commander to a subtree.
  • CommandInfo is a real exported type — callbacks used to be declared as (info: CommandCallback) => void, so info was typed as the callback itself and every info.isInitial needed a @ts-ignore. Delete those. CommandValue, CommandOptions, CommandEvent, CommandState and VectorBinding are exported too.

@react-three/jolt/controllers

  • <Vehicle> / useVehicle. One component for both vehicle types, a typed vehicleSettings prop, injectable chassis (bodyObject, children-as-chassis) and wheels (wheels, wheelObjects), and followCamera.
  • Camera rig options. cameraPosition, minPitch / maxPitch (the old hard-coded -1.5 / 0.5 clamp), and everything else set before the first step rather than mutated onto a rig that is already running.
  • followMode — 'free', 'movement', 'lookAt'.
  • Whiskers — the boom steers around a corner before the wall becomes a problem.
  • Real teardown. CharacterControllerSystem, CameraRigManager / CameraBoom, VehicleSystem / VehicleManager and WheelState all have idempotent destroy() methods, and each system's pre-step listener is a stored reference so it can actually be removed. The components release everything on unmount, so mounting and unmounting them no longer leaks.
  • Character controller events, isMoving, isSliding and isGrounded. CharacterControllerSystem.events is now public and typed (CharacterEventMap): move, stop, slide, slideEnd, jump, land, ground, airborne, crouch, stand, contactAdded, contactPersisted, contactRemoved, plus the existing action. New <CharacterController> props for every one of them, useCharacterEvent(system, type, handler), and moveThreshold / slideThreshold. isMoving/isSliding used to be declared fields nothing ever assigned — they're real getters now, alongside a new isGrounded.
  • headAngle / onHeadHit. A character bumping its head on a ceiling/overhang no longer keeps its upward velocity until gravity alone brings it back down — a contact within headAngle of straight-up cancels it, once per new contact.
  • Vehicle secondary physics. bodyRoll (spring-damped chassis tilt), wheelSmoothing (eased rendered suspension/steering), and skid detection with onSkidStart / onSkidEnd, all on by default and independently configurable through vehicleSettings (or setBodyRoll() / setWheelSmoothing() / setSkid() live). Plus engine/audio readouts: rpm, gear, shifting, clutch, throttle, brakeInput, speed, speedKmh, skidding, onEngine(fn). WheelState publishes slipRatio, lateralSlip, isSkidding, hasContact, suspensionLength, spinVelocity and spinAngle.

7. Behaviour changes

<Physics>

  • interpolate now actually interpolates. It was declared but commented out of the destructure, so it never reached the physics system; with it wired up, bodies are drawn between the last two steps. The frame loop no longer allocates.
  • timeStep is a new prop — the "vary" code path was unreachable from React before.
  • maxSubSteps is new, and the accumulator is now bounded. Simulation time beyond maxSubSteps * timeStep is dropped (with a warning under setDebug(true)) instead of queued, which is what used to turn one long frame into a spiral of death. Negative and NaN frame deltas are dropped too.
  • defaultShape is new — the Jolt answer to rapier's colliders.
  • updatePriority is typed number instead of any.
  • module no longer reinitialises the WASM module. initJolt(factory) used to delete Raw.module and spin up a brand new instance on every call — including the one <Physics module={x}> made on every render — with no way to free the old one and every live handle left dangling. The same factory reference now reuses the module, and swapping to a different one while a world exists is refused with a warning. <Physics> also calls suspend() unconditionally now; toggling module used to change the number of hooks between renders.
  • The three-world cap is gone, and unmounting actually frees the world. Every PhysicsSystem used to share a cache keyed by React's useId(), so a remount grew the map instead of reusing a slot, and once it held three entries a fourth <Physics> was silently handed the first world's interface — two components then shared bodies without either knowing, and the loser's filter tables were orphaned. destroy() now really walks the world (disposables → constraints → bodies → events → the interface → the listener objects), <Physics> defers it past the React commit so children clean up against a live world, and mounting past the real heap limit (~six worlds) is an actionable error instead of an abort(OOM) that kills the module. See Destroying a world.
  • NUM_OBJECT_LAYERS was 3 while Layer had four members (Layer.RIG included), so the broadphase/object layer pair filter tables were one entry too small and wrote past their own end — aliasing unrelated layer pairs. It's derived from Layer itself now (issue #95).

Bodies and events

  • <RigidBody>'s listener effect never registered anything. Its dependency array read rigidBodyRef.current, a mutable ref, which is not reactive — on the render that created the body the effect had already run with undefined. If you concluded that contact props didn't work, they do now.
  • <InstancedRigidBodies> never destroyed its bodies or its InstancedMesh. Both are released on unmount now, count changes add and remove bodies incrementally, and shrinking count no longer copies the old, larger buffer over the new one.
  • BodyState.color on a non-instanced body fell through into setColorAt, a method only THREE.InstancedMesh has. It works, sets instanceColor.needsUpdate on instanced bodies, and clones a shared material exactly once instead of mutating it.
  • BodyState.scale = 2 used to apply a NaN scale (inScale instanceof Number is always false). A plain number works, and non-uniform scale is supported wherever Jolt allows it.
  • <Heightfield> no longer creates or leaks a stale body when url changes (or the component unmounts) while a previous image is still loading, and an invalid sample grid throws a clear error naming the count instead of failing silently.
  • BodyState.mass reports the mass the simulation actually uses. The getter used to read the shape's density-derived mass, unrelated to a mass option or a later write; it now reads 1 / MotionProperties.GetInverseMass(). The setter (MotionProperties.ScaleToMass) scales the inertia tensor with the mass and leaves the body's degrees of freedom alone, where the old path pushed a fresh MassProperties through SetMassProperties and quietly unlocked every axis you had locked. See Material and mass.
  • <InstancedRigidBodies count={0}> mounts and grows to N, instead of starting broken.
  • Setting a static body's position/rotation used to do nothing visible — it moved the Jolt body but never synced the three.js object. BodySystem.movedStatics fixes that; see Moving a static body.
  • <RigidBody friction> was declared and read by nothing. It, restitution and the new gravityFactor prop now go through the same reactive effect as mass and the damping props, and that effect is keyed on the body instance rather than a non-reactive ref (so it also reaches a body created later because it has <Shape> children) and tests !== undefined instead of truthiness, so friction={0} and gravityFactor={0} are no longer read as "unset".
  • <RigidBody> is a compound host now. When it has to combine several shapes (sibling colliders, an offset collider, a collider beside a mesh) its children register plain ShapeDescriptors and the body composes and owns the compound, instead of every child calling setActiveShape and the last one silently winning. A lone collider with a position/rotation becomes a one-child compound now, so its offset is honoured instead of dropped as a root transform. Nothing changes for the common case (one <Shape>, or meshes only).

Constraints

ConstraintSystem.removeConstraint() was an empty function with its body commented out — and it is useConstraint's entire cleanup path, so every constraint ever created leaked and stayed in the world. It works now, which means constraints actually disappear when your component unmounts. If you worked around the old behaviour by not removing bodies, you can stop.

Also: addConstraint/useConstraint/the options object are typed now (ConstraintType, ConstraintOptions, ConstraintTypeMap), hinge motors call the methods that exist (SetTargetAngularVelocity/SetTargetAngle), createMotorSettings() no longer throws a ReferenceError, and useConstraint re-creates its constraint when the type, bodies or option values change. New: constraintSystem.constraints, removeConstraintsForBody(), removeAllConstraints().

Queries

  • A Raycaster in the default 'closest' mode never reset its collector between casts, so every cast after the first returned the first hit. Fixed — if your code compensated for stale hits, remove the workaround.
  • AdvancedRaycaster used to throw "a JSImplementation must implement all functions" on its first cast. Fixed.
  • Multicaster gained destroy() (it leaked its raycaster) and now clears results between casts instead of growing forever.
  • ShapeCollider.destroy() was a no-op; it now frees everything it owns and is idempotent. Setting position/rotation/matrix no longer allocates a transform per call, and collider.shape is properly reference counted.
  • Raycaster.drawMarker() drew a world-axis-aligned cross whatever it hit; markers now orient along the hit normal, and the debug objects are pooled instead of a fresh geometry/material/Object3D per cast.
  • ShapecastHit's constructor used to destroy() the by-value return of GetPointOnRay() — the binder's shared static temporary — and impactNormal leaked a BodyID, SubShapeID and RVec3 per read. Both fixed.
  • Shapecaster never freed its default SphereShape, and its shape setter's RShapeCast.set_mShape() call was dead code (only a read-only mShape getter exists on the runtime binding) — setting shape after construction never took effect. Both fixed: the shape is reference counted like ShapeCollider's, and the setter rebuilds the live RShapeCast.
  • useRaycaster / useAdvancedRaycaster / useMulticaster only freed their last instance, at unmount — a dependency change (origin/direction/type) built a fresh raycaster without destroying the one it replaced. Each hook now destroys its previous instance on every dep change.
  • Raycaster, Shapecaster, ShapeCollider and Multicaster now share QueryBase / CastQueryBase / HitBase (issue #217) instead of duplicating the same filter setup, destroy bookkeeping and (for the ray-like casters) ~230 lines of debug-drawing code — see Queries: shared base. No public signature changed, but Multicaster.destroy() is idempotent now where it previously had no guard at all against being called twice (a double-free of its owned Raycaster's allocations).

Shapes

  • <Shape> rebuilt its shape only when type changed, and never released anything. Changing size / radius / height / scale / children now replaces the shape exactly once and releases the superseded one; unmounting releases everything it owns.
  • A ConeGeometry used to become a NaN-sized cylinder (three keeps its own { radius } parameters); it is now inferred as a tapered cylinder.
  • Cylinders thinner than the default 0.5 convex radius no longer fail to build.
  • generateShapeSettings('box') with no options no longer throws, and a numeric size means a cube rather than (size, NaN, NaN).
  • updateScaleShape was a console.warn stub; it wraps the shape in a ScaledShape.
  • The shape system leaked one WASM object per point, vertex and triangle. Building a mesh collider from a 2k-triangle sphere left 3078 live Jolt objects behind; it now leaves none.
  • The heightfield descriptor's sample spacing was planeWidth / sampleCount, where sampleCount samples actually span sampleCount - 1 segments — so the physics field was one sample wider than the mesh drawn on top of it. It's planeWidth / (sampleCount - 1) now, with addHeightfield's centring offset derived from the same numbers and the depth of a non-square plane honoured on z. See Generation.

Input (@react-three/jolt/addons)

  • useCommand tears itself down. The commander was a module-level singleton that attached four window listeners and a gamepad poll on first use and never released them. It is reference counted now, commands register in an effect rather than during render, and everything is removed on unmount.
  • Command.setOptions indexed by value (setOptions({ sensitivity: 2 }) wrote command[2]); fixed.
  • VectorCommand mutated the shared preset when given bindings, leaking bindings into every other command using that preset; fixed.
  • vectorPresets.look names its vertical directions up / down, but VectorCommand only mapped forward / backward onto y — so the whole look preset drove yaw with its pitch bindings. Fixed.
  • Commander.updateState never removed a command that went inactive, so useCommandState consumers kept acting on an input nobody was giving any more. Fixed.
  • useLookCommand removes its mouseout listener and reads state through refs.
  • The commander no longer throws in environments with no Gamepad API (SSR, tests), and the poller removes every listener it adds — gamepad.js used to leave a window error listener behind permanently.

Controllers

  • Step listeners are removable. All four controller systems subscribed with inline arrows that removeStepListener's identity match could never find, so a destroyed character kept being pre-stepped against a freed CharacterVirtual and CameraRigManager.detachFromLoop() silently did nothing.
  • CharacterController.on, CameraRigManager.onCamera and VehicleManager.onPreStep / onPostCollide / onPostStep / onAction keep their signatures and are reimplemented on the shared emitter.
  • createWheelSettings understands an explicit wheel position, vector-valued settings and suspensionSpring, and no longer assigns the library's own layout keys onto the Jolt settings.
  • The five character-vs-character CharacterContactListenerJS callbacks were dead code and are removed, with a test that fails if Jolt ever starts calling them.

Memory

The conversion helpers changed contract: vec3.jolt(), vec3.rjolt() and quat.jolt() used to return their argument when it was already a Jolt object, so the call sites that destroyed the result were freeing memory Jolt still owned. They now always return a new object the caller owns.

If you wrote code against the old behaviour — passing a Jolt vector in and not freeing the result because it was the same object — you are now leaking one object per call. Review those call sites.

Also fixed along the way: vec3.jolt(0, 1, 2) no longer reads a zero first component as "no argument"; quat.jolt(undefined) returns identity instead of throwing; the shape system no longer leaks one WASM object per vertex/triangle; RaycastHit/ShapecastHit.impactNormal no longer leak a BodyID, SubShapeID and RVec3 per read.

generateJoltMatrix() returns a real RMat44 copy — the binder returns a pointer to a single static temporary from a "by value" return, so the matrix used to be silently rewritten by the next caller, and destroying it freed memory the binder owns.

See Memory & lifecycle for the full ownership rules, and Contributing before you touch systems/.

Logging

~17 unconditional console.log calls are gone, and the remaining warnings are gated behind setDebug(true) (off by default). If you relied on the library's console output, opt back in.

8. Packaging

All three packages declare "sideEffects": false, ship exports maps with a ./package.json entry and a default fallback, keep main/module/types for older resolvers, and externalise their real dependencies properly (gamepad.js and suspend-react used to be silently inlined into dist). files now includes CHANGELOG.md, and each package declares "type": "commonjs" and "engines": { "node": ">=22" } of its own.

The declaration files changed shape: exports nests types inside import / require, so ESM and CJS consumers each get their own bundled .d.mts / .d.cts rather than a graph of per-file declarations. That is what fixes arethetypeswrong's "masquerading as CJS" finding under node16 (from ESM). publint and @arethetypeswrong/cli --pack are clean in all four resolution modes on all three packages. Nothing about how you import them changes.