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 CM tearing #1455

Closed
wants to merge 9 commits into from
Closed

Conversation

salvoravida
Copy link

fix #1351

Hi folks.
My idea is to change forceRender into useSelector hook, let instead set a state, and return that state.
This should transform redux state changes into react useState changes that should batched and eventually paused form react concurrent mode

@netlify
Copy link

netlify bot commented Nov 8, 2019

Deploy preview for react-redux-docs ready!

Built with commit 16a6253

https://deploy-preview-1455--react-redux-docs.netlify.com

@salvoravida salvoravida mentioned this pull request Nov 8, 2019
@salvoravida
Copy link
Author

this should fix this demo
#1351 (comment)

@dai-shi
Copy link
Contributor

dai-shi commented Nov 8, 2019

This is probably a similar approach to my old implementation of reactive-react-redux.

It is important to keep consistency during intermediate updates, so you want to keep the test unchanged.
I think it's doable if you take the same approach as use-subscription.

It would actually be interesting to develop user-subscription based react-redux,
although I'm not motivated because I would like to push my approach.
I think the redux team is waiting for the core support, so they might not be so interested with the approach with use-subscription. But, anyway forking and experimenting should be good.

@salvoravida
Copy link
Author

finally it fix even useTransition and isPending

https://codesandbox.io/s/test-redux-in-concurrent-mode-with-react-redux-pytbh

@salvoravida
Copy link
Author

This is probably a similar approach to my old implementation of reactive-react-redux.

It is important to keep consistency during intermediate updates, so you want to keep the test unchanged.
I think it's doable if you take the same approach as use-subscription.

It would actually be interesting to develop user-subscription based react-redux,
although I'm not motivated because I would like to push my approach.
I think the redux team is waiting for the core support, so they might not be so interested with the approach with use-subscription. But, anyway forking and experimenting should be good.

the only test that i fix, is the one where the parent force the same value to the child, that need 2 render to CORRECTLY process all store updates. reading store on render is an hack to have 1 render less on that "rarely usecase" but that break the normal queque from Redux to useState.

@eddyw
Copy link

eddyw commented Nov 8, 2019

See #1351 (comment)
If component is wrapped within React.memo, it doesn't work 😛

