Skip to content

Commit 6aa8254

Browse files
authored
Add ref to Fragment (#32465)
*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.
1 parent ca8f91f commit 6aa8254

23 files changed

+1258
-49
lines changed

Diff for: packages/react-art/src/ReactFiberConfigART.js

+21
Original file line numberDiff line numberDiff line change
@@ -318,6 +318,27 @@ export function cloneMutableTextInstance(textInstance) {
318318
return textInstance;
319319
}
320320

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+
321342
export function finalizeInitialChildren(domElement, type, props) {
322343
return false;
323344
}

Diff for: packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js

+230
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ import {getCurrentRootHostContainer} from 'react-reconciler/src/ReactFiberHostCo
3434
import hasOwnProperty from 'shared/hasOwnProperty';
3535
import {checkAttributeStringCoercion} from 'shared/CheckStringCoercion';
3636
import {REACT_CONTEXT_TYPE} from 'shared/ReactSymbols';
37+
import {OffscreenComponent} from 'react-reconciler/src/ReactWorkTags';
3738

3839
export {
3940
setCurrentUpdatePriority,
@@ -2159,6 +2160,235 @@ export function subscribeToGestureDirection(
21592160
}
21602161
}
21612162

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+
21622392
export function clearContainer(container: Container): void {
21632393
const nodeType = container.nodeType;
21642394
if (nodeType === DOCUMENT_NODE) {

0 commit comments

Comments
 (0)