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

[5.1.0-test.1] Connected components don't re render after state update #965

Closed
ematipico opened this issue Jun 23, 2018 · 52 comments
Closed

Comments

@ematipico
Copy link

I gave a first try to the new beta and I can see some problems (unless I should make some changes).

The components that are connected to the store via connect don't get updated (they don't re render). While debugging I can see that the function mapStateToProps gets called correctly (and I can see the changes) after the action but the component doesn't re-render hence it doesn't show the new values.

I hope it could help

@markerikson
Copy link
Contributor

Thanks. Can you put together an example reproducing the issue?

@ematipico
Copy link
Author

ematipico commented Jun 24, 2018

Closing, as the problem was probably due to an internal change of mine. Will re-open if I can understand the issue.

EDIT: Actually it's not, previous version works and new version doesn't work. Will try to figure out what's going on and provide some example if I can

@ematipico ematipico reopened this Jun 24, 2018
@gaearon
Copy link
Contributor

gaearon commented Jun 24, 2018

My guess is that getDerivedStateFromProps usage pattern is incorrect. Lack of comparisons in it is concerning. The tests probably don’t catch it because they’re not using 16.4 yet.

It’s worth reading https://reactjs.org/blog/2018/06/07/you-probably-dont-need-derived-state.html and applying that information to latest changes.

@gaearon
Copy link
Contributor

gaearon commented Jun 24, 2018

(I’m referring to React Redux implementation, not your application code)

@cellog
Copy link
Contributor

cellog commented Jul 14, 2018

@gaearon is correct. The current implementation calls gDSFP when props change, and then the updater changes the state, which causes gDSFP to re-run, which finds that the state is the same, which sets shouldComponentUpdate to false, and nothing re-renders.

so we have:

props change -> getDerivedStateFromProps -> runUpdater -> setState(props, shouldComponentUpdate: true) -> getDerivedStateFromProps -> runUpdater -> setState(shouldComponentUpdate: false) -> shouldComponentUpdate -> returns false

because of this, react-redux can't store the derived props in state and shouldn't be using gDSFP at all. Instead, I think that Connect should be managing the 2 possible mutations independently. The store subscription can simply update the state to the new store state to force a re-render check, and we run updater in shouldComponentUpdate to retrieve the derived props from the new props and the store state. Once the derived props have been generated, we can store them to use in render().

As I'm relatively unfamiliar with the internals of react-redux, I have a fix in a fork, but I'm working on making existing tests pass before making a pull request.

I have one question regarding one of the tests. in connect.spec, in test should pass state consistently to mapState I see that we have a case where batched updates don't actually trigger a store change before subscriptions are updated. Specifically:

      // The store state stays consistent when setState calls are batched
      ReactDOM.unstable_batchedUpdates(() => {
        store.dispatch({ type: 'APPEND', body: 'c' })
      })
      expect(childMapStateInvokes).toBe(2)

Why is it expected that child mapStateToProps would be called? In this case, the props and the state remain unchanged. It seems to me that this test is wrong. Before I change this, I wonder if the experts could weigh in? Shouldn't we only run mapStateToProps to generate derived props when either (1) the connected component's props change or (2) the store's state is changed? Not on (3) when any action is dispatched? My assumption here is that mapStateToProps is always a pure function, but maybe I'm missing something?

Thanks in advance

cellog added a commit to cellog/react-redux that referenced this issue Jul 14, 2018
This commit fixes reduxjs#965

The essence of the problem is that getDerivedStateFromProps is called when the incoming props OR incoming local state changes. So we cannot store anything in state that is needed in shouldComponentUpdate.

This commit splits up the tracking of incoming props, incoming store state changes, and removes getDerivedStateFromProps and the usage of local state to store any information.

Instead, local state is used as a flag solely to track whether the incoming store state has changed.

Since derived props are needed in shouldComponentUpdate, it is generated there and then compared to the previous version of derived props.

If forceUpdate() is called, this bypasses sCU, and so a check in render() compares the props that should have been passed to sCU to those passed to render(). If they are different, it generates them just-in-time.

To summarize:
1) shouldComponentUpdate is ONLY used to process changes to incoming props
2) runUpdater (renamed to triggerUpdateOnStoreStateChange) checks to see if the store state has changed, and stores the state, then updates the counter in local state in order to trigger a new sCU call to re-generate derived state.

Because of these changes, getDerivedStateFromProps and the polyfill are both removed.

All tests pass on my machine, but there is at least 1 side effects to the new design:

 - Many of the tests pass state unchanged to props, and pass this to child components. With these changes, the two updates are processed separately. Props changes are processed first, and then state changes are processed.

