Add ref cleanup function (#25686)
Add option for ref function to return a clean up function. ```jsx <div ref={(_ref) => { // Use `_ref` return () => { // Clean up _ref }; }} /> ``` If clean up function is not provided. Ref function is called with null like it has been before. ```jsx <div ref={(_ref) => { if (_ref) { // Use _ref } else { // Clean up _ref } }} /> ```
Samuel Susla committed
Nov 29, 2022 at 16:41 UTC
e98225485a124e35abc4cea82e6da944472ce7c7
6 files changed
+195
-99
packages/react-dom/src/__tests__/refs-test.js
+133
-29
@@ -401,35 +401,6 @@ describe('ref swapping', () => {
401
'Expected ref to be a function, a string, an object returned by React.createRef(), or null.',
402
);
403
});
404
-
405
- it('should warn about callback refs returning a function', () => {
406
- const container = document.createElement('div');
407
- expect(() => {
408
- ReactDOM.render(<div ref={() => () => {}} />, container);
409
- }).toErrorDev('Unexpected return value from a callback ref in div');
410
-
411
- // Cleanup should warn, too.
412
- expect(() => {
413
- ReactDOM.render(<span />, container);
414
- }).toErrorDev('Unexpected return value from a callback ref in div', {
415
- withoutStack: true,
416
- });
417
-
418
- // No warning when returning non-functions.
419
- ReactDOM.render(<p ref={() => ({})} />, container);
420
- ReactDOM.render(<p ref={() => null} />, container);
421
- ReactDOM.render(<p ref={() => undefined} />, container);
422
-
423
- // Still warns on functions (not deduped).
424
- expect(() => {
425
- ReactDOM.render(<div ref={() => () => {}} />, container);
426
- }).toErrorDev('Unexpected return value from a callback ref in div');
427
- expect(() => {
428
- ReactDOM.unmountComponentAtNode(container);
429
- }).toErrorDev('Unexpected return value from a callback ref in div', {
430
- withoutStack: true,
431
- });
432
- });
404
});
405
406
describe('root level refs', () => {
@@ -612,3 +583,136 @@ describe('strings refs across renderers', () => {
583
expect(inst.refs.child2).toBe(div2.firstChild);
584
});
585
});
586
+
587
+describe('refs return clean up function', () => {
588
+ it('calls clean up function if it exists', () => {
589
+ const container = document.createElement('div');
590
+ let cleanUp = jest.fn();
591
+ let setup = jest.fn();
592
+
593
+ ReactDOM.render(
594
+ <div
595
+ ref={_ref => {
596
+ setup(_ref);
597
+ return cleanUp;
598
+ }}
599
+ />,
600
+ container,
601
+ );
602
+
603
+ ReactDOM.render(
604
+ <div
605
+ ref={_ref => {
606
+ setup(_ref);
607
+ }}
608
+ />,
609
+ container,
610
+ );
611
+
612
+ expect(setup).toHaveBeenCalledTimes(2);
613
+ expect(cleanUp).toHaveBeenCalledTimes(1);
614
+ expect(cleanUp.mock.calls[0][0]).toBe(undefined);
615
+
616
+ ReactDOM.render(<div ref={_ref => {}} />, container);
617
+
618
+ expect(cleanUp).toHaveBeenCalledTimes(1);
619
+ expect(setup).toHaveBeenCalledTimes(3);
620
+ expect(setup.mock.calls[2][0]).toBe(null);
621
+
622
+ cleanUp = jest.fn();
623
+ setup = jest.fn();
624
+
625
+ ReactDOM.render(
626
+ <div
627
+ ref={_ref => {
628
+ setup(_ref);
629
+ return cleanUp;
630
+ }}
631
+ />,
632
+ container,
633
+ );
634
+
635
+ expect(setup).toHaveBeenCalledTimes(1);
636
+ expect(cleanUp).toHaveBeenCalledTimes(0);
637
+
638
+ ReactDOM.render(
639
+ <div
640
+ ref={_ref => {
641
+ setup(_ref);
642
+ return cleanUp;
643
+ }}
644
+ />,
645
+ container,
646
+ );
647
+
648
+ expect(setup).toHaveBeenCalledTimes(2);
649
+ expect(cleanUp).toHaveBeenCalledTimes(1);
650
+ });
651
+
652
+ it('handles ref functions with stable identity', () => {
653
+ const container = document.createElement('div');
654
+ const cleanUp = jest.fn();
655
+ const setup = jest.fn();
656
+
657
+ function _onRefChange(_ref) {
658
+ setup(_ref);
659
+ return cleanUp;
660
+ }
661
+
662
+ ReactDOM.render(<div ref={_onRefChange} />, container);
663
+
664
+ expect(setup).toHaveBeenCalledTimes(1);
665
+ expect(cleanUp).toHaveBeenCalledTimes(0);
666
+
667
+ ReactDOM.render(
668
+ <div className="niceClassName" ref={_onRefChange} />,
669
+ container,
670
+ );
671
+
672
+ expect(setup).toHaveBeenCalledTimes(1);
673
+ expect(cleanUp).toHaveBeenCalledTimes(0);
674
+
675
+ ReactDOM.render(<div />, container);
676
+
677
+ expect(setup).toHaveBeenCalledTimes(1);
678
+ expect(cleanUp).toHaveBeenCalledTimes(1);
679
+ });
680
+
681
+ it('warns if clean up function is returned when called with null', () => {
682
+ const container = document.createElement('div');
683
+ const cleanUp = jest.fn();
684
+ const setup = jest.fn();
685
+ let returnCleanUp = false;
686
+
687
+ ReactDOM.render(
688
+ <div
689
+ ref={_ref => {
690
+ setup(_ref);
691
+ if (returnCleanUp) {
692
+ return cleanUp;
693
+ }
694
+ }}
695
+ />,
696
+ container,
697
+ );
698
+
699
+ expect(setup).toHaveBeenCalledTimes(1);
700
+ expect(cleanUp).toHaveBeenCalledTimes(0);
701
+
702
+ returnCleanUp = true;
703
+
704
+ expect(() => {
705
+ ReactDOM.render(
706
+ <div
707
+ ref={_ref => {
708
+ setup(_ref);
709
+ if (returnCleanUp) {
710
+ return cleanUp;
711
+ }
712
+ }}
713
+ />,
714
+ container,
715
+ );
716
+ }).toErrorDev('Unexpected return value from a callback ref in div');
717
+ });
718
+});
packages/react-reconciler/src/ReactFiber.new.js
+3
@@ -148,6 +148,7 @@ function FiberNode(
148
this.index = 0;
149
150
this.ref = null;
151
+ this.refCleanup = null;
152
153
this.pendingProps = pendingProps;
154
this.memoizedProps = null;
@@ -336,6 +337,7 @@ export function createWorkInProgress(current: Fiber, pendingProps: any): Fiber {
337
workInProgress.sibling = current.sibling;
338
workInProgress.index = current.index;
339
workInProgress.ref = current.ref;
340
+ workInProgress.refCleanup = current.refCleanup;
341
342
if (enableProfilerTimer) {
343
workInProgress.selfBaseDuration = current.selfBaseDuration;
@@ -880,6 +882,7 @@ export function assignFiberPropertiesInDEV(
882
target.sibling = source.sibling;
883
target.index = source.index;
884
target.ref = source.ref;
885
+ target.refCleanup = source.refCleanup;
886
target.pendingProps = source.pendingProps;
887
target.memoizedProps = source.memoizedProps;
888
target.updateQueue = source.updateQueue;
packages/react-reconciler/src/ReactFiber.old.js
+3
@@ -148,6 +148,7 @@ function FiberNode(
148
this.index = 0;
149
150
this.ref = null;
151
+ this.refCleanup = null;
152
153
this.pendingProps = pendingProps;
154
this.memoizedProps = null;
@@ -336,6 +337,7 @@ export function createWorkInProgress(current: Fiber, pendingProps: any): Fiber {
337
workInProgress.sibling = current.sibling;
338
workInProgress.index = current.index;
339
workInProgress.ref = current.ref;
340
+ workInProgress.refCleanup = current.refCleanup;
341
342
if (enableProfilerTimer) {
343
workInProgress.selfBaseDuration = current.selfBaseDuration;
@@ -880,6 +882,7 @@ export function assignFiberPropertiesInDEV(
882
target.sibling = source.sibling;
883
target.index = source.index;
884
target.ref = source.ref;
885
+ target.refCleanup = source.refCleanup;
886
target.pendingProps = source.pendingProps;
887
target.memoizedProps = source.memoizedProps;
888
target.updateQueue = source.updateQueue;
packages/react-reconciler/src/ReactFiberCommitWork.new.js
+27
-35
@@ -286,8 +286,32 @@ function safelyAttachRef(current: Fiber, nearestMountedAncestor: Fiber | null) {
286
287
function safelyDetachRef(current: Fiber, nearestMountedAncestor: Fiber | null) {
288
const ref = current.ref;
289
+ const refCleanup = current.refCleanup;
290
+
291
if (ref !== null) {
290
- if (typeof ref === 'function') {
292
+ if (typeof refCleanup === 'function') {
293
+ try {
294
+ if (shouldProfile(current)) {
295
+ try {
296
+ startLayoutEffectTimer();
297
+ refCleanup();
298
+ } finally {
299
+ recordLayoutEffectDuration(current);
300
+ }
301
+ } else {
302
+ refCleanup();
303
+ }
304
+ } catch (error) {
305
+ captureCommitPhaseError(current, nearestMountedAncestor, error);
306
+ } finally {
307
+ // `refCleanup` has been called. Nullify all references to it to prevent double invocation.
308
+ current.refCleanup = null;
309
+ const finishedWork = current.alternate;
310
+ if (finishedWork != null) {
311
+ finishedWork.refCleanup = null;
312
+ }
313
+ }
314
+ } else if (typeof ref === 'function') {
315
let retVal;
316
try {
317
if (shouldProfile(current)) {
@@ -1572,25 +1596,15 @@ function commitAttachRef(finishedWork: Fiber) {
1596
instanceToUse = instance;
1597
}
1598
if (typeof ref === 'function') {
1575
- let retVal;
1599
if (shouldProfile(finishedWork)) {
1600
try {
1601
startLayoutEffectTimer();
1579
- retVal = ref(instanceToUse);
1602
+ finishedWork.refCleanup = ref(instanceToUse);
1603
} finally {
1604
recordLayoutEffectDuration(finishedWork);
1605
}
1606
} else {
1584
- retVal = ref(instanceToUse);
1585
- }
1586
- if (__DEV__) {
1587
- if (typeof retVal === 'function') {
1588
- console.error(
1589
- 'Unexpected return value from a callback ref in %s. ' +
1590
- 'A callback ref should not return a function.',
1591
- getComponentNameFromFiber(finishedWork),
1592
- );
1593
- }
1607
+ finishedWork.refCleanup = ref(instanceToUse);
1608
}
1609
} else {
1610
if (__DEV__) {
@@ -1609,27 +1623,6 @@ function commitAttachRef(finishedWork: Fiber) {
1623
}
1624
}
1625
1612
-function commitDetachRef(current: Fiber) {
1613
- const currentRef = current.ref;
1614
- if (currentRef !== null) {
1615
- if (typeof currentRef === 'function') {
1616
- if (shouldProfile(current)) {
1617
- try {
1618
- startLayoutEffectTimer();
1619
- currentRef(null);
1620
- } finally {
1621
- recordLayoutEffectDuration(current);
1622
- }
1623
- } else {
1624
- currentRef(null);
1625
- }
1626
- } else {
1627
- // $FlowFixMe unable to narrow type to the non-function case
1628
- currentRef.current = null;
1629
- }
1630
- }
1631
-}
1632
-
1626
function detachFiberMutation(fiber: Fiber) {
1627
// Cut off the return pointer to disconnect it from the tree.
1628
// This enables us to detect and warn against state updates on an unmounted component.
@@ -4450,7 +4443,6 @@ function invokePassiveEffectUnmountInDEV(fiber: Fiber): void {
4443
export {
4444
commitPlacement,
4445
commitAttachRef,
4453
- commitDetachRef,
4446
invokeLayoutEffectMountInDEV,
4447
invokeLayoutEffectUnmountInDEV,
4448
invokePassiveEffectMountInDEV,
packages/react-reconciler/src/ReactFiberCommitWork.old.js
+27
-35
@@ -286,8 +286,32 @@ function safelyAttachRef(current: Fiber, nearestMountedAncestor: Fiber | null) {
286
287
function safelyDetachRef(current: Fiber, nearestMountedAncestor: Fiber | null) {
288
const ref = current.ref;
289
+ const refCleanup = current.refCleanup;
290
+
291
if (ref !== null) {
290
- if (typeof ref === 'function') {
292
+ if (typeof refCleanup === 'function') {
293
+ try {
294
+ if (shouldProfile(current)) {
295
+ try {
296
+ startLayoutEffectTimer();
297
+ refCleanup();
298
+ } finally {
299
+ recordLayoutEffectDuration(current);
300
+ }
301
+ } else {
302
+ refCleanup();
303
+ }
304
+ } catch (error) {
305
+ captureCommitPhaseError(current, nearestMountedAncestor, error);
306
+ } finally {
307
+ // `refCleanup` has been called. Nullify all references to it to prevent double invocation.
308
+ current.refCleanup = null;
309
+ const finishedWork = current.alternate;
310
+ if (finishedWork != null) {
311
+ finishedWork.refCleanup = null;
312
+ }
313
+ }
314
+ } else if (typeof ref === 'function') {
315
let retVal;
316
try {
317
if (shouldProfile(current)) {
@@ -1572,25 +1596,15 @@ function commitAttachRef(finishedWork: Fiber) {
1596
instanceToUse = instance;
1597
}
1598
if (typeof ref === 'function') {
1575
- let retVal;
1599
if (shouldProfile(finishedWork)) {
1600
try {
1601
startLayoutEffectTimer();
1579
- retVal = ref(instanceToUse);
1602
+ finishedWork.refCleanup = ref(instanceToUse);
1603
} finally {
1604
recordLayoutEffectDuration(finishedWork);
1605
}
1606
} else {
1584
- retVal = ref(instanceToUse);
1585
- }
1586
- if (__DEV__) {
1587
- if (typeof retVal === 'function') {
1588
- console.error(
1589
- 'Unexpected return value from a callback ref in %s. ' +
1590
- 'A callback ref should not return a function.',
1591
- getComponentNameFromFiber(finishedWork),
1592
- );
1593
- }
1607
+ finishedWork.refCleanup = ref(instanceToUse);
1608
}
1609
} else {
1610
if (__DEV__) {
@@ -1609,27 +1623,6 @@ function commitAttachRef(finishedWork: Fiber) {
1623
}
1624
}
1625
1612
-function commitDetachRef(current: Fiber) {
1613
- const currentRef = current.ref;
1614
- if (currentRef !== null) {
1615
- if (typeof currentRef === 'function') {
1616
- if (shouldProfile(current)) {
1617
- try {
1618
- startLayoutEffectTimer();
1619
- currentRef(null);
1620
- } finally {
1621
- recordLayoutEffectDuration(current);
1622
- }
1623
- } else {
1624
- currentRef(null);
1625
- }
1626
- } else {
1627
- // $FlowFixMe unable to narrow type to the non-function case
1628
- currentRef.current = null;
1629
- }
1630
- }
1631
-}
1632
-
1626
function detachFiberMutation(fiber: Fiber) {
1627
// Cut off the return pointer to disconnect it from the tree.
1628
// This enables us to detect and warn against state updates on an unmounted component.
@@ -4450,7 +4443,6 @@ function invokePassiveEffectUnmountInDEV(fiber: Fiber): void {
4443
export {
4444
commitPlacement,
4445
commitAttachRef,
4453
- commitDetachRef,
4446
invokeLayoutEffectMountInDEV,
4447
invokeLayoutEffectUnmountInDEV,
4448
invokePassiveEffectMountInDEV,
packages/react-reconciler/src/ReactInternalTypes.js
+2
@@ -131,6 +131,8 @@ export type Fiber = {
131
| (((handle: mixed) => void) & {_stringRef: ?string, ...})
132
| RefObject,
133
134
+ refCleanup: null | (() => void),
135
+
136
// Input is the data coming into process this fiber. Arguments. Props.
137
pendingProps: any, // This type will be more specific once we overload the tag.
138
memoizedProps: any, // The props used to create the output.