Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(ReactNative): prioritize attribute config process function to allow processing function props #32119

Open
wants to merge 10 commits into
base: main
Choose a base branch
from

Conversation

hannojg
Copy link

@hannojg hannojg commented Jan 18, 2025

Summary

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:

How did you test this change?

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:

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
      },
    },
  },
}
compressed.mp4

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.

@hannojg hannojg marked this pull request as ready for review January 18, 2025 16:56
@hannojg hannojg changed the title feat(ReactNative): prioritize attribute config process function to allow processing function props feat(ReactNative): prioritize attribute config process function to allow processing function props Jan 18, 2025
@javache
Copy link
Member

javache commented Jan 22, 2025

This is an interesting direction, and likely we can support this without major changes. It does mean that these function props completely bypass React's event dispatching model, which I think warrants further discussion.

Maybe all direct events should be passed through like this? Or maybe we can should change the event emitter API so you can emit direct callbacks as if they were a function (with a return value)?

In the short-term, please add unit tests here to demonstrate the changed behaviour.

Comment on lines 481 to 491
} else if (typeof attributeConfig.process === 'function') {
// An atomic prop with custom processing.
newValue = attributeConfig.process(prop);
} 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.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure this is correct. You're assuming now that attributeConfig is always going to be an object which you can read process function of.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Right, good catch tanks. Added an additional check for object. Will also add unit tests in a moment

@react-sizebot
Copy link

react-sizebot commented Jan 22, 2025

Comparing: c492f97...f319630

Critical size changes

Includes critical production bundles, as well as any change greater than 2%:

Name +/- Base Current +/- gzip Base gzip Current gzip
oss-stable/react-dom/cjs/react-dom.production.js = 6.68 kB 6.68 kB = 1.83 kB 1.83 kB
oss-stable/react-dom/cjs/react-dom-client.production.js = 515.10 kB 514.24 kB = 91.81 kB 91.74 kB
oss-experimental/react-dom/cjs/react-dom.production.js = 6.69 kB 6.69 kB = 1.83 kB 1.83 kB
oss-experimental/react-dom/cjs/react-dom-client.production.js = 558.79 kB 557.28 kB = 99.13 kB 98.97 kB
facebook-www/ReactDOM-prod.classic.js = 597.07 kB 595.79 kB = 104.95 kB 104.85 kB
facebook-www/ReactDOM-prod.modern.js = 587.50 kB 586.21 kB = 103.41 kB 103.30 kB
facebook-www/ReactFreshRuntime-dev.classic.js = 13.82 kB 12.37 kB = 3.19 kB 2.99 kB
facebook-www/ReactFreshRuntime-dev.modern.js = 13.82 kB 12.37 kB = 3.19 kB 2.99 kB
oss-experimental/react-refresh/cjs/react-refresh-runtime.development.js = 13.80 kB 12.36 kB = 3.18 kB 2.98 kB
oss-stable-semver/react-refresh/cjs/react-refresh-runtime.development.js = 13.80 kB 12.36 kB = 3.18 kB 2.98 kB
oss-stable/react-refresh/cjs/react-refresh-runtime.development.js = 13.80 kB 12.36 kB = 3.18 kB 2.98 kB

Significant size changes

Includes any change greater than 0.2%:

Expand to show
Name +/- Base Current +/- gzip Base gzip Current gzip
facebook-react-native/react-dom/cjs/ReactDOMProfiling-profiling.js = 571.43 kB 570.27 kB = 100.76 kB 100.67 kB
facebook-react-native/react-dom/cjs/ReactDOMClient-profiling.js = 565.49 kB 564.32 kB = 99.61 kB 99.51 kB
facebook-www/ReactDOM-profiling.classic.js = 624.13 kB 622.83 kB = 108.83 kB 108.71 kB
facebook-react-native/react-dom/cjs/ReactDOMProfiling-prod.js = 546.16 kB 545.01 kB = 96.95 kB 96.82 kB
oss-stable/react-reconciler/cjs/react-reconciler.production.js = 390.74 kB 389.92 kB = 63.40 kB 63.30 kB
oss-stable-semver/react-reconciler/cjs/react-reconciler.production.js = 390.72 kB 389.90 kB = 63.37 kB 63.27 kB
facebook-www/ReactDOMTesting-prod.classic.js = 611.79 kB 610.50 kB = 108.64 kB 108.54 kB
facebook-www/ReactDOM-profiling.modern.js = 614.51 kB 613.21 kB = 107.25 kB 107.13 kB
facebook-react-native/react-dom/cjs/ReactDOMClient-prod.js = 540.65 kB 539.50 kB = 95.87 kB 95.74 kB
facebook-www/ReactDOMTesting-prod.modern.js = 602.22 kB 600.93 kB = 107.10 kB 106.99 kB
facebook-www/ReactDOM-prod.classic.js = 597.07 kB 595.79 kB = 104.95 kB 104.85 kB
facebook-www/ReactDOM-prod.modern.js = 587.50 kB 586.21 kB = 103.41 kB 103.30 kB
facebook-www/ReactReconciler-prod.classic.js = 462.46 kB 461.37 kB = 74.19 kB 74.11 kB
facebook-www/ReactReconciler-prod.modern.js = 452.32 kB 451.22 kB = 72.63 kB 72.55 kB
oss-experimental/react-dom/cjs/react-dom-unstable_testing.production.js = 573.52 kB 572.01 kB = 102.73 kB 102.57 kB
oss-experimental/react-dom/cjs/react-dom-client.production.js = 558.79 kB 557.28 kB = 99.13 kB 98.97 kB
oss-experimental/react-server-dom-parcel/cjs/react-server-dom-parcel-server.node.production.js = 94.25 kB 93.93 kB = 19.49 kB 19.44 kB
oss-experimental/react-server-dom-parcel/cjs/react-server-dom-parcel-server.edge.production.js = 92.33 kB 92.01 kB = 19.07 kB 19.02 kB
oss-experimental/react-server-dom-parcel/cjs/react-server-dom-parcel-server.node.development.js = 149.08 kB 148.57 kB = 27.72 kB 27.63 kB
oss-experimental/react-server-dom-parcel/cjs/react-server-dom-parcel-server.edge.development.js = 148.81 kB 148.30 kB = 27.49 kB 27.41 kB
oss-experimental/react-server-dom-parcel/cjs/react-server-dom-parcel-server.browser.production.js = 90.80 kB 90.48 kB = 18.74 kB 18.69 kB
oss-stable-semver/react-server-dom-parcel/cjs/react-server-dom-parcel-server.node.production.js = 89.79 kB 89.47 kB = 18.75 kB 18.70 kB
oss-stable/react-server-dom-parcel/cjs/react-server-dom-parcel-server.node.production.js = 89.79 kB 89.47 kB = 18.75 kB 18.70 kB
oss-experimental/react-server-dom-parcel/cjs/react-server-dom-parcel-server.browser.development.js = 144.35 kB 143.84 kB = 26.76 kB 26.67 kB
oss-stable-semver/react-server-dom-parcel/cjs/react-server-dom-parcel-server.edge.production.js = 87.73 kB 87.42 kB = 18.33 kB 18.29 kB
oss-stable/react-server-dom-parcel/cjs/react-server-dom-parcel-server.edge.production.js = 87.73 kB 87.42 kB = 18.33 kB 18.29 kB
oss-stable-semver/react-server-dom-parcel/cjs/react-server-dom-parcel-server.browser.production.js = 86.29 kB 85.98 kB = 18.01 kB 17.96 kB
oss-stable/react-server-dom-parcel/cjs/react-server-dom-parcel-server.browser.production.js = 86.29 kB 85.98 kB = 18.01 kB 17.96 kB
oss-stable-semver/react-server-dom-parcel/cjs/react-server-dom-parcel-server.node.development.js = 135.37 kB 134.86 kB = 25.33 kB 25.23 kB
oss-stable/react-server-dom-parcel/cjs/react-server-dom-parcel-server.node.development.js = 135.37 kB 134.86 kB = 25.33 kB 25.23 kB
oss-stable-semver/react-server-dom-parcel/cjs/react-server-dom-parcel-server.edge.development.js = 134.15 kB 133.64 kB = 25.02 kB 24.93 kB
oss-stable/react-server-dom-parcel/cjs/react-server-dom-parcel-server.edge.development.js = 134.15 kB 133.64 kB = 25.02 kB 24.93 kB
oss-stable-semver/react-server-dom-parcel/cjs/react-server-dom-parcel-server.browser.development.js = 131.38 kB 130.87 kB = 24.49 kB 24.41 kB
oss-stable/react-server-dom-parcel/cjs/react-server-dom-parcel-server.browser.development.js = 131.38 kB 130.87 kB = 24.49 kB 24.41 kB
oss-experimental/react-debug-tools/cjs/react-debug-tools.development.js = 32.24 kB 31.60 kB = 5.77 kB 5.69 kB
oss-stable-semver/react-debug-tools/cjs/react-debug-tools.development.js = 32.24 kB 31.60 kB = 5.77 kB 5.69 kB
oss-stable/react-debug-tools/cjs/react-debug-tools.development.js = 32.24 kB 31.60 kB = 5.77 kB 5.69 kB
oss-experimental/react-debug-tools/cjs/react-debug-tools.production.js = 28.72 kB 28.15 kB = 5.64 kB 5.56 kB
oss-stable-semver/react-debug-tools/cjs/react-debug-tools.production.js = 28.72 kB 28.15 kB = 5.64 kB 5.56 kB
oss-stable/react-debug-tools/cjs/react-debug-tools.production.js = 28.72 kB 28.15 kB = 5.64 kB 5.56 kB
facebook-www/ReactFreshRuntime-dev.classic.js = 13.82 kB 12.37 kB = 3.19 kB 2.99 kB
facebook-www/ReactFreshRuntime-dev.modern.js = 13.82 kB 12.37 kB = 3.19 kB 2.99 kB
oss-experimental/react-refresh/cjs/react-refresh-runtime.development.js = 13.80 kB 12.36 kB = 3.18 kB 2.98 kB
oss-stable-semver/react-refresh/cjs/react-refresh-runtime.development.js = 13.80 kB 12.36 kB = 3.18 kB 2.98 kB
oss-stable/react-refresh/cjs/react-refresh-runtime.development.js = 13.80 kB 12.36 kB = 3.18 kB 2.98 kB

