Skip to content
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

Add ref to Fragment (alternative) #32465

Open
wants to merge 13 commits into
base: main
Choose a base branch
from

Conversation

jackpope
Copy link
Contributor

This API is experimental and subject to change or removal.

This PR is an alternative to #32421 based on feedback: #32421 (review) . The difference here is that we traverse from the Fragment's fiber at operation time instead of keeping a set of children on the FragmentInstance. We still need to handle newly added or removed child nodes to apply event listeners and observers, so we treat those updates as effects.

Fragment Refs

This PR extends React's Fragment component to accept a ref prop. The Fragment's ref will attach to a custom host instance, which will provide an Element-like API for working with the Fragment's host parent and host children.

Here I've implemented addEventListener, removeEventListener, and focus to get started but we'll be iterating on this by adding additional APIs in future PRs. This sets up the mechanism to attach refs and perform operations on children. The FragmentInstance is implemented in react-dom here but is planned for Fabric as well.

The API works by targeting the first level of host children and proxying Element-like APIs to allow developers to manage groups of elements or elements that cannot be easily accessed such as from a third-party library or deep in a tree of Functional Component wrappers.

import {Fragment, useRef} from 'react';

const fragmentRef = useRef(null);

<Fragment ref={fragmentRef}>
  <div id="A" />
  <Wrapper>
    <div id="B">
      <div id="C" />
    </div>
  </Wrapper>
  <div id="D" />
</Fragment>

In this case, calling fragmentRef.current.addEventListener() would apply an event listener to A, B, and D. C is skipped because it is nested under the first level of Host Component. If another Host Component was appended as a sibling to A, B, or D, the event listener would be applied to that element as well and any other APIs would also affect the newly added child.

This is an implementation of the basic feature as a starting point for feedback and further iteration.

@react-sizebot
Copy link

react-sizebot commented Feb 24, 2025

Comparing: 22e39ea...d30251d

Critical size changes

Includes critical production bundles, as well as any change greater than 2%:

Name +/- Base Current +/- gzip Base gzip Current gzip
oss-stable/react-dom/cjs/react-dom.production.js = 6.68 kB 6.68 kB +0.11% 1.83 kB 1.83 kB
oss-stable/react-dom/cjs/react-dom-client.production.js +0.02% 515.71 kB 515.80 kB +0.01% 92.09 kB 92.10 kB
oss-experimental/react-dom/cjs/react-dom.production.js = 6.69 kB 6.69 kB +0.11% 1.83 kB 1.83 kB
oss-experimental/react-dom/cjs/react-dom-client.production.js = 565.64 kB 565.64 kB = 100.84 kB 100.84 kB
facebook-www/ReactDOM-prod.classic.js +1.06% 636.70 kB 643.46 kB +1.15% 112.08 kB 113.37 kB
facebook-www/ReactDOM-prod.modern.js +1.08% 627.02 kB 633.78 kB +1.16% 110.49 kB 111.78 kB

Significant size changes

Includes any change greater than 0.2%:

