@samitouri / QOS-React / commits / 6aa8254bb7

Add ref to Fragment (#32465)

*This API is experimental and subject to change or removal.* This PR is an alternative to https://github.com/facebook/react/pull/32421 based on feedback: https://github.com/facebook/react/pull/32421#pullrequestreview-2625382015 . 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.

Jack Pope committed Mar 12, 2025 at 10:32 UTC 6aa8254bb7353fe3096289edc669cf168e9fd190
23 files changed +1258 -49
packages/react-art/src/ReactFiberConfigART.js
+21
@@ -318,6 +318,27 @@ export function cloneMutableTextInstance(textInstance) {
318 return textInstance;
319 }
320
321 +export type FragmentInstanceType = null;
322 +
323 +export function createFragmentInstance(fiber): null {
324 + return null;
325 +}
326 +
327 +export function updateFragmentInstanceFiber(fiber, instance): void {
328 + // Noop
329 +}
330 +
331 +export function commitNewChildToFragmentInstance(
332 + child,
333 + fragmentInstance,
334 +): void {
335 + // Noop
336 +}
337 +
338 +export function deleteChildFromFragmentInstance(child, fragmentInstance): void {
339 + // Noop
340 +}
341 +
342 export function finalizeInitialChildren(domElement, type, props) {
343 return false;
344 }
packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js
+230
@@ -34,6 +34,7 @@ import {getCurrentRootHostContainer} from 'react-reconciler/src/ReactFiberHostCo
34 import hasOwnProperty from 'shared/hasOwnProperty';
35 import {checkAttributeStringCoercion} from 'shared/CheckStringCoercion';
36 import {REACT_CONTEXT_TYPE} from 'shared/ReactSymbols';
37 +import {OffscreenComponent} from 'react-reconciler/src/ReactWorkTags';
38
39 export {
40 setCurrentUpdatePriority,
@@ -2159,6 +2160,235 @@ export function subscribeToGestureDirection(
2160 }
2161 }
2162
2163 +type EventListenerOptionsOrUseCapture =
2164 + | boolean
2165 + | {
2166 + capture?: boolean,
2167 + once?: boolean,
2168 + passive?: boolean,
2169 + signal?: AbortSignal,
2170 + ...
2171 + };
2172 +
2173 +type StoredEventListener = {
2174 + type: string,
2175 + listener: EventListener,
2176 + optionsOrUseCapture: void | EventListenerOptionsOrUseCapture,
2177 +};
2178 +
2179 +export type FragmentInstanceType = {
2180 + _fragmentFiber: Fiber,
2181 + _eventListeners: null | Array<StoredEventListener>,
2182 + addEventListener(
2183 + type: string,
2184 + listener: EventListener,
2185 + optionsOrUseCapture?: EventListenerOptionsOrUseCapture,
2186 + ): void,
2187 + removeEventListener(
2188 + type: string,
2189 + listener: EventListener,
2190 + optionsOrUseCapture?: EventListenerOptionsOrUseCapture,
2191 + ): void,
2192 + focus(): void,
2193 +};
2194 +
2195 +function FragmentInstance(this: FragmentInstanceType, fragmentFiber: Fiber) {
2196 + this._fragmentFiber = fragmentFiber;
2197 + this._eventListeners = null;
2198 +}
2199 +// $FlowFixMe[prop-missing]
2200 +FragmentInstance.prototype.addEventListener = function (
2201 + this: FragmentInstanceType,
2202 + type: string,
2203 + listener: EventListener,
2204 + optionsOrUseCapture?: EventListenerOptionsOrUseCapture,
2205 +): void {
2206 + if (this._eventListeners === null) {
2207 + this._eventListeners = [];
2208 + }
2209 +
2210 + const listeners = this._eventListeners;
2211 + // Element.addEventListener will only apply uniquely new event listeners by default. Since we
2212 + // need to collect the listeners to apply to appended children, we track them ourselves and use
2213 + // custom equality check for the options.
2214 + const isNewEventListener =
2215 + indexOfEventListener(listeners, type, listener, optionsOrUseCapture) === -1;
2216 + if (isNewEventListener) {
2217 + listeners.push({type, listener, optionsOrUseCapture});
2218 + traverseFragmentInstanceChildren(
2219 + this,
2220 + this._fragmentFiber.child,
2221 + addEventListenerToChild,
2222 + type,
2223 + listener,
2224 + optionsOrUseCapture,
2225 + );
2226 + }
2227 + this._eventListeners = listeners;
2228 +};
2229 +function addEventListenerToChild(
2230 + child: Instance,
2231 + type: string,
2232 + listener: EventListener,
2233 + optionsOrUseCapture?: EventListenerOptionsOrUseCapture,
2234 +): boolean {
2235 + child.addEventListener(type, listener, optionsOrUseCapture);
2236 + return false;
2237 +}
2238 +// $FlowFixMe[prop-missing]
2239 +FragmentInstance.prototype.removeEventListener = function (
2240 + this: FragmentInstanceType,
2241 + type: string,
2242 + listener: EventListener,
2243 + optionsOrUseCapture?: EventListenerOptionsOrUseCapture,
2244 +): void {
2245 + const listeners = this._eventListeners;
2246 + if (listeners === null) {
2247 + return;
2248 + }
2249 + if (typeof listeners !== 'undefined' && listeners.length > 0) {
2250 + traverseFragmentInstanceChildren(
2251 + this,
2252 + this._fragmentFiber.child,
2253 + removeEventListenerFromChild,
2254 + type,
2255 + listener,
2256 + optionsOrUseCapture,
2257 + );
2258 + const index = indexOfEventListener(
2259 + listeners,
2260 + type,
2261 + listener,
2262 + optionsOrUseCapture,
2263 + );
2264 + if (this._eventListeners !== null) {
2265 + this._eventListeners.splice(index, 1);
2266 + }
2267 + }
2268 +};
2269 +function removeEventListenerFromChild(
2270 + child: Instance,
2271 + type: string,
2272 + listener: EventListener,
2273 + optionsOrUseCapture?: EventListenerOptionsOrUseCapture,
2274 +): boolean {
2275 + child.removeEventListener(type, listener, optionsOrUseCapture);
2276 + return false;
2277 +}
2278 +// $FlowFixMe[prop-missing]
2279 +FragmentInstance.prototype.focus = function (this: FragmentInstanceType) {
2280 + traverseFragmentInstanceChildren(
2281 + this,
2282 + this._fragmentFiber.child,
2283 + setFocusIfFocusable,
2284 + );
2285 +};
2286 +
2287 +function traverseFragmentInstanceChildren<A, B, C>(
2288 + fragmentInstance: FragmentInstanceType,
2289 + child: Fiber | null,
2290 + fn: (Instance, A, B, C) => boolean,
2291 + a: A,
2292 + b: B,
2293 + c: C,
2294 +): void {
2295 + while (child !== null) {
2296 + if (child.tag === HostComponent) {
2297 + if (fn(child.stateNode, a, b, c)) {
2298 + return;
2299 + }
2300 + } else if (
2301 + child.tag === OffscreenComponent &&
2302 + child.memoizedState !== null
2303 + ) {
2304 + // Skip hidden subtrees
2305 + } else {
2306 + traverseFragmentInstanceChildren(
2307 + fragmentInstance,
2308 + child.child,
2309 + fn,
2310 + a,
2311 + b,
2312 + c,
2313 + );
2314 + }
2315 + child = child.sibling;
2316 + }
2317 +}
2318 +
2319 +function normalizeListenerOptions(
2320 + opts: ?EventListenerOptionsOrUseCapture,
2321 +): string {
2322 + if (opts == null) {
2323 + return '0';
2324 + }
2325 +
2326 + if (typeof opts === 'boolean') {
2327 + return `c=${opts ? '1' : '0'}`;
2328 + }
2329 +
2330 + return `c=${opts.capture ? '1' : '0'}&o=${opts.once ? '1' : '0'}&p=${opts.passive ? '1' : '0'}`;
2331 +}
2332 +
2333 +function indexOfEventListener(
2334 + eventListeners: Array<StoredEventListener>,
2335 + type: string,
2336 + listener: EventListener,
2337 + optionsOrUseCapture: void | EventListenerOptionsOrUseCapture,
2338 +): number {
2339 + for (let i = 0; i < eventListeners.length; i++) {
2340 + const item = eventListeners[i];
2341 + if (
2342 + item.type === type &&
2343 + item.listener === listener &&
2344 + normalizeListenerOptions(item.optionsOrUseCapture) ===
2345 + normalizeListenerOptions(optionsOrUseCapture)
2346 + ) {
2347 + return i;
2348 + }
2349 + }
2350 + return -1;
2351 +}
2352 +
2353 +export function createFragmentInstance(
2354 + fragmentFiber: Fiber,
2355 +): FragmentInstanceType {
2356 + return new (FragmentInstance: any)(fragmentFiber);
2357 +}
2358 +
2359 +export function updateFragmentInstanceFiber(
2360 + fragmentFiber: Fiber,
2361 + instance: FragmentInstanceType,
2362 +): void {
2363 + instance._fragmentFiber = fragmentFiber;
2364 +}
2365 +
2366 +export function commitNewChildToFragmentInstance(
2367 + childElement: Instance,
2368 + fragmentInstance: FragmentInstanceType,
2369 +): void {
2370 + const eventListeners = fragmentInstance._eventListeners;
2371 + if (eventListeners !== null) {
2372 + for (let i = 0; i < eventListeners.length; i++) {
2373 + const {type, listener, optionsOrUseCapture} = eventListeners[i];
2374 + childElement.addEventListener(type, listener, optionsOrUseCapture);
2375 + }
2376 + }
2377 +}
2378 +
2379 +export function deleteChildFromFragmentInstance(
2380 + childElement: Instance,
2381 + fragmentInstance: FragmentInstanceType,
2382 +): void {
2383 + const eventListeners = fragmentInstance._eventListeners;
2384 + if (eventListeners !== null) {
2385 + for (let i = 0; i < eventListeners.length; i++) {
2386 + const {type, listener, optionsOrUseCapture} = eventListeners[i];
2387 + childElement.removeEventListener(type, listener, optionsOrUseCapture);
2388 + }
2389 + }
2390 +}
2391 +
2392 export function clearContainer(container: Container): void {
2393 const nodeType = container.nodeType;
2394 if (nodeType === DOCUMENT_NODE) {
packages/react-dom/src/__tests__/ReactDOMFragmentRefs-test.js new
+620
@@ -0,0 +1,620 @@
1 +/**
2 + * Copyright (c) Meta Platforms, Inc. and affiliates.
3 + *
4 + * This source code is licensed under the MIT license found in the
5 + * LICENSE file in the root directory of this source tree.
6 + *
7 + * @emails reactcore
8 + */
9 +
10 +'use strict';
11 +
12 +let React;
13 +let ReactDOMClient;
14 +let act;
15 +let container;
16 +let Fragment;
17 +let Activity;
18 +
19 +describe('FragmentRefs', () => {
20 + beforeEach(() => {
21 + jest.resetModules();
22 + React = require('react');
23 + Fragment = React.Fragment;
24 + Activity = React.unstable_Activity;
25 + ReactDOMClient = require('react-dom/client');
26 + act = require('internal-test-utils').act;
27 + container = document.createElement('div');
28 + document.body.appendChild(container);
29 + });
30 +
31 + afterEach(() => {
32 + document.body.removeChild(container);
33 + });
34 +
35 + // @gate enableFragmentRefs
36 + it('attaches a ref to Fragment', async () => {
37 + const fragmentRef = React.createRef();
38 + const root = ReactDOMClient.createRoot(container);
39 +
40 + await act(() =>
41 + root.render(
42 + <div id="parent">
43 + <Fragment ref={fragmentRef}>
44 + <div id="child">Hi</div>
45 + </Fragment>
46 + </div>,
47 + ),
48 + );
49 + expect(container.innerHTML).toEqual(
50 + '<div id="parent"><div id="child">Hi</div></div>',
51 + );
52 +
53 + expect(fragmentRef.current).not.toBe(null);
54 + });
55 +
56 + // @gate enableFragmentRefs
57 + it('accepts a ref callback', async () => {
58 + let fragmentRef;
59 + const root = ReactDOMClient.createRoot(container);
60 +
61 + await act(() => {
62 + root.render(
63 + <Fragment ref={ref => (fragmentRef = ref)}>
64 + <div id="child">Hi</div>
65 + </Fragment>,
66 + );
67 + });
68 +
69 + expect(fragmentRef._fragmentFiber).toBeTruthy();
70 + });
71 +
72 + // @gate enableFragmentRefs
73 + it('is available in effects', async () => {
74 + function Test() {
75 + const fragmentRef = React.useRef(null);
76 + React.useLayoutEffect(() => {
77 + expect(fragmentRef.current).not.toBe(null);
78 + });
79 + React.useEffect(() => {
80 + expect(fragmentRef.current).not.toBe(null);
81 + });
82 + return (
83 + <Fragment ref={fragmentRef}>
84 + <div />
85 + </Fragment>
86 + );
87 + }
88 +
89 + const root = ReactDOMClient.createRoot(container);
90 + await act(() => root.render(<Test />));
91 + });
92 +
93 + describe('focus()', () => {
94 + // @gate enableFragmentRefs
95 + it('focuses the first focusable child', async () => {
96 + const fragmentRef = React.createRef();
97 + const root = ReactDOMClient.createRoot(container);
98 +
99 + function Test() {
100 + return (
101 + <div>
102 + <Fragment ref={fragmentRef}>
103 + <div id="child-a" />
104 + <style>{`#child-c {}`}</style>
105 + <a id="child-b" href="/">
106 + B
107 + </a>
108 + <a id="child-c" href="/">
109 + C
110 + </a>
111 + </Fragment>
112 + </div>
113 + );
114 + }
115 +
116 + await act(() => {
117 + root.render(<Test />);
118 + });
119 +
120 + await act(() => {
121 + fragmentRef.current.focus();
122 + });
123 + expect(document.activeElement.id).toEqual('child-b');
124 + document.activeElement.blur();
125 + });
126 +
127 + // @gate enableFragmentRefs
128 + it('preserves document order when adding and removing children', async () => {
129 + const fragmentRef = React.createRef();
130 + const root = ReactDOMClient.createRoot(container);
131 +
132 + function Test({showA, showB}) {
133 + return (
134 + <Fragment ref={fragmentRef}>
135 + {showA && <a href="/" id="child-a" />}
136 + {showB && <a href="/" id="child-b" />}
137 + </Fragment>
138 + );
139 + }
140 +
141 + // Render with A as the first focusable child
142 + await act(() => {
143 + root.render(<Test showA={true} showB={false} />);
144 + });
145 + await act(() => {
146 + fragmentRef.current.focus();
147 + });
148 + expect(document.activeElement.id).toEqual('child-a');
149 + document.activeElement.blur();
150 + // A is still the first focusable child, but B is also tracked
151 + await act(() => {
152 + root.render(<Test showA={true} showB={true} />);
153 + });
154 + await act(() => {
155 + fragmentRef.current.focus();
156 + });
157 + expect(document.activeElement.id).toEqual('child-a');
158 + document.activeElement.blur();
159 +
160 + // B is now the first focusable child
161 + await act(() => {
162 + root.render(<Test showA={false} showB={true} />);
163 + });
164 + await act(() => {
165 + fragmentRef.current.focus();
166 + });
167 + expect(document.activeElement.id).toEqual('child-b');
168 + document.activeElement.blur();
169 + });
170 + });
171 +
172 + describe('event listeners', () => {
173 + // @gate enableFragmentRefs
174 + it('adds and removes event listeners from children', async () => {
175 + const parentRef = React.createRef();
176 + const fragmentRef = React.createRef();
177 + const childARef = React.createRef();
178 + const childBRef = React.createRef();
179 + const root = ReactDOMClient.createRoot(container);
180 +
181 + let logs = [];
182 +
183 + function handleFragmentRefClicks() {
184 + logs.push('fragmentRef');
185 + }
186 +
187 + function Test() {
188 + React.useEffect(() => {
189 + fragmentRef.current.addEventListener(
190 + 'click',
191 + handleFragmentRefClicks,
192 + );
193 +
194 + return () => {
195 + fragmentRef.current.removeEventListener(
196 + 'click',
197 + handleFragmentRefClicks,
198 + );
199 + };
200 + }, []);
201 + return (
202 + <div ref={parentRef}>
203 + <Fragment ref={fragmentRef}>
204 + <>Text</>
205 + <div ref={childARef}>A</div>
206 + <>
207 + <div ref={childBRef}>B</div>
208 + </>
209 + </Fragment>
210 + </div>
211 + );
212 + }
213 +
214 + await act(() => {
215 + root.render(<Test />);
216 + });
217 +
218 + childARef.current.addEventListener('click', () => {
219 + logs.push('A');
220 + });
221 +
222 + childBRef.current.addEventListener('click', () => {
223 + logs.push('B');
224 + });
225 +
226 + // Clicking on the parent should not trigger any listeners
227 + parentRef.current.click();
228 + expect(logs).toEqual([]);
229 +
230 + // Clicking a child triggers its own listeners and the Fragment's
231 + childARef.current.click();
232 + expect(logs).toEqual(['fragmentRef', 'A']);
233 +
234 + logs = [];
235 +
236 + childBRef.current.click();
237 + expect(logs).toEqual(['fragmentRef', 'B']);
238 +
239 + logs = [];
240 +
241 + fragmentRef.current.removeEventListener('click', handleFragmentRefClicks);
242 +
243 + childARef.current.click();
244 + expect(logs).toEqual(['A']);
245 +
246 + logs = [];
247 +
248 + childBRef.current.click();
249 + expect(logs).toEqual(['B']);
250 + });
251 +
252 + // @gate enableFragmentRefs
253 + it('adds and removes event listeners from children with multiple fragments', async () => {
254 + const fragmentRef = React.createRef();
255 + const nestedFragmentRef = React.createRef();
256 + const nestedFragmentRef2 = React.createRef();
257 + const childARef = React.createRef();
258 + const childBRef = React.createRef();
259 + const childCRef = React.createRef();
260 + const root = ReactDOMClient.createRoot(container);
261 +
262 + await act(() => {
263 + root.render(
264 + <div>
265 + <Fragment ref={fragmentRef}>
266 + <div ref={childARef}>A</div>
267 + <div>
268 + <Fragment ref={nestedFragmentRef}>
269 + <div ref={childBRef}>B</div>
270 + </Fragment>
271 + </div>
272 + <Fragment ref={nestedFragmentRef2}>
273 + <div ref={childCRef}>C</div>
274 + </Fragment>
275 + </Fragment>
276 + </div>,
277 + );
278 + });
279 +
280 + let logs = [];
281 +
282 + function handleFragmentRefClicks() {
283 + logs.push('fragmentRef');
284 + }
285 +
286 + function handleNestedFragmentRefClicks() {
287 + logs.push('nestedFragmentRef');
288 + }
289 +
290 + function handleNestedFragmentRef2Clicks() {
291 + logs.push('nestedFragmentRef2');
292 + }
293 +
294 + fragmentRef.current.addEventListener('click', handleFragmentRefClicks);
295 + nestedFragmentRef.current.addEventListener(
296 + 'click',
297 + handleNestedFragmentRefClicks,
298 + );
299 + nestedFragmentRef2.current.addEventListener(
300 + 'click',
301 + handleNestedFragmentRef2Clicks,
302 + );
303 +
304 + childBRef.current.click();
305 + // Event bubbles to the parent fragment
306 + expect(logs).toEqual(['nestedFragmentRef', 'fragmentRef']);
307 +
308 + logs = [];
309 +
310 + childARef.current.click();
311 + expect(logs).toEqual(['fragmentRef']);
312 +
313 + logs = [];
314 + childCRef.current.click();
315 + expect(logs).toEqual(['fragmentRef', 'nestedFragmentRef2']);
316 +
317 + logs = [];
318 +
319 + fragmentRef.current.removeEventListener('click', handleFragmentRefClicks);
320 + nestedFragmentRef.current.removeEventListener(
321 + 'click',
322 + handleNestedFragmentRefClicks,
323 + );
324 + childCRef.current.click();
325 + expect(logs).toEqual(['nestedFragmentRef2']);
326 + });
327 +
328 + // @gate enableFragmentRefs
329 + it('adds an event listener to a newly added child', async () => {
330 + const fragmentRef = React.createRef();
331 + const childRef = React.createRef();
332 + const root = ReactDOMClient.createRoot(container);
333 + let showChild;
334 +
335 + function Component() {
336 + const [shouldShowChild, setShouldShowChild] = React.useState(false);
337 + showChild = () => {
338 + setShouldShowChild(true);
339 + };
340 +
341 + return (
342 + <div>
343 + <Fragment ref={fragmentRef}>
344 + <div id="a">A</div>
345 + {shouldShowChild && (
346 + <div ref={childRef} id="b">
347 + B
348 + </div>
349 + )}
350 + </Fragment>
351 + </div>
352 + );
353 + }
354 +
355 + await act(() => {
356 + root.render(<Component />);
357 + });
358 +
359 + expect(fragmentRef.current).not.toBe(null);
360 + expect(childRef.current).toBe(null);
361 +
362 + let hasClicked = false;
363 + fragmentRef.current.addEventListener('click', () => {
364 + hasClicked = true;
365 + });
366 +
367 + await act(() => {
368 + showChild();
369 + });
370 + expect(childRef.current).not.toBe(null);
371 +
372 + childRef.current.click();
373 + expect(hasClicked).toBe(true);
374 + });
375 +
376 + // @gate enableFragmentRefs
377 + it('applies event listeners to host children nested within non-host children', async () => {
378 + const fragmentRef = React.createRef();
379 + const childRef = React.createRef();
380 + const nestedChildRef = React.createRef();
381 + const root = ReactDOMClient.createRoot(container);
382 +
383 + function Wrapper({children}) {
384 + return children;
385 + }
386 +
387 + await act(() => {
388 + root.render(
389 + <div>
390 + <Fragment ref={fragmentRef}>
391 + <div ref={childRef}>Host A</div>
392 + <Wrapper>
393 + <Wrapper>
394 + <Wrapper>
395 + <div ref={nestedChildRef}>Host B</div>
396 + </Wrapper>
397 + </Wrapper>
398 + </Wrapper>
399 + </Fragment>
400 + </div>,
401 + );
402 + });
403 + const logs = [];
404 + fragmentRef.current.addEventListener('click', e => {
405 + logs.push(e.target.textContent);
406 + });
407 +
408 + expect(logs).toEqual([]);
409 + childRef.current.click();
410 + expect(logs).toEqual(['Host A']);
411 + nestedChildRef.current.click();
412 + expect(logs).toEqual(['Host A', 'Host B']);
413 + });
414 +
415 + // @gate enableFragmentRefs
416 + it('allows adding and cleaning up listeners in effects', async () => {
417 + const root = ReactDOMClient.createRoot(container);
418 +
419 + let logs = [];
420 + function logClick(e) {
421 + logs.push(e.currentTarget.id);
422 + }
423 +
424 + let rerender;
425 + let removeEventListeners;
426 +
427 + function Test() {
428 + const fragmentRef = React.useRef(null);
429 + // eslint-disable-next-line no-unused-vars
430 + const [_, setState] = React.useState(0);
431 + rerender = () => {
432 + setState(p => p + 1);
433 + };
434 + removeEventListeners = () => {
435 + fragmentRef.current.removeEventListener('click', logClick);
436 + };
437 + React.useEffect(() => {
438 + fragmentRef.current.addEventListener('click', logClick);
439 +
440 + return removeEventListeners;
441 + });
442 +
443 + return (
444 + <Fragment ref={fragmentRef}>
445 + <div id="child-a" />
446 + </Fragment>
447 + );
448 + }
449 +
450 + // The event listener was applied
451 + await act(() => root.render(<Test />));
452 + expect(logs).toEqual([]);
453 + document.querySelector('#child-a').click();
454 + expect(logs).toEqual(['child-a']);
455 +
456 + // The event listener can be removed and re-added
457 + logs = [];
458 + await act(rerender);
459 + document.querySelector('#child-a').click();
460 + expect(logs).toEqual(['child-a']);
461 + });
462 +
463 + // @gate enableFragmentRefs
464 + it('does not apply removed event listeners to new children', async () => {
465 + const root = ReactDOMClient.createRoot(container);
466 + const fragmentRef = React.createRef(null);
467 + function Test() {
468 + return (
469 + <Fragment ref={fragmentRef}>
470 + <div id="child-a" />
471 + </Fragment>
472 + );
473 + }
474 +
475 + let logs = [];
476 + function logClick(e) {
477 + logs.push(e.currentTarget.id);
478 + }
479 + await act(() => {
480 + root.render(<Test />);
481 + });
482 + fragmentRef.current.addEventListener('click', logClick);
483 + const childA = document.querySelector('#child-a');
484 + childA.click();
485 + expect(logs).toEqual(['child-a']);
486 +
487 + logs = [];
488 + fragmentRef.current.removeEventListener('click', logClick);
489 + childA.click();
490 + expect(logs).toEqual([]);
491 + });
492 +
493 + describe('with activity', () => {
494 + // @gate enableFragmentRefs && enableActivity
495 + it('does not apply event listeners to hidden trees', async () => {
496 + const parentRef = React.createRef();
497 + const fragmentRef = React.createRef();
498 + const root = ReactDOMClient.createRoot(container);
499 +
500 + function Test() {
501 + return (
502 + <div ref={parentRef}>
503 + <Fragment ref={fragmentRef}>
504 + <div>Child 1</div>
505 + <Activity mode="hidden">
506 + <div>Child 2</div>
507 + </Activity>
508 + <div>Child 3</div>
509 + </Fragment>
510 + </div>
511 + );
512 + }
513 +
514 + await act(() => {
515 + root.render(<Test />);
516 + });
517 +
518 + const logs = [];
519 + fragmentRef.current.addEventListener('click', e => {
520 + logs.push(e.target.textContent);
521 + });
522 +
523 + const [child1, child2, child3] = parentRef.current.children;
524 + child1.click();
525 + child2.click();
526 + child3.click();
527 + expect(logs).toEqual(['Child 1', 'Child 3']);
528 + });
529 +
530 + // @gate enableFragmentRefs && enableActivity
531 + it('applies event listeners to visible trees', async () => {
532 + const parentRef = React.createRef();
533 + const fragmentRef = React.createRef();
534 + const root = ReactDOMClient.createRoot(container);
535 +
536 + function Test() {
537 + return (
538 + <div ref={parentRef}>
539 + <Fragment ref={fragmentRef}>
540 + <div>Child 1</div>
541 + <Activity mode="visible">
542 + <div>Child 2</div>
543 + </Activity>
544 + <div>Child 3</div>
545 + </Fragment>
546 + </div>
547 + );
548 + }
549 +
550 + await act(() => {
551 + root.render(<Test />);
552 + });
553 +
554 + const logs = [];
555 + fragmentRef.current.addEventListener('click', e => {
556 + logs.push(e.target.textContent);
557 + });
558 +
559 + const [child1, child2, child3] = parentRef.current.children;
560 + child1.click();
561 + child2.click();
562 + child3.click();
563 + expect(logs).toEqual(['Child 1', 'Child 2', 'Child 3']);
564 + });
565 +
566 + // @gate enableFragmentRefs && enableActivity
567 + it('handles Activity modes switching', async () => {
568 + const fragmentRef = React.createRef();
569 + const fragmentRef2 = React.createRef();
570 + const parentRef = React.createRef();
571 + const root = ReactDOMClient.createRoot(container);
572 +
573 + function Test({mode}) {
574 + return (
575 + <div id="parent" ref={parentRef}>
576 + <Fragment ref={fragmentRef}>
577 + <Activity mode={mode}>
578 + <div id="child1">Child</div>
579 + <Fragment ref={fragmentRef2}>
580 + <div id="child2">Child 2</div>
581 + </Fragment>
582 + </Activity>
583 + </Fragment>
584 + </div>
585 + );
586 + }
587 +
588 + await act(() => {
589 + root.render(<Test mode="visible" />);
590 + });
591 +
592 + let logs = [];
593 + fragmentRef.current.addEventListener('click', () => {
594 + logs.push('clicked 1');
595 + });
596 + fragmentRef2.current.addEventListener('click', () => {
597 + logs.push('clicked 2');
598 + });
599 + parentRef.current.lastChild.click();
600 + expect(logs).toEqual(['clicked 1', 'clicked 2']);
601 +
602 + logs = [];
603 + await act(() => {
604 + root.render(<Test mode="hidden" />);
605 + });
606 + parentRef.current.firstChild.click();
607 + parentRef.current.lastChild.click();
608 + expect(logs).toEqual([]);
609 +
610 + logs = [];
611 + await act(() => {
612 + root.render(<Test mode="visible" />);
613 + });
614 + parentRef.current.lastChild.click();
615 + // Event order is flipped here because the nested child re-registers first
616 + expect(logs).toEqual(['clicked 2', 'clicked 1']);
617 + });
618 + });
619 + });
620 +});
packages/react-native-renderer/src/ReactFiberConfigFabric.js
+29
@@ -591,6 +591,35 @@ export function waitForCommitToBeReady(): null {
591 return null;
592 }
593
594 +export type FragmentInstanceType = null;
595 +
596 +export function createFragmentInstance(
597 + fragmentFiber: Fiber,
598 +): FragmentInstanceType {
599 + return null;
600 +}
601 +
602 +export function updateFragmentInstanceFiber(
603 + fragmentFiber: Fiber,
604 + instance: FragmentInstanceType,
605 +): void {
606 + // Noop
607 +}
608 +
609 +export function commitNewChildToFragmentInstance(
610 + child: PublicInstance,
611 + fragmentInstance: FragmentInstanceType,
612 +): void {
613 + // Noop
614 +}
615 +
616 +export function deleteChildFromFragmentInstance(
617 + child: PublicInstance,
618 + fragmentInstance: FragmentInstanceType,
619 +): void {
620 + // Noop
621 +}
622 +
623 export const NotPendingTransition: TransitionStatus = null;
624 export const HostTransitionContext: ReactContext<TransitionStatus> = {
625 $$typeof: REACT_CONTEXT_TYPE,
packages/react-native-renderer/src/ReactFiberConfigNative.js
+29
@@ -202,6 +202,35 @@ export function cloneMutableTextInstance(
202 throw new Error('Not yet implemented.');
203 }
204
205 +export type FragmentInstanceType = null;
206 +
207 +export function createFragmentInstance(
208 + fragmentFiber: Fiber,
209 +): FragmentInstanceType {
210 + return null;
211 +}
212 +
213 +export function updateFragmentInstanceFiber(
214 + fragmentFiber: Fiber,
215 + instance: FragmentInstanceType,
216 +): void {
217 + // Noop
218 +}
219 +
220 +export function commitNewChildToFragmentInstance(
221 + child: PublicInstance,
222 + fragmentInstance: FragmentInstanceType,
223 +): void {
224 + // Noop
225 +}
226 +
227 +export function deleteChildFromFragmentInstance(
228 + child: PublicInstance,
229 + fragmentInstance: FragmentInstanceType,
230 +): void {
231 + // Noop
232 +}
233 +
234 export function finalizeInitialChildren(
235 parentInstance: Instance,
236 type: string,
packages/react-noop-renderer/src/createReactNoop.js
+12
@@ -512,6 +512,18 @@ function createReactNoop(reconciler: Function, useMutation: boolean) {
512 throw new Error('Not yet implemented.');
513 },
514
515 + createFragmentInstance(parentInstance) {
516 + return null;
517 + },
518 +
519 + commitNewChildToFragmentInstance(child, fragmentInstance) {
520 + // Noop
521 + },
522 +
523 + deleteChildFromFragmentInstance(child, fragmentInstance) {
524 + // Noop
525 + },
526 +
527 scheduleTimeout: setTimeout,
528 cancelTimeout: clearTimeout,
529 noTimeout: -1,
packages/react-reconciler/src/ReactChildFiber.js
+37 -13
@@ -47,6 +47,7 @@ import isArray from 'shared/isArray';
47 import {
48 enableAsyncIterableChildren,
49 disableLegacyMode,
50 + enableFragmentRefs,
51 } from 'shared/ReactFeatureFlags';
52
53 import {
@@ -214,10 +215,14 @@ function validateFragmentProps(
215 const keys = Object.keys(element.props);
216 for (let i = 0; i < keys.length; i++) {
217 const key = keys[i];
217 - if (key !== 'children' && key !== 'key') {
218 + if (
219 + key !== 'children' &&
220 + key !== 'key' &&
221 + (enableFragmentRefs ? key !== 'ref' : true)
222 + ) {
223 if (fiber === null) {
219 - // For unkeyed root fragments there's no Fiber. We create a fake one just for
220 - // error stack handling.
224 + // For unkeyed root fragments without refs (enableFragmentRefs),
225 + // there's no Fiber. We create a fake one just for error stack handling.
226 fiber = createFiberFromElement(element, returnFiber.mode, 0);
227 if (__DEV__) {
228 fiber._debugInfo = currentDebugInfo;
@@ -227,11 +232,19 @@ function validateFragmentProps(
232 runWithFiberInDEV(
233 fiber,
234 erroredKey => {
230 - console.error(
231 - 'Invalid prop `%s` supplied to `React.Fragment`. ' +
232 - 'React.Fragment can only have `key` and `children` props.',
233 - erroredKey,
234 - );
235 + if (enableFragmentRefs) {
236 + console.error(
237 + 'Invalid prop `%s` supplied to `React.Fragment`. ' +
238 + 'React.Fragment can only have `key`, `ref`, and `children` props.',
239 + erroredKey,
240 + );
241 + } else {
242 + console.error(
243 + 'Invalid prop `%s` supplied to `React.Fragment`. ' +
244 + 'React.Fragment can only have `key` and `children` props.',
245 + erroredKey,
246 + );
247 + }
248 },
249 key,
250 );
@@ -517,6 +530,9 @@ function createChildReconciler(
530 lanes,
531 element.key,
532 );
533 + if (enableFragmentRefs) {
534 + coerceRef(updated, element);
535 + }
536 validateFragmentProps(element, updated, returnFiber);
537 return updated;
538 }
@@ -1619,6 +1635,9 @@ function createChildReconciler(
1635 if (child.tag === Fragment) {
1636 deleteRemainingChildren(returnFiber, child.sibling);
1637 const existing = useFiber(child, element.props.children);
1638 + if (enableFragmentRefs) {
1639 + coerceRef(existing, element);
1640 + }
1641 existing.return = returnFiber;
1642 if (__DEV__) {
1643 existing._debugOwner = element._owner;
@@ -1670,6 +1689,9 @@ function createChildReconciler(
1689 lanes,
1690 element.key,
1691 );
1692 + if (enableFragmentRefs) {
1693 + coerceRef(created, element);
1694 + }
1695 created.return = returnFiber;
1696 if (__DEV__) {
1697 // We treat the parent as the owner for stack purposes.
@@ -1742,17 +1764,19 @@ function createChildReconciler(
1764 // not as a fragment. Nested arrays on the other hand will be treated as
1765 // fragment nodes. Recursion happens at the normal flow.
1766
1745 - // Handle top level unkeyed fragments as if they were arrays.
1746 - // This leads to an ambiguity between <>{[...]}</> and <>...</>.
1767 + // Handle top level unkeyed fragments without refs (enableFragmentRefs)
1768 + // as if they were arrays. This leads to an ambiguity between <>{[...]}</> and <>...</>.
1769 // We treat the ambiguous cases above the same.
1770 // We don't use recursion here because a fragment inside a fragment
1771 // is no longer considered "top level" for these purposes.
1750 - const isUnkeyedTopLevelFragment =
1772 + const isUnkeyedUnrefedTopLevelFragment =
1773 typeof newChild === 'object' &&
1774 newChild !== null &&
1775 newChild.type === REACT_FRAGMENT_TYPE &&
1754 - newChild.key === null;
1755 - if (isUnkeyedTopLevelFragment) {
1776 + newChild.key === null &&
1777 + (enableFragmentRefs ? newChild.props.ref === undefined : true);
1778 +
1779 + if (isUnkeyedUnrefedTopLevelFragment) {
1780 validateFragmentProps(newChild, null, returnFiber);
1781 newChild = newChild.props.children;
1782 }
packages/react-reconciler/src/ReactFiberBeginWork.js
+4
@@ -116,6 +116,7 @@ import {
116 disableDefaultPropsExceptForClasses,
117 enableHydrationLaneScheduling,
118 enableViewTransition,
119 + enableFragmentRefs,
120 } from 'shared/ReactFeatureFlags';
121 import isArray from 'shared/isArray';
122 import shallowEqual from 'shared/shallowEqual';
@@ -987,6 +988,9 @@ function updateFragment(
988 renderLanes: Lanes,
989 ) {
990 const nextChildren = workInProgress.pendingProps;
991 + if (enableFragmentRefs) {
992 + markRef(current, workInProgress);
993 + }
994 reconcileChildren(current, workInProgress, nextChildren, renderLanes);
995 return workInProgress.child;
996 }
packages/react-reconciler/src/ReactFiberCommitEffects.js
+17 -1
@@ -11,6 +11,7 @@ import type {Fiber} from './ReactInternalTypes';
11 import type {UpdateQueue} from './ReactFiberClassUpdateQueue';
12 import type {FunctionComponentUpdateQueue} from './ReactFiberHooks';
13 import type {HookFlags} from './ReactHookEffectTags';
14 +import type {FragmentInstanceType} from './ReactFiberConfig';
15 import {
16 getViewTransitionName,
17 type ViewTransitionState,
@@ -24,9 +25,11 @@ import {
25 enableSchedulingProfiler,
26 enableUseEffectCRUDOverload,
27 enableViewTransition,
28 + enableFragmentRefs,
29 } from 'shared/ReactFeatureFlags';
30 import {
31 ClassComponent,
32 + Fragment,
33 HostComponent,
34 HostHoistable,
35 HostSingleton,
@@ -48,6 +51,7 @@ import {
51 import {
52 getPublicInstance,
53 createViewTransitionInstance,
54 + createFragmentInstance,
55 } from './ReactFiberConfig';
56 import {
57 captureCommitPhaseError,
@@ -877,7 +881,7 @@ function commitAttachRef(finishedWork: Fiber) {
881 case HostComponent:
882 instanceToUse = getPublicInstance(finishedWork.stateNode);
883 break;
880 - case ViewTransitionComponent:
884 + case ViewTransitionComponent: {
885 if (enableViewTransition) {
886 const instance: ViewTransitionState = finishedWork.stateNode;
887 const props: ViewTransitionProps = finishedWork.memoizedProps;
@@ -888,6 +892,18 @@ function commitAttachRef(finishedWork: Fiber) {
892 instanceToUse = instance.ref;
893 break;
894 }
895 + instanceToUse = finishedWork.stateNode;
896 + break;
897 + }
898 + case Fragment:
899 + if (enableFragmentRefs) {
900 + const instance: null | FragmentInstanceType = finishedWork.stateNode;
901 + if (instance === null) {
902 + finishedWork.stateNode = createFragmentInstance(finishedWork);
903 + }
904 + instanceToUse = finishedWork.stateNode;
905 + break;
906 + }
907 // Fallthrough
908 default:
909 instanceToUse = finishedWork.stateNode;
packages/react-reconciler/src/ReactFiberCommitHostEffects.js
+131 -20
@@ -13,6 +13,7 @@ import type {
13 SuspenseInstance,
14 Container,
15 ChildSet,
16 + FragmentInstanceType,
17 } from './ReactFiberConfig';
18 import type {Fiber, FiberRoot} from './ReactInternalTypes';
19
@@ -24,6 +25,7 @@ import {
25 HostText,
26 HostPortal,
27 DehydratedFragment,
28 + Fragment,
29 } from './ReactWorkTags';
30 import {ContentReset, Placement} from './ReactFiberFlags';
31 import {
@@ -50,11 +52,14 @@ import {
52 acquireSingletonInstance,
53 releaseSingletonInstance,
54 isSingletonScope,
55 + commitNewChildToFragmentInstance,
56 + deleteChildFromFragmentInstance,
57 } from './ReactFiberConfig';
58 import {captureCommitPhaseError} from './ReactFiberWorkLoop';
59 import {trackHostMutation} from './ReactFiberMutationTracking';
60
61 import {runWithFiberInDEV} from './ReactCurrentFiber';
62 +import {enableFragmentRefs} from 'shared/ReactFeatureFlags';
63
64 export function commitHostMount(finishedWork: Fiber) {
65 const type = finishedWork.type;
@@ -199,19 +204,46 @@ export function commitShowHideHostTextInstance(node: Fiber, isHidden: boolean) {
204 }
205 }
206
202 -function getHostParentFiber(fiber: Fiber): Fiber {
207 +export function commitNewChildToFragmentInstances(
208 + fiber: Fiber,
209 + parentFragmentInstances: Array<FragmentInstanceType>,
210 +): void {
211 + for (let i = 0; i < parentFragmentInstances.length; i++) {
212 + const fragmentInstance = parentFragmentInstances[i];
213 + commitNewChildToFragmentInstance(fiber.stateNode, fragmentInstance);
214 + }
215 +}
216 +
217 +export function commitFragmentInstanceInsertionEffects(fiber: Fiber): void {
218 let parent = fiber.return;
219 while (parent !== null) {
220 + if (isFragmentInstanceParent(parent)) {
221 + const fragmentInstance: FragmentInstanceType = parent.stateNode;
222 + commitNewChildToFragmentInstance(fiber.stateNode, fragmentInstance);
223 + }
224 +
225 if (isHostParent(parent)) {
206 - return parent;
226 + return;
227 }
228 +
229 parent = parent.return;
230 }
231 +}
232
211 - throw new Error(
212 - 'Expected to find a host parent. This error is likely caused by a bug ' +
213 - 'in React. Please file an issue.',
214 - );
233 +export function commitFragmentInstanceDeletionEffects(fiber: Fiber): void {
234 + let parent = fiber.return;
235 + while (parent !== null) {
236 + if (isFragmentInstanceParent(parent)) {
237 + const fragmentInstance: FragmentInstanceType = parent.stateNode;
238 + deleteChildFromFragmentInstance(fiber.stateNode, fragmentInstance);
239 + }
240 +
241 + if (isHostParent(parent)) {
242 + return;
243 + }
244 +
245 + parent = parent.return;
246 + }
247 }
248
249 function isHostParent(fiber: Fiber): boolean {
@@ -226,6 +258,10 @@ function isHostParent(fiber: Fiber): boolean {
258 );
259 }
260
261 +function isFragmentInstanceParent(fiber: Fiber): boolean {
262 + return fiber && fiber.tag === Fragment && fiber.stateNode !== null;
263 +}
264 +
265 function getHostSibling(fiber: Fiber): ?Instance {
266 // We're going to search forward into the tree until we find a sibling host
267 // node. Unfortunately, if multiple insertions are done in a row we have to
@@ -288,6 +324,7 @@ function insertOrAppendPlacementNodeIntoContainer(
324 node: Fiber,
325 before: ?Instance,
326 parent: Container,
327 + parentFragmentInstances: null | Array<FragmentInstanceType>,
328 ): void {
329 const {tag} = node;
330 const isHost = tag === HostComponent || tag === HostText;
@@ -298,6 +335,16 @@ function insertOrAppendPlacementNodeIntoContainer(
335 } else {
336 appendChildToContainer(parent, stateNode);
337 }
338 + // TODO: Enable HostText for RN
339 + if (
340 + enableFragmentRefs &&
341 + tag === HostComponent &&
342 + // Only run fragment insertion effects for initial insertions
343 + node.alternate === null &&
344 + parentFragmentInstances !== null
345 + ) {
346 + commitNewChildToFragmentInstances(node, parentFragmentInstances);
347 + }
348 trackHostMutation();
349 return;
350 } else if (tag === HostPortal) {
@@ -319,10 +366,20 @@ function insertOrAppendPlacementNodeIntoContainer(
366
367 const child = node.child;
368 if (child !== null) {
322 - insertOrAppendPlacementNodeIntoContainer(child, before, parent);
369 + insertOrAppendPlacementNodeIntoContainer(
370 + child,
371 + before,
372 + parent,
373 + parentFragmentInstances,
374 + );
375 let sibling = child.sibling;
376 while (sibling !== null) {
325 - insertOrAppendPlacementNodeIntoContainer(sibling, before, parent);
377 + insertOrAppendPlacementNodeIntoContainer(
378 + sibling,
379 + before,
380 + parent,
381 + parentFragmentInstances,
382 + );
383 sibling = sibling.sibling;
384 }
385 }
@@ -332,6 +389,7 @@ function insertOrAppendPlacementNode(
389 node: Fiber,
390 before: ?Instance,
391 parent: Instance,
392 + parentFragmentInstances: null | Array<FragmentInstanceType>,
393 ): void {
394 const {tag} = node;
395 const isHost = tag === HostComponent || tag === HostText;
@@ -342,6 +400,16 @@ function insertOrAppendPlacementNode(
400 } else {
401 appendChild(parent, stateNode);
402 }
403 + // TODO: Enable HostText for RN
404 + if (
405 + enableFragmentRefs &&
406 + tag === HostComponent &&
407 + // Only run fragment insertion effects for initial insertions
408 + node.alternate === null &&
409 + parentFragmentInstances !== null
410 + ) {
411 + commitNewChildToFragmentInstances(node, parentFragmentInstances);
412 + }
413 trackHostMutation();
414 return;
415 } else if (tag === HostPortal) {
@@ -362,10 +430,15 @@ function insertOrAppendPlacementNode(
430
431 const child = node.child;
432 if (child !== null) {
365 - insertOrAppendPlacementNode(child, before, parent);
433 + insertOrAppendPlacementNode(child, before, parent, parentFragmentInstances);
434 let sibling = child.sibling;
435 while (sibling !== null) {
368 - insertOrAppendPlacementNode(sibling, before, parent);
436 + insertOrAppendPlacementNode(
437 + sibling,
438 + before,
439 + parent,
440 + parentFragmentInstances,
441 + );
442 sibling = sibling.sibling;
443 }
444 }
@@ -377,40 +450,78 @@ function commitPlacement(finishedWork: Fiber): void {
450 }
451
452 // Recursively insert all host nodes into the parent.
380 - const parentFiber = getHostParentFiber(finishedWork);
453 + let hostParentFiber;
454 + let parentFragmentInstances = null;
455 + let parentFiber = finishedWork.return;
456 + while (parentFiber !== null) {
457 + if (enableFragmentRefs && isFragmentInstanceParent(parentFiber)) {
458 + const fragmentInstance: FragmentInstanceType = parentFiber.stateNode;
459 + if (parentFragmentInstances === null) {
460 + parentFragmentInstances = [fragmentInstance];
461 + } else {
462 + parentFragmentInstances.push(fragmentInstance);
463 + }
464 + }
465 + if (isHostParent(parentFiber)) {
466 + hostParentFiber = parentFiber;
467 + break;
468 + }
469 + parentFiber = parentFiber.return;
470 + }
471 + if (hostParentFiber == null) {
472 + throw new Error(
473 + 'Expected to find a host parent. This error is likely caused by a bug ' +
474 + 'in React. Please file an issue.',
475 + );
476 + }
477
382 - switch (parentFiber.tag) {
478 + switch (hostParentFiber.tag) {
479 case HostSingleton: {
480 if (supportsSingletons) {
385 - const parent: Instance = parentFiber.stateNode;
481 + const parent: Instance = hostParentFiber.stateNode;
482 const before = getHostSibling(finishedWork);
483 // We only have the top Fiber that was inserted but we need to recurse down its
484 // children to find all the terminal nodes.
389 - insertOrAppendPlacementNode(finishedWork, before, parent);
485 + insertOrAppendPlacementNode(
486 + finishedWork,
487 + before,
488 + parent,
489 + parentFragmentInstances,
490 + );
491 break;
492 }
493 // Fall through
494 }
495 case HostComponent: {
395 - const parent: Instance = parentFiber.stateNode;
396 - if (parentFiber.flags & ContentReset) {
496 + const parent: Instance = hostParentFiber.stateNode;
497 + if (hostParentFiber.flags & ContentReset) {
498 // Reset the text content of the parent before doing any insertions
499 resetTextContent(parent);
500 // Clear ContentReset from the effect tag
400 - parentFiber.flags &= ~ContentReset;
501 + hostParentFiber.flags &= ~ContentReset;
502 }
503
504 const before = getHostSibling(finishedWork);
505 // We only have the top Fiber that was inserted but we need to recurse down its
506 // children to find all the terminal nodes.
406 - insertOrAppendPlacementNode(finishedWork, before, parent);
507 + insertOrAppendPlacementNode(
508 + finishedWork,
509 + before,
510 + parent,
511 + parentFragmentInstances,
512 + );
513 break;
514 }
515 case HostRoot:
516 case HostPortal: {
411 - const parent: Container = parentFiber.stateNode.containerInfo;
517 + const parent: Container = hostParentFiber.stateNode.containerInfo;
518 const before = getHostSibling(finishedWork);
413 - insertOrAppendPlacementNodeIntoContainer(finishedWork, before, parent);
519 + insertOrAppendPlacementNodeIntoContainer(
520 + finishedWork,
521 + before,
522 + parent,
523 + parentFragmentInstances,
524 + );
525 break;
526 }
527 default:
packages/react-reconciler/src/ReactFiberCommitWork.js
+56 -3
@@ -61,6 +61,7 @@ import {
61 disableLegacyMode,
62 enableComponentPerformanceTrack,
63 enableViewTransition,
64 + enableFragmentRefs,
65 } from 'shared/ReactFeatureFlags';
66 import {
67 FunctionComponent,
@@ -85,6 +86,7 @@ import {
86 CacheComponent,
87 TracingMarkerComponent,
88 ViewTransitionComponent,
89 + Fragment,
90 } from './ReactWorkTags';
91 import {
92 NoFlags,
@@ -164,6 +166,7 @@ import {
166 cancelRootViewTransitionName,
167 restoreRootViewTransitionName,
168 isSingletonScope,
169 + updateFragmentInstanceFiber,
170 } from './ReactFiberConfig';
171 import {
172 captureCommitPhaseError,
@@ -235,6 +238,8 @@ import {
238 commitHostRemoveChild,
239 commitHostSingletonAcquisition,
240 commitHostSingletonRelease,
241 + commitFragmentInstanceDeletionEffects,
242 + commitFragmentInstanceInsertionEffects,
243 } from './ReactFiberCommitHostEffects';
244 import {
245 commitEnterViewTransitions,
@@ -767,8 +772,15 @@ function commitLayoutEffectOnFiber(
772 }
773 break;
774 }
770 - // Fallthrough
775 + break;
776 }
777 + case Fragment:
778 + if (enableFragmentRefs) {
779 + if (flags & Ref) {
780 + safelyAttachRef(finishedWork, finishedWork.return);
781 + }
782 + }
783 + // Fallthrough
784 default: {
785 recursivelyTraverseLayoutEffects(
786 finishedRoot,
@@ -1353,6 +1365,9 @@ function commitDeletionEffectsOnFiber(
1365 if (!offscreenSubtreeWasHidden) {
1366 safelyDetachRef(deletedFiber, nearestMountedAncestor);
1367 }
1368 + if (enableFragmentRefs && deletedFiber.tag === HostComponent) {
1369 + commitFragmentInstanceDeletionEffects(deletedFiber);
1370 + }
1371 // Intentional fallthrough to next branch
1372 }
1373 case HostText: {
@@ -1563,6 +1578,14 @@ function commitDeletionEffectsOnFiber(
1578 }
1579 break;
1580 }
1581 + case Fragment: {
1582 + if (enableFragmentRefs) {
1583 + if (!offscreenSubtreeWasHidden) {
1584 + safelyDetachRef(deletedFiber, nearestMountedAncestor);
1585 + }
1586 + }
1587 + // Fallthrough
1588 + }
1589 default: {
1590 recursivelyTraverseDeletionEffects(
1591 finishedRoot,
@@ -1947,6 +1970,7 @@ function commitMutationEffectsOnFiber(
1970 }
1971 case HostComponent: {
1972 recursivelyTraverseMutationEffects(root, finishedWork, lanes);
1973 +
1974 commitReconciliationEffects(finishedWork, lanes);
1975
1976 if (flags & Ref) {
@@ -2270,7 +2294,7 @@ function commitMutationEffectsOnFiber(
2294 }
2295 break;
2296 }
2273 - case ViewTransitionComponent:
2297 + case ViewTransitionComponent: {
2298 if (enableViewTransition) {
2299 if (flags & Ref) {
2300 if (!offscreenSubtreeWasHidden && current !== null) {
@@ -2298,7 +2322,8 @@ function commitMutationEffectsOnFiber(
2322 popMutationContext(prevMutationContext);
2323 break;
2324 }
2301 - // Fallthrough
2325 + break;
2326 + }
2327 case ScopeComponent: {
2328 if (enableScopeAPI) {
2329 recursivelyTraverseMutationEffects(root, finishedWork, lanes);
@@ -2321,6 +2346,13 @@ function commitMutationEffectsOnFiber(
2346 }
2347 break;
2348 }
2349 + case Fragment:
2350 + if (enableFragmentRefs) {
2351 + if (current && current.stateNode !== null) {
2352 + updateFragmentInstanceFiber(finishedWork, current.stateNode);
2353 + }
2354 + }
2355 + // Fallthrough
2356 default: {
2357 recursivelyTraverseMutationEffects(root, finishedWork, lanes);
2358 commitReconciliationEffects(finishedWork, lanes);
@@ -2638,6 +2670,10 @@ export function disappearLayoutEffects(finishedWork: Fiber) {
2670 // TODO (Offscreen) Check: flags & RefStatic
2671 safelyDetachRef(finishedWork, finishedWork.return);
2672
2673 + if (enableFragmentRefs && finishedWork.tag === HostComponent) {
2674 + commitFragmentInstanceDeletionEffects(finishedWork);
2675 + }
2676 +
2677 recursivelyTraverseDisappearLayoutEffects(finishedWork);
2678 break;
2679 }
@@ -2658,6 +2694,13 @@ export function disappearLayoutEffects(finishedWork: Fiber) {
2694 if (enableViewTransition) {
2695 safelyDetachRef(finishedWork, finishedWork.return);
2696 }
2697 + recursivelyTraverseDisappearLayoutEffects(finishedWork);
2698 + break;
2699 + }
2700 + case Fragment: {
2701 + if (enableFragmentRefs) {
2702 + safelyDetachRef(finishedWork, finishedWork.return);
2703 + }
2704 // Fallthrough
2705 }
2706 default: {
@@ -2765,6 +2808,10 @@ export function reappearLayoutEffects(
2808 }
2809 case HostHoistable:
2810 case HostComponent: {
2811 + // TODO: Enable HostText for RN
2812 + if (enableFragmentRefs && finishedWork.tag === HostComponent) {
2813 + commitFragmentInstanceInsertionEffects(finishedWork);
2814 + }
2815 recursivelyTraverseReappearLayoutEffects(
2816 finishedRoot,
2817 finishedWork,
@@ -2857,6 +2904,12 @@ export function reappearLayoutEffects(
2904 safelyAttachRef(finishedWork, finishedWork.return);
2905 break;
2906 }
2907 + break;
2908 + }
2909 + case Fragment: {
2910 + if (enableFragmentRefs) {
2911 + safelyAttachRef(finishedWork, finishedWork.return);
2912 + }
2913 // Fallthrough
2914 }
2915 default: {
packages/react-reconciler/src/forks/ReactFiberConfig.custom.js
+8
@@ -45,6 +45,7 @@ export type ViewTransitionInstance = null | {name: string, ...};
45 export opaque type InstanceMeasurement = mixed;
46 export type EventResponder = any;
47 export type GestureTimeline = any;
48 +export type FragmentInstanceType = null;
49
50 export const rendererVersion = $$$config.rendererVersion;
51 export const rendererPackageName = $$$config.rendererPackageName;
@@ -160,6 +161,13 @@ export const subscribeToGestureDirection =
161 export const createViewTransitionInstance =
162 $$$config.createViewTransitionInstance;
163 export const clearContainer = $$$config.clearContainer;
164 +export const createFragmentInstance = $$$config.createFragmentInstance;
165 +export const updateFragmentInstanceFiber =
166 + $$$config.updateFragmentInstanceFiber;
167 +export const commitNewChildToFragmentInstance =
168 + $$$config.commitNewChildToFragmentInstance;
169 +export const deleteChildFromFragmentInstance =
170 + $$$config.deleteChildFromFragmentInstance;
171
172 // -------------------
173 // Persistence
packages/react-test-renderer/src/ReactFiberConfigTestHost.js
+29
@@ -449,6 +449,35 @@ export function createViewTransitionInstance(
449 return null;
450 }
451
452 +export type FragmentInstanceType = null;
453 +
454 +export function createFragmentInstance(
455 + fragmentFiber: Object,
456 +): FragmentInstanceType {
457 + return null;
458 +}
459 +
460 +export function updateFragmentInstanceFiber(
461 + fragmentFiber: Object,
462 + instance: FragmentInstanceType,
463 +): void {
464 + // Noop
465 +}
466 +
467 +export function commitNewChildToFragmentInstance(
468 + child: Instance,
469 + fragmentInstance: FragmentInstanceType,
470 +): void {
471 + // noop
472 +}
473 +
474 +export function deleteChildFromFragmentInstance(
475 + child: Instance,
476 + fragmentInstance: FragmentInstanceType,
477 +): void {
478 + // Noop
479 +}
480 +
481 export function getInstanceFromNode(mockNode: Object): Object | null {
482 const instance = nodeToInstanceMap.get(mockNode);
483 if (instance !== undefined) {
packages/react/src/__tests__/ReactElementValidator-test.internal.js
+7 -3
@@ -427,9 +427,13 @@ describe('ReactElementValidator', () => {
427 const root = ReactDOMClient.createRoot(document.createElement('div'));
428 await act(() => root.render(React.createElement(Foo)));
429 assertConsoleErrorDev([
430 - 'Invalid prop `a` supplied to `React.Fragment`. React.Fragment ' +
431 - 'can only have `key` and `children` props.\n' +
432 - ' in Foo (at **)',
430 + gate('enableFragmentRefs')
431 + ? 'Invalid prop `a` supplied to `React.Fragment`. React.Fragment ' +
432 + 'can only have `key`, `ref`, and `children` props.\n' +
433 + ' in Foo (at **)'
434 + : 'Invalid prop `a` supplied to `React.Fragment`. React.Fragment ' +
435 + 'can only have `key` and `children` props.\n' +
436 + ' in Foo (at **)',
437 ]);
438 });
439
packages/react/src/__tests__/ReactJSXElementValidator-test.js
+16 -8
@@ -221,9 +221,13 @@ describe('ReactJSXElementValidator', () => {
221 root.render(<Foo />);
222 });
223 assertConsoleErrorDev([
224 - 'Invalid prop `a` supplied to `React.Fragment`. React.Fragment ' +
225 - 'can only have `key` and `children` props.\n' +
226 - ' in Foo (at **)',
224 + gate('enableFragmentRefs')
225 + ? 'Invalid prop `a` supplied to `React.Fragment`. React.Fragment ' +
226 + 'can only have `key`, `ref`, and `children` props.\n' +
227 + ' in Foo (at **)'
228 + : 'Invalid prop `a` supplied to `React.Fragment`. React.Fragment ' +
229 + 'can only have `key` and `children` props.\n' +
230 + ' in Foo (at **)',
231 ]);
232 });
233
@@ -246,11 +250,15 @@ describe('ReactJSXElementValidator', () => {
250 await act(() => {
251 root.render(<Foo />);
252 });
249 - assertConsoleErrorDev([
250 - 'Invalid prop `ref` supplied to `React.Fragment`.' +
251 - ' React.Fragment can only have `key` and `children` props.\n' +
252 - ' in Foo (at **)',
253 - ]);
253 + assertConsoleErrorDev(
254 + gate('enableFragmentRefs')
255 + ? []
256 + : [
257 + 'Invalid prop `ref` supplied to `React.Fragment`.' +
258 + ' React.Fragment can only have `key` and `children` props.\n' +
259 + ' in Foo (at **)',
260 + ],
261 + );
262 });
263
264 it('does not warn for fragments of multiple elements without keys', async () => {
packages/shared/ReactFeatureFlags.js
+2 -1
@@ -160,9 +160,10 @@ export const enableInfiniteRenderLoopDetection = false;
160 export const enableUseEffectCRUDOverload = false;
161
162 export const enableFastAddPropertiesInDiffing = true;
163 -
163 export const enableLazyPublicInstanceInFabric = false;
164
165 +export const enableFragmentRefs = false;
166 +
167 // -----------------------------------------------------------------------------
168 // Ready for next major.
169 //
packages/shared/forks/ReactFeatureFlags.native-fb.js
+1
@@ -83,6 +83,7 @@ export const enableThrottledScheduling = false;
83 export const enableViewTransition = false;
84 export const enableSwipeTransition = false;
85 export const enableScrollEndPolyfill = true;
86 +export const enableFragmentRefs = false;
87
88 // Flow magic to verify the exports of this file match the original version.
89 ((((null: any): ExportsType): FeatureFlagsType): ExportsType);
packages/shared/forks/ReactFeatureFlags.native-oss.js
+2
@@ -76,6 +76,8 @@ export const enableFastAddPropertiesInDiffing = false;
76 export const enableLazyPublicInstanceInFabric = false;
77 export const enableScrollEndPolyfill = true;
78
79 +export const enableFragmentRefs = false;
80 +
81 // Profiling Only
82 export const enableProfilerTimer = __PROFILE__;
83 export const enableProfilerCommitHooks = __PROFILE__;
packages/shared/forks/ReactFeatureFlags.test-renderer.js
+2
@@ -76,6 +76,8 @@ export const enableFastAddPropertiesInDiffing = true;
76 export const enableLazyPublicInstanceInFabric = false;
77 export const enableScrollEndPolyfill = true;
78
79 +export const enableFragmentRefs = false;
80 +
81 // TODO: This must be in sync with the main ReactFeatureFlags file because
82 // the Test Renderer's value must be the same as the one used by the
83 // react package.
packages/shared/forks/ReactFeatureFlags.test-renderer.native-fb.js
+1
@@ -71,6 +71,7 @@ export const enableSwipeTransition = false;
71 export const enableFastAddPropertiesInDiffing = false;
72 export const enableLazyPublicInstanceInFabric = false;
73 export const enableScrollEndPolyfill = true;
74 +export const enableFragmentRefs = false;
75
76 // Flow magic to verify the exports of this file match the original version.
77 ((((null: any): ExportsType): FeatureFlagsType): ExportsType);
packages/shared/forks/ReactFeatureFlags.test-renderer.www.js
+2
@@ -87,5 +87,7 @@ export const enableFastAddPropertiesInDiffing = false;
87 export const enableLazyPublicInstanceInFabric = false;
88 export const enableScrollEndPolyfill = true;
89
90 +export const enableFragmentRefs = false;
91 +
92 // Flow magic to verify the exports of this file match the original version.
93 ((((null: any): ExportsType): FeatureFlagsType): ExportsType);
packages/shared/forks/ReactFeatureFlags.www-dynamic.js
+1
@@ -41,6 +41,7 @@ export const enableLazyPublicInstanceInFabric = false;
41 export const enableViewTransition = __VARIANT__;
42 export const enableComponentPerformanceTrack = __VARIANT__;
43 export const enableScrollEndPolyfill = __VARIANT__;
44 +export const enableFragmentRefs = __VARIANT__;
45
46 // TODO: These flags are hard-coded to the default values used in open source.
47 // Update the tests so that they pass in either mode, then set these
packages/shared/forks/ReactFeatureFlags.www.js
+1
@@ -39,6 +39,7 @@ export const {
39 enableViewTransition,
40 enableComponentPerformanceTrack,
41 enableScrollEndPolyfill,
42 + enableFragmentRefs,
43 } = dynamicFeatureFlags;
44
45 // On WWW, __EXPERIMENTAL__ is used for a new modern build.