Found while porting #3744 to v10 (#3824). Filing on the v9 line because it reproduces on shipped 9.7.0, and #3744 was released believing it handled this.
Reproduced on origin/master @ 0a107412
Capture a pointer on a mesh, reconstruct the instance via an args change mid-drag, then move the pointer off the mesh:
[v9] off-mesh move BEFORE swap: 1
[v9] off-mesh move AFTER swap: 0
Before the swap the capture is live and onPointerMove fires off-mesh, as it should. After the swap it is silent — the drag dies.
Why swapInteractivity does not cover it
swapInteractivity re-keys the capture map:
internal.capturedMap.forEach((captures) => {
const captureData = captures.get(object)
if (captureData) {
captures.delete(object)
captures.set(newObject, captureData)
}
})
But PointerCaptureTarget carries its own copy of the hit:
export interface PointerCaptureTarget {
intersection: Intersection
target: Element
}
…and that stored intersection is what gets replayed into the hit list on every subsequent event:
if ('pointerId' in event && state.internal.capturedMap.has(event.pointerId)) {
for (let captureData of state.internal.capturedMap.get(event.pointerId)!.values()) {
if (!duplicates.has(makeId(captureData.intersection))) intersections.push(captureData.intersection)
}
}
Its object and eventObject still reference the discarded object, whose __r3f link was severed during the swap. So the replayed hit resolves to a corpse and never reaches a handler — the capture "survived" the map surgery while pointing at nothing.
Suggested fix
Rewrite the stored intersection alongside the re-key:
captures.set(newObject, {
...captureData,
intersection: {
...captureData.intersection,
object: captureData.intersection.object === object ? newObject : captureData.intersection.object,
eventObject: captureData.intersection.eventObject === object ? newObject : captureData.intersection.eventObject,
},
})
Note on v10
v10 has the same defect (#3826) but not the same fix. Rewriting the intersection there is necessary and not sufficient: v10 additionally routes pointermove through a deferred, frame-timed path (frameTimedRaycasts / pointerDirty) that does not exist on v9, and the repaired hit still fails to reach onIntersect. On this line there is no deferred path, so the rewrite above should be the whole fix.
Disclosure: investigated with Claude. The numbers above are from a real jest run against a fresh yarn install on origin/master; the probe was a scratch file and is not included here.
Found while porting #3744 to v10 (#3824). Filing on the v9 line because it reproduces on shipped
9.7.0, and #3744 was released believing it handled this.Reproduced on
origin/master@0a107412Capture a pointer on a mesh, reconstruct the instance via an
argschange mid-drag, then move the pointer off the mesh:Before the swap the capture is live and
onPointerMovefires off-mesh, as it should. After the swap it is silent — the drag dies.Why
swapInteractivitydoes not cover itswapInteractivityre-keys the capture map:But
PointerCaptureTargetcarries its own copy of the hit:…and that stored intersection is what gets replayed into the hit list on every subsequent event:
Its
objectandeventObjectstill reference the discarded object, whose__r3flink was severed during the swap. So the replayed hit resolves to a corpse and never reaches a handler — the capture "survived" the map surgery while pointing at nothing.Suggested fix
Rewrite the stored intersection alongside the re-key:
Note on v10
v10 has the same defect (#3826) but not the same fix. Rewriting the intersection there is necessary and not sufficient: v10 additionally routes
pointermovethrough a deferred, frame-timed path (frameTimedRaycasts/pointerDirty) that does not exist on v9, and the repaired hit still fails to reachonIntersect. On this line there is no deferred path, so the rewrite above should be the whole fix.Disclosure: investigated with Claude. The numbers above are from a real jest run against a fresh
yarn installonorigin/master; the probe was a scratch file and is not included here.