-
Notifications
You must be signed in to change notification settings - Fork 775
fix(ScrollControls): cache ReactDOM root for React 19 compatibility #2557
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Conversation
Prevent duplicate createRoot() calls by caching the root instance on the DOM element, fixing React 19 error on component remount.
|
@iamadagostino is attempting to deploy a commit to the Poimandres Team on Vercel. A member of the Team first needs to authorize it. |
|
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. |
|
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? |
|
Hi @artokun, I’ve prepared two minimal CodeSandbox demos to show the issue and the fix: How to reproduce:
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. |
artokun
left a comment
There was a problem hiding this 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) |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
- The
statevariable used in the root caching logic (lines 245-264) comes fromuseScroll()on line 234, which returnsScrollControlsState(already typed withfixed: HTMLDivElementon line 37). - This
useThree()call only extractssizefor layout calculations and is unrelated to thestate.fixedproperty being accessed below.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
See R247
src/web/ScrollControls.tsx
Outdated
| Object.defineProperty(state.fixed, '__r3_root', { | ||
| value: r, | ||
| configurable: true, | ||
| writable: false, | ||
| }) |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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!
src/web/ScrollControls.tsx
Outdated
| 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 |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
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.ScrollHtmlwas creating a new root on each remount, breaking compatibility with React 19.What
__r3_rootpropertyChecklist