Expand to show
Name +/- Base Current +/- gzip Base gzip Current gzip
facebook-www/ReactDOM-prod.modern.js +1.08% 627.02 kB 633.78 kB +1.16% 110.49 kB 111.78 kB
facebook-www/ReactDOM-prod.classic.js +1.06% 636.70 kB 643.46 kB +1.15% 112.08 kB 113.37 kB
facebook-www/ReactDOM-profiling.modern.js +1.03% 654.41 kB 661.17 kB +1.12% 114.44 kB 115.72 kB
facebook-www/ReactDOM-profiling.classic.js +1.02% 664.14 kB 670.90 kB +1.11% 116.03 kB 117.31 kB
facebook-www/ReactDOMTesting-prod.modern.js +1.00% 641.74 kB 648.18 kB +1.04% 114.21 kB 115.39 kB
facebook-www/ReactDOMTesting-prod.classic.js +0.99% 651.42 kB 657.86 kB +1.02% 115.79 kB 116.97 kB
facebook-www/ReactDOM-dev.modern.js +0.71% 1,106.33 kB 1,114.21 kB +0.73% 184.52 kB 185.87 kB
facebook-www/ReactDOM-dev.classic.js +0.71% 1,115.48 kB 1,123.35 kB +0.71% 186.27 kB 187.60 kB
facebook-www/ReactDOMTesting-dev.modern.js +0.67% 1,123.24 kB 1,130.74 kB +0.65% 188.40 kB 189.63 kB
facebook-www/ReactDOMTesting-dev.classic.js +0.66% 1,132.38 kB 1,139.89 kB +0.64% 190.13 kB 191.34 kB
facebook-www/ReactReconciler-prod.modern.js +0.61% 485.40 kB 488.39 kB +0.57% 77.70 kB 78.15 kB
facebook-www/ReactReconciler-prod.classic.js +0.60% 495.67 kB 498.66 kB +0.53% 79.30 kB 79.73 kB
oss-stable-semver/react-noop-renderer/cjs/react-noop-renderer.production.js +0.53% 36.67 kB 36.86 kB +0.34% 6.86 kB 6.89 kB
oss-stable/react-noop-renderer/cjs/react-noop-renderer.production.js +0.53% 36.69 kB 36.89 kB +0.36% 6.89 kB 6.92 kB
oss-experimental/react-noop-renderer/cjs/react-noop-renderer.production.js +0.53% 36.70 kB 36.89 kB +0.36% 6.90 kB 6.92 kB
oss-stable-semver/react-noop-renderer/cjs/react-noop-renderer-persistent.production.js +0.53% 36.80 kB 36.99 kB +0.33% 6.88 kB 6.91 kB
oss-stable/react-noop-renderer/cjs/react-noop-renderer-persistent.production.js +0.53% 36.82 kB 37.02 kB +0.38% 6.91 kB 6.94 kB
oss-experimental/react-noop-renderer/cjs/react-noop-renderer-persistent.production.js +0.53% 36.83 kB 37.02 kB +0.38% 6.91 kB 6.94 kB
oss-stable-semver/react-noop-renderer/cjs/react-noop-renderer.development.js +0.50% 40.87 kB 41.08 kB +0.32% 7.46 kB 7.48 kB
oss-stable/react-noop-renderer/cjs/react-noop-renderer.development.js +0.50% 40.90 kB 41.10 kB +0.36% 7.48 kB 7.51 kB
oss-experimental/react-noop-renderer/cjs/react-noop-renderer.development.js +0.50% 40.90 kB 41.11 kB +0.36% 7.49 kB 7.51 kB
oss-stable-semver/react-noop-renderer/cjs/react-noop-renderer-persistent.development.js +0.50% 41.01 kB 41.22 kB +0.33% 7.47 kB 7.50 kB
oss-stable/react-noop-renderer/cjs/react-noop-renderer-persistent.development.js +0.50% 41.04 kB 41.24 kB +0.36% 7.50 kB 7.53 kB
oss-experimental/react-noop-renderer/cjs/react-noop-renderer-persistent.development.js +0.50% 41.04 kB 41.25 kB +0.36% 7.51 kB 7.53 kB
facebook-www/ReactReconciler-dev.modern.js +0.44% 760.25 kB 763.57 kB +0.34% 119.22 kB 119.63 kB
facebook-www/ReactReconciler-dev.classic.js +0.43% 769.46 kB 772.77 kB +0.33% 120.89 kB 121.29 kB
facebook-www/ReactART-prod.modern.js +0.32% 373.47 kB 374.66 kB +0.29% 62.86 kB 63.04 kB
facebook-www/ReactART-prod.classic.js +0.31% 383.42 kB 384.60 kB +0.31% 64.46 kB 64.66 kB
facebook-www/ReactART-dev.modern.js +0.23% 655.78 kB 657.28 kB +0.15% 103.79 kB 103.94 kB
facebook-www/ReactART-dev.classic.js +0.22% 665.28 kB 666.77 kB +0.15% 105.62 kB 105.78 kB

Generated by 🚫 dangerJS against e08683f

