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 20 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: 696950a...93e3956

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 = 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 651.00 kB +1.19% 113.09 kB 114.43 kB
facebook-www/ReactDOM-prod.modern.js +1.23% 633.53 kB 641.32 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.32 kB +1.20% 111.52 kB 112.86 kB
facebook-www/ReactDOM-prod.classic.js +1.21% 643.21 kB 651.00 kB +1.19% 113.09 kB 114.43 kB
facebook-www/ReactDOMTesting-prod.modern.js +1.15% 648.25 kB 655.72 kB +1.07% 115.24 kB 116.48 kB
facebook-www/ReactDOMTesting-prod.classic.js +1.14% 657.93 kB 665.40 kB +1.09% 116.81 kB 118.09 kB
facebook-www/ReactDOM-profiling.modern.js +1.12% 698.09 kB 705.89 kB +1.20% 120.81 kB 122.25 kB
facebook-www/ReactDOM-profiling.classic.js +1.10% 706.14 kB 713.93 kB +1.18% 122.13 kB 123.58 kB
facebook-www/ReactReconciler-prod.modern.js +0.95% 487.70 kB 492.34 kB +0.82% 77.97 kB 78.61 kB
facebook-www/ReactReconciler-prod.classic.js +0.93% 497.97 kB 502.61 kB +0.79% 79.58 kB 80.21 kB
facebook-www/ReactDOM-dev.modern.js +0.80% 1,157.04 kB 1,166.33 kB +0.78% 191.43 kB 192.91 kB
facebook-www/ReactDOM-dev.classic.js +0.80% 1,166.18 kB 1,175.47 kB +0.77% 193.13 kB 194.63 kB
facebook-www/ReactDOMTesting-dev.modern.js +0.76% 1,173.94 kB 1,182.86 kB +0.72% 195.27 kB 196.68 kB
facebook-www/ReactDOMTesting-dev.classic.js +0.75% 1,183.08 kB 1,192.00 kB +0.72% 197.04 kB 198.46 kB
facebook-www/ReactReconciler-dev.modern.js +0.66% 805.26 kB 810.60 kB +0.57% 124.94 kB 125.65 kB
facebook-www/ReactReconciler-dev.classic.js +0.66% 814.46 kB 819.81 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.34% 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.36% 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.36% 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.34% 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.34% 7.61 kB 7.63 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.34% 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.35% 7.63 kB 7.65 kB
facebook-www/ReactART-prod.modern.js +0.44% 374.88 kB 376.53 kB +0.36% 63.02 kB 63.25 kB
facebook-www/ReactART-prod.classic.js +0.43% 384.82 kB 386.48 kB +0.38% 64.63 kB 64.88 kB
facebook-www/ReactART-dev.modern.js +0.34% 698.98 kB 701.36 kB +0.25% 109.31 kB 109.58 kB
facebook-www/ReactART-dev.classic.js +0.33% 708.48 kB 710.85 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 33d65ee

case Fragment:
if (enableFragmentRefs) {
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 expect to see two safelyAttachRef and two safelyDetachRef. They always come in pairs and is both for mount/unmount and hide/reappear.

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 safelyDetachRef calls to commitDeletionEffectsOnFiber and disappearLayoutEffects

case Fragment:
if (enableFragmentRefs) {
const instance: null | FragmentInstance = finishedWork.stateNode;
if (instance === null || instance._fragmentFiber !== finishedWork) {
Copy link
Collaborator

Choose a reason for hiding this comment

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

instance._fragmentFiber !== finishedWork I don't think it'll always be this Fiber will it? It could be the alternate.

It should be the same Instance even if the alternate updates.

Also this field should be opaque to the react-reconciler. It's the Config that decides where this lives. E.g. like this, expando, private field, or WeakMap.

You shouldn't really need to access it from the inside of the react-reconciler anyway.

You can probably just assume that's its the same one and that nobody can modify it.

@@ -318,6 +318,26 @@ export function cloneMutableTextInstance(textInstance) {
return textInstance;
}

export type FragmentInstance = null | {
_fragmentFiber: Object,
Copy link
Collaborator

Choose a reason for hiding this comment

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

The _fragmentFiber should be private to the config so this object form should not be necessary. It should be able to be just null.

In fact, ideally all Config types should be opaque. The places they're not is mainly because the Config itself is split into multiple files but it should really be a type internal to the package and then the exports in the Config file should always be opaque.

@jackpope jackpope force-pushed the fragment-refs-alt branch from fd0ea51 to bb9e7f0 Compare March 10, 2025 19:40
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
Contributor 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
Contributor 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
Contributor 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
Contributor 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
Contributor 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.

}
case Fragment: {
if (enableFragmentRefs) {
if (flags & Ref) {
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 you can skip this check for now. Nothing else is doing it and I don't actually think it's safe to do it.

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.

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