@@ -31,7 +31,7 @@ function useSelectorWithStoreAndSubscription(
selector !== latestSelector.current ||
latestSubscriptionCallbackError.current
Copy link

Choose a reason for hiding this comment

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

To be CM friendly, could we do something like:

try {
 ...
} catch(e) {
  if (typeof e === 'object' && e !== null && typeof e.then === 'function') throw e
  let errorMessage = ...

So we could do this, for example:

  const status = useSelector(s => {
    if (s.status === STATUS.PENDING) throw promise;
    return s.status;
  })
}

In React docs, it's using something like:
const value = resource.read()
where read() either returns the value or throws so it can bail out before the selector is set. Not sure if here is the best place to do it, but yeah, with CM not everything "thrown" is an error.

Copy link
Author

Choose a reason for hiding this comment

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

sorry i think that thow promise to comunicate with Suspense does NOT matter with useSelector, that has to select a state without tearing on render. that's another problem.

@salvoravida
Copy link
Author

salvoravida commented Nov 8, 2019

See #1351 (comment)
If component is wrapped within React.memo, it doesn't work 😛

here is the fix 😛😛😛 https://codesandbox.io/s/test-redux-in-concurrent-mode-with-react-redux-salvoravidareact-redux-9jo9z

but please note that is a bug of the current "experimental react" with "SimpleMemoComponent"
infact force memo Non simple it works.

react-dom-internal

function updateMemoComponent(current$$1, workInProgress, Component, nextProps, updateExpirationTime, renderExpirationTime) {
  if (current$$1 === null) {
    var type = Component.type;

    if (isSimpleFunctionComponent(type) && Component.compare === null && // SimpleMemoComponent codepath doesn't resolve outer props either.
    Component.defaultProps === undefined) {
      var resolvedType = type;

      {
        resolvedType = resolveFunctionForHotReloading(type);
      } // If this is a plain function component without default props,
      // and with only the default shallow comparison, we upgrade it
      // to a SimpleMemoComponent to allow fast path updates.


      workInProgress.tag = SimpleMemoComponent;
      workInProgress.type = resolvedType;

      {
        validateFunctionComponentInDev(workInProgress, type);
      }

      return updateSimpleMemoComponent(current$$1, workInProgress, resolvedType, nextProps, updateExpirationTime, renderExpirationTime);
    }

i will open a issue on React.

So this "issue" is not related to useSelector!

@markerikson
Copy link
Contributor

I'm not actually sure what this PR is trying to accomplish. Can you clarify?

Also, whatever it's doing only seems to involve useSelector, and not connect.

@salvoravida
Copy link
Author

I'm not actually sure what this PR is trying to accomplish. Can you clarify?

Also, whatever it's doing only seems to involve useSelector, and not connect.

  1. https:/dai-shi/will-this-react-global-state-work-in-concurrent-mode
    tearing when dispatching fast action and slow updates, please @dai-shi can you add your thought?

  2. fix support dispatch action within "startTransaction"
    here demo ok : https://codesandbox.io/s/test-redux-in-concurrent-mode-with-react-redux-pytbh
    here demo KO : https://codesandbox.io/s/test-redux-in-concurrent-mode-with-react-redux-crbfn
    isPending is not working

@salvoravida
Copy link
Author

salvoravida commented Nov 8, 2019

the problem was reading store on render phase.
The mental modal is -> store Changes -> subcriptions call -> enqueque storeState, NOT forceRender

@markerikson
Copy link
Contributor

markerikson commented Nov 8, 2019

The point of forceRender is that the Redux store notification process is occurring outside of React, and therefore React has no way to know that a re-render is needed. Therefore, we need to tell React that it should queue up a re-render, and we are literally "forcing a render".

Now, the exact mechanics of that may be up for discussion.

For any potential change, I would specifically like to see some unit tests that fail under the existing implementation, and pass under a new implementation.

I'd also like to know how this affects our performance benchmarks. I'm not sure if we ever actually added the useSelector implementations to our benchmarks repo. @MrWolfZ , are this still only on your box?

@salvoravida
Copy link
Author

The point of forceRender is that the Redux store notification process is occurring outside of React, and therefore React has no way to know that a re-render is needed. Therefore, we need to tell React that it should queue up a re-render, and we are literally "forcing a render".

Now, the exact mechanics of that may be up for discussion.

For any potential change, I would specifically like to see some unit tests that fail under the existing implementation, and pass under a new implementation.

yes i know. the only change is that reduxStore.getState() must be done ONLY on useEffect and non on render otherwise there is the tearing effect when react render 50 items in a random way.

anyway, ok i will update with 2 test that simulates the 2 demos

@markerikson
Copy link
Contributor

Sure. The other half of this is that we need an equivalent implementation for connect(), as well.

@dai-shi
Copy link
Contributor

dai-shi commented Nov 8, 2019

I'm not sure if we ever actually added the useSelector implementations to our benchmarks repo.

@markerikson I did. Please check out the PR and I can adjust if you have suggestions.

@dai-shi
Copy link
Contributor

dai-shi commented Nov 8, 2019

@salvoravida In my experience, if you read store in render, you need to deal with the case that the store state is change from the time when checkForUpdates is called. That's what use-subscription is dealing with.

@salvoravida
Copy link
Author

Sure. The other half of this is that we need an equivalent implementation for connect(), as well.

done, fixed connect too.
Hope i have some time on weekend to add the CM tests.

Meanwhile if someone would like to try, here is a temp build : @salvoravida/react-redux 7.1.6 🎉

src/components/connectAdvanced.js Outdated Show resolved Hide resolved
if (count !== parentCount) {
sawInconsistentState = true
}
lastRenderInconsistentState = count !== parentCount
Copy link
Contributor

Choose a reason for hiding this comment

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

I don't understand this change. The whole purpose of this test was to ensure the selector never sees inconsistent state, not just on the latest render.

Copy link
Author

Choose a reason for hiding this comment

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

Because we NO more read the last state in the render phase, the "parent-child-issue" is resolved with 2 render, as every change of the store make 2 event-callback one on the parent and one on the child.
Before the issue was resolved with the "hack" of reading always the fresh Store state, even if we are on the "preview" scheduled refresh came out from "checkForUpdate" callback.

That hack while resolve the "parent-issue" with 1 render less, broke the changes normal queue on the the componente, that produce tearing on CM where every component could rendered in a random way, jumping the "changhes queue"

Copy link
Author

Choose a reason for hiding this comment

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

please try the demo of @dai-shi with my version and with old one. you will see the differences on a fast store changes, with slow render.

expect(childMapStateInvokes).toBe(2)
expect(childCalls).toEqual([['a', 'a'], ['ac', 'ac']])
expect(childMapStateInvokes).toBe(3)
expect(childCalls).toEqual([['a', 'a'], ['a', 'ac'], ['ac', 'ac']])
Copy link
Contributor

Choose a reason for hiding this comment

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

this changes the whole point of the test. this test ensures that mapState never sees inconsistent state. with this change you are asserting the exact opposite

Copy link
Author

Choose a reason for hiding this comment

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

Because we NO more read the last state in the render phase, the "parent-child-issue" is resolved with 2 render, as every change of the store make 2 event-callback one on the parent and one on the child.
Before the issue was resolved with the "hack" of reading always the fresh Store state, even if we are on the "preview" scheduled refresh came out from "checkForUpdate" callback.

That hack while resolve the "parent-issue" with 1 render less, broke the changes normal queue on the the componente, that produce tearing on CM where every component could rendered in a random way, jumping the "changhes queue"

please try the demo of @dai-shi with my version and with old one. you will see the differences on a fast store changes, with slow render.

Copy link
Contributor

Choose a reason for hiding this comment

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

If you are developing your own fork, it's fine and I think it's one possible approach.
But, if you would like to support all existing RR apps (which is necessary for the official RR), you want to keep the backward compatibility. As I said, it should be technically possible. Seems like you are used to the code base, I think you can do it. I'm not 100% sure though.

Copy link
Author

@salvoravida salvoravida Nov 9, 2019

Choose a reason for hiding this comment

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

@dai-shi i'm not dev my own fork, otherwise i havent opened a pr. that said i think that was a mistake reading fresh store state on render, to have 1 less render on that particular use case. Moreover it is of course back compatible with 1 render more (for that usecase)
Anyway i will try to explain better with a flow chart as soon as i can. That's all.

Copy link
Author

Choose a reason for hiding this comment

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

The Exactly point is: when a componet render it MUST read the storeState that was from HIS last storeChangeCallback!. Otherwise reading in render store.getState() could be newer than HIS callback queque. THAT'S THE POINT. Think more about that. test the demo.

Copy link
Contributor

Choose a reason for hiding this comment

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

Can you clarify what you mean by "more renders"? It sounds like you're saying we will cause additional render cycles to occur, which is going to be bad for performance.

Copy link
Contributor

Choose a reason for hiding this comment

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

@markerikson I believe this is not due to an additional render. let me try to outline the order of events here:

  1. the store updates
  2. the parent sees the change first (due to tiered subscription) and renders
  3. since the parent rendered and changed the props, the child renders as well. with this change, during the render the child will see the last state it knows about, which is the previous store state (since due to tiered subscription the child didn't see the update yet). funnily enough this is actually the inverse of the stale props issue, since now we actually see newer parent props with an old state instead of old props with a new state
  4. once the render of the parent finishes the child finally sees the state update and re-renders itself with consistent props and state

it feels to me that tiered subscription and CM might just be incompatible

@salvoravida as @dai-shi pointed out, this discussion is not about the correctness of your change, but about preserving the existing behavior for backwards compatibility. there are likely tons of apps out there that have mapStateToProps written in a way that depends on the props and state being consistent. that's why it is important we think of a way to keep the existing behavior. in the useSelector hook we took the approach of simply ignoring these situations and dealing with errors by forcing a re-render. that was a deliberate decision we could make because it was a new API. connect is sadly different

src/hooks/useSelector.js Outdated Show resolved Hide resolved
@salvoravida
Copy link
Author

@salvoravida In my experience, if you read store in render, you need to deal with the case that the store state is change from the time when checkForUpdates is called. That's what use-subscription is dealing with.

this pr does not read store on render, but on callback handler.

@dai-shi
Copy link
Contributor

dai-shi commented Nov 10, 2019

this pr does not read store on render, but on callback handler.

Sorry, that's my misunderstanding from your original comment. I will try to understand again how it's working.

Have you come up with any idea to keep the test unchanged?

@salvoravida
Copy link
Author

salvoravida commented Nov 10, 2019

Hi folks, i have added dai-shi CM tests on /test/cm !
🎉 Good News ! @dai-shi update your site with react-redux :D

PASS  __tests__/all_spec.js (12.341s)
  react-redux
    √ check1: updated properly (8053ms)
    √ check2: no tearing during update (1ms)
    √ check3: ability to interrupt render
    √ check4: proper update after interrupt (2108ms)

Test Suites: 1 passed, 1 total
Tests:       4 passed, 4 total

@dai-shi
Copy link
Contributor

dai-shi commented Nov 10, 2019

Please calm down.
Reading useSelector.js carefully, I think I understand what you are trying to do.
It will be more CM friendly than the current RR, if it could be done right.
To my knowledge, we can't use useRef to keep single source of truth.
I could probably make a failing test, but it would be hard and I wouldn't dare to do it.

So, there are two issues in my mind. One is the changed test case, and the other is the useRef. I'm not sure but they might be from the single issue.

Based on your idea, I would think something like this might work.

const useSelector = (selector) => {
  const [selected, setSelected] = useState(selector(store.getState()));
  if (selected !== selector(store.getState())) {
    setSelected(selector(store.getState()));
  }
  ...
};

Hmm, I'm not sure if this solves the first issue...
(I'm not even sure if the same technique can be applied to connect)

You want to try this? It seems like you are so exited (which is good), and I'm not sure I can convince you.

src/hooks/useSelector.js Outdated Show resolved Hide resolved
@salvoravida
Copy link
Author

salvoravida commented Nov 11, 2019

There are so many points that we should discuss and find all an agreement, step by step, without mixing different points.

i will try to start with these:

  1. First of all, we should all agree that we CANNOT read store.getState() into render phase, because that could be "reading from the future" in CM when there are many slow-render-component, dispatches with LOW priority and Scheduler interrupt ROOT_RENDER and commit partial renders.
    That is "TEARING" effect.

  2. Second point is that storeChanges MUST be enquequed to useState with setState, so that every render can read reduxState from hisCallback and his queue

this fix also the others @dai-shi CM tests.

This is also why i do NOT think that your last example of useSelector can work in CM!

@dai-shi
Copy link
Contributor

dai-shi commented Nov 11, 2019

I understand we are not on the same page.

  • [First Goal] My goal is to at least make it work in CM. That is to make my CM tests (excluding check3) pass without changing useSelector.spec.js.
  • [Future Goal] Your goal is far ahead of it, and try to support state branching. That would be challenging and we might not be able to keep backward compatibility.

we should all agree that we CANNOT read store.getState() into render phase

I disagree if I stand for [First Goal]. For [Future Goal], yes.

@salvoravida
Copy link
Author

salvoravida commented Nov 11, 2019

you cannot have the [FG] CM safe (no tearing) while you want to read on render phase!

the test that i have changed is safe, was an hack before to force read store on render phase.
to explain better this, i will prepare new tests. just wait! :)

@dai-shi
Copy link
Contributor

dai-shi commented Nov 11, 2019

you cannot have the [FG] CM safe (no tearing) while you want to read on render phase!

Both are [FG]... I assume [First Goal]. I think we can based on my snippet (which is from use-subscription which is basically for external store for CM safe).

@eddyw
Copy link

eddyw commented Nov 11, 2019

@dai-shi

[Future Goal] Your goal is far ahead of it, and try to support state branching. That would be challenging and we might not be able to keep backward compatibility.

I don't think it's trying to do that. There will always be state branching as long as it uses useState or useReducer, and it'll happen whenever you set a value within a transition. In this case, dispatch an action within a transition. However, that's a completely unrelated issue to "fixing tearing"

If you think in useContextSelector, if you set the value passed to the provider within a transition, it will have this state branching as well.

I'm trying to describe this in the other thread. Also, it'd be backwards compatible of course, because existing RR apps just don't use useTransition and Suspense only supports React.lazy

@dai-shi
Copy link
Contributor

dai-shi commented Nov 11, 2019

@eddyw Thanks for the clarification. I agree fixing tearing is nothing to do with branching.
And I think it can be solved by reading store state in render and fallback to re-render if it's changed during renders. (BTW, your description of the research in the other thread is very interesting.)

@salvoravida
Copy link
Author

fixed new @dai-shi CM tests. now reduxState is 100% "streamed" from Redux to Components, without reading from the future or jumping queue callbacks! CM should be fine.


if (equalityFn(newSelectedState, latestSelectedState.current)) {
setReduxState(prev => {
prev.value = newReduxState
Copy link
Contributor

Choose a reason for hiding this comment

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

@salvoravida As I said in other thread, I'm not confident this works for the future. Do you ever feel like just trying my previous snippet???

Copy link
Author

@salvoravida salvoravida Nov 12, 2019

Choose a reason for hiding this comment

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

For what i have understood, useRef is not CM safe. its a shared mem between branches. we need an useStateRef, a way to save data to current branch states queue.
I think it is safe, just says React to not re-render, but store a value on that branch.

i dont' think that reading value from the future is ok in CM and event streaming, but i will try your idea asap.
Have you tried it?

Copy link
Contributor

Choose a reason for hiding this comment

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

This line is mutating the existing state value, though, which is definitely not CM-safe.

Copy link
Author

Choose a reason for hiding this comment

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

no, because it is executed only on next update, before next render.

Copy link
Author

@salvoravida salvoravida Nov 12, 2019

Choose a reason for hiding this comment

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

on every branch, so every branch has it's own reduxState queue changes. this is why there is NO more tearing, even if with last test, that simulate this edge case.

Copy link

Choose a reason for hiding this comment

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

hm, so it's more or less like...:

const [refState, forceUpdateRef] = React.useState({ current: null })
React.useImperativeHandle(refState, () => 'some value', [ ...optional_deps ])

Isn't this just like a short cut?, I mean, if keeping state in ref and just using forceUpdate. If it's within a transition, forceUpdate will be scheduled for later but the ref will still change which I think this does. Using useState for refs is just like merging a ref with forceUpdate into one line of code:

const ref = useRef()
const forceUpdate = useReducer(c => c + 1, 0)[1]
somewhere {
   ref.current = someValue
   forceUpdate()
}
...
const [ref, forceUpdate] = useState({ current: null })
somewhere {
   ref.current = someValue
   forceUpdate({ ...ref })
}

Both forceUpdate will schedule "for later" if within transition.

Copy link
Author

Choose a reason for hiding this comment

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

Yes but using ref, another rendering brahch could update the same value before or after, like shared mem between 3d. Instead states ref are duplicated on every branch and every state ref updater will work on its own branch rendering.

Copy link

@eddyw eddyw Nov 12, 2019

Choose a reason for hiding this comment

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

Idk, this is how I see it:

queue no transition => [
  setReduxState(p => { p.value = reduxState 1; return p }),
  setReduxState(p => { p.value = reduxState 2; return p }), // before p.value is reduxState 1
  setReduxState(p => { p.value = reduxState 3; return p }), // before p.value is reduxState 2
]
start transition queue => [
  later setReduxState(p => { p.value = reduxState 4; return p }), // previous 'p' is unknown
]
queue outside transition => [
  setReduxState(p => { p.value = reduxState 5; return p }), // before p.value is reduxState 3
]
stop transition queue => [
  run previous:
    initial state: reduxState 5 <<<< this is previous 'p' now
    setReduxState(p => { p.value = reduxState 4; return p }), // <<< inconsistent state
]

Now, current @dai-shi tests using transition are not exactly right. It's testing dispatching in transition.. but, since there is no Suspense, no component is waiting for anything, so there is no need to queue for later.

Sometimes there may be some flickering on screen, when you use transition but nothing causes interrupting of rendering (like throw promise on Suspense), I've seen on some demos I was testing with, I think this is a bug related to what Dan mentioned on twitter, maybe? Point is, you can't test using transition without causing interrupt on render (throw on Suspense), otherwise it is pointless to wrap it in startTransition.

Copy link
Contributor

Choose a reason for hiding this comment

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

I'm not following the discussion in deep, but:

without causing interrupt on render (throw on Suspense)

My understanding is a bit different. We can cause interrupt with heavy computation without throwing promise. That's the whole point of my tests.

P.S. Did I ever test using transition? Just today, I made new checks using useTransition based on @eddyw 's demo. No Suspense. No throwing promises. Is it pointless?

Copy link

Choose a reason for hiding this comment

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

Never mind, ignore the above 🙈
I've copied the tests over this PR locally which did have useTransition but you finished committing later, so I had them broken

@markerikson
Copy link
Contributor

This PR is stale, and it's unlikely we're going to make any CM-specific changes to v7 at this point. Instead, we're more likely to rewrite things into a v8 release that builds on top of useMutableSource somehow.

@markerikson markerikson closed this Apr 7, 2021
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Concurrent Mode
5 participants