I updated the affected tests to show that there are "in-between" states where the state and props are inconsistent and mapStateToProps is called with these changes. If the old behavior is desired, that would require another redesign, I suspect.
@reduxjs reduxjs deleted a comment from VenkaiahKagga Jul 15, 2018
@markerikson
Copy link
Contributor

@cellog : thanks for the research and the PR! I'm off on a vacation atm, but I've been meaning to take a day to try pushing some of the React-Redux work forward. No guarantees on a timeline, but this is definitely now on my list to look at.

It looks like that test was added to fix #86 . At that point, React-Redux still had problems with connected children. The classic example is a connected list with connected list items, and if you deleted the data for a list item, the list item's mapState would run before the parent re-rendered and potentially explode because the data it needed was no longer in the store. This was resolved in v5 by enforcing top-down subscription behavior. It does look like the test has been tweaked some over time as a couple other assumptions have changed. To be honest, though, I'm not entirely sure what the test is actually proving in relation to batching, especially since it's only dispatching once inside the unstable_batchedUpdates() call. @gaearon , @epeli , can either of you comment on that?

Per your questions at the end, here's the expected sequence when an action is dispatched as of v5:

  • Root reducer runs
  • Subscriber functions all run, which includes a separate subscriber callback for each connected component instance.
  • Each connect callback checks to see if prevStoreState === currentStoreState. If they're the same, it assumes that the action did not result in store state updates, and bails out.
  • Assuming the root state has changed, connect then calls your mapState function, and does a shallow equality check on the returned object to determine if a re-render is needed.

Sequence in this test:

  • Action is dispatched, state is updated
  • Parent mapState runs, parent re-renders
  • Child still exists, child mapState runs, return value is an empty object, no need to re-render

So sure, I'd expect the child's mapState to have executed once for that one action, but I'm not sure what that proves.

@esamattis
Copy link
Contributor

I'm travelling at the moment and unable checkit for a week or so as don't remember it on top of my head. I'll check on this when I'm home again.

@cellog
Copy link
Contributor

cellog commented Jul 16, 2018

FYI, I don't think instance properties are async-unsafe. In the memoization example:

https://reactjs.org/blog/2018/06/07/you-probably-dont-need-derived-state.html#what-about-memoization

class Example extends Component {
  // State only needs to hold the current filter text value:
  state = { filterText: "" };

  // Re-run the filter whenever the list array or filter text changes:
  filter = memoize(
    (list, filterText) => list.filter(item => item.text.includes(filterText))
  );

  handleChange = event => {
    this.setState({ filterText: event.target.value });
  };

  render() {
    // Calculate the latest filtered list. If these arguments haven't changed
    // since the last render, `memoize-one` will reuse the last return value.
    const filteredList = this.filter(this.props.list, this.state.filterText);

    return (
      <Fragment>
        <input onChange={this.handleChange} value={this.state.filterText} />
        <ul>{filteredList.map(item => <li key={item.id}>{item.text}</li>)}</ul>
      </Fragment>
    );
  }
}

The recommended approach uses an instance variable filter

So that is not an issue.

@markerikson
Copy link
Contributor

Hmm. Interesting.

@gaearon , @bvaughn , @acdlite : can any of you provide some clarification around use of component instance variables and async safety?

@bvaughn
Copy link

bvaughn commented Jul 17, 2018

FYI, I don't think instance properties are async-unsafe.

Instance properties can be tricky to get right.

