Skip to content

Add ref to Fragment (alternative) #32465

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

Merged
merged 21 commits into from
Mar 12, 2025
Merged

Conversation

jackpope
Copy link
Member

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: 696950a...2ad745e

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.05% 1.83 kB 1.83 kB
oss-stable/react-dom/cjs/react-dom-client.production.js +0.04% 518.54 kB 518.75 kB +0.02% 92.45 kB 92.46 kB
oss-experimental/react-dom/cjs/react-dom.production.js = 6.69 kB 6.69 kB = 1.83 kB 1.83 kB
oss-experimental/react-dom/cjs/react-dom-client.production.js = 590.26 kB 589.35 kB = 105.09 kB 104.90 kB
facebook-www/ReactDOM-prod.classic.js +1.21% 643.21 kB 650.98 kB +1.18% 113.09 kB 114.43 kB
facebook-www/ReactDOM-prod.modern.js +1.23% 633.53 kB 641.30 kB +1.20% 111.52 kB 112.86 kB
facebook-react-native/react/cjs/JSXRuntime-dev.js = 11.54 kB 11.26 kB = 3.17 kB 3.09 kB
facebook-react-native/react/cjs/JSXDEVRuntime-dev.js = 11.34 kB 11.06 kB = 3.15 kB 3.07 kB
facebook-react-native/react-is/cjs/ReactIs-prod.js = 4.77 kB 4.58 kB = 1.17 kB 1.10 kB
facebook-react-native/react-is/cjs/ReactIs-profiling.js = 4.77 kB 4.58 kB = 1.17 kB 1.10 kB
facebook-react-native/react-is/cjs/ReactIs-dev.js = 5.31 kB 5.07 kB = 1.20 kB 1.13 kB
facebook-react-native/react/cjs/JSXRuntime-prod.js = 1.17 kB 0.96 kB = 0.56 kB 0.49 kB
facebook-react-native/react/cjs/JSXRuntime-profiling.js = 1.17 kB 0.96 kB = 0.56 kB 0.49 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.23% 633.53 kB 641.30 kB +1.20% 111.52 kB 112.86 kB
facebook-www/ReactDOM-prod.classic.js +1.21% 643.21 kB 650.98 kB +1.18% 113.09 kB 114.43 kB
facebook-www/ReactDOMTesting-prod.modern.js +1.15% 648.25 kB 655.70 kB +1.07% 115.24 kB 116.47 kB
facebook-www/ReactDOMTesting-prod.classic.js +1.13% 657.93 kB 665.38 kB +1.09% 116.81 kB 118.09 kB
facebook-www/ReactDOM-profiling.modern.js +1.11% 698.09 kB 705.86 kB +1.19% 120.81 kB 122.25 kB
facebook-www/ReactDOM-profiling.classic.js +1.10% 706.14 kB 713.91 kB +1.18% 122.13 kB 123.57 kB
facebook-www/ReactReconciler-prod.modern.js +0.95% 487.70 kB 492.31 kB +0.82% 77.97 kB 78.61 kB
facebook-www/ReactReconciler-prod.classic.js +0.93% 497.97 kB 502.58 kB +0.79% 79.58 kB 80.21 kB
facebook-www/ReactDOM-dev.modern.js +0.80% 1,157.04 kB 1,166.30 kB +0.77% 191.43 kB 192.91 kB
facebook-www/ReactDOM-dev.classic.js +0.79% 1,166.18 kB 1,175.44 kB +0.77% 193.13 kB 194.62 kB
facebook-www/ReactDOMTesting-dev.modern.js +0.76% 1,173.94 kB 1,182.83 kB +0.72% 195.27 kB 196.68 kB
facebook-www/ReactDOMTesting-dev.classic.js +0.75% 1,183.08 kB 1,191.97 kB +0.72% 197.04 kB 198.46 kB
facebook-www/ReactReconciler-dev.modern.js +0.66% 805.26 kB 810.58 kB +0.57% 124.94 kB 125.65 kB
facebook-www/ReactReconciler-dev.classic.js +0.65% 814.46 kB 819.78 kB +0.57% 126.61 kB 127.33 kB
oss-stable-semver/react-noop-renderer/cjs/react-noop-renderer.production.js +0.52% 37.50 kB 37.70 kB +0.37% 6.98 kB 7.01 kB
oss-stable/react-noop-renderer/cjs/react-noop-renderer.production.js +0.52% 37.53 kB 37.72 kB +0.36% 7.01 kB 7.03 kB
oss-experimental/react-noop-renderer/cjs/react-noop-renderer.production.js +0.52% 37.53 kB 37.73 kB +0.36% 7.01 kB 7.04 kB
oss-stable-semver/react-noop-renderer/cjs/react-noop-renderer-persistent.production.js +0.52% 37.63 kB 37.82 kB +0.36% 7.00 kB 7.02 kB
oss-stable/react-noop-renderer/cjs/react-noop-renderer-persistent.production.js +0.52% 37.65 kB 37.85 kB +0.37% 7.03 kB 7.05 kB
oss-experimental/react-noop-renderer/cjs/react-noop-renderer-persistent.production.js +0.52% 37.66 kB 37.85 kB +0.37% 7.03 kB 7.06 kB
oss-stable-semver/react-noop-renderer/cjs/react-noop-renderer.development.js +0.49% 41.76 kB 41.96 kB +0.36% 7.58 kB 7.60 kB
oss-stable/react-noop-renderer/cjs/react-noop-renderer.development.js +0.49% 41.78 kB 41.99 kB +0.36% 7.61 kB 7.63 kB
oss-experimental/react-noop-renderer/cjs/react-noop-renderer.development.js +0.49% 41.79 kB 41.99 kB +0.35% 7.61 kB 7.64 kB
oss-stable-semver/react-noop-renderer/cjs/react-noop-renderer-persistent.development.js +0.49% 41.90 kB 42.10 kB +0.36% 7.59 kB 7.62 kB
oss-stable/react-noop-renderer/cjs/react-noop-renderer-persistent.development.js +0.49% 41.92 kB 42.13 kB +0.35% 7.62 kB 7.65 kB
oss-experimental/react-noop-renderer/cjs/react-noop-renderer-persistent.development.js +0.49% 41.93 kB 42.13 kB +0.37% 7.63 kB 7.65 kB
facebook-www/ReactART-prod.modern.js +0.44% 374.88 kB 376.51 kB +0.35% 63.02 kB 63.24 kB
facebook-www/ReactART-prod.classic.js +0.42% 384.82 kB 386.46 kB +0.38% 64.63 kB 64.87 kB
facebook-www/ReactART-dev.modern.js +0.34% 698.98 kB 701.33 kB +0.25% 109.31 kB 109.58 kB
facebook-www/ReactART-dev.classic.js +0.33% 708.48 kB 710.82 kB +0.25% 111.14 kB 111.42 kB
facebook-react-native/react/cjs/React-dev.js = 51.21 kB 51.03 kB = 11.41 kB 11.36 kB
facebook-react-native/react/cjs/React-profiling.js = 20.16 kB 19.95 kB = 5.21 kB 5.16 kB
facebook-react-native/react/cjs/React-prod.js = 19.73 kB 19.52 kB = 5.13 kB 5.08 kB
facebook-react-native/react/cjs/JSXRuntime-dev.js = 11.54 kB 11.26 kB = 3.17 kB 3.09 kB
facebook-react-native/react/cjs/JSXDEVRuntime-dev.js = 11.34 kB 11.06 kB = 3.15 kB 3.07 kB
facebook-react-native/react-is/cjs/ReactIs-prod.js = 4.77 kB 4.58 kB = 1.17 kB 1.10 kB
facebook-react-native/react-is/cjs/ReactIs-profiling.js = 4.77 kB 4.58 kB = 1.17 kB 1.10 kB
facebook-react-native/react-is/cjs/ReactIs-dev.js = 5.31 kB 5.07 kB = 1.20 kB 1.13 kB
facebook-react-native/react/cjs/JSXRuntime-prod.js = 1.17 kB 0.96 kB = 0.56 kB 0.49 kB
facebook-react-native/react/cjs/JSXRuntime-profiling.js = 1.17 kB 0.96 kB = 0.56 kB 0.49 kB

