Repository: facebook/react
Pull request: feat(ReactNative): prioritize attribute config `process` function to allow processing function props

<!--
  Thanks for submitting a pull request!
  We appreciate you spending the time to work on these changes. Please provide enough information so that others can review your pull request. The three fields below are mandatory.

  Before submitting a pull request, please make sure the following is done:

  1. Fork [the repository](https://github.com/facebook/react) and create your branch from `main`.
  2. Run `yarn` in the repository root.
  3. If you've fixed a bug or added code that should be tested, add tests!
  4. Ensure the test suite passes (`yarn test`). Tip: `yarn test --watch TestName` is helpful in development.
  5. Run `yarn test --prod` to test in the production environment. It supports the same options as `yarn test`.
  6. If you need a debugger, run `yarn test --debug --watch TestName`, open `chrome://inspect`, and press "Inspect".
  7. Format your code with [prettier](https://github.com/prettier/prettier) (`yarn prettier`).
  8. Make sure your code lints (`yarn lint`). Tip: `yarn linc` to only check changed files.
  9. Run the [Flow](https://flowtype.org/) type checks (`yarn flow`).
  10. If you haven't already, complete the CLA.

  Learn more about contributing: https://reactjs.org/docs/how-to-contribute.html
-->

## Summary

<!--
 Explain the **motivation** for making this change. What existing problem does the pull request solve?
-->

In react-native props that are passed as function get converted to a boolean (`true`). This is the default pattern for event handlers in react-native.
However, there are reasons for why you might want to opt-out of this behavior, and instead, pass along the actual function as the prop.
Right now, there is no way to do this, and props that are functions always get set to `true`.
The `ViewConfig` attributes already have the API for a `process` function. I simply moved the check for the process function up, so if a ViewConfig's prop attribute configured a process function this is always called first.
This provides an API to opt out of the default behavior. 

This is the accompanied PR for react-native:

- https://github.com/facebook/react-native/pull/48777

## How did you test this change?

<!--
  Demonstrate the code is solid. Example: The exact commands you ran and their output, screenshots / videos if the pull request changes the user interface.
  How exactly did you verify that your PR solves the issue you wanted to solve?
  If you leave this empty, your PR will very likely be closed.
-->

I modified the code manually in a template react-native app and confirmed its working. This is a code path you only need in very special cases, thus it's a bit hard to provide a test for this. I recorded a video where you can see that the changes are active and the prop is being passed as native value.

For this I created a custom native component with a view config that looked like this:

```js
const viewConfig = {
  uiViewClassName: 'CustomView',
  bubblingEventTypes: {},
  directEventTypes: {},
  validAttributes: {
    nativeProp: {
      process: (nativeProp) => {
		// Identity function that simply returns the prop function callback
        // to opt out of this prop being set to `true` as its a function
        return nativeProp
      },
    },
  },
}
```


https://github.com/user-attachments/assets/493534b2-a508-4142-a760-0b1b24419e19

Additionally I made sure that this doesn't conflict with any existing view configs in react native. In general, this shouldn't be a breaking change, as for existing view configs it didn't made a difference if you simply set `myProp: true` or `myProp: { process: () => {...} }` because as soon as it was detected that the prop is a function the config wouldn't be used (which is what this PR fixes).
Probably everyone, including the react-native core components use `myProp: true` for callback props, so this change should be fine. 


Changed files:
filename: packages/react-native-renderer/src/ReactNativeAttributePayloadFabric.js
patch: @@ -254,14 +254,17 @@ function diffProperties(
     prevProp = prevProps[propKey];
     nextProp = nextProps[propKey];
 
-    // functions are converted to booleans as markers that the associated
-    // events should be sent from native.
     if (typeof nextProp === 'function') {
-      nextProp = (true: any);
-      // If nextProp is not a function, then don't bother changing prevProp
-      // since nextProp will win and go into the updatePayload regardless.
-      if (typeof prevProp === 'function') {
-        prevProp = (true: any);
+      const attributeConfigHasProcess = typeof attributeConfig === 'object' && typeof attributeConfig.process === 'function';
+      if (!attributeConfigHasProcess) {
+        // functions are converted to booleans as markers that the associated
+        // events should be sent from native.
+        nextProp = (true: any);
+        // If nextProp is not a function, then don't bother changing prevProp
+        // since nextProp will win and go into the updatePayload regardless.
+        if (typeof prevProp === 'function') {
+          prevProp = (true: any);
+        }
       }
     }
 
@@ -444,18 +447,22 @@ function addNestedProperty(
       } else {
         continue;
       }
-    } else if (typeof prop === 'function') {
-      // A function prop. It represents an event handler. Pass it to native as 'true'.
-      newValue = true;
-    } else if (typeof attributeConfig !== 'object') {
-      // An atomic prop. Doesn't need to be flattened.
-      newValue = prop;
-    } else if (typeof attributeConfig.process === 'function') {
-      // An atomic prop with custom processing.
-      newValue = attributeConfig.process(prop);
-    } else if (typeof attributeConfig.diff === 'function') {
-      // An atomic prop with custom diffing. We don't need to do diffing when adding props.
-      newValue = prop;
+    } else if (typeof attributeConfig === 'object') {
+      if (typeof attributeConfig.process === 'function') {
+        // An atomic prop with custom processing.
+        newValue = attributeConfig.process(prop);
+      } else if (typeof attributeConfig.diff === 'function') {
+        // An atomic prop with custom diffing. We don't need to do diffing when adding props.
+        newValue = prop;
+      }
+    } else {
+      if (typeof prop === 'function') {
+        // A function prop. It represents an event handler. Pass it to native as 'true'.
+        newValue = true;
+      } else {
+        // An atomic prop. Doesn't need to be flattened.
+        newValue = prop;
+      }
     }
 
     if (newValue !== undefined) {

filename: packages/react-native-renderer/src/__tests__/ReactNativeAttributePayloadFabric-test.internal.js
patch: @@ -102,6 +102,14 @@ describe('ReactNativeAttributePayloadFabric.create', () => {
     expect(processA).toBeCalledWith(2);
   });
 
+  it('should use the process attribute for functions as well', () => {
+    const process = x => x;
+    const nextFunction = () => {};
+    expect(create({a: nextFunction}, {a: {process}})).toEqual({
+      a: nextFunction,
+    });
+  });
+
   it('should work with undefined styles', () => {
     expect(create({style: undefined}, {style: {b: true}})).toEqual(null);
     expect(create({style: {a: '#ffffff', b: 1}}, {style: {b: true}})).toEqual({
@@ -452,4 +460,21 @@ describe('ReactNativeAttributePayloadFabric.diff', () => {
       ),
     ).toEqual(null);
   });
+
+  it('should use the process function config when prop is a function', () => {
+    const process = jest.fn(a => a);
+    const nextFunction = function () {};
+    expect(
+      diff(
+        {
+          a: function () {},
+        },
+        {
+          a: nextFunction,
+        },
+        {a: {process}},
+      ),
+    ).toEqual({a: nextFunction});
+    expect(process).toBeCalled();
+  });
 });