re-rxjs / react-rxjs

React bindings for RxJS
https://react-rxjs.org
MIT License
551 stars 20 forks source link

Subscribing in render phase is not "safe" #101

Closed joshribakoff closed 4 years ago

joshribakoff commented 4 years ago

In the same vein as my concerns in https://github.com/kosich/react-rxjs-elements/issues/4 I walked through your library to see how it works, it is very clever!

However, I see that in render phase, the library is calling toPromise (essentially subscribing in render). Would it be accurate to say that the way this library works is as following:

Is this all correct? If subscribing to a stream triggers side effects like a network request, this could be really important to users for it to be documented as a tradeoff the library makes, so they can know this upfront instead of learning it the "hard way" after writing an app.

If I understand, there is a tradeoff here where:

josepot commented 4 years ago

Is this all correct?

Partially, yes.

Is this "safe" for effectful streams?

Most definitely. We have been using these bindings at my company for some months now: we use lots of streams that trigger side-effects (websocket connections, http requests, etc) and we have never ran into the kind of issue that you are describing.

If subscribing to a stream triggers side effects like a network request, this could be really important to users for it to be documented

As I already mentioned in a previous issue: we are still working on the docs site, and this in particular will be properly explained.

However, I think you are missing some context:

1) What you are describing would only happen on a component that's being mounted and that is leveraging the Suspense API because that stream has never emitted anything yet. Also that's assuming that the user is not using the useSubscribe hook above the Suspense component and that the user hasn't increased the graceTime of the hook.

2) I could argue that if you have a component makes React take longer than 200ms to commit on a mounting component after the suspense promise resolves, then it's very possible that you actually have a bigger issue already :sweat_smile:

3) However, even if that was the case, the user would have at least 2 ways to avoid that hypothetical issue:

For instance, let's say that that following code is problematic:

const [useUser] = bind(id => requestUser(id));

const User: React.FC<{userId: number}> = ({ userId }) => {
  const user = useUser(userId);
  return (
    <ComponentVeryVeryExpensiveToMount user={user} />
  );
}

export const SuspendedUser: React.FC<{userId: number}> = ({ userId }) => (
  <Suspense fallback={null}>
    <User userId={userId} />
  </Suspense>
)

The problem could easily be solved like this:

const [useUser, getUser$] = bind(id => requestUser(id));

const User: React.FC<{userId: number}> = ({ userId }) => {
  const user = useUser(userId);
  return (
    <VeryExpensiveToMountComponent user={user} />
  );
}

export const SuspendedUser: React.FC<{userId: number}> = ({ userId }) => {
  useSubscribe(getUser$(userId))
  return (
    <Suspense fallback={null}>
      <User userId={userId} />
    </Suspense>
  );
}

or like this:

const [useUser] = bind(id => requestUser(id), 5_000);

const User: React.FC<{userId: number}> = ({ userId }) => {
  const user = useUser(userId);
  return (
    <VeryExpensiveToMountComponent user={user} />
  );
}

export const SuspendedUser: React.FC<{userId: number}> = ({ userId }) => (
  <Suspense fallback={null}>
    <User userId={userId} />
  </Suspense>
)

Also, you may find this passing test interesting. FTR: currently, that test would probably not pass on the experimental version of React. However, it does pass on the latest release of React 17, so I think that we are on a good spot right now :slightly_smiling_face:.

All that being said, I think that I got an idea to further mitigate this (IMO very hypothetical) issue.

joshribakoff commented 4 years ago

At work we use RxJS to control cars & to power some of our operational safety systems. If we send 2 RPCs to the car instead of 1 because React happened to, for example, take longer than 200ms to complete a render, that could result in grave consequences, for example. I think it's a legit concern. For us at least, increasing the timeout would not be acceptable, because there is no guarantee React completes the render within that time window, which is why you shouldn't normally do side effects in render phase like this, according to the React core team...

I think that I got an idea to further mitigate this (IMO very hypothetical) issue.

Why close the issue, instead of leaving open & discussing your idea?

Use useSubscribe just above the Suspense component.

