Migration
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.
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:
| package | before | now |
|---|---|---|
react / react-dom | 18.2 | >=19.0.0 (r3f 10 is tested against 19.2.x) |
@react-three/fiber | 8.16 | >=10.0.0-0 |
three | 0.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-physicsis 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 anRVec3rather than aVec3. In the single-precision builds the two are interchangeable at runtime, but not in the types. vec3.rjolt()is new — theRVec3counterpart ofvec3.jolt(). Use it wherever you feed a position back into Jolt.generateJoltMatrix()returns anRMat44, which is whatCollideShapeandRShapeCasttake. 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.shapecastis aJolt.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
| what | before | now |
|---|---|---|
| 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 arrays | BodyState.contactAddedListeners / contactRemovedListeners / contactPersistedListeners / activationListeners | BodyState.events, an Emitter |
| The 900 ms contact debounce | BodyState.contactThreshold, contactTimestamps | gone. Enter/persist/exit come from Jolt's sub-shape pairs, which is what the debounce was compensating for |
| Collision sub-groups | hard-coded 0/1/2 semantics | a GroupFilterTable. Every sub-group pair collides until you call disableCollision(a, b) |
BodyEvents / WorldEvents types | in types.ts | deleted. They were unreachable and described signatures that were never dispatched. Use BodyEventMap / WorldEventMap |
BodyState.getPosition(true) | Jolt.Vec3 | Jolt.RVec3 |
vec3.jolt() / vec3.rjolt() / quat.jolt() | returned their argument when it already was a Jolt object | always return a new object the caller owns |
Raw.joltInterfaces / PhysicsSystem.maxInterfaces | a 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 argument | the interface cache key | a debug label only. destroy(pid)'s argument is likewise ignored. Both still compile |
jolt-physics | a dependency | a peer — add it to your app |
| Console output | ~17 unconditional console.logs | off 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
| what | before | now |
|---|---|---|
useCommand return value | the Command, from the first render | undefined 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.js | a dependency | removed; gamepads are polled in-house. GamepadInputEvent and its payload are unchanged |
Commander.getSnapsot | the only spelling | deprecated in favour of getSnapshot |
@react-three/jolt/controllers
| what | before | now |
|---|---|---|
<VehicleFourWheel> | the vehicle component | deprecated; forwards to <Vehicle type="fourWheel">. Its type string is replaced by <Vehicle type="twoWheel"> |
VehicleFourWheelManager | the class | renamed FourWheelVehicleManager; old name kept as a deprecated alias |
VehicleManagerTwoWheels | the class | renamed TwoWheelVehicleManager; old name kept as a deprecated alias |
| Two-wheel default settings | merged over your settings | merged under them — a motorcycle built with a custom mass or custom wheels no longer silently gets the defaults back |
VehicleManager.settings | any | a fully resolved, typed ResolvedVehicleSettings |
camera-controls | a dependency | removed 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.
| deprecated | use |
|---|---|
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() |
createMeshForShape | createMeshFromShape (they were byte-identical; both names still work) |
VehicleFourWheel, VehicleFourWheelProps, VehicleFourWheelManager, VehicleManagerTwoWheels, VehicleFourWheelSettings, VehicleTwoWheelSettings | the 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
| gone | instead |
|---|---|
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/onContactValidateon<RigidBody>and<InstancedRigidBodies>; the same names plusonSettledandonActivityChangeon<Physics>;bodyState.on(type, fn),physicsSystem.events, and theuseBodyEvent/useWorldEventhooks. 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, theShapeDescriptorunion, and explicit descriptors for tapered capsules, cylinders and tapered cylinders. - Mutable compounds.
<Shape dynamic>,addSubShape/removeSubShape/modifySubShape(free functions andBodyStatemethods),BodyState.notifyShapeChanged. - Object scaling. A scaled mesh below a described object produces a
scaleddescriptor;describeShape'sapplyObjectScale;offsetCenterOfMass. - Per-body collision groups.
bodySystem.setGroupCollision/disableCollision/enableCollision/isCollisionEnabled/subGroupCount,BodyState.collisionGroup/collisionSubGroup,BodySystem.destroy(). useMouseRaycaster, and normal-oriented, pooled debug markers onRaycaster.<Physics>props.timeStep,maxSubSteps,defaultShape, a workinginterpolate, and a bounded accumulator. PlusPhysicsSystem.accumulator,resetAccumulator(),invalidatePoseCache(),destroyed.- Memory helpers.
withJolt()/withRJolt()/withQuat(),joltScratch,createShapeFromSettings()/releaseShape(),setDebug(), and the allocation-freeBodyState.readPose()/getInterpolatedPose()/resetPoseCache(). dynamicMeshStrategyon 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 typedConstraintType/ConstraintOptions/ConstraintTypeMap.<Attractor>/useAttractor(). A point that pulls (or pushes) every dynamic body withinrange, with rapier's three falloff curves — a@react-three/rapierscene'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 andshowContacts. Replaces the old per-bodydebugboolean.- 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/materialIndexgive individual quads their own friction/restitution via a newSurfaceMaterialTable. - Named collider components and
<RigidBody colliders>.<CuboidCollider>,<BallCollider>,<CapsuleCollider>,<CylinderCollider>,<ConeCollider>,<ConvexHullCollider>,<TrimeshCollider>,<HeightfieldCollider>— rapier-compatibleargs(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/onSensorExitscoped to that sub-shape. activateOnChangeandmatrixAutoUpdate. 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/rotationwork ontype="static"now;BodySystem.movedStaticsdrains 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 ofmoveKinematic'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 becausedestroy()is now idempotent on every query type, not justShapeCollider. - Real world teardown.
PhysicsSystem.destroy()walks the whole world in dependency order instead of freeing theJoltInterfaceand stopping,<Physics>defers it past the React commit so children clean up against a live world, andregisterDisposable()ties a non-body object's lifetime to the world.JoltModule(renamed fromRaw, which is kept as an alias),getJoltModule()andfree()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), thestandardGamepadButtons/gamepadButtonName/standardGamepadStickshelpers,hasGamepadSupport()andGamepadPoller. Connect/disconnect is handled, and a yanked controller releases whatever it was holding. useLookCommandtouch and gamepad —{ mouse, touch, gamepad, sensitivity, invertY }.<CommanderProvider>/CommanderContextto scope a commander to a subtree.CommandInfois a real exported type — callbacks used to be declared as(info: CommandCallback) => void, soinfowas typed as the callback itself and everyinfo.isInitialneeded a@ts-ignore. Delete those.CommandValue,CommandOptions,CommandEvent,CommandStateandVectorBindingare exported too.
@react-three/jolt/controllers
<Vehicle>/useVehicle. One component for both vehicle types, a typedvehicleSettingsprop, injectable chassis (bodyObject, children-as-chassis) and wheels (wheels,wheelObjects), andfollowCamera.- Camera rig options.
cameraPosition,minPitch/maxPitch(the old hard-coded-1.5/0.5clamp), 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/VehicleManagerandWheelStateall have idempotentdestroy()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,isSlidingandisGrounded.CharacterControllerSystem.eventsis now public and typed (CharacterEventMap):move,stop,slide,slideEnd,jump,land,ground,airborne,crouch,stand,contactAdded,contactPersisted,contactRemoved, plus the existingaction. New<CharacterController>props for every one of them,useCharacterEvent(system, type, handler), andmoveThreshold/slideThreshold.isMoving/isSlidingused to be declared fields nothing ever assigned — they're real getters now, alongside a newisGrounded. 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 withinheadAngleof straight-up cancels it, once per new contact.- Vehicle secondary physics.
bodyRoll(spring-damped chassis tilt),wheelSmoothing(eased rendered suspension/steering), andskiddetection withonSkidStart/onSkidEnd, all on by default and independently configurable throughvehicleSettings(orsetBodyRoll()/setWheelSmoothing()/setSkid()live). Plus engine/audio readouts:rpm,gear,shifting,clutch,throttle,brakeInput,speed,speedKmh,skidding,onEngine(fn).WheelStatepublishesslipRatio,lateralSlip,isSkidding,hasContact,suspensionLength,spinVelocityandspinAngle.
7. Behaviour changes
<Physics>
interpolatenow 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.timeStepis a new prop — the"vary"code path was unreachable from React before.maxSubStepsis new, and the accumulator is now bounded. Simulation time beyondmaxSubSteps * timeStepis dropped (with a warning undersetDebug(true)) instead of queued, which is what used to turn one long frame into a spiral of death. Negative andNaNframe deltas are dropped too.defaultShapeis new — the Jolt answer to rapier'scolliders.updatePriorityis typednumberinstead ofany.moduleno longer reinitialises the WASM module.initJolt(factory)used to deleteRaw.moduleand 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 callssuspend()unconditionally now; togglingmoduleused to change the number of hooks between renders.- The three-world cap is gone, and unmounting actually frees the world. Every
PhysicsSystemused to share a cache keyed by React'suseId(), 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 anabort(OOM)that kills the module. See Destroying a world. NUM_OBJECT_LAYERSwas3whileLayerhad four members (Layer.RIGincluded), 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 fromLayeritself now (issue #95).
Bodies and events
<RigidBody>'s listener effect never registered anything. Its dependency array readrigidBodyRef.current, a mutable ref, which is not reactive — on the render that created the body the effect had already run withundefined. If you concluded that contact props didn't work, they do now.<InstancedRigidBodies>never destroyed its bodies or itsInstancedMesh. Both are released on unmount now,countchanges add and remove bodies incrementally, and shrinkingcountno longer copies the old, larger buffer over the new one.BodyState.coloron a non-instanced body fell through intosetColorAt, a method onlyTHREE.InstancedMeshhas. It works, setsinstanceColor.needsUpdateon instanced bodies, and clones a shared material exactly once instead of mutating it.BodyState.scale = 2used to apply aNaNscale (inScale instanceof Numberis 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 whenurlchanges (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.massreports the mass the simulation actually uses. The getter used to read the shape's density-derived mass, unrelated to amassoption or a later write; it now reads1 / 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 freshMassPropertiesthroughSetMassPropertiesand 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/rotationused to do nothing visible — it moved the Jolt body but never synced the three.js object.BodySystem.movedStaticsfixes that; see Moving a static body. <RigidBody friction>was declared and read by nothing. It,restitutionand the newgravityFactorprop now go through the same reactive effect asmassand 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!== undefinedinstead of truthiness, sofriction={0}andgravityFactor={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 plainShapeDescriptors and the body composes and owns the compound, instead of every child callingsetActiveShapeand the last one silently winning. A lone collider with aposition/rotationbecomes 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
Raycasterin 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. AdvancedRaycasterused to throw"a JSImplementation must implement all functions"on its first cast. Fixed.Multicastergaineddestroy()(it leaked its raycaster) and now clearsresultsbetween casts instead of growing forever.ShapeCollider.destroy()was a no-op; it now frees everything it owns and is idempotent. Settingposition/rotation/matrixno longer allocates a transform per call, andcollider.shapeis 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/Object3Dper cast.ShapecastHit's constructor used todestroy()the by-value return ofGetPointOnRay()— the binder's shared static temporary — andimpactNormalleaked aBodyID,SubShapeIDandRVec3per read. Both fixed.Shapecasternever freed its defaultSphereShape, and itsshapesetter'sRShapeCast.set_mShape()call was dead code (only a read-onlymShapegetter exists on the runtime binding) — settingshapeafter construction never took effect. Both fixed: the shape is reference counted likeShapeCollider's, and the setter rebuilds the liveRShapeCast.useRaycaster/useAdvancedRaycaster/useMulticasteronly 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,ShapeColliderandMulticasternow shareQueryBase/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, butMulticaster.destroy()is idempotent now where it previously had no guard at all against being called twice (a double-free of its ownedRaycaster's allocations).
Shapes
<Shape>rebuilt its shape only whentypechanged, and never released anything. Changingsize/radius/height/scale/ children now replaces the shape exactly once and releases the superseded one; unmounting releases everything it owns.- A
ConeGeometryused 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 numericsizemeans a cube rather than(size, NaN, NaN).updateScaleShapewas aconsole.warnstub; it wraps the shape in aScaledShape.- 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, wheresampleCountsamples actually spansampleCount - 1segments — so the physics field was one sample wider than the mesh drawn on top of it. It'splaneWidth / (sampleCount - 1)now, withaddHeightfield'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)
useCommandtears itself down. The commander was a module-level singleton that attached fourwindowlisteners 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.setOptionsindexed by value (setOptions({ sensitivity: 2 })wrotecommand[2]); fixed.VectorCommandmutated the shared preset when givenbindings, leaking bindings into every other command using that preset; fixed.vectorPresets.looknames its vertical directionsup/down, butVectorCommandonly mappedforward/backwardontoy— so the wholelookpreset drove yaw with its pitch bindings. Fixed.Commander.updateStatenever removed a command that went inactive, souseCommandStateconsumers kept acting on an input nobody was giving any more. Fixed.useLookCommandremoves itsmouseoutlistener 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.jsused to leave awindowerrorlistener 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 freedCharacterVirtualandCameraRigManager.detachFromLoop()silently did nothing. CharacterController.on,CameraRigManager.onCameraandVehicleManager.onPreStep/onPostCollide/onPostStep/onActionkeep their signatures and are reimplemented on the shared emitter.createWheelSettingsunderstands an explicit wheelposition, vector-valued settings andsuspensionSpring, and no longer assigns the library's own layout keys onto the Jolt settings.- The five character-vs-character
CharacterContactListenerJScallbacks 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.