events.connect throws TypeError on null target during DOM-reparenting animations
Describe the bug
createPointerEvents.connect (in events.ts) does target.addEventListener(...) with no null-guard, while the matching disconnect IS null-guarded via if (events.connected). Under certain re-render conditions, r3f calls events.connect(target) with target == null and crashes with:
TypeError: Cannot read properties of null (reading 'addEventListener')
at Object.connect (events.esm.js:16157)
at CanvasImpl.useIsomorphicLayoutEffect.run
at Provider.useIsomorphicLayoutEffectI see this 23+ times per fullscreen entry/exit when a <Canvas> subtree is DOM-reparented during a spring animation (using a Fullscreenable pattern that calls appendChild between two containers each frame). Plus 2 errors at initial mount in some component arrangements.
Repro
Minimal-ish: any setup where the parent of <Canvas> is being re-rendered rapidly during a transition that toggles state inside the parent (forcing CanvasImpl's no-deps useIsomorphicLayoutEffect to re-run + its Bridge to potentially remount). Concretely: a custom fullscreen wrapper that reparents the <Canvas> subtree via appendChild while also driving setState on each animation frame.
Verified in r3f 9.5.0.
Source
react-three-fiber.esm.js,CanvasImplat line 12:useIsomorphicLayoutEffecthas no deps, fires on every render.await root.current.configure(...)thenroot.current.render(...). TheonCreatedcallback (line 86) callsstate.events.connect(eventSource ? ... : divRef.current).events-5a94e5eb.esm.js,createPointerEvents.connect(line 16141): unconditionally callstarget.addEventListener(...).Provider.useIsomorphicLayoutEffect(line 15795): also callsstate.events.connect(rootElement). Guarded against repeat connects viaevents.connectedcheck, so this typically fires at most once per Provider mount.
Root cause
The per-frame trigger is Provider remounting during the animation, most plausibly because useBridge (line 51-65) memoizes on [fiber, ContextBridge] and ContextBridge from its-fine's useContextBridge() can return a new component reference under context churn. When Bridge's component identity changes, reconciler.updateContainer unmounts/remounts Provider → its [] mount-effect re-fires → invokes user-onCreated → CanvasImpl's wrapped onCreated calls events.connect(divRef.current). During the animation divRef.current can be transiently null between renders.
Proposed fix
Mirror the existing disconnect null-guard onto connect:
connect: (target: HTMLElement | null) => {
if (!target) return
// ...rest unchanged
}And update the TS signature to allow null:
connect?: (target: TTarget | null) => voidSkipping a null-target call is safe: the next non-null connect(target) calls disconnect() first (no-op when connected is undefined) and re-attaches all listeners cleanly. No leak, no missed listeners.
Why I think this matters even though there's a userland workaround
Userland can wrap r3f's default events factory with a null-guard (which is what I'm doing), but that's
- coupling consumer code to an undocumented r3f internal contract,
- tied to r3f's
EventManagershape, so a future r3f minor that touches that shape would silently drop the guard, - forcing every host wrapping
<Canvas>(SharedCanvas-style multi-pane integrations are increasingly common) to reproduce the same shim.
Closing #3544 noted that the analogous symptom (containerRect 0×0 during animation) was resolved with a small defensiveness change. This is the same architectural cause showing up in a slightly different code path.
Reproduction
I can put together a minimal CodeSandbox repro if helpful — please let me know if that would speed review.
Source: pmndrs/react-three-fiber