Skip to content

[v9] Pointer capture is dropped when an instance is reconstructed, despite swapInteractivity #3831

Description

@DennisSmolek

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions