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

fix #2192 - dont recycle frozen objects #2193

Closed

Conversation

mike-marcacci
Copy link
Contributor

@mike-marcacci mike-marcacci commented Nov 15, 2017

This is one possible fix for #2192, which simply checks if objects are frozen before attempting to modify them.

Do note that while this fixes my use case and any others that are serialized as simple JSON-compatible types, this won't fix cases where a custom scalar is hydrated to a more complex type, such as a Date object. For this, you may want to consider adding a check to skip the recycle if the inputs aren't simple objects or arrays:

if (
  Object.getPrototypeOf(nextData) !== Object.getPrototypeOf(prevData) ||
  (
    Object.getPrototypeOf(nextData) !== Object.prototype &&
    Object.getPrototypeOf(nextData) !== Array.prototype
  )
) {
  return nextData;
}

But, since this may have more consequences, and I would really like my use case fixed ASAP 😜 I've omitted that "fix" from this PR. Please let me know if you'd like me to open a separate issue for that.

@mike-marcacci
Copy link
Contributor Author

This also fixes #2049 and is an alternative to #2051.

@dbslone
Copy link

dbslone commented Nov 17, 2017

I'm curious what the implications of removing the freeze logic entirely from Relay Modern is since its appears to only be applied when __DEV__ is true. Or I could modify #2051 to use this global flag to determine if it should thaw the frozen object. Just want to help in anyway I can so I can stop using my forked version.

@mike-marcacci
Copy link
Contributor Author

@dbslone - I hadn't realized that freezing was only done in dev (which makes sense). I think perhaps the right solution here is to flag the object as "scalar" when it is created, and check for that here instead. We could just use a Symbol defined in relay as the flagging key, and export it so people who (for example) hydrate a custom scalar to a Date object can flag it.

Out of a desire to get this fixed ASAP, I'll submit another PR for that approach right now to replace this one. Apologies to the relay team for the noise!

@mike-marcacci
Copy link
Contributor Author

Actually, I realized that:

  1. Symbol probably isn't something we can use in Relay, since it needs to target older browsers
  2. The "flagging" approach solves a problem that I'm not sure really exists
  3. There's no reason we'd want to attempt to modify a frozen object in production anyhow!

So, I'm re-opening this :)

@mike-marcacci mike-marcacci reopened this Nov 17, 2017
@ivosabev
Copy link

Any status on this? Will it be merged soon?

@mike-marcacci
Copy link
Contributor Author

Ya, we're running a fork locally, since this pretty much blocks development (although it works fine in production). I can't imagine this change would be problematic...

@ekosz
Copy link

ekosz commented Jan 5, 2018

@jstejada @kassens Would a Relay contributor be willing to look / merge this? This is a blocker for us as well (we use a JSON scalar).

Also PRs #2142 & #1868 are really important for me and my team. Some communication on why they haven't been commented in 3+ months would be really helpful. There hasn't been a lot of communication about FB's internal priorities & open source strategy when it comes to Relay.

@ivosabev
Copy link

ivosabev commented Jan 5, 2018

I agree with @ekosz, the lack of clear roadmap of the project has been a big issue since Relay was open sourced. There are no clear milestones or time dates of features being released. I am great proponent of Relay and it pains me to see such bad communication with the community. I hope this changes in the future.

@ekosz
Copy link

ekosz commented Jan 18, 2018

@kassens @jstejada Any consideration for this in 1.5.0? If not, what would need to be added to be considered?

@alloy
Copy link
Contributor

alloy commented Jan 28, 2018

@ekosz I don’t know the answer to that, but maybe some added test coverage could help reviewing?

@alloy
Copy link
Contributor

alloy commented Jan 28, 2018

@dbslone @mike-marcacci I’m not entirely sure I followed everything correct, but should #2051 be closed in favour of this one?

@mike-marcacci
Copy link
Contributor Author

@alloy I certainly would, given that the only reason mutation exists here in the first place is to preserve the object identity, which is lost in #2051 by copying.

@dbslone
Copy link

dbslone commented Jan 29, 2018

@alloy I will close my PR in favor of continuing the discussion in this thread

@alloy
Copy link
Contributor

alloy commented Jan 29, 2018

Thanks @dbslone 🙏🏽

@alloy
Copy link
Contributor

alloy commented Jan 29, 2018

@mike-marcacci Alright, so could you add a test that specifically demonstrates that?

@mike-marcacci
Copy link
Contributor Author

Sure thing, I’ll add a couple to the PR tonight.

@mike-marcacci
Copy link
Contributor Author

I've added 3 tests, all of which demonstrate the problem (they fail on the current master branch) and the fix (pass on the PR).

@alloy
Copy link
Contributor

alloy commented Jan 30, 2018

Nice 👌

@jstejada This looks good to me.

Copy link
Contributor

@facebook-github-bot facebook-github-bot left a comment

Choose a reason for hiding this comment

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

@jstejada has imported this pull request. If you are a Facebook employee, you can view this diff on Phabricator.

Copy link
Contributor

@jstejada jstejada left a comment

Choose a reason for hiding this comment

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

I think this looks good @mike-marcacci. Just added a quick question

@@ -30,29 +30,31 @@ function recycleNodesInto<T>(prevData: T, nextData: T): T {
const prevArray = Array.isArray(prevData) ? prevData : null;
const nextArray = Array.isArray(nextData) ? nextData : null;
if (prevArray && nextArray) {
const isFrozen = Object.isFrozen(nextArray);
canRecycle =
Copy link
Contributor

Choose a reason for hiding this comment

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

I might be missing something, but would it make sense to just assign canRecycle to false if it is frozen?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Hey @jstejada, that's a good question. I would not want to prematurely decide that canRecycle is false, since it's still possible for wasEqual && nextValue === prevArray[ii] to be true, and we wouldn't want to lose out on the ability to recycle deep objects just because it is frozen.

Copy link
Contributor

Choose a reason for hiding this comment

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

Ah I see, thanks for the explanation!

}, true) && prevArray.length === nextArray.length;
} else if (!prevArray && !nextArray) {
// Assign local variables to preserve Flow type refinement.
const prevObject = prevData;
const nextObject = nextData;
const prevKeys = Object.keys(prevObject);
const nextKeys = Object.keys(nextObject);
const isFrozen = Object.isFrozen(nextObject);
canRecycle =
Copy link
Contributor

Choose a reason for hiding this comment

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

same here?

@@ -30,29 +30,31 @@ function recycleNodesInto<T>(prevData: T, nextData: T): T {
const prevArray = Array.isArray(prevData) ? prevData : null;
const nextArray = Array.isArray(nextData) ? nextData : null;
if (prevArray && nextArray) {
const isFrozen = Object.isFrozen(nextArray);
Copy link
Contributor

Choose a reason for hiding this comment

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

Since we only freeze in DEV, perhaps we should only perform this isFrozen check in dev?
cc @leebyron

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Ya, I thought about that too but figured isFrozen should be pretty darn quick, and there’s no reason to try to modify a frozen object ever. Happy to add that though.

Copy link
Contributor

Choose a reason for hiding this comment

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

Sounds good, I can go ahead and add that tiny change before landing this PR if that sounds good to you!

Copy link
Contributor Author

Choose a reason for hiding this comment

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

👍 Sure!

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Hey, not sure if you already made the change over at FB, but I added the dev check here. Let me know if there's anything more I can do to help get this merged...

Copy link
Contributor Author

@mike-marcacci mike-marcacci left a comment

Choose a reason for hiding this comment

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

Hey there! Sorry for the delay (I was off the grid for the last few days). I'll go ahead and add that other test; hopefully my other comments make sense! Also, for what it's worth, I totally don't have any hangups about getting my PR accepted, so if somebody makes a preferred fix on your internal codebase, please feel free to merge that one right away 😊

const prevData = [{foo: 1}, {bar: 2}];
const nextData = [{foo: 1}, {bar: 2}];
Object.freeze(nextData);
expect(recycleNodesInto(prevData, nextData)).toBe(prevData);
Copy link
Contributor Author

@mike-marcacci mike-marcacci Mar 5, 2018

Choose a reason for hiding this comment

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

Hmm, I'm pretty sure this test is correct. It's testing that even if objects are frozen, we still recycle prevData object if it is deeply equal to nextData. This is important for preventing identical renders from triggering react diffing downstream.

The behavior of frozen objects differs from mutable ones only in that we can't preserve object equity in unchanged branches of a state tree that does have changes.

Given this prevData:

       A1
  B1        B2
C1  C2    C3  C4

we can recycle the entire object if nextData looks like this:

       A1
  B1        B2
C1  C2    C3  C4

...whether or not the anything is frozen.

However, if our nextData looks like this:

       A2
  B1        B3
C1  C2    C3  C5

...we can recycle B1 from prevData, but this requires mutating A2, which crashes the whole app if A2 is frozen. If we were really trying to optimize the frozen object case, it would be possible to create a new A2 to hold the prevData B1 and nextData B2, but since this only effects the dev environment, that's optimization that is not worth doing.

const nextData = [{foo: 1}, {bar: 2}];
Object.freeze(nextData);
expect(recycleNodesInto(prevData, nextData)).toBe(prevData);
});
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Sure thing!

@@ -30,29 +30,33 @@ function recycleNodesInto<T>(prevData: T, nextData: T): T {
const prevArray = Array.isArray(prevData) ? prevData : null;
const nextArray = Array.isArray(nextData) ? nextData : null;
if (prevArray && nextArray) {
const isFrozen =
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Either way is fine by me! Am I correct in assuming that these dev flags get replaced with constants in the build process, and then all constant paths get compiled out? If so, then it really doesn't matter – you'll end up with no checks in production regardless of where they happen in dev.

@mike-marcacci
Copy link
Contributor Author

mike-marcacci commented Mar 5, 2018

I was thinking about this again (encountered this in another dev environment this morning) and just wanted to clarify one thing from my explanation above, which supports the argument that these isFrozen checks stay where I put them:

In the second example, we CAN and DO recycle B1 from prevData as long as A2 is mutable. This way, if B1 is frozen but unchanged, we have the same behavior on dev and production. Because relay performs this freezing on custom scalars, which are by definition leaf nodes from graphql's perspective, this by far the most common case and should work correctly even in dev. Since the checks don't effect a production build at all (assuming they get compiled out), this definitely feels like the correct behavior.

@mike-marcacci
Copy link
Contributor Author

mike-marcacci commented Mar 16, 2018

As I just commented on your review comment, the test you requested already exists, so I believe this PR is good to go. I've switched my process.env checks with the __DEV__ globals to follow the style used throughout the rest of relay. I also confirmed that these checks are stripped from production builds, so I don't think there's any reason to move them.

I'm sorry to be pushy, but this is a tremendous headache for me. Every time anyone runs yarn in development, they have to apply a patch to relay. In the meantime, we're going to ditch the patches and run off a fork I've temporarily published to @boltline/relay-runtime. Until this or another fix get merged I'll keep this in sync with Facebook's relay-runtime releases, but I'll deprecate it on NPM as soon as a fix is available.

Please let me know if there's anything else I can do to help.

@mike-marcacci
Copy link
Contributor Author

mike-marcacci commented Mar 16, 2018

I cleaned up 3 tests and rebased from master to clean up the commit history, but it looks like doing so pulled in some breakage from master. I'll rebase again once master is passing. Apologies for the extra notifications.

@jquense
Copy link
Contributor

jquense commented Mar 25, 2018

Can we get another look here? This is super annoying to have to deal with.

@mike-marcacci
Copy link
Contributor Author

mike-marcacci commented Apr 1, 2018

Hey @jstejada when you get a chance would you mind looking over this again in the context of my above comments?

@dbslone
Copy link

dbslone commented Apr 16, 2018

@jstejada Any updates on when this will be merged?

@jstejada
Copy link
Contributor

hey @mike-marcacci, sorry for the delay in getting back to this. I'll take a look at your updated changes and comments soon. Thanks for taking the time to update this!

@mike-marcacci
Copy link
Contributor Author

For anybody else using my fork, @boltline/relay-runtime has been updated to 1.6.0 in step with FB's relay-runtime.

Copy link
Contributor

@facebook-github-bot facebook-github-bot left a comment

Choose a reason for hiding this comment

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

@jstejada has imported this pull request. If you are a Facebook employee, you can view this diff on Phabricator.

@virtualirfan
Copy link

Just checking in … is there a plan to merge this PR? I'm hitting this for doing subscriptions of a Json scalar type. I imagine a lot of people are hitting this, no?

@virtualirfan
Copy link

@mike-marcacci Wanted to test your fork … is that repo public? Couldn't find it.

@mike-marcacci
Copy link
Contributor Author

mike-marcacci commented Jun 8, 2018

Hey @virtualirfan, I published it to NPM as @boltline/relay-runtime (since it was being used in the boltline project) but the repo I my personal one: https://github.com/mike-marcacci/relay

I didn't expect this to take so long, so I didn't bother to do a neat fork and change the readme/metadata/etc from relay's to mine.

It's literally just git checkout {version tag} && git cherry-pick 4561712a0cf0bd1a6d73806b016d73926e3010a5 then published.

@virtualirfan
Copy link

Just wanted to report that the latest @boltline/relay-runtime published NPM package is working well for me via aliasing. See below.

@jstejada a validation point that might help you pull this PR into the next release.

module.exports = {
...
  resolve: {
...
    alias: {
...
      'relay-runtime': '@boltline/relay-runtime',
    }
  }
}

@dbslone
Copy link

dbslone commented Jul 27, 2018

Hey @jstejada what is the status on this being merged?

@dbslone
Copy link

dbslone commented Sep 18, 2018

@jstejada any update on when this PR will be merged? Is the conflict with the test file all thats holding it up?

Copy link
Contributor

@facebook-github-bot facebook-github-bot left a comment

Choose a reason for hiding this comment

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

jstejada has imported this pull request. If you are a Facebook employee, you can view this diff on Phabricator.

@ivosabev
Copy link

What is the status of this, it has been a year?

@josephsavona
Copy link
Contributor

The discussion internally is around potential performance impact. We're checking the best way to structure the code such that there is little-to-no overhead in production builds.

@mike-marcacci
Copy link
Contributor Author

mike-marcacci commented Oct 24, 2018

@josephsavona there can't possibly be a performance impact in production builds because all the changes are stripped during the production build process, as mentioned above.

@josephsavona
Copy link
Contributor

@mike-marcacci In an ideal world yes, constant propagation and dead code elimination would remove all of the newly added code in prod builds (including the extra if conditions). But that depends on what optimizations are being applied; we’re checking how this affects internal builds in different environments.

@mike-marcacci
Copy link
Contributor Author

mike-marcacci commented Oct 24, 2018

@josephsavona, ah that makes sense. I didn't realize that FB utilized multiple build systems internally; that seems like quite a challenge to keep track of!

I will say that while I know this is a particularly hot piece of code, the fact that __DEV__ is checked before Object.isFrozen means that the added cost in naive production builds is simply the evaluation of a boolean.

You made me curious about the possible implications, so I wrote a quick benchmark to make sure my suspicions were correct. Perhaps this will be helpful. :)

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.

10 participants