Skip to content

Conversation

@iamadagostino
Copy link

Description

Prevent duplicate createRoot() calls by caching the root instance on the DOM element, fixing React 19 error on component remount.

Why

React 19 throws an error when ReactDOM.createRoot() is called multiple times on the same container. ScrollHtml was creating a new root on each remount, breaking compatibility with React 19.

What

  • Cache ReactDOM root instance on the DOM element using __r3_root property
  • Reuse cached root on component remount
  • Fixes React 19 compatibility while maintaining backwards compatibility

Checklist

  • Ready to be merged

Prevent duplicate createRoot() calls by caching the root instance on
the DOM element, fixing React 19 error on component remount.
@vercel
Copy link

vercel bot commented Oct 13, 2025

@iamadagostino is attempting to deploy a commit to the Poimandres Team on Vercel.

A member of the Team first needs to authorize it.

@codesandbox-ci
Copy link

codesandbox-ci bot commented Oct 13, 2025

This pull request is automatically built and testable in CodeSandbox.

To see build info of the built libraries, click here or the icon next to each commit SHA.

@artokun
Copy link

artokun commented Oct 15, 2025

Is this for when you have multiple ScrollControls in one app? Can you create two separate simple Code Sandbox links? One that shows the issue and another that fixes it with your git:branch package?

@iamadagostino
Copy link
Author

Hi @artokun,

I’ve prepared two minimal CodeSandbox demos to show the issue and the fix:

How to reproduce:

  1. Open either link above.
  2. In CodeSandbox, open the Console panel (upper-right).
  3. Click the Toggle ScrollControls button in the preview.

On React 19, the broken sandbox will log:

You are calling ReactDOMClient.createRoot() on a container that has already been passed to createRoot() before. Instead, call root.render() on the existing root instead if you want to update it.

Copy link

@artokun artokun left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think you narrowed a proper solution, but we need to make sure that we're properly typing and checking assumptions around the state object returned by useThree as it could be reactive. The burden of proof is on you, that starts with proper types.

const state = useScroll()
const group = React.useRef<HTMLDivElement>(null!)
React.useImperativeHandle(ref, () => group.current, [])
const { width, height } = useThree((state) => state.size)
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can you update useThree types to include state.fixed and tag me for review again?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  • The state variable used in the root caching logic (lines 245-264) comes from useScroll() on line 234, which returns ScrollControlsState (already typed with fixed: HTMLDivElement on line 37).
  • This useThree() call only extracts size for layout calculations and is unrelated to the state.fixed property being accessed below.

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

See R247

Comment on lines 250 to 254
Object.defineProperty(state.fixed, '__r3_root', {
value: r,
configurable: true,
writable: false,
})
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

verify the return type of the useThree lambda, if it's a valtio proxy, you'll need to properly define it in the store. we don't want to be setting properties this way.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The state.fixed here is from useScroll() (line 234), which returns ScrollControlsState - not from R3F's store. It's a plain HTMLDivElement created via useState (line 68), not a Valtio proxy.
Setting __r3_root on this DOM element doesn't mutate any reactive store state.
The property is defined non-enumerable and is used solely to cache the ReactDOM root instance to prevent React 19's "createRoot already called" error on remount.

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it's clever that you're adding a property on a html element, I just haven't seen this done anywhere else which is why it's confusing me. Can we create a new type that has this modification? like a HTMLDivElementWithR3Root

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Perfect! I created the HTMLDivElementWithR3Root type (lines 9-11) that extends HTMLDivElement with an optional __r3_root?: ReactDOM.Root property. This pattern allows us to cache the root instance on the DOM element itself, preventing React 19's "createRoot already called" error when ScrollHtml remounts. The type makes it explicit and type-safe, while the non-enumerable property definition keeps it hidden from standard DOM inspection. Thanks for pushing for clarity here!

const root = React.useMemo(() => {
try {
// If a root was previously created on this element, reuse it.
if (state.fixed && (state.fixed as any).__r3_root) return (state.fixed as any).__r3_root
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What I am trying to avoid is this. why is there a __r3_root property on a HTMLDIvElement? Seems odd that we need to typecast this to any.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch! I've replaced the as any typecast with a proper type definition. Created a new type HTMLDivElementWithR3Root (lines 9-11) that explicitly defines the __r3_root property on the HTMLDivElement. This makes it clear that we intentionally store the ReactDOM root instance on this element to cache it across remounts, and TypeScript now knows about this property without needing unsafe casts.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants