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

RFC: createElement changes and surrounding deprecations #107

Open
wants to merge 3 commits into
base: main
Choose a base branch
from
Open
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
325 changes: 325 additions & 0 deletions text/0000-create-element-changes.md
@@ -0,0 +1,325 @@
- Start Date: 2019-02-20
- RFC PR: (leave this empty)
- React Issue: (leave this empty)

# Summary

NOTE: This proposal will sound scary. Keep in mind, while reading this, that the actual upgrade path should be very simple for most people since the deprecated things are mostly edge cases and any common ones can be codemodded.

This proposal simplifies how React.createElement works and ultimately lets us remove the need for forwardRef.

- Deprecate "module pattern" components.
- Deprecate defaultProps on function components.
- Deprecate spreading `key` from objects.
- Deprecate string refs (and remove production mode `_owner` field).
- Move `ref` extraction to class render time and `forwardRef` render time.
- Move `defaultProps` resolution to class render time.
- Change JSX transpilers to use a new element creation method.
- Always pass children as props.
- Pass `key` separately from other props.
- In DEV,
- Pass a flag determining if it was static or not.
- Pass `__source` and `__self` separately from other props.

The goal is to bring element creation down to this logic:

```

Choose a reason for hiding this comment

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

Suggested change
```
```js

function jsx(type, props, key) {
return {
$$typeof: ReactElementSymbol,
type,
key,
props,
};
}
```

# Motivation

In React 0.12 time frame we did a bunch of small changes to how `key`, `ref` and `defaultProps` works. Particularly, they get resolved early on in the `React.createElement(...)` call. This made sense when everything was classes, but since then, we've introduced function components. Hooks have also make function components more prevalent. It might be time to reevaluate some of those designs to simplify things (at least for function components).

Choose a reason for hiding this comment

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

Suggested change
In React 0.12 time frame we did a bunch of small changes to how `key`, `ref` and `defaultProps` works. Particularly, they get resolved early on in the `React.createElement(...)` call. This made sense when everything was classes, but since then, we've introduced function components. Hooks have also make function components more prevalent. It might be time to reevaluate some of those designs to simplify things (at least for function components).
In React 0.12 time frame we did a bunch of small changes to how `key`, `ref` and `defaultProps` works. Particularly, they get resolved early on in the `React.createElement(...)` call. This made sense when everything was classes, but since then, we've introduced function components. Hooks have also made function components more prevalent. It might be time to reevaluate some of those designs to simplify things (at least for function components).


Element creation is a hot path because it is used a lot but also because it is always recreated in rerenders.

`React.createElement(...)` was never intended to be the implementation of JSX but was the best we could do with tooling at the time. It was intended as what you might write manually (if you didn't want to use the createFactory form). The alternatives never provided enough value to warrant rolling them out everywhere. It has a number of issues:

- We need to do a dynamic test against a component if it has a `.defaultProps` during every element creation call. This can't be optimized well because the function it is called within is highly megamorphic.
- `.defaultProps` in element creation doesn't work with `React.lazy` so in that case we also have to check for resolving defaultProps in the render phase too, and means that the semantics are inconsistent anyway.
- Children are passed as var args and we have to patch them onto props dynamically instead of statically knowning the shape of the props at the callsite.
- The transform uses `React.createElement` which is a dynamic property look up instead of a constant closed over module scope. This minimizes poorly and takes a little cost to run.
Copy link
Contributor

Choose a reason for hiding this comment

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

Unfortunately this currently only applies when not also transformed to CommonJS. Both SWC and Babel will use _jsxruntime.jsx() when the CommonJS transform is applied.
Only Babel playground offers a shareabled link to highlight the issue but you can verify the same behavior with SWC (https://swc.rs/playground) when editing the JSON config directly.

It works as intended without any module transforms though.

- We don't know if the passed in props is a user created object that can be mutated so we must always clone it once.
- `key` and `ref` gets extracted from JSX props provided so even if we didn't clone, we'd have to delete a prop, which would cause that object to become map-like.
- `key` and `ref` can be spread in dynamically so without prohibitive analysis, we don't know if these patterns will include them `<div {...props} />`.
- The transform relies on a the name `React` being in scope of JSX. I.e. you have to import the default. This is unfortunate as more things like Hooks are typically used as named arguments. Ideally, you wouldn't have to important anything to use JSX.
sebmarkbage marked this conversation as resolved.
Show resolved Hide resolved
Copy link

Choose a reason for hiding this comment

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

Suggested change
- The transform relies on a the name `React` being in scope of JSX. I.e. you have to import the default. This is unfortunate as more things like Hooks are typically used as named arguments. Ideally, you wouldn't have to important anything to use JSX.
- The transform relies on a the name `React` being in scope of JSX. I.e. you have to import the default. This is unfortunate as more things like Hooks are typically used as named imports. Ideally, you wouldn't have to important anything to use JSX.


Other than performance this is also about a long term simplification of number of concepts you have to learn to use React. In particular, `forwardRef` and `defaultProps` are no longer something special.

Additionally, if we ever want to standardize more of JSX we need to start moving away from some of the more esoteric legacy behaviors of React.

# Detailed design

The design will include three steps. 1) A new JSX transform. 2) Deprecations and warnings. 3) Actual semantic breakages.

## JSX transform changes

There are many combinations of transpilers, bundlers and downstream tooling that needs to be updated to make any changes to React JSX support.

### Auto-import

The first thing we need to change is getting rid of the requirement to have a `React` identifier in scope.

Ideally the element creation should be part of the transpiler's own runtime. There are some practical concerns. For one, we have both DEV mode and PROD mode. The DEV mode version is a lot more complicated and integrated into React. We also make subtle changes between versions - such as this one.

It's much easier to iterate on new versions by deploying npm packages than updates to the compiler toolchain. Therefore, it might be best if the actual implementation still lives in the `react` package.

Ideally you wouldn't need to put write any imports to use JSX:
sebmarkbage marked this conversation as resolved.
Show resolved Hide resolved

```js
function Foo() {
return <div />;
}
```

Then it would compile to include this dependency, and the subsequently, the bundler would resolve it to whatever it wants.

```js
import {jsx} from "react";
function Foo() {
return jsx('div', ...);
Copy link

@streamich streamich Feb 22, 2019

Choose a reason for hiding this comment

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

Instead of importing import {jsx} from "react", have you considered injecting the jsx?

function Foo() {
  return jsx => jsx('div', ...);
}

or

function Foo(props, jsx) {
  return jsx('div', ...);
}

Also, have you considered calling it h, instead of jsx, like Vue, Preact and the rest of the community does?

Copy link
Member

Choose a reason for hiding this comment

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

jsx makes more sense to me. The rest of the community isn’t strictly true - Inferno uses createVNode, for example.

Choose a reason for hiding this comment

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

@trueadm Inferno is 100x less popular than Vue and Preact. And there are at least 10 more libs that use h.

Copy link
Member

@trueadm trueadm Feb 22, 2019

Choose a reason for hiding this comment

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

@streamich Where did you get the 100x figure from? Why are you even comparing popularity for? It's completely irrelevant for what we're looking for. Inferno influenced many of today's frameworks and libraries in the ecosystem - including React, Preact and Vue 3. That accounts for much more than Github stars or npm downloads in my eyes. My reference to Inferno was specific to this RFC too. createVNode in Inferno was designed purely for JSX compilation, not for users to hand-write. For those wanting to write UIs without JSX, the option to use something like hyperscript is still available (it just wraps around internal APIs).

There is a reason why h is used, and that's because it was originally short for hyperscript which has a specific API that was adopted by virtual DOM frameworks. https://github.com/Raynos/virtual-hyperscript. This differs from what this RFC is trying to do, as the signature will no longer be hyperscript or even hyperscript-like.

Choose a reason for hiding this comment

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

@trueadm Your point about the different signature is good, other points not so much.

But is is the signature that much different? The signature of various h across the libraries are quite a bit different, too.

Copy link
Member

@trueadm trueadm Feb 22, 2019

Choose a reason for hiding this comment

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

@streamich The core team aren't looking to leverage hyperscript though. We're looking for a better signature for creating React elements, specifically from JSX input. I don't see jsx as something people will be manually using, which was why hyperscript came around in the first place (to be a better alternative to createElement for building UIs in non-JSX libraries).

There's nothing stopping people from creating a wrapping library that offers an API like h that wraps around this new API. Like I said in my original point, there's a reason why not every library uses h, and my given example was Inferno. It was nothing to do with popularity, it was because of consumption. You never write createVNode in Inferno, it's got an API very much like the one proposed in this RFC, in where it's purely created from JSX compilation. You'd use h or createElement in Inferno, if you were writing the nodes manually by hand (without JSX). Those APIs are just wrappers around createVNode. I'm suggesting the exact same hypothesis here.

Choose a reason for hiding this comment

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

Yeah this definitely warrants it's own function name. We will want to export an h() shim that calls jsx() internally if this moves forward. h() would imply hyperscript compat and I don't think it would be a good idea to break that assumption.

Copy link

@streamich streamich Feb 22, 2019

Choose a reason for hiding this comment

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

The core team aren't looking to leverage hyperscript though. We're looking for a better signature for creating React elements, specifically from JSX input.

OK, I see your point and it makes perfect sense. And I see you proposed signature with flags, which also makes sense:

image

It's just from reading this RFC, the proposed signature

image

image

is pretty much what some libraries call h, with maybe exception for third key argument.

But if you add flags there, then you are right, then it definitely does not make sense to call it h,

}
```