Not clear on why you say this. It could be problematic for numerous reasons I listed in my OP. You subscribe in render. If React doesn't commit within 200ms, the effect will be double subscribed. This seems like it can happen on any initial render that takes over 200ms, which I've seen happen a ton FYI. I know simpler apps don't normally take that long to render, but many larger apps often do. Likewise, many larger apps may run into memory use problems if they're mounting hundreds or thousands of hooks quickly & the streams are retained for an extra 200ms.

I think the implications of this are also that you cannot cleanly unsubscribe when a component unmounts either (because you'd have to lower the timeout really low to 0, which would cause the above issue to happen)

we have never ran into the kind of issue that you are describing.

What happens if you stress test your app & turn on CPU throttling in the browser devtools?

josepot commented 4 years ago

For us at least, increasing the timeout would not be acceptable, because there is no guarantee React completes the render within that time window

Which is why the first solution that I provided was using useSubscribe :slightly_smiling_face:. Also, regardless of that, I would like to suggest that you make the requests idempotent so that no one get's killed :smile:

which is why you shouldn't normally do side effects in render phase like this, according to the React core team...

Things have changed a bit on that regard.

Why close the issue, instead of leaving open & discussing your idea?

Because I answered the question with all sort of details, and because the issue doesn't have to be open in order to continue the conversation. I like to have open issue for things that are actionable/need to be fixed. I really don't think that this is an issue. It's a very good question and concern and I do agree that should be explained properly in the docs, for sure.

Not clear on why you say this. It could be problematic for numerous reasons I listed in my OP. You subscribe in render. If React doesn't commit within 200ms, the effect will be double subscribed.

Not if you use useSubscribe on top of Suspense, have you look at the docs of useSubscribe?

This seems like it can happen on any initial render that takes over 200ms, which I've seen happen a ton FYI.

So have I. However, I really think that this is an x/y problem, because the whole point of Suspense is to avoid that issue. Think about the example that I just gave you: if you are using Suspense and you have a component that takes a long time to mount inside a Suspense component, then the rest of the app will mount a lot faster (because the expensive component would be put on suspense :wink:). That means that the subscription of useSubscribe will start before react tries to mount the expensive component that takes longer than 200ms to mount, and that subscription will prevent the source subscription from being closed.

What happens if you stress test your app & turn on CPU throttling in the browser devtools?

I've tried to reproduce the hypothetical issue that you are describing in many accounts and I haven't been able to reproduce the issue using useSubscribe.

However, if you are actually able to come up with a code-sandbox that reproduces this issue (while using useSubscribe in the way that I described), please let me know it and I will re-open the issue, for sure!

joshribakoff commented 4 years ago

I would like to suggest that you make the requests idempotent so that no one get's killed 😄

This is a bit disingenuous, first of all it implies I'm using tech I don't understand, second of all it implies we don't need side effectul requests. For example, a logs endpoint which is not idempotent by design because it would decrease write throughput, and it can be assumed client applications can manage to send the request just once.

Things have changed a bit on that regard.

From the suspense docs you just linked to:

We’ve already kicked off the requests in fetchProfileData(). It gave us a special “resource” instead of a Promise. In a realistic example, it would be provided by our data library’s Suspense integration, like Relay.

They trigger the side effect outside of render, not inside render. Nowhere are they saying to do your side effects in render method. Elsewhere in the docs it says we shouldn't have effects in render, and these suspense docs do not say otherwise. In fact they specifically do the side effect outside of render in the doc you are linking to.

Elsewhere in the docs they're also very explicit:

the render method itself shouldn’t cause side effects. It would be too early — we typically want to perform our effects after React has updated the DOM.

Not if you use useSubscribe on top of Suspense, have you look at the docs of useSubscribe?

I'm not reporting an issue about that, I'm just reporting an issue about this connectObservable pattern & how it subscribes directly in render.

come up with a code-sandbox that reproduces this issue (while using useSubscribe in the way that I described)

This feels a bit like telling the doctor your foot hurts when you walk & them saying don't walk. The issue isn't with the hook that doesn't subscribe in render. The issue is with the hook that does subscribe directly in render.

josepot commented 4 years ago

This is a bit disingenuous, first of all it implies I'm using tech I don't understand, second of all it implies we don't need side effectul requests. For example, a logs endpoint which is not idempotent by design because it would decrease write throughput, and it can be assumed client applications can manage to send the request just once.

My apologies @joshribakoff , it was meant to be a friendly joke. I did not mean to imply that you don't know what you are doing :handshake:. However, now I can see now how that didn't come across as I intended. I honestly apologize for that.

I'm not reporting an issue about that, I'm just reporting an issue about this connectObservable pattern & how it subscribes directly in render.

From the React docs that you mentioned:

In a realistic example, it would be provided by our data library’s Suspense integration, like Relay.

IMO what we are doing is not so far off. You see, the subscription/Promise should be there already. I think that it's important to understand why the react team doesn't want side-effects on render. The reason is because react may call the render method as many times as it needs to and given the same state and props, the function should always behave the same... I know that strictly speaking our render is not referentially transparent, but almost :smile:... It's like saying that a function that memoizes values is impure because it accesses a cache that it's outside of its scope. It's true, memoized functions are impure, but 99% of the cases they can be used like if they were referentially transparent. I think that what we are doing is something like that, because it doesn't matter how many times react recalls the render method, it would always behave the same.

IMO we understand what we are doing and that's why we can get away with making an early subscription on the init function of a useReducer, because it doesn't matter how many times react decides to recall the hook, it will always behave the same (from React's POV, of course).

I understand your concern, but after having tested this a lot, I assure you that this has never been problematic for us.

I'm not reporting an issue about that, I'm just reporting an issue about this connectObservable pattern & how it subscribes directly in render.

Well, you changed the title after I closed the issue. Before the title was a question and I didn't know that this was your worry.

The issue isn't with the hook that doesn't subscribe in render. The issue is with the hook that does subscribe directly in render.

I think that you are being a bit dogmatic about this... I will need something more than an appeal to authority, to consider this a real issue. That's why I think that it's important to show an example where doing what we are doing is going to be a real problem.

joshribakoff commented 4 years ago

Here's your reproduction case, https://www.youtube.com/watch?v=w5vOCKzSoYQ&feature=youtu.be

// @ts-ignore
import React, { useEffect, useState, Suspense, unstable_useTransition } from "react";
import "./App.css";
import { connectObservable } from "react-rxjs";
import { Observable, Observer } from "rxjs";

let subscribeCount = 0;
const obs = Observable.create((observer: Observer<any>) => {
  subscribeCount++;
  console.log("subscribed", subscribeCount, performance.now());
  observer.next('test')
  return () => {
    console.log("unsubscribe", performance.now());
  };

});

let renderCount = 0;

const [useCounter] = connectObservable(obs, 10);

function App() {
  const [startTransition, isPending] = unstable_useTransition({ timeoutMs: 3000 });
  const [foo, setFoo] = useState(false);
  const [bar, setBar] = useState(false);
  useEffect(() => {
    const int1 = setInterval(()=>{
      setBar(bar=>!bar)
    }, 100)
    const int2 = setInterval(() => {
      startTransition(()=>{
        setFoo(foo => !foo);
      })
    }, 2000);
    return ()=>{
      clearInterval(int1)
      clearInterval(int2)
    }
  }, [/*startTransition*/]);
  return (
    <>
      bar: {bar ? 1 :0}
      <br />
      foo: {foo ? 1 :0}
      <br />
      isPending: {isPending? 1 :0}
      <br />
      {foo ? <App2 /> : null}
    </>
  );
}

function App2() {
  console.log("render", renderCount);
  useEffect(()=>{
    console.log('mount', performance.now())
    return () => console.log('unmount', performance.now())
  },[])
  renderCount++;
  const count = useCounter();
  console.log(performance.now());
  let c
  for (let i = 0; i <= 1000; i++) {
    c = i * i
    console.log(i, performance.now())
  }
  console.log(performance.now());
  return <div className="App">count {count}</div>;
}

export default App;

. The reason is because react may call the render method as many times as it needs to and given the same state and props, the function should always behave the same.

No, I don't think that's the full story I'm afraid. As shown here, React can abort rendering for higher priority updates. Your component can render without ever mounting.

It's like saying that a function that memoizes values is impure because it accesses a cache that it's outside of its scope.

That would be a correct statement.

we understand what we are doing and that's why we can get away with making an early subscription on the init function

I'm not saying not to make this library. Just that you should have a warning or something, like one of the docusarous callouts that its experimental & could lead to non determinism.

Before the title was a question

Sorry, I was trying to be light handed in my feedback, but I do feel strongly that side effects in render could cause issue for newcomers, which is why I'm pushing back harder

I think that you are being a bit dogmatic about this... I will need something more than an appeal to authority, to consider this a real issue. That's why I think that it's important to show an example where doing what we are doing is going to be a real problem.

I gave an example, but IMO the React core team telling people not to do this because they plan to break the assumptions made here is a good enough reason for me not to do it in my lib. I just figured I'd point it out so you could be aware of the tradeoff sooner rather than later. I was hoping for less of a "debate" and more of a "thanks for pointing this out" and maybe a docs PR, but I can also see a lot of hard work has gone into this & how my feedback can come off with some friction here.

voliva commented 4 years ago

Hey - Thank you for comming up with this, it's a pretty valid concern. We're currently working in the docs and it's something we need to make it clear.

I'd like to clarify something though - For the example you provided, App2 is the only relevant bit, App is just unmounting/remounting App2 to replicate the race-condition. Sometimes when App2 mounts, it will subscribe - unsubscribe - subscribe again for one single mount lifecycle.

There are two things to note though:

This is all something we need to make very clear in the docs - We already have some of this work done in the core concepts section, and we'd love to hear feedback from it (points that need clarifying, etc.) although it's still WIP. Again, thank you for raising this.

joshribakoff commented 4 years ago

@voliva thats correct the App component (sorry for the terrible names) is mounting and un mounting App2 as a “low priority” update and is just setting some other arbitrary higher priority state which sometimes interrupts the lower priority update causing App 2 to render and “abort” or “miss” the mount/unmount

I would say your very detailed description above clarifies the tradeoffs such that users can reasonably use it safely (although I’m personally leaning towards being against this idea of side effects in render phase in my library)

Thanks for the detailed response if you need anyone to bounce ideas off of or review the docs feel free to ping me and best of luck with it all!

josepot commented 4 years ago

Hi @joshribakoff,

Thanks for providing your insights. However, I don't appreciate the following comments:

I'm not saying not to make this library. Just that you should have a warning or something, like one of the docusarous callouts that its experimental & could lead to non determinism.

Please, do not tell us again what we should do. It's not cool. Constructive criticism is very welcome, but blatant orders are not. This library hasn't been publicly announced yet, we haven't had the chance to publish our docs site yet and I don't appreciate this kind of unsolicited advice when we haven't had the chance to explain ourselves.

I just figured I'd point it out so you could be aware of the tradeoff sooner rather than later. I was hoping for less of a "debate" and more of a "thanks for pointing this out" and maybe a docs PR

:open_mouth: In my very first comment I already told you that we were aware of this and that the docs were in their way. I even provided you with a link to a test that we are using to make sure that this is not an issue in the stable versions. I also told you that we were aware that the current implementation can be problematic with the experimental version of React.

Also, my very first comment was an answer to a question. It turns out that -as you have admitted- your intention was not to ask a question, but to point out to us that we were doing something that you are convinced that it's not safe. So, after I answered and closed the question you decided to do a bunch of edits to the title and to your original comment. It's pretty obvious by now that you were not interested into what I had to say about your very good question. You had already made your mind that what we are doing is "not safe" and you were not open to the possibility that maybe we knew what we were doing. Even after I told you that we have been using these bindings on different projects with great success.

And last @joshribakoff I never suggested that you should be using these techniques on your own library. So, I don't understand why you have to bring your library into the comments of this conversation. We have chosen different paths, we are solving different problems, we are creating completely different mental-models and trade-offs and we understand reactivity very differently. I was hoping that we could help each other and learn from each other, I'm not interested in competing with any library.

joshribakoff commented 4 years ago

Hey, I'm sorry if my comment came off as a blatant order or unsolicited advice. Looking forward to the docs