react-native-community / discussions-and-proposals

Discussions and proposal related to the main React Native project
https://facebook.github.io/react-native/
1.66k stars 125 forks source link

Unifying the way platforms are handled #50

Open adrienthiery opened 5 years ago

adrienthiery commented 5 years ago

Introduction

Out of the box, react-native is for building Android and iOS (and TvOS?) apps, so react-native init starts us of with an android and ios folder and project. However we have seen more and more effort to see implementations for new platforms (windows, MacOS, web (react-native-web or react-native-dom), desktop alltogether) which shows us that react-native is more than just iOS and android.

The Core of It

I understand that Facebook started react-native having mobile in mind, but should it stay like that?

Or should we try to move react-native as a Platform that let's you choose which platform you target and treat all of them equally in terms of difficulty to "Add a platform". I'm imagining a world where you could start a React Native app with only iOS in it, but then add platforms as you go in an easy way, the way react-native-windows seems to handle it.

In the same way, you could start a React Native app with only windows, and then add MacOS and Linux support iterations after iterations.

So I guess the discussion is : should the integration of all platforms be equal (and not have 2 that are "preferred"). Platforms concerned by this discussion being:

Discussion points

Disclaimer : I haven't tried all the frameworks mentioned above and haven't read all the information which is out there about the slimening and the objectives of React Native, so feel free to tell me to go read the docs and close that issue if this direction is already decided/discussed 😅

TheSavior commented 5 years ago

Our team definitely wants to enable other platforms to be first class citizens. Another platform that wasn’t included in your list is ReactVR / React360. :-)

I think this would likely be much simpler after the slimmening but thinking about what it would take doesn’t need to wait until then.

Our team at Facebook knows this is a problem but since we don’t maintain any out of tree platforms we aren’t sure what the pain points are.

It would be great if the maintainers of a bunch of these projects could work together to define the problems and what a possible solution would like, being explicit on the technical changes that would need to occur to better support them.

Also, how many of those changes that need to occur require Facebook to actually do the work vs Facebook just saying “we agree with your direction and will help land PRs”?

kelset commented 5 years ago

There have been conversations regarding this since Leland's React Primitive, in a way. And also @matthargett's talk at RNEU was about how React Native allows to target way more platforms.

That said, I agree with Eli in the fact that after the slimmening it should be easier, also because there are other conversations ongoing that affect this subject, like https://github.com/react-native-community/discussions-and-proposals/pull/49

And overall, yeah, there is a whole complexity around organisation and management and ownership and maintenance.

adrienthiery commented 5 years ago

@TheSavior I think it's more of a “we agree with your direction and will help land PRs” that is needed from Facebook, although help on redesigning the way we add platforms would be of great help!

@kelset Yes, it's actually partly matt's talk that pushed me to try react-native-windows and I really like the approach they have to "add the windows platform". I need to look into it more to see how to have a similar approach for any platform that might be integratable into the core (or just into the cli) but was wondering if right now was the time

kevinvangelder commented 5 years ago

This is something I've wanted since the early days of working on a react-native-windows project (about the same time TvOS was forced into the core). The more modularized the officially supported platforms become the easier it will be for third party platforms to replicate the interface they're using. For example, we were having issues with Metro refusing to work with our platform and we couldn't find any documentation on how the default platforms were interfacing with it, so we didn't have an example and were wandering around in the dark.

I know @rozele and @pre10der89 probably have lots to share on this subject.

TheSavior commented 5 years ago

I want to talk about how we can sufficiently support out of tree platforms without relying on Haste.

As mentioned in the React Native Open Source Roadmap, we are working on migrating internal FB code to use the RN public JS API instead of requiring modules via haste, aka changing this:

const View = require('View');

to

const {View} = require('react-native');
// and eventually to this:
// import {View} from 'react-native';

We are almost done making this change internally and want to keep additional code in the old style from landing by disallowing using Haste to require these files. In doing this, it requires us to also remove Haste from react-native core.

For example, ScrollView.js which currently has const View = require('View') will need to use a relative path.

One side effect of this, from what I've been told, is that out of tree platforms currently rely on Haste to inject platform specific behavior. For example, ViewProps spreads PlatformViewPropTypes which different platforms add a platform specific module for which metro resolves via Haste.