The problem is that not all tooling supports adding new dependencies from a transform. The first step is figuring out how this can be done idiomatically in the current ecosystem.

### Pass `key` separately from props

Currently, key is passed as part of props but we'll want to special case it in the future so we need to pass it as a separate argument.

```js
jsx('div', props, key)
```

### Always pass children as props

In `createElement`, children gets passed as var args. In the new transform, we'll always just add them to the props object - inline.

The reason we pass them as var args is to distinguish static children from dynamic ones in DEV. We can instead pass a boolean or use two different functions to distinguish them. My proposal is that we compile `<div>{a}{b}</div>` to `jsxs('div', {children: [a, b]})` and `<div>{a}</div>` to `jsx('div', {children:a})`. The `jsxs` function indicates that the top array was created by React. The nice property of this strategy is that even if you don't have separate build steps for PROD and DEV, we can still issue the key warning and still pay no cost in PROD.

### DEV only transforms

We have special transforms meant only for DEV. `__source` and `__self` are not part of `props`. We can pass them as separate arguments instead.

One possible solution is to compile DEV as a separate function:

```js
jsxDEV(type, props, key, isStaticChildren, source, self)
```

That way we can easily error if the transform doesn't match.

### Spread only

This particular pattern:

```js
<div {...props} />
```

Can currently be safely optimized to:

```js
createElement('div', props)
```

That's because `createElement()` always clones the passed object. We want to avoid cloning in the `jsx()` function with the new transform. Most of the time this won't be observable because the JSX creates a new inline object anyway. This is a special case where it doesn't.

We could solve this by always cloning inline:

```js
jsx('div', {...props})
```

Or, we could leave it like this:

```js
jsx('div', props)
```

This would be a breaking change, but we could always clone in the call in a minor and then make the breaking change later in a major. The new semantics would be that the passed in object gets frozen (in DEV).

## Deprecate "module pattern" components

```js
const Foo = (props) => {
return {
onClick() {
//...
}
render() {
return <div onClick={this.onClick.bind(this)} />;
}
}
};
```

It causes some implementation complexity just by existing.

This is pretty straight forward to upgrade from. This is a very unusual pattern and most people don't know you can. The key is that your class constructor need to have a `Component.prototype.isReactComponent` property and handle being called with `new` (i.e. no arrow functions). Even if you happen to use the module pattern you can add a prototype with a `isReactComponent` property and use function expression instead of arrow function.

```js
function Foo(props) {
return {
onClick() {
//...
}
render() {
return <div onClick={this.onClick.bind(this)} />;
}
}
};
Foo.prototype = {isReactComponent: true};
```

The important goal here is that if we're going to introduce different semantics between classes and function components, we need to know before calling them which semantics we're going to apply.

## Deprecate `defaultProps` on function components

`defaultProps` is very useful on classes because the props object gets passed to many different methods. Life-cycles, callbacks etc. Each one in its own scope. This makes it hard to use JS default arguments because you'd have to replicate the same defaults in each function.

```js
class Foo {
static defaultProps = {foo: 1};
componentDidMount() {
let foo = this.props.foo;
console.log(foo);
}
componentDidUpdate() {
let foo = this.props.foo;
console.log(foo);
}
componentWillUnmount() {
let foo = this.props.foo;
console.log(foo);
}
handleClick = () => {
let foo = this.props.foo;
console.log(foo);
}
render() {
let foo = this.props.foo;
console.log(foo);
return <div onClick={this.handleClick} />;
}
}
```