Function bindings are okay. (In fact they're often necessary!) But be wary of this.props or this.state references inside of your bound functions, because they might hold stale values (if the component has been rendered but not yet committed, or if an error occurred while rendering). If you need to access something in e.g. component state, use a setState updater function and read from the parameter passed to it as mentioned in the docs.

Other instance properties can be okay as well, although I would suggest avoiding them when possible. If you look at our create-subscription package, for example, you'll see that we use a couple of instance variables in it. The key here is to only update these values from a commit phase lifecycle (componentDidMount or componentDidUpdate) to ensure they are correct.

Does this clarify? I can elaborate if not.

@markerikson
Copy link
Contributor

@bvaughn : thanks, that helps. I think I have two general points of concern:

  • instance variables not being updated properly due to the render phase re-running multiple times with alternate values
  • memoization logic not memoizing right for the same reason

@cellog : based on those comments, I'm not sure that the current PR code is valid. Don't have any specific suggestions atm, though.

@markerikson
Copy link
Contributor

markerikson commented Jul 17, 2018

@bvaughn : lemme expand on that a bit and see if you have any suggestions.

connect has always implemented shouldComponentUpdate. Unfortunately, it's not just a question of whether the incoming "own props" from a parent have changed - there's the results of mapState and mapDispatch, which could be dependent on ownProps, and the user could have customized how those results should be merged together as well.

In connect v4, we set an instance flag in cWRP to indicate that props had changed, and the Redux store subscription callback set a similar flag and basically called this.setState({storeState}). From there, sCU was just:

      shouldComponentUpdate() {
        return !pure || this.haveOwnPropsChanged || this.hasStoreStateChanged
      }

In v5, we moved all the heavy lifting on the comparisons out into separate memoized selectors (homegrown, no use of Reselect). The core logic can be seen here:

function handleSubsequentCalls(nextState, nextOwnProps) {
const propsChanged = !areOwnPropsEqual(nextOwnProps, ownProps)
const stateChanged = !areStatesEqual(nextState, state)
state = nextState
ownProps = nextOwnProps
if (propsChanged && stateChanged) return handleNewPropsAndNewState()
if (propsChanged) return handleNewProps()
if (stateChanged) return handleNewState()
return mergedProps
}

We then re-run this giant selector in both the Redux store callback and, up until now, in cWRP. Those both update the selector, and it spits out a boolean that gets used in shouldComponentUpdate, as well as the resulting new combined props for the wrapped component.

What I'm concerned about is that it looks like having the selector get run multiple times during the render phase would cause its memoization to be wrong. It would be saving references to "unused" versions of ownProps, and that would cause incorrect determinations of whether to re-render.

In a sense, the selector really is a gigantic instance variable, as it's both an instance variable and stateful internally.

The only idea that's coming to mind at the moment is if we were to somehow split up the selector logic to separate out the "has something changed?" and "save the results" steps.

@cellog
Copy link
Contributor

cellog commented Jul 17, 2018

I think the short version of the question is:

How can we calculate props for our child component and prevent unnecessary renders of the child component? React-redux does it before sCU currently

@timdorr
Copy link
Member

timdorr commented Jul 17, 2018

@cellog

It would be up to the wrapped component to provide its own sCU implementation. Our sCU is only concerned with if the mSTP selector result has changed or if the incoming props are different, which would require a re-render of the wrapped component. Any render blocking beyond that is up to the wrapped component (or a better use of mSTP).

@markerikson

What I'm concerned about is that it looks like having the selector get run multiple times during the render phase would cause its memoization to be wrong. It would be saving references to "unused" versions of ownProps, and that would cause incorrect determinations of whether to re-render.

It's not so much that the memoization is wrong. It's working as intended, but is called multiple times and causing the local "updater" (previously this.selector) state to be compared twice. Because of the memoization, it's comparing the same result to itself and saying there has been no change.

We're basically cheating in gDSFP by keeping a local state hidden and making the function impure/non-idempotent. In retrospect, I shouldn't have merged in that async safety PR as-is and required that to be fixed. React 16.3 hid the problem, but 16.4 makes it readily apparent.

Async concerns aside, we have just a bad use of the React lifecycle here. I'll look through this stuff tonight and see if there's a better refactor that we're just missing.

@timdorr
Copy link
Member

timdorr commented Jul 17, 2018

Also of some interest (although quite a ways away and very much breaking): facebook/react#13216

@bvaughn
Copy link

bvaughn commented Jul 17, 2018

instance variables not being updated properly due to the render phase re-running multiple times with alternate values

You shouldn't update instance values from render phase lifecycles. This is why my post above said to only update these values from a commit phase lifecycle (componentDidMount or componentDidUpdate) to ensure they are correct.

So long as you're following this rule, re-running render multiple times should not cause any improper updates.

What I'm concerned about is that it looks like having the selector get run multiple times during the render phase would cause its memoization to be wrong.

Not sure I follow here either. Why would calling a memoized function multiple times cause anything to be wrong? Presumably, the function would return the memoized value unless the input changed– in which case it would recalculate.

In an async app, you might want a cache size that's larger than one– so you low and high priority updates don't unnecessarily bust your cache– but I don't see where multiple calls implies incorrectness, provided you aren't updating instance properties during render phase (which you shouldn't be).

@cellog
Copy link
Contributor

cellog commented Jul 17, 2018

A bit of a new idea occurred to me on how to solve this. Basically, what we are looking for is a way to generate derived props and then run the complete life cycle based on those props, and only do it if they have changed.

And we just learned the safest place to do this is post-render or in render itself. So we need a way to run shouldComponentUpdate after render, and the most useful one would do what PureComponent does, but with the generated props.

So what if we made connect 2 components? The first would simply take incoming props and incoming store state, and call mSP to generate derived props. It would do this every time, and then pass the result to its child, a "middle man" PureComponent-based component. The component would only update on props changing, and so we could simply put updateChildSubs() in the componentDidUpdate() of the child. That component would have as its child the WrappedComponent.

This way, no funny business is needed with instance variables, and we are fully async safe.

If this indeed seems viable as a solution to @bvaughn (from cursory read) I can whip up a sample and see if I can get all the existing tests to pass with it.

@timdorr
Copy link
Member

timdorr commented Jul 18, 2018

@cellog That's what the pure option does currently, which is enabled by default. We act essentially as a PureComponent already.

Again, the core issue is the misuse of gDSFP. We're literally doing the first thing the 16.4 blog post says not to do. So, the refactor would be to not do that. We should do something closer to @bvaughn's subscription example: https://gist.github.com/bvaughn/d569177d70b50b58bff69c3c4a5353f3

@bvaughn
Copy link

bvaughn commented Jul 18, 2018

@timdorr's comment sounds right to me, although I'm skimming this thread a bit 😅

The only downside to that approach is that it can cause sync deopts for async rendering, but I think it's necessary at least until/unless a suspense-based solution is in place.

@cellog
Copy link
Contributor

cellog commented Jul 18, 2018

Yes, the current source misuses gDSFP, that is clear. The issue I see is that the subscription example you linked to ONLY uses the data from the external source. the rest of the props are not used in deriving state. React-redux uses both props and the external data from the redux store to derive props.

I'll see if I can turn these abstract words into code on another branch, it should make it easier to understand what I'm trying to say. Appreciate the patience and feedback. Basically, right now, we do:

<ConnectAdvanced props={...} store=store>
 <WrappedComponent props={derivedProps} />
</ConnectAdvanced>

And we try to handle the sCU and deriving and subscriptions in the single component, which causes issues since we can't get derived props in time for sCU unless we calculate in sCU, which is not OK, as it turns out.

I'm suggesting that instead:

<GenerateDerivedProps props={...} store=store>
 <DecideWhetherToRender props={derivedProps}>
  <WrappedComponent props={derivedProps} />
 </DecideWhetherToRender>
</GenerateDerivedProps>

This way, we can use gDSFP and handle subscriptions in cDM and cDU in the GenerateDerivedProps component, which will always re-generate and pass to the DecideWhetherToRender component. This component can memoize the derivedProps, and use that in sCU to decide whether to update. Also, the cDU can contain notification of child subscriptions unconditionally, or we can do that directly in sCU if we would return false (react-redux 5.0 behavior that is missing in 5.1 test)

And, to continue, if pure option is false, then we simply don't use sCU in DecideWhetherToRender

In short, this removes the race conditions of generating derived props and using those props to decide whether to render. The race condition is that we need to store BOTH the generated props, and the store state that we used to generate them in state to work properly. That's why gDSFP and a single connecting component is unlikely to work for react-redux (as I see it).

Make more sense?

@markerikson
Copy link
Contributor

@cellog : unfortunately, I don't think that's a viable option for now. Having only one wrapper component for connect could be considered part of our API contract so far, and there's plenty of code in the wild (even if only in tests) that expects just one wrapper.

Someone else did suggest the 2-step approach in the comments of #898 (see some of the CodeSandbox links).

I may take some time tomorrow to look at this more as well.

@markerikson
Copy link
Contributor

@bvaughn : right, I was agreeing with you on the instance variables thing, and saying that our current approach violates that.

Per memoization, the problem I'm seeing is that in an async scenario, rerunning the render phase with varying values would cause the memoization to not cache hit as desired, and also effectively update an "instance variable" in the render phase.

@cellog
Copy link
Contributor

cellog commented Jul 18, 2018

What I'm getting from the React maintainer's comments and from the excited end-users commenting on that PR is that perhaps react-redux will work best if both subscription and dispatch is handled via React, and will resolve tearing and other async issues since React has to do that.

I'm going to abandon this PR, and temporarily use my fork or something similar in the case where I need it (docs website for ion-router) until things settle a bit.

I don't think it is wise to do a half version of the switch, the complexity is too large. I will say this is all very exciting, because React is finally going to be capable of managing state internally, which was always why redux was external: it couldn't do it properly before.

@markerikson
Copy link
Contributor

@cellog : well, we still need to come up with a resolution for our current issue. This started with #897 and #919 , and our current goal is to patch up the existing implementation to resolve any deprecation warnings related to lifecycle methods. Per my writeup in #950 , after that I'd like to rework the internals to use the new context API, and that would be a major version bump.

The chatter in that other issue can be ignored for now, especially around rearchitecting things to be React-centric. We need to figure out a solution that works with our current architecture.

@cellog
Copy link
Contributor

cellog commented Jul 18, 2018

Well, this PR does solve the deprecation issue. So perhaps that's the answer. We know that solving async will require major surgery, so future-proofing becomes unnecessary

@markerikson
Copy link
Contributor

What I mean by that is that the memoized selector is itself stored as an instance variable, and if I call it, it's going to cache the values I passed in. It may be stretching the concept, but I can see that being similar to directly updating actual instance variables on the component.

Our selector logic is all homegrown, and definitely just has a cache size of 1 right now, as seen here:

export function pureFinalPropsSelectorFactory(
mapStateToProps,
mapDispatchToProps,
mergeProps,
dispatch,
{ areStatesEqual, areOwnPropsEqual, areStatePropsEqual }
) {
let hasRunAtLeastOnce = false
let state
let ownProps
let stateProps
let dispatchProps
let mergedProps

@markerikson
Copy link
Contributor

So per the last couple comments: I guess there's a question of just how async-safe we need to make 5.x. We seem to have fixed the <StrictMode> warnings, but we're also doing stuff that <StrictMode> presumably isn't catching. Can we get away with 5.x not being totally "safe", if we're going to try to have a 6.0 release that hopefully is different enough to fix all the concerns?

I think moving to new context in a 6.0 release is a step in the right direction, but we're probably going to run into the same issues regarding when we try to calculate props and shouldComponentUpdate flags.

@bvaughn
Copy link

bvaughn commented Jul 18, 2018

The memoized selector is the instance variable, and it isn't changed. The value it returns may change, based on the input, but that's okay. 😄 It's no different than if you were computing the values inline each time, just more efficient.

The important thing is that work that's done during render doesn't somehow stick around and cause problems if that render is bailed out on before committing. In this case, it wouldn't, since the memoized selector would only return the bailed-out-on-computations if it were passed the bailed-out-on-parameters (which it shouldn't be).

@cellog
Copy link
Contributor

cellog commented Jul 18, 2018

So the problem is that input may change between sCU and render? When we are taking about "input" is that props/state, or are you talking external things like store state?

@markerikson
Copy link
Contributor

I've got a couple pieces floating around in my head that I think may let us solve this.

Ever since Brian posted his create-subscription package, I've felt that it looks very relevant to what we're doing over here with React-Redux. (In fact, I specifically tried to ask Brian about this in #890 (comment) .) Ironically, the README says "Redux/Flux stores should use the context API instead.". Sure, agreed, but we haven't gotten that far yet :)

create-subscription lays out a pattern of best practices for working with a non-React subscription. We really ought to use that as our model, and make connect conform to the approach shown there.

Meanwhile, we've established that updating instance variables in the render phase is a no-no. We also know that gDSFP will run both when the parent re-renders and when setState() has actually executed a state update. However, note that it does not run if you use the functional form of setState() and return null.

Also important: if you pass a completion callback to setState(), it does execute regardless of whether there was an actual state update applied or not, while componentDidUpdate() only runs if there was a state update and ensuing re-render.

I threw together a quick example over at https://codesandbox.io/s/yjx19v21nz that logs out some of the differences in execution between functional setState that applies an update, vs returning null for no update.

Looking in create-subscription, I see that the subscription callback it generates immediately calls setState() with an updater function, and does the "has anything changed?" check inside:

        const callback = (value: Value | void) => {
          if (this._hasUnmounted) {
            return;
          }

          this.setState(state => {
            // If the value is the same, skip the unnecessary state update.
            if (value === state.value) {
              return null;
            }

            // If this event belongs to an old or uncommitted data source, ignore it.
            if (source !== state.source) {
              return null;
            }

            return {value};
          });
        };

The 5.1.0-test.1 release does use functional setState inside the Redux subscription callback, but note that it always returns a new state value:

      onStateChange() {
        this.runUpdater(this.notifyNestedSubs)
      }

      runUpdater(callback = noop) {
        if (this.isUnmounted) {
          return
        }

        this.setState(prevState => prevState.updater(this.props, prevState), callback)
      }


  function getDerivedStateFromProps(nextProps, prevState) {
    return prevState.updater(nextProps, prevState)
  }


function makeUpdater(sourceSelector, store) {
  return function updater(props, prevState) {
    try {
      const nextProps = sourceSelector(store.getState(), props)
      if (nextProps !== prevState.props || prevState.error) {
        return {
          shouldComponentUpdate: true,
          props: nextProps,
          error: null,
        }
      }
      return {
        shouldComponentUpdate: false,
      }
    } catch (error) {
      return {
        shouldComponentUpdate: true,
        error,
      }
    }
  }
}

Also, note that the "updater" function closes over the store, and grabs the latest Redux state every time it runs. It also is reused as the entire implementation of gDSFP.

I haven't fully nailed this down in my head yet, but my gut says we can come up with an approach that works based on all this info.

Here's a 100% completely untested code sample that demonstrates what I'm thinking might work:

class ConnectAdvanced extends Component {
    constructor(props) {
        super(props);
        
        const store = props[storeKey] || context[storeKey];
        const reduxStoreState = store.getState();
        const childPropsSelector = createChildPropsSelector(),
        
        this.state = {
            ownProps : props,
            childProps : childPropsSelector(reduxStoreState, ownProps)
            childPropsSelector
            store,
            reduxStoreState,
        };
    }

    shouldComponentUpdate(nextProps, nextState) {
        // standard shallow equality check on props and state
    }
    
    static getDerivedStateFromProps(currProps, currState) {
        const {ownProps, childProps, reduxStoreState, childPropsSelector} = currState;
        
        if(shallowEqual(currProps, ownProps)) {
            return null;
        }
        
        const newChildProps = childPropsSelector(reduxStoreState, currProps);
        
        if(newChildProps === childProps) {
            return null;
        }
        
        return {
            ownProps : currProps,
            childProps : newChildProps,
        };
    }
    
    onStateChange = (notifyNestedSubs) => {
        const newReduxStoreState = this.state.store.getState();
        
        this.setState(currState => {
             const {ownProps, childProps, reduxStoreState, childPropsSelector} = currState;
             
             if(reduxStoreState === newReduxStoreState) {
                 return null;
             }
             
             const newChildProps = childPropsSelector(newReduxStoreState, ownProps);
             
             if(newChildProps === childProps) {
                 return null;
             }
             
             return {
                 reduxStoreState : newReduxStoreState,
                 childProps : newChildProps,
             };
             
        }, notifyNestedSubs);
    }
}

So, the key here is that we keep all the relevant info in component state, access it in both gDSFP and a setState() updater, and do checks to completely bail out of updates in both cases if necessary.

My main concern here is still the idea of the memoized selector getting called multiple times in a row in an async scenario, and thus the cache wouldn't work properly. I think we can resolve this by making the selector not actually cache values internally. If you look at the snippet I pasted earlier, it stores references to:

  • state (the Redux store state)
  • ownProps
  • stateProps (mapState result)
  • dispatchProps (mapDispatch result)
  • mergedProps (mergeProps result)

Well, the sample I just wrote up would keep ownProps, reduxStoreState, and childProps (same as mergedProps) in the connect component state. Why not just keep stateProps and dispatchProps there too? We can rewrite the selector to return all of the above as in a wrapper result object, put all of that into component state, and pass all of them in as arguments to the selector instead of having it memoize on them internally. Then, we don't need to have the selector tell us if the component needs to update or not - we just run standard shallow equality checks in shouldComponentUpdate (and maybe just on state, not on props).

I'm babbling a bit here, but I think this can all work.

Thoughts?

@markerikson
Copy link
Contributor

Follow-up thought. This relies on having the store in the component and and still subscribing there. I don't think this would work with the POC approach I had in #898 where I switched to having <Provider> subscribe and pass down the store state via context, because the store state would only be accessible inside the <ReactReduxContext.Consumer>. That might necessitate the double-wrapper component approach after all for v6. (Frankly, one of the reasons I dislike the double-wrapper idea is simply because it's that many more "irrelevant" tree nodes in the React DevTools, and I'd be hesitant to go that route until there's a good way to mark components as "automatically hide this in the DevTools" or something similar.)

@cellog
Copy link
Contributor

cellog commented Jul 19, 2018

That's a much more nuanced usage of sets tate, it might make a difference. The only thing I immediately see is that the notification of nested subs should happen if child props doesn't update, i.e. Inside the block that returns null. In addition, if the state changes, but our child props don't change, we still need to store the changed redux state, in case a props change would cause an update to child props (we will be storing a stale copy of store state).

The first issue is fixable via doing the "componentDidUpdate should update nested subs" as the callback to setState, but then if we fix the 2nd issue, it will trigger a false update. Perhaps doing a narrower sCU that ignores the saved redux store state would solve that issue.

The pseudocode is worth un-pseudoing and seeing how it plays out.

@cellog
Copy link
Contributor

cellog commented Jul 19, 2018

Also relevant to 2 components is we need to use forwardRef, but that's a next step issue. Progress is yummy!

P. S. I have been toying with writing a "redux in react" just to see how hard/easy it would be to do it and transparently support the existing store enhancers. It's an interesting thought experiment, since the state being in component state means initialization of state happens in the constructor, but anything fancy would have to be in componentDidMount. If I find anything relevant to the v7 talks, I'll share.

Basically, if it ends up looking simpler, it might be a better way to implement react-redux. Right now, it's looking like the connect component will only need 1 element, the wrapped component itself, because we can do all the "should we update" stuff inside the render props context consumer. Stay tuned!

@markerikson
Copy link
Contributor

markerikson commented Jul 19, 2018

@cellog : per my sandbox, the setState callback runs even if there was no state update applied (ie, the updater function returned null). Also, I see that the callback runs after cDU. So, that looks like it should work either way, and we don't need to do the "temp-assign cDUNotifyNestedSubs" bit any more.

Yes, you're right about needing to update the cached copy of the Redux store state. Really, the sCU implementation might just be return nextState.childProps !== this.state.childProps.

I'm still really not sold on any of these "Redux in React" ideas, but I'd be interested in seeing what you put together nonetheless.

I want to tackle implementing this idea, but I'll be busy over the next couple days, so I may not immediately have time. If someone else wants to try first, I'd suggest a new branch off the existing 5.1.0-test.1 tag as a starting point - I think it's actually not too far off where this needs to go.

@markerikson
Copy link
Contributor

I've been trying to poke at this, but I'm realizing it's a bit more complicated than I first thought.

The biggest issue is the separation between the lower-level connectAdvanced() function, where all the component behavior is implemented, and the higher-level connect() function, which has our standard default memoized selector implementation. Overall, connectAdvanced() expects to get a selectorFactory argument, which returns a function that looks like (reduxStoreState, ownProps) => mergedChildProps.

My comments above conflated the two parts of the implementation. I was thinking of changing connect's selector factory to return an object containing {stateProps, dispatchProps, mergedProps}, rather than directly returning mergedProps, but that would break if anyone actually calls connectAdvanced() with their own selector factory.

A quick search on Github suggests that there are a few people out there doing that, so I don't want to break that use case (especially in what's supposed to be a minor version).

I supposed it could be handled by kind of special-casing knowledge of connect's default behavior inside of connectAdvanced. I find that conceptually annoying, but it might be necessary.

const selectorResult = selector(nextState, nextProps, otherArgs);

let childProps;
if(selectorResult.mergedProps) {
    childProps = selectorResult.mergedProps; // default connect case
}
else {
    childProps = selectorResult; // custom selector factory was provided
}

@cellog
Copy link
Contributor

cellog commented Jul 21, 2018

I started poking too, and although it's only been a couple minutes, most of the tests are passing. However, subscriptions are not working yet, so that's the next step.

I got my redux-in-react working, except the dev tools browser extension won't work with it because the author hard-coded in using redux's createStore instead of using the one passed to it :). So I haven't quite gotten over that stumbling block.

