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

Inline fbjs/lib/emptyObject #13055

Merged
merged 9 commits into from Jun 19, 2018
Merged

Inline fbjs/lib/emptyObject #13055

merged 9 commits into from Jun 19, 2018

Conversation

gaearon
Copy link
Collaborator

@gaearon gaearon commented Jun 15, 2018

Continuing #13046 and #13054.

This one was a bit more challenging, but it was nice to uncover the current assumptions. (Which tended to break with module resetting, for example.)

In most places I just inlined the code to create a DEV-frozen object. But a few places relied on reference equality semantics. I will add inline comments to describe what I did to fix those.

@@ -157,7 +156,17 @@ function coerceRef(
return current.ref;
}
const ref = function(value) {
const refs = inst.refs === emptyObject ? (inst.refs = {}) : inst.refs;
Copy link
Collaborator Author

@gaearon gaearon Jun 15, 2018

Choose a reason for hiding this comment

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

This part was a bit shady.

By default we set inst.refs to a frozen, shared object (in fact, we do this in two places: Component constructor, and later in the renderer). Whenever we want to mutate a ref for the first time, we check whether it's still "the same" pooled object, and if it is, we swap it out with the mutable one.

In some obscure cases where emptyObject identity changes over time (e.g. due to a module reset in Jest, or due to a duplicate fbjs), this wouldn't work and cause very confusing errors.

I've changed this to use an explicit flag instead. It is only set and read in this module. By default it's not set.

When the flag is not set, the first attempt to set a legacy ref will know that we need to create a new mutable refs object. Then we set the flag. Next time we attach a ref, the flag tells us it's safe to mutate now.

I could have put the flag on the mutable refs object instead. But that could break code like Object.keys(this.refs).map(...). I could make it non-enumerable. But Object.defineProperty is kinda slow. I figured an instance property is a fair game because we already use a bunch of those for legacy context.

This only affects classes using string refs. There is no cost for classes that don't use them. Even for those that do, it's a single boolean field.

This lets us get rid of the dependency on strict equality.

Copy link
Collaborator

Choose a reason for hiding this comment

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

You’re messing with the hidden classes here so there can still be a cost.

I’m not sure this is worth it.

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're already messing with them for legacy context. The only kind of components I'm worried about regressing (Relay) has both legacy context and legacy refs. This gives an incentive to migrate off both without regressing the current situation.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

I guess context is more localized in practice than legacy refs. Maybe not worth risking.

I can keep it on the React object but I think this will require some mocking of React itself in tests to ensure the object is preserved throughout resetModules. It was a bit easier for a separate module.

Copy link
Collaborator

Choose a reason for hiding this comment

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

I don’t see how the problems you mentioned trying to fix are still problems after this change even without this.

Even before I’m not sure how that could happen before but since it is now inline here, how would this happen? Somehow try to reuse the instance in the same renderer?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Pushed an alternative fix

@@ -28,14 +27,19 @@ if (__DEV__) {
warnedAboutMissingGetChildContext = {};
}

export const emptyContextObject = {};
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

This is exported because there are subtle assumptions around its referential equality in a few places throughout the Fiber codebase. It's used a sentinel value. This makes it explicit for this particular use case.

// This is needed for some tests that rely on string refs
// but reset modules between loading different renderers.
const obj = require.requireActual('fbjs/lib/emptyObject');
jest.mock('fbjs/lib/emptyObject', () => obj);
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 now drop this.

@pull-bot
Copy link

pull-bot commented Jun 15, 2018

ReactDOM: size: 0.0%, gzip: 0.0%

Details of bundled changes.

Comparing: ae14317...d385fe3

react-dom

File Filesize Diff Gzip Diff Prev Size Current Size Prev Gzip Current Gzip ENV
react-dom.development.js 0.0% 0.0% 624.18 KB 624.3 KB 145.76 KB 145.77 KB UMD_DEV
react-dom.production.min.js 0.0% 0.0% 95.14 KB 95.17 KB 30.76 KB 30.78 KB UMD_PROD
react-dom.development.js +0.1% +0.1% 616.35 KB 616.71 KB 143.57 KB 143.68 KB NODE_DEV
react-dom.production.min.js 0.0% 🔺+0.1% 94.87 KB 94.88 KB 30.1 KB 30.12 KB NODE_PROD
react-dom-server.browser.development.js -0.2% -0.1% 100.26 KB 100.04 KB 26.47 KB 26.46 KB UMD_DEV
react-dom-server.browser.development.js 0.0% 0.0% 92.71 KB 92.72 KB 24.89 KB 24.89 KB NODE_DEV
react-dom-server.browser.production.min.js -0.2% -0.2% 14.41 KB 14.38 KB 5.52 KB 5.5 KB NODE_PROD
react-dom-server.node.development.js 0.0% 0.0% 94.63 KB 94.64 KB 25.42 KB 25.43 KB NODE_DEV
react-dom-server.node.production.min.js -0.2% -0.2% 15.21 KB 15.19 KB 5.82 KB 5.81 KB NODE_PROD
ReactDOM-dev.js +0.1% +0.1% 626.41 KB 626.77 KB 142.99 KB 143.11 KB FB_WWW_DEV
ReactDOM-prod.js 0.0% 0.0% 271.66 KB 271.75 KB 51.2 KB 51.22 KB FB_WWW_PROD
ReactDOMServer-dev.js 0.0% 0.0% 96.44 KB 96.45 KB 24.82 KB 24.83 KB FB_WWW_DEV
ReactDOMServer-prod.js -0.1% -0.1% 31.92 KB 31.9 KB 7.78 KB 7.77 KB FB_WWW_PROD
react-dom.profiling.min.js -0.0% +0.1% 95.78 KB 95.78 KB 30.4 KB 30.42 KB NODE_PROFILING

react-art

File Filesize Diff Gzip Diff Prev Size Current Size Prev Gzip Current Gzip ENV
react-art.development.js +0.1% +0.2% 408.3 KB 408.51 KB 91.13 KB 91.34 KB UMD_DEV
react-art.production.min.js 0.0% 🔺+0.1% 82.35 KB 82.38 KB 25.38 KB 25.41 KB UMD_PROD
react-art.development.js +0.1% +0.2% 336.53 KB 336.98 KB 73.14 KB 73.27 KB NODE_DEV
react-art.production.min.js 0.0% 🔺+0.2% 47.07 KB 47.08 KB 14.61 KB 14.65 KB NODE_PROD
ReactART-dev.js +0.1% +0.2% 329.25 KB 329.7 KB 69.06 KB 69.2 KB FB_WWW_DEV
ReactART-prod.js 🔺+0.1% 🔺+0.1% 142.67 KB 142.79 KB 24.53 KB 24.55 KB FB_WWW_PROD

react-test-renderer

File Filesize Diff Gzip Diff Prev Size Current Size Prev Gzip Current Gzip ENV
react-test-renderer.development.js +0.1% +0.1% 342.57 KB 342.77 KB 74.17 KB 74.27 KB UMD_DEV
react-test-renderer.production.min.js 🔺+0.1% 🔺+0.2% 47.25 KB 47.28 KB 14.52 KB 14.55 KB UMD_PROD
react-test-renderer.development.js +0.1% +0.2% 334.93 KB 335.37 KB 72.09 KB 72.22 KB NODE_DEV
react-test-renderer.production.min.js 0.0% 🔺+0.1% 46.69 KB 46.7 KB 14.2 KB 14.21 KB NODE_PROD
react-test-renderer-shallow.development.js -0.9% -0.2% 24.12 KB 23.89 KB 6.49 KB 6.48 KB UMD_DEV
react-test-renderer-shallow.development.js 0.0% +0.3% 15.69 KB 15.69 KB 4.15 KB 4.16 KB NODE_DEV
react-test-renderer-shallow.production.min.js -0.4% -0.2% 7.51 KB 7.48 KB 2.48 KB 2.47 KB NODE_PROD
ReactTestRenderer-dev.js +0.1% +0.2% 340.77 KB 341.21 KB 71.7 KB 71.84 KB FB_WWW_DEV
ReactShallowRenderer-dev.js 0.0% +0.3% 16.46 KB 16.47 KB 4.29 KB 4.3 KB FB_WWW_DEV

react-reconciler

File Filesize Diff Gzip Diff Prev Size Current Size Prev Gzip Current Gzip ENV
react-reconciler.development.js +0.1% +0.2% 327.14 KB 327.5 KB 69.64 KB 69.75 KB NODE_DEV
react-reconciler.production.min.js 0.0% 🔺+0.2% 46.8 KB 46.8 KB 13.98 KB 14 KB NODE_PROD
react-reconciler-persistent.development.js +0.1% +0.2% 325.76 KB 326.12 KB 69.06 KB 69.17 KB NODE_DEV
react-reconciler-persistent.production.min.js 0.0% 🔺+0.2% 46.81 KB 46.81 KB 13.99 KB 14.01 KB NODE_PROD

react-native-renderer

File Filesize Diff Gzip Diff Prev Size Current Size Prev Gzip Current Gzip ENV
ReactNativeRenderer-dev.js +0.1% +0.2% 460.56 KB 461.02 KB 100.99 KB 101.15 KB RN_FB_DEV
ReactNativeRenderer-prod.js 0.0% 🔺+0.1% 206.16 KB 206.26 KB 36.17 KB 36.21 KB RN_FB_PROD
ReactNativeRenderer-dev.js +0.1% +0.2% 460.27 KB 460.73 KB 100.93 KB 101.09 KB RN_OSS_DEV
ReactNativeRenderer-prod.js 🔺+0.1% 🔺+0.1% 198.72 KB 198.82 KB 34.72 KB 34.75 KB RN_OSS_PROD
ReactFabric-dev.js +0.1% +0.1% 450.87 KB 451.27 KB 98.62 KB 98.75 KB RN_FB_DEV
ReactFabric-prod.js 0.0% 0.0% 191 KB 191.08 KB 33.33 KB 33.34 KB RN_FB_PROD
ReactFabric-dev.js +0.1% +0.1% 450.91 KB 451.31 KB 98.63 KB 98.77 KB RN_OSS_DEV
ReactFabric-prod.js 0.0% 0.0% 191.03 KB 191.11 KB 33.35 KB 33.36 KB RN_OSS_PROD
ReactNativeRenderer-profiling.js +0.1% +0.1% 201.25 KB 201.35 KB 35.27 KB 35.3 KB RN_OSS_PROFILING
ReactFabric-profiling.js 0.0% 0.0% 193.25 KB 193.33 KB 33.82 KB 33.82 KB RN_OSS_PROFILING

Generated by 🚫 dangerJS

@sebmarkbage
Copy link
Collaborator

sebmarkbage commented Jun 15, 2018

I think we rely on reference equality for empty function too in some cases.

What’s the goal of this? Why not just keep a shared instance but inside the React bundle?

const refs = inst.refs === emptyObject ? (inst.refs = {}) : inst.refs;
let refs;
// This flag tells us if we need to initialize `this.refs`.
if (inst.__reactInternalHasLegacyRefs) {
Copy link
Collaborator

Choose a reason for hiding this comment

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

Small not-even-worth-mentioning nit: since this isn't always a boolean, might be better to explicitly compare to undefined instead, like we do for other optional types.


import {TYPES, EVENT_TYPES, childrenAsString} from './ReactARTInternals';

const pooledTransform = new Transform();

const UPDATE_SIGNAL = {};
const emptyObject = {};
Copy link
Collaborator

Choose a reason for hiding this comment

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

Why go from a descript explicit identity to a non-descript, easy to accidentally share object?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

I was on the fence about this. Even if it's shared, does it matter if it's frozen in DEV? Seems like you'd quickly catch the mistake. It's also used under this name for context. Do you want to have a separate EMPTY_CONTEXT too? I can revert that part.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Pushed 1f0c930 which makes explicit naming consistent across renderers

@gaearon
Copy link
Collaborator Author

gaearon commented Jun 15, 2018

I think we rely on reference equality for empty function too in some cases.

Don't think so. I've already removed empty function in #13054. It wasn't used in comparisons.

What’s the goal of this? Why not just keep a shared instance but inside the React bundle?

I started doing that but then realized I don't see the point. It's just a few places. I also dislike that having it encourages implicit assumptions about reference equality which are later hard to debug.

@sebmarkbage
Copy link
Collaborator

A lot of the legit reasons to use an object is as a tag that cannot be duplicated. Kind of like Symbol. I don’t necessarily see reference equality as a bad thing. It is only bad if two objects that are two conceptual different signals have shared identity.

@gaearon
Copy link
Collaborator Author

gaearon commented Jun 15, 2018

It is only bad if two objects that are two conceptual different signals have shared identity.

I agree, but that's exactly what happened. "Empty refs" and "empty context" and "empty host context" and "update signal" are all different things. I found it difficult to understand where it was important in the code and where it wasn't. So that's the motivation for not sharing them.

The non-duplicatedness doesn't survive package boundaries without weird artifacts in edge cases. So I was trying to make it more local too.

@gaearon
Copy link
Collaborator Author

gaearon commented Jun 15, 2018

Some other alternatives for refs:

  • Keep empty object on React. This doesn't solve the problem of resetting modules or duplicate React in which case you'll get confusing errors about writing to a frozen object (as you do today).
  • Add a hidden field to the pooled empty refs object. But if we start relying on this it will break with older 16.x Reacts that don't have that field.
  • Use a local pooled object in the reconciler since we overwrite inst.refs anyway. This solves the problem of resetting modules. But it will break the case where a ref's owner is a different React (and thus hasn't been reassigned by this renderer).

Each renderer would have its own local LegacyRefsObject function.

While in general we don't want `instanceof`, here it lets us do a simple check: did *we* create the refs object?
Then we can mutate it.

If the check didn't pass, either we're attaching ref for the first time (so we know to use the constructor),
or (unlikely) we're attaching a ref to a component owned by another renderer. In this case, to avoid "losing"
refs, we assign them onto the new object. Even in that case it shouldn't "hop" between renderers anymore.
@gaearon
Copy link
Collaborator Author

gaearon commented Jun 15, 2018

Okay, so I pushed an alternative in 2e006cc which doesn't rely on cross-package object identity working, and doesn't break hidden classes. It does use Object.assign on the refs object (not the instance) just in case. But in practice this shouldn't matter because I expect initial inst.refs to always be empty so it shouldn't mess up the hidden class of LegacyRefsObject. (And of course later we set and delete properties on it anyway.)

@sebmarkbage
Copy link
Collaborator

I see the problem you’re trying to fix now. I was under the impression that we set refs in the renderer and not the isomorphic package.

For everything else we set it up in both. Eg updater can be different until after the constructor.

So what if we do the same here and set it to the renderer’s instance? That way it is always the same.

const refs = inst.refs === emptyObject ? (inst.refs = {}) : inst.refs;
let refs;
// This flag tells us if we need to initialize `this.refs`.
if (inst.__reactInternalHasLegacyRefs === undefined) {
Copy link
Collaborator

Choose a reason for hiding this comment

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

Checking for a nonexistent property is one of the most expensive things you can do if it is not cached. Because it has traverse the prototype chain.

@@ -157,7 +156,17 @@ function coerceRef(
return current.ref;
}
const ref = function(value) {
const refs = inst.refs === emptyObject ? (inst.refs = {}) : inst.refs;
Copy link
Collaborator

Choose a reason for hiding this comment

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

I don’t see how the problems you mentioned trying to fix are still problems after this change even without this.

Even before I’m not sure how that could happen before but since it is now inline here, how would this happen? Somehow try to reuse the instance in the same renderer?

@gaearon
Copy link
Collaborator Author

gaearon commented Jun 15, 2018

I was under the impression that we set refs in the renderer and not the isomorphic package.

We set in both. Normally checking for the renderer-owned emptyObject would be sufficient.

I think it's insufficient if the component is created by renderer A but ends up being an string ref owner of a component rendered by a renderer B (e.g. a duplicate ReactDOM). I'm not even sure this is possible but maybe it is in some edge case?

Like

ReactDOM.render(<Parent />, root) // parent.refs = emptyObjectFromReactDOM

class Parent {
  render() {
    var el = <Child ref="child" /> // <--- Parent is owner
    return <Indirection>{el}</Indirection>
  }
}

class Indirection {
  componentDidMount() {
    AnotherReactDOMCopy.render(this.props.children, root2)
    // element._owner.refs !== emptyObjectFromAnotherReactDOMCopy
    // so it will think it's safe to assign to them, but they're frozen
  }
}

In other words, a ref owner isn't necessarily created by the same renderer.

@sebmarkbage
Copy link
Collaborator

Interesting. If that is possible it is only because they share the current owner. In which case they have a shared isomorphic package.

The reason I’m pushing on this is since we have plans on relying even more on a shared isomorphic package and that I generally think of the OCaml/Rust style of variants to be the appropriate way to handle conditional states. So it’s interesting to explore beyond this change.

I don’t like exposing objects with prototype chains because people inspect them and it gets different hidden classes than other objects. Typically this gets an empty object with expandos.

@gaearon
Copy link
Collaborator Author

gaearon commented Jun 15, 2018

Wouldn't it switch to dictionary mode anyway as soon as we delete on it? (Just a few lines below, when detaching a ref.)

@gaearon
Copy link
Collaborator Author

gaearon commented Jun 18, 2018

I added a regression test for the case I explained above (which is easy to break with alternative solutions), and pushed a different approach. I'm relying on React for sharing the empty object (specifically for refs), and read it one from new React.Component().refs. Unlike sharing on a React internal, this will work even for older isomorphic React versions.

It's not currently possible to resetModules() between several renderers
without also resetting the `React` module. However, that leads to losing
the referential identity of the empty ref object, and thus subsequent
checks in the renderers for whether it is pooled fail (and cause assignments
to a frozen object).

This has always been the case, but we used to work around it by shimming
fbjs/lib/emptyObject in tests and preserving its referential identity.
This won't work anymore because we've inlined it. And preserving referential
identity of React itself wouldn't be great because it could be confusing during
testing (although we might want to revisit this in the future by moving its
stateful parts into a separate package).

For now, I'm removing string ref usage from this test because only this is
the only place in our tests where we hit this problem, and it's only
related to string refs, and not just ref mechanism in general.
@gaearon
Copy link
Collaborator Author

gaearon commented Jun 18, 2018

The last commit changes the ReactART test because it hits an unfortunate edge case (string refs + resetModules) that breaks the current setup. To be clear it was broken before too, but shimming emptyObject in tests worked around it.

A longer term fix might be to extract all shared state (including current owner etc) into yet another package as we discussed at some point, and then always preserving it across module resets, and locking its version.

@gaearon gaearon mentioned this pull request Jun 18, 2018
@gaearon
Copy link
Collaborator Author

gaearon commented Jun 19, 2018

I'm going to land this. Looks like sharing refs was the only concern and Seb suggested putting it on the React object, I did it through new React.Component().refs. Test coverage I added should be helpful if we want to change this again (e.g. via React.__emptyObject or something else).

@gaearon gaearon merged commit b1b3acb into facebook:master Jun 19, 2018
@gaearon gaearon deleted the no-empty-object branch June 19, 2018 12:41
Copy link
Contributor

@trueadm trueadm left a comment

Choose a reason for hiding this comment

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

Thanks for the detailed explanation in person, it makes sense and the code matches the intentions here. So this all looks good to me.

NMinhNguyen referenced this pull request in enzymejs/react-shallow-renderer Jan 29, 2020
* Inline fbjs/lib/emptyObject

* Explicit naming

* Compare to undefined

* Another approach for detecting whether we can mutate

Each renderer would have its own local LegacyRefsObject function.

While in general we don't want `instanceof`, here it lets us do a simple check: did *we* create the refs object?
Then we can mutate it.

If the check didn't pass, either we're attaching ref for the first time (so we know to use the constructor),
or (unlikely) we're attaching a ref to a component owned by another renderer. In this case, to avoid "losing"
refs, we assign them onto the new object. Even in that case it shouldn't "hop" between renderers anymore.

* Clearer naming

* Add test case for strings refs across renderers

* Use a shared empty object for refs by reading it from React

* Remove string refs from ReactART test

It's not currently possible to resetModules() between several renderers
without also resetting the `React` module. However, that leads to losing
the referential identity of the empty ref object, and thus subsequent
checks in the renderers for whether it is pooled fail (and cause assignments
to a frozen object).

This has always been the case, but we used to work around it by shimming
fbjs/lib/emptyObject in tests and preserving its referential identity.
This won't work anymore because we've inlined it. And preserving referential
identity of React itself wouldn't be great because it could be confusing during
testing (although we might want to revisit this in the future by moving its
stateful parts into a separate package).

For now, I'm removing string ref usage from this test because only this is
the only place in our tests where we hit this problem, and it's only
related to string refs, and not just ref mechanism in general.

* Simplify the condition
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.

None yet

6 participants