However, in function components there really isn't much need for this pattern since you can just use JS default arguments and all the places where you typically use these values are within the same scope.
Copy link
Contributor

Choose a reason for hiding this comment

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

It makes for a more consistent API though. It doesn't matter what at what particular elementType I'm looking at currently. All I know is that a static property called defaultProps is responsible for default values.

Makes (human and machine) parsing of a given component definition much easier: "Just" scan for defaultProps.

This is also tricky for doc generators like react-docgen. It currently only supports defaultProps if destructuring is happening in the function signature. This might lead to an opinionated pattern how function component signatures should look like e.g.

function Component(props) {
  const { optionalProp = 'defaulted' } = props;
}

does not work currently.

Choose a reason for hiding this comment

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

@eps1lon As far as I know the plan is to deprecate class-based components entirely in the long run. Viewed from that perspective the decision to remove defaultProps or the other things in this RFC make a lot more sense.

Copy link
Contributor

Choose a reason for hiding this comment

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

Deprecate=split them into own module

Copy link
Member

Choose a reason for hiding this comment

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

Yeah we can't meaningfully "deprecate" class components (and don't intend to) in the observable future. However we do want to de-emphasize them if the Hooks adoption is successful and growing. Then simplifying the modern API at the cost of some clumsiness in the de-emphasized API seems justified.


```js
function Foo({foo = 1}) {
useEffect(() => {
console.log(foo);
return () => {
console.log(foo);
};
});
let handleClick = () => {
console.log(foo);
};
console.log(foo);
return <div onClick={handleClick} />;
}
```

We'd add a warning in createElement if something that doesn't have `.prototype.isReactComponent` on it uses `defaultProps`. This includes other special components like `forwardRef` and `memo`.

It's trickier to upgrade if you're passing the whole props object around but you can always reconstruct it if needed:

Choose a reason for hiding this comment

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

In my reading, the React Fire Ticket #13525 seemed to suggest that destructuring is not really a preferred pattern here (classname being renamed to class, giving destructuring of class a beginner-unfriendly syntax).
While I'm all in favor of default props being deprecated in favor of default props & destructuring there seems to be a slight conflict here in terms of simplicity & education of new developers.

I have no real opinion of either way, just wanted to point it out early in the thought process.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Yea. Tbh I’ve grown to favor keeping className instead of class for similar reasons.

The plan for Fire is more lava than set in stone.


```js
function Foo({foo = 1, bar = "hello"}) {
let props = {foo, bar};
//...
}
```

## Deprecate spreading `key` from objects

This pattern is currently supported:

```js
let randomObj = {key: 'foo'};
let element = <div {...randomObj} />;
element.key; // 'foo'
```

The problem with this is that we can't statically know if this object is going to pass a key or not. So for every set of props, we have to do an expensive dynamic property check to see if there is a `key` prop in there.

My proposal is that we solve this by treating a static `key` prop as different from one provided through spread. I think that as a second step we might want to even give separate syntax such as:

```js
<div @key="Hi" />
Copy link

Choose a reason for hiding this comment

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

How about react:key? It clarifies that it’s a React-specific prop that’s not passed to the component.

```

To minimize churn and open up a larger discussion about this syntax, we'd instead treat `key` as a keyword in JSX and pass it separately.

The backwards compatible implementation of `jsx(...)`, we would still support `key` passed as props. We'd just pull it off props and issue a warning that this pattern is deprecated. The upgrade path is to just pass it to JSX separately if you need it.

```js
let {key, ...props} = obj;
<div key={key} {...props} />
```

An unresolved issue is how we distinguish `<div key="Hi" {...props} />` from `<div {...props} key="Hi" />` which currently have different semantics depending on if props has a `key`.

In a later major, we'd stop extracting key from props and therefore props is now just passthrough.

## Deprecate string refs (and remove production mode `_owner` field).

We know we want to deprecate string refs. We already warn about this in strict mode. It's time to start warning about it in general.

In a future major, we'll remove string refs and that will let us get rid of the `_owner` field from elements.

We have a transform that adds `__self`. We can use this to issue different warnings when `__self` and `_owner` have the same value. In those cases it is safe to run an automated codemod of string refs from `ref="foo"` to `ref={n => this.refs.foo = n}`. Therefore the recommendation becomes to first fix all the cases where `__self` and `_owner` are *different* since those need manual intervention. That warning can go out earlier. After that warning has taken effect, we can next tell people to run a codemod for the rest.
Copy link
Contributor

Choose a reason for hiding this comment

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

This does requires a change in React since this.refs is a sealed object in DEV unless we use string refs. Which means the codemod can't be applied in versions where string refs are deprecated (we already have a deprecation warning in StrictMode trees). Unless we change the codemod to use

ref={(current) => {
  if (process.env.NODE_ENV !== 'production') {
    if (Object.isSealed(this.refs)) {
      this.refs = {}
    }
  }
  this.refs.stringRef = current;
}}

instead: https://codesandbox.io/s/correct-string-refs-codemod-yejy2e?file=/src/App.js

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

We can also just unseal it in versions where you're supposed to apply the codemod.

Copy link
Contributor

Choose a reason for hiding this comment

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

It's not just about unsealing I realized. React classes share the same object in this.refs until we attach string refs. So the proper codemod would apply

const emptyRefsObject = new React.Component().refs;
...
ref={(current) => {
  if (this.refs === emptyRefsObject) {
    // This is a lazy pooled frozen object, so we need to initialize.
    this.refs = {};
  }
  if (current === null) {
    delete this.refs.stringRef;
  } else {
    this.refs.stringRef = current;
  }
}}

to ensure the same behavior as string refs.

Or we would ensure that each new react class component instance has its own refs object by default


## Move `ref` extraction to class render time and `forwardRef` render time

In a minor, we'll add an enumerable getter in DEV for `props.ref` if a ref is defined on its element. This will warn if you try to access it. However, in class components, we'll detect this and create a copy of the props before passing it into the class. The same thing applies to `forwardRef`. We can also special case `cloneElement`.

Since you can't pass a ref to anything but host, class and forwardRef, it should be fairly unusual that you're spreading props that has a ref on it. Hopefully it is possible to work around the remaining cases.

In the next major, we'll start copying the ref onto both the props and the `element.ref`. React will now use the `props.ref` as the source of truth for `forwardRef` and classes and it will still create a shallow copy of props that excludes the ref in these cases. At the same time, we'll add a getter for `element.ref` in DEV that warns if you access it. The upgrade path is now to just access it off props if you need it from the element.

## Move `defaultProps` resolution to class render time

In a minor, after we've already deprecated `defaultProps` for non-classes, we'd start adding getters for all props that were resolved using `defaultProps` in DEV. Before passing this object to a class, we'd do a shallow clone and pass the props without the getters. These getters issue a warning, that you're reading from props of an element early on. Maybe `cloneElement` gets a special pass.

The upgrade path here, is to just avoid reading from `element.props` or move away from relying on `defaultProps` and passing them in explicitly or resolving them in the class.

In the next major, we'd stop resolving `defaultProps` during element creation and instead, we'd only resolve it right before we pass them into class components.

# Drawbacks

The main drawback here is that there is some manual work to upgrade user code. The type of things that needs to be changed are unusual patterns but they may be spread out. We can add warnings that track most patterns so if you have sufficient logging tools to follow up on these few edge cases, upgrades should be managable.

It also puts churn on the surrounding tooling ecosystem - like type systems.

There might also be a slight performance cost during the transition period.

# Alternatives

An alternative would be to leave this in place for now and then try to address it more holistically as a larger compiler project that might include more changes as well.

# Adoption strategy

Early on we'd have to deploy any changes to JSX transforms since it takes a long time for these to be deployed and they're typically not versioned together with React. The implementation would be backwards compatible.

In a minor version close in time to an expected major release we would include warnings for the deprecated patterns and instructions for how to change them to one that doesn't warn.

In the next major version, we would actually move ref/defaultProps resolution to classes. forwardRef would extract it from props, but now we could soft-deprecate forwardRef and instead just recommend pulling it off of props.

# How we teach this

The hard part is teaching the upgrade path. Once done, the result is significantly simpler since we remove many concepts. Especially if you teach function components first.

# Unresolved questions

- How do we distinguish `<div key="foo" {...props} />` from `<div {...props} key="foo" />` during the upgrade path stage?