In any case, I'm going to try to finish poking at the subscriptions issue and get a working PR today. If you beat me to it, all is good :) I'm learning tons about both react-redux and react, which is what I wanted in the first place.

@markerikson
Copy link
Contributor

Yeah, I've got several tests failing on my end as well. Looks like it's mostly around children expecting to see updated props from parents before mapState runs.

I'll try to find a couple minutes to throw my WIP up on a branch later, and we can compare notes.

@cellog
Copy link
Contributor

cellog commented Jul 21, 2018

Ok, so I got it working but it's the same problem I found in the pull request I made. We can't get both props and Redux state update at the same time unless we do some dangerous caching magic.

For example, in the "should subscribe properly when a middle connected component does not subscribe" test in connect.spec if I change out the expect inside @connect for the C component to instead log the calls to mapState, I see 3 calls. The first is the initial, the second is the state update from child notification, and the third is when parent props are propagated down. Also worth noting is that setState callbacks don't trigger until the entire tree has been reconciled, so we may see a big performance hit as the whole tree renders with props changes, then the children get the new redux state, then the tree reconciles, then the next children get their redux state...

The solution could be to create a second kind of subscription, which would simply tell child components that redux is about to update, and to delay processing props until the redux update comes in. But this is inherently dangerous in an async environment.

So perhaps being 16.4-ready means the only way to do this is with the new context?

Also worth considering and perhaps more to the point: getDerivedPropsFromState behaves completely differently in 16.3 and earlier. I think that the current 5.1-test.1 should be released as a version for 16.3 and earlier, and then release a new version for 16.4+ based on the new behavior. So the 16.3 version would use the polyfill, but the 16.4+ version wouldn't.

@cellog
Copy link
Contributor

cellog commented Jul 21, 2018

https://github.com/cellog/react-redux/tree/another-fix-5.1 is the branch I'm working on. All tests pass but the 3 mentioned in the 2nd to most recent commit.

Can't figure out an easy way to fix this yet, going to try again

Also FYI, the thing seems to work in 16.3 the same way, because the gDSFP implementation doesn't care about state changes

@cellog
Copy link
Contributor

cellog commented Jul 21, 2018

I can't figure it out. We need a way to store the last state and pass that to getDerivedStateFromProps which is actually impossible with just 1 component.

The best solution I see is to split up into 2 components, one that manages redux state by retrieving from a Context Consumer, and passes that into the component in order to process props and redux store simultaneously. Honestly, I'm not sure there is another solution!

@ematipico
Copy link
Author

getDerivedPropsFromState was buggy, so we can't release code relying on that bug. React team really talked a lot about it. If no solution is possible, at this point a breaking change should be considered

@markerikson
Copy link
Contributor

Yeah, I'm see mostly the same test failures myself. Just pushed up my WIP as https://github.com/reduxjs/react-redux/tree/5.1-rework-memoization .

As @ematipico said, 16.3 is effectively buggy, and 16.4 is out, so I see no reason to try to target 16.3.

I'm not ready to give up on this approach yet, but I agree this is awfully tricky.

If necessary, yeah, we may just have to say that there is no stopgap 5.x release, and move on to seeing what a 6.0 would look like.

@cellog
Copy link
Contributor

cellog commented Jul 22, 2018

One new possibility that occurred to me this morning: if we put the store in state instead of its value, it may be possible to delay updating until we know whether the parent component is updating state. It would require changing subscription updates so that if it is notifying a nested subscription, we tell it "heads up, a parent component just updated" and then it can watch and wait. If no props change comes down, it would trigger a redux update, otherwise edit could pull in redux state just in time for props. I may have time to turn this into code. It will likely break bathed redux actions, but hard to know until I write the code.

@cellog
Copy link
Contributor

cellog commented Jul 22, 2018

@markerikson check out the pull request. The solution was so simply, it's stupid I didn't try it before. Instead of using our cached store state, I changed gDSFP to pull the current store state, and voila, the unnecessary mapState calls went bye-bye. Pull request #980 is the implementation.