Generated by 🚫 dangerJS against b46ae58

focus(): void,
};

function FragmentInstancePseudoElement(
Copy link
Collaborator

@sebmarkbage sebmarkbage Mar 10, 2025

Choose a reason for hiding this comment

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

Nit: This is not really a "PseudoElement" in the DOM terminology. The reason ViewTransitionPseudoElement is called this is because it represents a reified/first-class version of a PseudoElement Instance as a DOM concept of an Element that's part of another Element.

This is similar but it's conceptually more like a real Element or just a Fragment.

So maybe FragmentInstance is the right name? or maybe just Fragment?

This matters for when you log these object in the console and the name of the instance shows as [Fragment].

Copy link
Member Author

Choose a reason for hiding this comment

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

Yes just realized this as I started to read the view transition spec with the PsuedoElement concepts. Will rename to FragmentInstance and have the type be FragmentInstanceType

newChild.key === null;
if (isUnkeyedTopLevelFragment) {
newChild.key === null &&
(enableFragmentRefs ? newChild.props.ref === undefined : true);
Copy link
Collaborator

Choose a reason for hiding this comment

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

This is subtle but this means that null and undefined are different and will reconcile differently. Which is technically a breaking change.

It also means that <div ref={maybeUndefined && ref}>{children}</div> will clear the state of the whole subtree when it switches.

If it was ref === null || ref === undefined then it would clear state even if you did <div ref={c ? ref : null}> which also doesn't seem right.

So I think what you have is probably the best compromise.

Copy link
Member Author

Choose a reason for hiding this comment

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

This is specific to Fragment components, right? So your example of clearing state of the <div /> subtree would be limited to <Fragment ref={}> subtree?

I basically want to know here whether a ref prop was declared at all. But I don't think we can distinguish a maybeUndefined ref value.

Copy link
Collaborator

Choose a reason for hiding this comment

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

function Component({ children, header, c }) {
  const ref = useRef();
  return <Fragment ref={c ? ref : undefined}>
     <h1>{header}</h1>
     {children}
  </Fragment>
}

This would clear the state of all children and header deeply any time c changes.

It could also be written as:

function Component({ children, header }) {
  const ref = useRef();
  if (header) {
    return <Fragment ref={ref}>
       <h1>{header}</h1>
       {children}
    </Fragment>
  } else {
    return <>
       <h1>Default Header</h1>
       {children}
    </>
  }
}

If header switches from existing to not existing this would clear the state of children if the ref exists but it would not if the ref doesn't exist.

It also shows up in situations like:

<div>
  <Fragment ref={ref}>{...}</Fragment>
</div>

Or if it's a component that just returns children.

So not necessarily the return value of a Component.

updateFragmentInstanceFiber(finishedWork, current.stateNode);
}
if (flags & Ref) {
safelyAttachRef(finishedWork, finishedWork.return);
Copy link
Collaborator

Choose a reason for hiding this comment

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

I don't know where this pattern is coming from but this should be in reappearLayoutEffects, not here. Attaching refs happen in the layout effects which can happen either in commitLayoutEffects or reappearLayoutEffects.

Copy link
Member Author

Choose a reason for hiding this comment

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

Question regarding reappearLayoutEffects, I see a bunch of // TODO: Check flags & Ref. What's the reason those aren't already checked? Does the flag not come through at this phase?

Copy link
Collaborator

Choose a reason for hiding this comment

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

I think it's not safe to check the flag because it would've already been reset at this point. The reappearing doesn't always happen on a tree that was just rendered so those can be old Fibers. I think it would have to be like a static flag. I think the TODO isn't quite right (which isn't unusual for TODOs).

}

export function commitNewChildToFragmentInstance(
childElement: Element,
Copy link
Collaborator

Choose a reason for hiding this comment

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

The type that this should reference is Instance because that's the type that's exposed to the react-reconciler. Technically it should be opaque and so this would be wrong if it was.

while (parent !== null) {
if (isFragmentInstanceParent(parent)) {
const fragmentInstance: FragmentInstance = parent.stateNode;
commitNewChildToFragmentInstance(fiber.stateNode, fragmentInstance);
Copy link
Collaborator

Choose a reason for hiding this comment

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

You expect this to be an Element but this Fiber can be either a HostText or HostComponent so it can also be a Text. Because you call commitFragmentInstanceInsertionEffects for both HostText and HostComponent.

Currently you don't have any methods that operate on Text Nodes so you could probably skip HostText or call another Config that is a noop for now. Like maybe React Native could focus HostText?

Copy link
Member Author

Choose a reason for hiding this comment

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

For now I'll check against HostComponent specifically and then follow up with any additional work to enable text for React Native.

@@ -298,6 +339,9 @@ function insertOrAppendPlacementNodeIntoContainer(
} else {
appendChildToContainer(parent, stateNode);
}
if (enableFragmentRefs) {
commitFragmentInstanceInsertionEffects(node);
Copy link
Collaborator

Choose a reason for hiding this comment

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

This is called for both insertions AND moves which is not what I think you expected because you'd add duplicate event listeners any time something moves.

I think maybe it's currently ok to check if node.alternate === null to know if it's an initial insertion.

@@ -214,6 +219,38 @@ function getHostParentFiber(fiber: Fiber): Fiber {
);
}

export function commitFragmentInstanceInsertionEffects(fiber: Fiber): void {
let parent = fiber.return;
while (parent !== null) {
Copy link
Collaborator

@sebmarkbage sebmarkbage Mar 10, 2025

Choose a reason for hiding this comment

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

I don't love this loop. If you add 1000 children in bunch of nested components, you'd have to backtrack the same number of levels 1000 times.

And this is for every insertion whether you use Fragment refs or not.

That's why getHostParentFiber is called earlier and passed into insertOrAppendPlacementNode. That way if one Component is mounted which has 1000 children, we only get the getHostParentFiber once.

There's two cases here. A) is that the Fragment is above the Placement or the same level as the Placement. B) is if the Fragment is below the Placement.

For A you could do the loop at the same time as getHostParentFiber and use the same loop. Since this is for every update whether you use Fragment refs or not, it really applies to every update anyway. Might as well unify. Then you can build up an array of parent FragmentInstances and pass that array down though the same way the parent argument is passed to insertOrAppendPlacementNodeIntoContainer. Otherwise pass null if there is none. So there's only an allocation if there are many matches. It's an unfortunate allocation but since there can be more than one match it kind of needs to be one but it would only be the case where the children of the Fragment directly changes. That way if you have 1000 children being inserted into one fragment, we only collect that parent fragment once.

If it's below the Placement itself then it really must be new itself and so it shouldn't even have a a FragmentInstance yet because that gets created in the layout phase. So we don't need to care about B.

Copy link
Member Author

@jackpope jackpope Mar 11, 2025

Choose a reason for hiding this comment

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

Unifying with getHostParentFiber makes sense to me, thanks for the explanation here.

commitFragmentInstanceInsertionEffects is also called from reappearLayoutEffects. I don't think we can avoid this loop at each HostComponent there, but let me know if you see a way there. If that turns out to slow down the reappear effects, maybe we could hold some state on the hidden HostComponent fibers to signal they were attached previously and should search for fragment instance parents again on reappear. Just to avoid running it on all HostComponent.

Copy link
Collaborator

@sebmarkbage sebmarkbage left a comment

Choose a reason for hiding this comment

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

One more nit on the flag check to address first. I didn't triple check all the feedback I think you addressed it all.

@jackpope jackpope merged commit 6aa8254 into facebook:main Mar 12, 2025
194 checks passed
@jackpope jackpope mentioned this pull request Mar 12, 2025
github-actions bot pushed a commit that referenced this pull request Mar 12, 2025
*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.

```javascript
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.

DiffTrain build for [6aa8254](6aa8254)
github-actions bot pushed a commit that referenced this pull request Mar 12, 2025
*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.

```javascript
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.

DiffTrain build for [6aa8254](6aa8254)
huozhi pushed a commit to vercel/next.js that referenced this pull request Mar 12, 2025
jackpope added a commit that referenced this pull request Mar 18, 2025
`focus()` was added in #32465.
Here we add `focusLast()` and `blur()`. I also extended `focus` to take
options.

`focus` will focus the first focusable element. `focusLast` will focus
the last focusable element. We could consider a `focusFirst` naming or
even the `focusWithin` used by test selector APIs as well.

`blur` will only have an effect if the current `document.activeElement`
is one of the fragment children.
github-actions bot pushed a commit that referenced this pull request Mar 18, 2025
 was added in #32465.
Here we add  and . I also extended  to take
options.

 will focus the first focusable element.  will focus
the last focusable element. We could consider a  naming or
even the  used by test selector APIs as well.

 will only have an effect if the current
is one of the fragment children.

DiffTrain build for [c69a5fc](c69a5fc)
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.

6 participants