In order to get rid of all of this, we need to take a different approach. I'm not exactly sure what this approach should be and I'm hoping that the maintainers of out of tree platforms can help provide that insight.

For context, we are having a similar problem right now as we are working on Fabric. Right now, many components have different props on iOS and Android. With fabric, these props are in c++ and shared on both platforms (and other platforms will likely reuse this c++ code). That means that in core, iOS and Android are not able to add additional props for the same component that don't exist on the other.

Concretely, this has led us to a path where we have three options in our toolbelt for each prop / component:

  1. Unify the props on both platforms. For example, Switch on iOS uses value as the prop to designate the state of the Switch, Android uses on. We will unify these as they shouldn't have been different in the first place. Note that the JS API won't change as this difference is currently papered over from JavaScript using a Platform check.
  2. Props on some platforms will be no-ops. For example, a component with PropA and PropB will generate both props in c++, one platform could support both, and the other platform could have one as a no-op. The platform could implement it in the future knowing that it is using the consistent API. This would mean that these props should be purposefully cross platform and never be prefixed with something like ios_. I could imagine other platforms proposing new props to merge into core if they could/would potentially be implemented by many platforms.
  3. Native components with purposefully diverging props are probably just different components. If a native component on a specific platform has a bunch of custom props that no other platform would have, potentially that behavior should be pulled into a separate component. For example, if TV has a bunch of custom props that don't make sense on anything other than TVs, perhaps instead of adding those props to View, there should be a TVView component.

I've only thought about this from the perspective of component props and I'm curious what other situations there are that out of tree platforms currently use haste for to implement their functionality. Do the three constraints listed above satisfy the cases for other platforms? What other situations are there that we should be thinking about? How else could they be solved?

cc everyone I know working on 3rd party platforms: @rozele, @vincentriemer, @necolas, @empyrical, @acoates-ms, @matthargett, @douglowder.

TheSavior commented 5 years ago

We've been having some more conversations internally about this and I wanted to better articulate some of the constraints we have and a proposed approach.

Constraints:

Flow types must be exact, can't dynamically load different props based on platform

Since we will be using Flow Types to generate the native code for each component, the flow types must be strict, and exact. All props defined in the type will generate equivalent code for that prop on native. This is the interface of the component that platforms must implement.

Platform specific props shouldn't require changes to core

If react-native-appletv has a custom prop for View that is only relevant to appletv, the appletv library should be able to move forward without needing that prop to be added to the interface for View in the core spec. This constraint will enable platforms to move quickly on adding custom support for their platform without having to get things approved and merged by Facebook. Also, a custom prop for AppleTV shouldn't cause code to be generated for Android.

This approach should work the same for out of tree platforms as well as Android and iOS

Right now View has a bunch of props that are unique for Android and unique for iOS. This is a similar problem for out of tree platforms and whatever approach we take for other platforms should support Android and iOS becoming consistent with that.

Apps that support multiple platforms should only include a single platform's code in the compiled app.

When an app supports iOS and Windows, when building the iOS app there shouldn't be any native code or JS code that is specific to Windows, and vice versa.

Props for one platform should be errors when passed to another platform

Today the props for View are a union of Android and iOS props. If you are building an iOS bundle, it should be a type error to specify a prop that is only defined on Android, and vice versa.

Ideally avoid using platform extensions like .android.js

This approach has always been a non-standard thing that React Native has had. It would be great to not rely on this more and instead find a more generic solution that is more consistent with the rest of the JavaScript ecosystem.

Proposed Solution:

Since we can't upstream the properties for each platform, and we have to lock down the properties on View so platforms can't hook in and extend the View, platform specific properties need to be defined on a separate, custom component from View.

Platforms that need specific properties on View will create a separate native component, potentially named after that platform. For example, AppleTV might create an AppleTVView native component that inherits from the base View class. The platform can then provide additional props on that component without concern of other platforms.

In places where these custom properties are necessary, product code that uses multiple platforms can switch on which View they render. Something like the following. With Metro's inline imports and platform specific bundles, only the if statement for the current platform and the module it uses will be in the resulting bundle.

import {View} from 'react-native';
import {AndroidView} from 'react-native-android';
import {AppleTVView} from 'react-native-appletv';

