apollo-client icon indicating copy to clipboard operation
apollo-client copied to clipboard

Fixes #10065: fix race condition in useReactiveVar.

Open Huulivoide opened this issue 3 years ago • 3 comments

Checklist:

  • [ ] If this PR is a new feature, please reference an issue where a consensus about the design was reached (not necessary for small changes)
  • [ ] Make sure all of the significant new logic is covered by tests

Huulivoide avatar Sep 05 '22 09:09 Huulivoide

@Huulivoide: Thank you for submitting a pull request! Before we can merge it, you'll need to sign the Apollo Contributor License Agreement here: https://contribute.apollographql.com/

apollo-cla avatar Sep 05 '22 09:09 apollo-cla

I tried the current changes against your reproduction from #10065 (thanks for that!), and the counter appears to keep updating without getting stuck, so that's good news!

benjamn avatar Sep 16 '22 15:09 benjamn

I would drop the isSameValue check because it doesn't actually improve anything. It only makes the code more complex, and brings in a an additional bug vector.

React already does the same check internally https://reactjs.org/docs/hooks-reference.html#bailing-out-of-a-state-update

Also the early return on line 20 breaks things badly. It is of utmost importance, that we always subscribe to the changes in the RV, which can now be skipped.

I added this verison to https://codesandbox.io/s/usereactivevar-gets-stuck-0hixe3?file=/src/App.js and it sometimes, not always, gets stuck in the very early phase.

Huulivoide avatar Sep 19 '22 11:09 Huulivoide

Hello,

We just faced the same issue in our project with a counter counting up and down. So if I may intervene I want to do a few comments.

First of all, this is not exactly a race condition, actually the problem comes from the fact that the value of the react state may be different from the value of the reactive variable whenever the hook is triggered (due to react batching), hence, when the observer is set up again, the next change could notify the value of the react state and cause the issue (the reactive variable will update and notify observer then delete them, while the react state will bail out since it does not see any change). To reproduce the issue you need to set different values in the reactive variable in the same batch and again the same value kind of values in another batch. Your unit test which only increases value therefore cannot reproduce the issue.

// What you can do instead is:
rv(rv()+1); rv(rv()+1);
setTimeout(() => { rv(rv()-1); rv(rv()-1) }, 100);

I did a simple POC based on this idea => https://github.com/delph123/apollo-usereactivevar-bug

I tend to agree with Huulivoide, the return on line 20 causes more issue in the implemation so it should be deleted.

As for the same value check, it is indeed not really relevant. With the proposed fix, all updates are notified and we could set the react state multiple times in same batch in lots of other situations (take my poc for example). Instead you could completely abstract the react state from the reactive variable and make the react state be a counter which will help avoid setState calls. But as mentionned by Huulivoide react already does this and I think it's actually prefereable to delegate this task to react. Plus the original implementation works with a change of target reactive variable (ok - I can't find the use case but then again that's another bug I suppose).

I adapted my POC with the alternative implementation in case you want to play with it but again, on my side, I prefer the proposition of Huulivoide.

All the best!

delph123 avatar Sep 28 '22 07:09 delph123

Hey @Huulivoide 👋

Appreciate the patience with this PR! I'd love to see if we can get this in a 3.8.x patch release in the near future.

I actually think we can fix this in a simpler way by using useSyncExternalStore. Here is what that would look like:

// use our polyfilled version of useSyncExternalStore
import { useSyncExternalStore } from './useSyncExternalStore'; 

function useReactiveVar<T>(rv: ReactiveVar<T>): T {
  return useSyncExternalStore(
    useCallback(
      (update) => {
        return rv.onNextChange(function onNext() {
          update();
          rv.onNextChange(onNext);
        });
      },
      [rv]
    ),
    () => rv(),
    () => rv(),
  );
}

I've tried this out locally and the test passes as expected.

@delph123 I've also tried this approach using a fork of your reproduction and the app behaves as expected.

@Huulivoide are you still interested in seeing this through? If not, I'd be happy to get this PR in a state thats mergeable.

jerelmiller avatar Aug 30 '23 18:08 jerelmiller

Ah, yes! You're absolutely right, the useSyncExternalStore is probably the best fit for this use-case.

I'd love to see that land in 3.8.x branch. That would be a nice addition to useSuspenseQuery 👍

delph123 avatar Aug 30 '23 22:08 delph123

Deploy request for apollo-client-docs pending review.

Visit the deploys page to approve it

Name Link
Latest commit 7b7f2b6720d6f69e12af814c019a4a76e76938b3

netlify[bot] avatar Aug 31 '23 19:08 netlify[bot]

🦋 Changeset detected

Latest commit: 7b7f2b6720d6f69e12af814c019a4a76e76938b3

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
@apollo/client Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

changeset-bot[bot] avatar Aug 31 '23 19:08 changeset-bot[bot]