Generated by 🚫 dangerJS against ca657bf

@hannojg hannojg force-pushed the feat/prioritize-view-attribute-process-function branch from a2cc692 to 7adf510 Compare January 22, 2025 10:19
@mrousavy
Copy link

Maybe all direct events should be passed through like this?

This would probably require quite a few changes on the native side.

For reference, this is how Nitro converts a jsi::Function to a callable std::function: JSIConverter+Function.hpp
..this then has to be wrapped in a Objective-C block/Java callable as well, including argument parsing. To make it backwards compatible it's just gonna be a map as an argument.

But I can definitely look into this as a future improvement for react-native core/TurboModules once this PR is merged - this is one of the places where Nitro has safer memory management and better performance as well - so maybe I can upstream this somehow :)

@rubennorte
Copy link
Contributor

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.

Could you please share more details about the motivation for this change? What's your use case?

@mrousavy
Copy link

mrousavy commented Jan 22, 2025

What's your use case?

We have a custom framework (Nitro) that allows you to build native modules and soon also native components for React Native.

Instead of going through React Native's default prop converter (JS value (jsi::Value) <> Native values), we want to go through our own prop parser. (The reason for that is performance, and flexibility; we have custom types ("Hybrid Objects") that we need to parse ourselves - React Native's prop converter (TurboModules) does not support that (yet))

We built this, and it works for every prop so far (e.g. jsi::Value(5) (number) -> double(5)) except for functions because functions are not passed to the native C++ code as jsi::Function, but rather converted to a boolean (jsi::Value(true)) instead. So the actual function value is lost. (See example code for prop parsing in Nitro here)

We dug around the code and found out that this is because RN handles functions/callbacks differently - they are not retained on native, but instead built as a event dispatcher system on the JS side, loosely called from native. This PR allows to change this behaviour if needed - so no breaking change for existing code, but with a custom view config we can adjust it for our cases.

@hannojg
Copy link
Author

hannojg commented Jan 22, 2025

Added the unit test @javache . One question though, I opened another PR in react native for updating the diffProperties method to also handle functions props with a process function in its config:

Is it the right place to do it in /react-native or should it be added to /react (like this change) ?

@javache
Copy link
Member

javache commented Jan 22, 2025

One question though, I opened another PR in react native for updating the diffProperties method to also handle functions props with a process function in its config:

Is it the right place to do it in /react-native or should it be added to /react (like this change) ?

Following up with @rubennorte on this - seems like this fork is only used for setNativeProps and we need to unfork this.

@javache
Copy link
Member

javache commented Jan 22, 2025

You've only changed fastAddProperties but this change should also be reflected in diffProperties - please change that too and make sure it's covered by tests. If there isn't already, could you please also add a test for the default behaviour (when there's no process function)

@javache
Copy link
Member

javache commented Jan 22, 2025

Also, could this work as a workaround? Instead of passing the function through directly, wrap it in another object. On the native side, it just means you have an extra jsi::Object to unwrap.

@mrousavy
Copy link

That's a good idea - we could wrap callbacks as in objects, I'll try to see if this works. If it isn't recursively parsing types it should work just fine.

But still I think we can avoid that workaround and just parse functions directly if possible :)

@hannojg hannojg force-pushed the feat/prioritize-view-attribute-process-function branch from 2313318 to cf9168f Compare January 22, 2025 21:32
@hannojg
Copy link
Author

hannojg commented Jan 22, 2025

I brought the changes over for diffProperties and added a test. There were already enough other tests covering the default case. Thanks!

// we don't assume its a regular event handler, but use the process method:
nextProp = attributeConfig.process(nextProp);
if (typeof prevProp === 'function') {
prevProp = attributeConfig.process(prevProp);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It doesn't seem right that we process here before diffing - for other properties we first diff and then we process. Assuming that we have we have a process method here also doesn't match the strict validation we do in the rest of the method.

This whole method seems to invoke process at multiple different points though, so it seem hard to keep the default for functions.

Could you try something like this?

const attributeConfigHasProcess = typeof attributeConfig === 'object' && typeof attributeConfig.process === 'function';
if (typeof nextProp === 'function' && !attributeConfigHasProcess) {
  // keep as before
}

/* rely on process being invoked as it does for other methods */

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yeah I see, that makes much more sense!

@hannojg hannojg force-pushed the feat/prioritize-view-attribute-process-function branch from 11eb8f8 to 9e74020 Compare January 30, 2025 15:40
@hannojg hannojg requested a review from javache January 30, 2025 15:40
@hannojg
Copy link
Author

hannojg commented Jan 30, 2025

Good again for review (thanks for taking the time and effort!)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging this pull request may close these issues.

6 participants