MyComponent(props) {
  const child = (
    <MyChildComponent>
      <Text>Whee!</Text>
    <MyChildComponent />
  );

  if (Platform.OS === 'Android') {
    return <AndroidView filterTouchesWhenObscured={true}>{child}</AndroidView>;
  } else if (Platform.OS === 'AppleTV') {
    return <AppleTVView parallax={3}>{child}</AppleTVView>;
  } else {
    return <View>{child}</View>;
  }
}

Thoughts?

acoates-ms commented 5 years ago

First impressions is that this looks pretty good.

A couple of curve balls to consider. First is I suspect View will still end up with too much on it to begin with. The natural instinct given the current state of things would be for view to have all the properties which are supported in both android and ios. This will likely mean that we will start with a base class that has some properties which don't apply to windows, macos etc. -- Not the end of the world, other platforms can certainly choose to ignore certainly properties. And I understand how annoying it would be to add the same property to both AndroidView and IOSView which makes it annoying to use. But something to keep in mind.

One thing we have been having discussions around is as you have these shared components that have code to support multiple platforms, are you able to use those components without bringing in all the dependencies of all the platforms. In your example will metro/haul be able to compile that module when targeting AppleTV without having to install react-native-android? I suspect the import would fail even if it isn't being used? We really want to be able to get to the point where you are able to write a react-native-appletv app without having to install the android bits. This is something that is avoided currently by having completely separate *.platform.js files.

I'll also bring up the possibility of having a tree of platforms. As we expand the list of platforms we may decide we want to have some shared classes between different platforms. It not entirely clear how you would want to dice it, but you could imagine an apple platform which serves as the base for ios, appletv and macos. And a win platform which is a base of win32 and uwp. I haven't had time to fully digest what that would mean in the proposed world. It is something we have been playing with in the current *.platform.js resolution world. But I do like the idea of getting rid of that system completely, as it would help things like flow/typescript type checking not working correctly with that resolution system currently. But I could imagine having an IOSView inherit from an AppleView which inherits from View.

The last big problem that out of tree components have that I would be remise to avoid mentioning is around having to copy/paste the built in components. An example of this would be TextInput. If the entirety of TextInput was render() { <RCTTextInput …props> }, then everything would be well typed and enforced in the fabric model. -- Each platform would just have to implement the native side of RCTTextInput. But instead its full of giant if platform statements that dramatically changes the render logic. This means that to implement TextInput in another platform you have to copy/paste the current textinput.js. Pick one of the render forks (android vs ios), and then implement something. Then forever keep track of any change to the base textinput.js component and duplicate that change in your platforms fork of that file. I recognize that this isn't completely related to the above discussion, but is still a prime issue with most out of tree implementations.

empyrical commented 5 years ago

This might be too crazy/weird/complex, but the new custom resolver support that @arcanis added to enable PNP support might be useful here.

There is a new option for .flowconfig files that works like so:

module.resolver=<PROJECT_ROOT>/.pnp.js

For every module lookup it would do, it calls a node subprocess doing something like this (I forget what the actual arguments of the command are - it could differ from this hypothetical!)

node .pnp.js {module doing the requiring} {module to require}

Which would print an absolute path to stdout to the module which Flow would then use.

What I am proposing is making a custom resolver that would make a copy of ViewPropTypes (or whatever other files might need it) in a temporary folder, and do a codemod on it to import types for out-of-tree platforms' custom prop types.

Let's say react-native-example wants to add a custom prop to <View /> called doSomethingCool.

It would have a file called: react-native-example/lib/ExampleViewProps.js

And would look like this:

export type ExampleViewProps= $ReadOnly<{|
  doSomethingCool?: ?boolean,
|}>;

And an entry in its package.json like this:

{
  "rnpm": {
    "flow": {
      "View": {
        "ExampleViewProps": "./lib/ExampleViewProps.js"
      }
  }
}

When ViewPropTypes is required by Flow, it would create a copy of it in a temp directory with a codemod applied to it that would apply a transformation like this:

import type {ViewStyleProp} from 'StyleSheet';
import type {TVViewProps} from 'TVViewPropTypes';
+++ import type {ExampleViewProps} from 'react-native-example/lib/ExampleViewProps';

// ...

export type ViewProps = $ReadOnly<{|
  ...DirectEventProps,
  ...GestureResponderEventProps,
  ...TouchEventProps,
  ...AndroidViewProps,
  ...IOSViewProps,
+++  ...ExampleViewProps,

This way, prop types from an addon platform would "just work", and would also be properly removed if you were to uninstall the platform. I think TypeScript has custom resolver support too, so something like this could be done for TS prop types too.

But given all the issues that were had with tooling by the haste resolver, it would not surprise me if this was even worse for custom tooling support 😱

But I thought I would put that idea out there. Could also potentially enable other special codemods by other platforms, but should probably by kept to a minimum so things don't get too fragile. babel-plugin-module-resolver as a replacement for Haste for allowing platforms to override arbitrary JS files in react-native is an option

vincentriemer commented 5 years ago

My early impressions are positive, but I agree with @acoates-ms that a concern would be if a platform wanted to provide a core view/module extension with platform-specific functionality, I'd want to avoid needing to copy-paste the JS implementation. This is less of an issue for react-native-dom at this point because I'm trying to avoid adding platform-specific functionality until the RN core is well supported, but that could be a concern in the future.

necolas commented 5 years ago

platform specific properties need to be defined on a separate, custom component from View

One of the downsides here seems to be that every platform may need to define a custom "View", even if the majority of them share custom properties. For example renderers for web, windows, macos might all need to add mouse and keyboard events to "View". What determines when features/props are shared across enough platforms for them to be added to the core component API?

The platform can then provide additional props on that component without concern of other platforms.

More recently there's been an effort to remove platform-specific components and props from the core components. One of my concerns here would be that this could again increase platform-fragmentation of the core components, making it harder to target multiple platforms without increasing the number of platform forks in your app code.

With Metro's inline imports and platform specific bundles, only the if statement for the current platform and the module it uses will be in the resulting bundle.

I suppose this could be suggested as the replacement for *.platform.js files, as the result would be essentially the same. However, someone's probably going to try to unify the APIs again to avoid having to import handfuls of platform forks in their app code.

For example, where "react-platform" exports a singular View and papers over the platform-specific differences (plus forwards any statics, refs, etc).

export const View = (props) => {
  if (Platform.OS === 'android') {
    return <AndroidView {...props} />
  } else if (Platform.OS === 'appletv') {
    return <AppleTVView {...props} />
  if (Platform.OS === 'ios') {
    return <IOSView {...props} />
  if (Platform.OS === 'windows') {
    return <WindowsView {...props} />
  if (Platform.OS === 'web') {
    return <WebView {...props} />
  } else {
    return <View {...props} />;
  }
}

Allowing you to go back to writing code like this.

import {View} from 'react-platform';

export const MyComponent = (props) => <View filterTouchesWhenObscured={true} />

And relying on the compiler to be aware of which platforms support which props, so it can remove them from the source code before it gets bundled.

TheSavior commented 5 years ago

Yay! Feedback!

@acoates-ms:

First is I suspect View will still end up with too much on it to begin with.

Yeah, I could imagine that this is likely to happen. I don't have much of an answer here. Perhaps we are fine with this knowing that it is a place to start and will remove a majority of props from existing for all platforms. We can probably refine more in the future.

are you able to use those components without bringing in all the dependencies of all the platforms. In your example will metro/haul be able to compile that module when targeting AppleTV without having to install react-native-android?

What do you mean by "compile"? Do you mean compiling the native code for the component? Like can C# code for windows compile the project without having to have react-native-android installed? If so, yes, I think this is an explicit design goal.

On the JS side, if your product code has the platform checks to decide which to render, then I think the bundler would need to have those platforms to be able to bundle. I think this becomes more interesting when you use an npm packages which support some combination of packages and have the platform checks for those platforms. I'd imagine those packages would define a peer-dependency for those platforms which wouldn't resolve if the platform isn't installed which would require them to be either wrapped in a try/catch or have the import be inlined:

if (Platform.OS === 'myCustomPlatform') {
  const {MyCustomPlatformView} = require('react-native-mycustomplatform');
  return <MyCustomPlatformView />
}

How does this work today?

we want to have some shared classes between different platforms. [...] I could imagine having an IOSView inherit from an AppleView which inherits from View

This is how I'm thinking about it too. For example, maybe two different companies maintain two different projects, react-native-appletv and react-native-androidtv. These companies should be able to iterate independently from each other and from Facebook. However, if they find that their communities have overlap and a bunch of the concepts and props are the same on both platforms, they could collaborate on a react-native-tv base which defines a shared set of platform components which could be used in appletv and androidtv projects, and those projects could further add their own custom props on top of those.

I see this very similarly to how Browser vendors work. They can try out and iterate on APIs and platform features, making whatever changes they want without having buy in from other platforms. Once they realize that what they have is pretty well defined and generic to a broader group, they can propose for those APIs to be upstreamed and adopted by more browsers, requiring collaboration with the other vendors. In this case, for now, Facebook is the top most upstream decision maker, but it doesn't always have to be this way and we'd be set up better for a committee driven approach if we decide that makes sense.

@vincentriemer and @acoates-ms:

Problems with TextInput being full of Platform statements and requiring logic to be copied and pasted for other platforms.

I think we'll see that almost all of this will go away with Fabric. Those statements are because we don't have unified behavior and Props for that component. Fabric forces us to reconcile the props because they will be in shared C++ code. That should radically simplify the behavior of the component.

However, even if the component is much more simplified, if there is any logic in JS, and the component renders <NativeTextInput />, and the public API for RN is the JS <TextInput> and a different platform wants to render to a different Native component then yeah the JS will need to be duplicated for that platform. It seems like this won't be solved by this proposal but it will be strictly better than where we are today.

@empyrical Relying on our custom bundling logic and resolvers is problematic and we much prefer to not rely on it when we can avoid doing so. That makes it easier for tools like Haul or TypeScript to be able to support RN with a smaller amount of RN specific capabilities.

@necolas

What determines when features/props are shared across enough platforms for them to be added to the core component API? This is a good question without a good answer. The answer is probably "it depends" and would be on a case-by-case basis.

there's been an effort to remove platform-specific components and props from the core components.

Are you referring to moving platform specific components out as part of Lean Core (aka the Slimmening)?

making it harder to target multiple platforms without increasing the number of platform forks in your app code [...] someone's probably going to try to unify the APIs again to avoid having to import handfuls of platform forks in their app code.

I'd expect Platform specific forks in places where people are doing platform specific checks/behavior. In general for cross platform code though there would be no change. This also means that bundle sizes will be smaller because iOS code won't include the JS code for setting Android or web specific props.

Do you have another approach to this or a different tradeoff we should be making within these constraints?

"react-platform" exports a singular View and papers over the platform-specific differences

The biggest problem with this is that I don't know how you would typecheck this. You might be able to let it typecheck if you support props that aren't defined, but then there would be no way to guard against spelling mistakes like filtersTouchesWhenObscured (spot the spelling mistake!) 😄 Allowing any props even ones that aren't supported you can run into clashes when adding props in the future because they might conflict with a value that someone was passing unintentionally before, likely from a {...props}.

@mdvacca A concern that David raised was around app size with this approach. Creating an additional component on the native side takes up more size in the app than adding an additional unused prop to an existing component. The result of this conversation is that by removing from Android the native code for props that are only supported on iOS, we probably save more than Android adding an additional native component. When taken further, only including the code for that platform is probably the right approach.

empyrical commented 5 years ago

Alternatively, instead of adding a prop to <View/> or a view fork, a platform could create a new component instead.

Example, instead of this:

<AppleTVView parallax={3} />

There could be a component like:

<Parallax value={3} />

One advantage of this approach is that if you never use parallax in your app, it could be optimized out of the bundle easily.

Now, if a platform wanted to add a custom style attribute, like cursor for example, how could that be added? Or should adding a cursor on desktop/web platforms only be via custom components too?

cpojer commented 5 years ago

I'm slowly trying to catch up on this but this is really interesting to me and something I'd like to help with. It seems that there is consensus that React Native should support all environments well.

To move on this, I have a few more concrete questions, some of which are already being discussed:

I'd really love to hear answers to these which would help with my understanding of what we can do.

For the last one, since it's already being discussed, here are my further questions/thoughts:

rozele commented 5 years ago

I've been thinking about the user experience for adding platforms to an existing React Native app. I was going to create a separate issue for the proposal, but it seems relevant here.

Today, in react-native-windows we have an NPM package which manages the compatibility matrix between react-native and react-native-windows. The idea being that first you must install a CLI extension that knows the correct version of react-native-windows to install that is compatible with the currently installed version of react-native. So the two step process looks like this:

# From root of React Native project...
# Install CLI extension
yarn add rnpm-plugin-windows 
# Invoke CLI extension to resolve react-native-windows version and run project template init
react-native windows

My proposal is that we add a command to the @react-native-community/cli that manages this two-step process for you. E.g.:

# From root of React Native project...
react-native add-platform windows

Or, more generally:

react-native add-platform <platform>

The add-platform command would:

  1. Install react-native-<platform>@latest
  2. Inspect node_modules/react-native-<platform>/package.json for metadata about a resolver script
  3. Invoke the resolver script with the currently installed react-native version, which returns the appropriate version of react-native-<platform> to install.
  4. Re-install the correct version of react-native-<platform>.
  5. Inspect node_modules/react-native-<platform>/package.json for metadata about an init script
  6. Invoke the init script (for platform specific native templates / boilerplate / etc.).

Alternatively, we could give more control over the install process to the platform itself by doing something like:

  1. Install react-native-install-<platform>@latest
  2. Invoke main entry point of react-native-install-<platform>

At some far off point in the future, when the "slim core" work comes to it's full potential and we've separated out platforms, your experience for creating a new app for iOS, Android, Windows, and Linux might look like:

react-native init MyApp
react-native add-platform ios android windows linux

In the near term, however, this would just make platform extensions feel less ad hoc :).

I understand that we still have a lot of unknowns still with respect to how core overrides will be managed for platforms. However, this is a "low hanging" forcing function for how we can drive platform extensions towards uniformity of approach and architecture.

macintoshhelper commented 4 years ago

Hi, am adding a summary/rephrasing below of a comment that I made in https://github.com/lelandrichardson/react-primitives/issues/54, for extra visibility and to make sure it doesn't conflict with ongoing React Native efforts:

I've created a GitHub monorepo and npm namespace for @react-platform, and have started work on an ecosystem around it for cross-platform API and package wrappers, based on react-primitives and inspired heavily by the @types and flow-typed npm pattern. It's mainly based on the dependency injection system introduced by animatedjs and react-primitives, together with platform file extension resolving.

The main idea is to offer cross-platform React code interoperability as an external wrapper around React Native or out-of-tree platforms. e.g. @react-platform/svg (react-native-svg API) will resolve to the relevant web/native package, or render a stub <View> on unsupported platforms such as react-native-macos (with injectable fallbacks).

Another example: @react-platform/async-storage can have @react-native-community/async-storage as a peerDependency, and will resolve this for the supported platforms (iOS/Android/macOS/Windows/web), together with a core.sketch.js react-sketchapp implementation using Sketch's plugin settings storage, and will fallback to a JS only implementation with an in-memory object store for unsupported platforms. These fallbacks should be controllable during app initialisation via an injection API, in cases where error boundaries are more appropriate, or we want to log unimplemented platform-specifics to a file.

And @react-platform/native is offered with wrappers or polyfills for the React Native API. e.g. ScrollView falls back to View on react-sketchapp or react-figma, and a Dimensions and PixelRatio API will allow for injection, for platforms that don't have Dimensions natively, but want to do a bundler alias for a React Native codebase, even if this practice may be discouraged for typing purposes and supporting platform specific props.

Edit

I think that it's worth clarifying also, what counts as a React platform. Is using react-reconciler enough to count as a platform? Some runtime environments may be in sandboxes where running bundlers like Metro aren't appropriate, such as in design software plugins, so are these able to count as "React Native" platforms? I feel that file extensions should exist as a stable default for these use-cases (e.g. for react-sketchapp, react-figma, react-pdf), together with some standardisation around practises for react-reconciler, react-test-renderer usage. react-sketchapp renders from the JSON tree rather than reconciler, so does this count as a platform that's supported by the cross-platform ecosystem?

I'm new to the RFC process and the React Native open-source community, so I hope that I'm not causing any confusion, and hope that it's ok for me to use the @react-platform name in an unofficial capacity. I feel this is a big topic for a single issue and that it would be nice to have some community-led research and feedback to help surface ideas/code upstream.