@@ -3122,6 +3145,7 @@ function commitReconciliationEffects(
const flags = finishedWork.flags;
if (flags & Placement) {
commitHostPlacement(finishedWork);
commitFragmentInstanceInsertionEffects(finishedWork);
Copy link
Collaborator

Choose a reason for hiding this comment

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

You assume that finishedWork is a HostComponent here but it might not be.

E.g. <Fragment ref={...}>{c ? <Component><div /></Component> : null}</Fragment>.

In this case it would be the <Component> fiber that's being placed.

Instead, you should do this in insertOrAppendPlacementNode and insertOrAppendPlacementNodeIntoContainer. Those are recursively traversing down to find all the HostComponents inside the placement.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

One reason I put it here is because commitHostPlacement is gated by !supportsMutation. We eventually want this all to work in Fabric. Though there are probably additional considerations to make with that, I haven't taken a closer look at the RN implementation yet.

function getFragmentInstanceChildren(
fragmentInstance: FragmentInstance,
): Set<Element> {
const children = new Set<Element>();
Copy link
Collaborator

Choose a reason for hiding this comment

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

This creates a new allocation and a pretty heavy one. You don't need a Set here because you should know whether it's a duplicate or not just by the algorithm. No need to run expensive hash map code to dedupe.

Even with an array it's an unnecessary allocation and build up. Every time you perform an operation. It would be better to just do the loop inline inside the function that performs the operation. It's a little annoying to duplicate that code but it's not that much code and we have that loop duplicated so many times anyway.

Maybe it could be a helper that takes a static function without allocating any closures like:

traverseFragmentInstanceChildren(fn, arg) {
  ...
  fn(instance, arg);
}

One possible benefit of the array is that you could stash it on the instance as a cache and then reset it any time you get an insert/deletion. That way if you have multiple addEventListeners in a sequence you don't have to traverse. However, it might not actually be worth this tradeoff because it still requires allocation and extra memory storage for something that is likely a very shallow traversal anyway and not slow to begin with.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I updated to use a helper, though its a bit awkward with multiple args so I'm binding them. I believe that should avoid a lot of the perf hit of a closure allocation. Other traversals such as focusLast will need some refactoring or their own traversal loop, so maybe it will just end up with duplication afterall.

Also not sure about the tradeoff with caching the children. Seems unlikely there would be a large amount of listeners or observers on a single fragment instance. But something we can keep an eye on in experimental usage.

this: FragmentInstance,
fragmentFiber: Fiber,
) {
this._fragmentFiber = fragmentFiber;
Copy link
Collaborator

@sebmarkbage sebmarkbage Feb 25, 2025

Choose a reason for hiding this comment

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

You set this only once but that will be the initial Fiber which means that any time you traverse it, you'll observe the initial state of the tree. Because of alternate model you have a 50/50 chance of getting it right but you might actually get the alternate and not current here.

Instead, you need to update this to the new Fiber any time a new one commits. Basically in commitMutationEffectsOnFiber on the Fragment branch you update it.

Technically you only need to update it if there are Placement/Deletion in the subtree but that would hold on to an old Fiber for longer and would be confusing. So you might as well always update it.

Note that this is what we do for HostComponents too but only for props, but we should probably just update the Fiber instead.

https://github.com/facebook/react/blob/main/packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js#L773

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Thanks for the catch! This definitely revealed bugs once I logged the current state of the fiber tree after Placement/Deletion. I just didn't have a test yet that asserted on anything requiring the traversal of a newly added node. I've updated to set current's reference to finishedWork in commitMutationEffectsOnFiber

childElements.add(child.stateNode);
}

if (child.sibling !== null) {
Copy link
Collaborator

Choose a reason for hiding this comment

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

It's typically better to do this with a loop since otherwise the recursion can get very deep and lead to stack overflow.

Since React does use a stack we do require a tree to be more shallow than required for a typical recursion but we don't require it to also handle sibling depth. You can have long list of items in deep trees.

Copy link
Collaborator

Choose a reason for hiding this comment

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

This also leads to a sort of breadth first search which I don't think is necessarily the intended order of insertion.

<Fragment>
  <Component><div id="a" /></Component>
  <div id="b" />
</Fragment>

This should have "a" first and then "b" but the current algorithm would visit "b" first.

Look at how we do other traversals where we do siblings in a loop and then recurse each child in that loop. Depth first.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Updated to be depth first and loop the siblings.

return;
}

if (child.tag === HostComponent) {
Copy link
Collaborator

Choose a reason for hiding this comment

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

This probably need to consider some other child tags as well. For example inside an <Activity> that's currently hidden we shouldn't discover any elements. (If we did then they would be duplicate when it gets revealed since they're added again.) So we need to bail out from searching inside Activity here.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Added a boundary based on OffscreenComponent. Another that could be relevant is HostPortal, though everything in portals like events and context follow the React tree, so it seems like for consistency we should include any host nodes within portals here as well.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
CLA Signed React Core Team Opened by a member of the React Core Team
Projects
None yet
Development

Successfully merging this pull request may close these issues.

5 participants