Repository: facebook/react
Pull request: Fix `ReactFabricHostComponent` methods if detached

## Summary

If `measure`, `measureInWindow` or `measureLayout` are called with handles that are unmounted, they currently throw TypeErrors because `stateNode` may be null. This changes the methods to be resilient to those cases.

This is similar to https://github.com/facebook/react/pull/21837.

## Test Plan

Ran `yarn flow fabric` and `yarn flow native` successfully.
Ran `yarn test ReactFabric-test.internal.js` successfully.

Changed files:
filename: packages/react-native-renderer/src/ReactFabricHostConfig.js
patch: @@ -137,17 +137,23 @@ class ReactFabricHostComponent {
   }
 
   measure(callback: MeasureOnSuccessCallback) {
-    fabricMeasure(
-      this._internalInstanceHandle.stateNode.node,
-      mountSafeCallback_NOT_REALLY_SAFE(this, callback),
-    );
+    const {stateNode} = this._internalInstanceHandle;
+    if (stateNode != null) {
+      fabricMeasure(
+        stateNode.node,
+        mountSafeCallback_NOT_REALLY_SAFE(this, callback),
+      );
+    }
   }
 
   measureInWindow(callback: MeasureInWindowOnSuccessCallback) {
-    fabricMeasureInWindow(
-      this._internalInstanceHandle.stateNode.node,
-      mountSafeCallback_NOT_REALLY_SAFE(this, callback),
-    );
+    const {stateNode} = this._internalInstanceHandle;
+    if (stateNode != null) {
+      fabricMeasureInWindow(
+        stateNode.node,
+        mountSafeCallback_NOT_REALLY_SAFE(this, callback),
+      );
+    }
   }
 
   measureLayout(
@@ -168,12 +174,18 @@ class ReactFabricHostComponent {
       return;
     }
 
-    fabricMeasureLayout(
-      this._internalInstanceHandle.stateNode.node,
-      relativeToNativeNode._internalInstanceHandle.stateNode.node,
-      mountSafeCallback_NOT_REALLY_SAFE(this, onFail),
-      mountSafeCallback_NOT_REALLY_SAFE(this, onSuccess),
-    );
+    const toStateNode = this._internalInstanceHandle.stateNode;
+    const fromStateNode =
+      relativeToNativeNode._internalInstanceHandle.stateNode;
+
+    if (toStateNode != null && fromStateNode != null) {
+      fabricMeasureLayout(
+        toStateNode.node,
+        fromStateNode.node,
+        mountSafeCallback_NOT_REALLY_SAFE(this, onFail),
+        mountSafeCallback_NOT_REALLY_SAFE(this, onSuccess),
+      );
+    }
   }
 
   setNativeProps(nativeProps: Object) {

filename: packages/react-native-renderer/src/__tests__/ReactFabric-test.internal.js
patch: @@ -414,6 +414,37 @@ describe('ReactFabric', () => {
     expect(successCallback).toHaveBeenCalledWith(10, 10, 100, 100, 0, 0);
   });
 
+  it('should no-op if calling measure on unmounted refs', () => {
+    const View = createReactNativeComponentClass('RCTView', () => ({
+      validAttributes: {foo: true},
+      uiViewClassName: 'RCTView',
+    }));
+
+    nativeFabricUIManager.measure.mockClear();
+
+    let viewRef;
+    act(() => {
+      ReactFabric.render(
+        <View
+          ref={ref => {
+            viewRef = ref;
+          }}
+        />,
+        11,
+      );
+    });
+    const dangerouslyRetainedViewRef = viewRef;
+    act(() => {
+      ReactFabric.stopSurface(11);
+    });
+
+    expect(nativeFabricUIManager.measure).not.toBeCalled();
+    const successCallback = jest.fn();
+    dangerouslyRetainedViewRef.measure(successCallback);
+    expect(nativeFabricUIManager.measure).not.toBeCalled();
+    expect(successCallback).not.toBeCalled();
+  });
+
   it('should call FabricUIManager.measureInWindow on ref.measureInWindow', () => {
     const View = createReactNativeComponentClass('RCTView', () => ({
       validAttributes: {foo: true},
@@ -442,6 +473,37 @@ describe('ReactFabric', () => {
     expect(successCallback).toHaveBeenCalledWith(10, 10, 100, 100);
   });
 
+  it('should no-op if calling measureInWindow on unmounted refs', () => {
+    const View = createReactNativeComponentClass('RCTView', () => ({
+      validAttributes: {foo: true},
+      uiViewClassName: 'RCTView',
+    }));
+
+    nativeFabricUIManager.measureInWindow.mockClear();
+
+    let viewRef;
+    act(() => {
+      ReactFabric.render(
+        <View
+          ref={ref => {
+            viewRef = ref;
+          }}
+        />,
+        11,
+      );
+    });
+    const dangerouslyRetainedViewRef = viewRef;
+    act(() => {
+      ReactFabric.stopSurface(11);
+    });
+
+    expect(nativeFabricUIManager.measureInWindow).not.toBeCalled();
+    const successCallback = jest.fn();
+    dangerouslyRetainedViewRef.measureInWindow(successCallback);
+    expect(nativeFabricUIManager.measureInWindow).not.toBeCalled();
+    expect(successCallback).not.toBeCalled();
+  });
+
   it('should support ref in ref.measureLayout', () => {
     const View = createReactNativeComponentClass('RCTView', () => ({
       validAttributes: {foo: true},
@@ -480,6 +542,119 @@ describe('ReactFabric', () => {
     expect(successCallback).toHaveBeenCalledWith(1, 1, 100, 100);
   });
 
+  it('should no-op if calling measureLayout on unmounted "from" ref', () => {
+    const View = createReactNativeComponentClass('RCTView', () => ({
+      validAttributes: {foo: true},
+      uiViewClassName: 'RCTView',
+    }));
+
+    nativeFabricUIManager.measureLayout.mockClear();
+
+    let viewRef;
+    let otherRef;
+    act(() => {
+      ReactFabric.render(
+        <View>
+          <View
+            foo="bar"
+            ref={ref => {
+              viewRef = ref;
+            }}
+          />
+          <View
+            ref={ref => {
+              otherRef = ref;
+            }}
+          />
+        </View>,
+        11,
+      );
+    });
+    const dangerouslyRetainedOtherRef = otherRef;
+    act(() => {
+      ReactFabric.render(
+        <View>
+          <View
+            foo="bar"
+            ref={ref => {
+              viewRef = ref;
+            }}
+          />
+          {null}
+        </View>,
+        11,
+      );
+    });
+
+    expect(nativeFabricUIManager.measureLayout).not.toBeCalled();
+    const successCallback = jest.fn();
+    const failureCallback = jest.fn();
+    viewRef.measureLayout(
+      dangerouslyRetainedOtherRef,
+      successCallback,
+      failureCallback,
+    );
+    expect(nativeFabricUIManager.measureLayout).not.toBeCalled();
+    expect(successCallback).not.toBeCalled();
+    expect(failureCallback).not.toBeCalled();
+  });
+
+  it('should no-op if calling measureLayout on unmounted "to" ref', () => {
+    const View = createReactNativeComponentClass('RCTView', () => ({
+      validAttributes: {foo: true},
+      uiViewClassName: 'RCTView',
+    }));
+
+    nativeFabricUIManager.measureLayout.mockClear();
+
+    let viewRef;
+    let otherRef;
+    act(() => {
+      ReactFabric.render(
+        <View>
+          <View
+            foo="bar"
+            ref={ref => {
+              viewRef = ref;
+            }}
+          />
+          <View
+            ref={ref => {
+              otherRef = ref;
+            }}
+          />
+        </View>,
+        11,
+      );
+    });
+    const dangerouslyRetainedViewRef = viewRef;
+    act(() => {
+      ReactFabric.render(
+        <View>
+          {null}
+          <View
+            ref={ref => {
+              otherRef = ref;
+            }}
+          />
+        </View>,
+        11,
+      );
+    });
+
+    expect(nativeFabricUIManager.measureLayout).not.toBeCalled();
+    const successCallback = jest.fn();
+    const failureCallback = jest.fn();
+    dangerouslyRetainedViewRef.measureLayout(
+      otherRef,
+      successCallback,
+      failureCallback,
+    );
+    expect(nativeFabricUIManager.measureLayout).not.toBeCalled();
+    expect(successCallback).not.toBeCalled();
+    expect(failureCallback).not.toBeCalled();
+  });
+
   it('returns the correct instance and calls it in the callback', () => {
     const View = createReactNativeComponentClass('RCTView', () => ({
       validAttributes: {foo: true},