I hadn't tried this because in past incarnations it broke batched redux actions, but since we still use the cached store state in our check of whether to update the redux store state, that keeps us from breaking on batched dispatch. Yay!

@nguyenanduong
Copy link

Hi all,
I am facing similar issue. I have a connected component that in some circumstances can trigger 2 updates to the store. The latter update might not actually change anything. As a result, the change in first update was not re-rendered.

props change #1 -> runUpdater -> setState(props, shouldComponentUpdate: true) ->
props change #2 -> runUpdater -> setState(props, shouldComponentUpdate: false) ->
React dropped updates from re-rendering queue

May that be a React issue instead?

Thanks

@markerikson
Copy link
Contributor

@nguyenanduong : which version of React-Redux are you using?

Also, we've already established that 5.1.0-test.1 is completely broken, and we're not going to be publishing a 5.1 release anyway, so I'm going to close this issue.

@nguyenanduong
Copy link

Yes, I am on 5.1.0-test.1
When is the 5.1 release is out? Which branch can I get it?
Thanks

@markerikson
Copy link
Contributor

Please DO NOT use this version. It is entirely broken. Revert to version 5.0.7.

As I said, we will not be releasing a 5.1 version. We are instead working on version 6.0, which is a major internal rewrite. See #950, #995, and #1000 for details.

@nguyenanduong
Copy link

Thanks, downgrading to 5.0.7 seems to work for me

@ifiokjr
Copy link

ifiokjr commented Oct 19, 2018

@markerikson Could a note of the fact that 5.1.0-test.1 is broken be added to the releases description.

Currently, it looks like it's encouraging usage.

image

I eagerly upgraded, only to find that it did, in fact, break everything 😄

@markerikson
Copy link
Contributor

Hmm. Good point. Let's see..

Oh hey, you CAN edit release notes after the fact. Okay, will do. Thanks for the notice!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
9 participants