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

Improve ref support on connectToStores (and remove refs from other components) #696

Merged

Conversation

pablopalacios
Copy link
Contributor

@pablopalacios pablopalacios commented May 15, 2021

addons-react/connectToStores

  • refactored tests
  • removed wrappedElementRef ref
  • add support for React.forwardRef with { forwardRef: true }

router/handleHistory

  • removed wrappedElement ref

router/handleRoute

  • replaced refs test with a test that checks if props are passed correctly
  • removed wrappedElement ref

addons-react/batchedUpdatePlugin

  • refactored tests to not rely on refs

addons-react/provideContext

  • removed wrappedElementRef ref

I confirm that this contribution is made under the terms of the license found in the root directory of this repository's source tree and that I have the authority necessary to make this contribution on behalf of its copyright owner.

@pablopalacios pablopalacios changed the title addons-react: refactor batchedUpdatePlugin tests Remove wrappedElement and wrappedElementRef from addons-react and router May 15, 2021
@redonkulus
Copy link
Contributor

Are refs not needed anymore? I forget why they were there in the first place.

@pablopalacios
Copy link
Contributor Author

pablopalacios commented May 15, 2021

That was the question that I was making myself all the time since the only place that I saw it was in the tests.

Buuut, I took a second look and I think we needed the refs so we could have access to the wrapped component instance or DOM in someway. Maybe one could have a connected input and need to focus it through a ref and maybe this ref mechanism that was implemented was allowing it.

If that is the case, probably React.forwardRef would be the way to go. If it's not, I would assume that it was only created for tests or when people would interact more often with components instances through their methods or something like this.

I'll make some experiments here and see what I find. But maybe something that I said already rang some bells on you... Any other idea?

@pablopalacios
Copy link
Contributor Author

pablopalacios commented May 16, 2021

Yup, I think I've figured out. The current refs allow the parent component to have access to the wrapped component through the connectToStores HOC. One use case is the focus one that I mentioned above.

I did the experiments with all variations of inputs and refs. You can check it live here and the source here.

As you can see, we currently don't support React.forwardRef components. This is bad because we require users to transform their components in classes (which may not be possible/desired sometimes). Besides that, the current implementation is quite mouthy and not documented. If one wants to focus a wrapped component, this is what must be called from the parent component:

// master branch (uses deprecated string ref)
this.ref.current.wrappedElement.focus()

// dev branch
this.ref.current.wrappedElementRef.current.focus()

My proposal is to follow react-redux approach. In their connect function, it's possible to pass an options object with forwardRef: true (see their docs here).

What they do is a conditional version of what is presented in the React docs regarding refs and hocs. You can check the relevant code from them here, but basically what they do is, if the user sets forwardRef, they wrap the hoc with React.forwardRef, otherwise they return the hoc straight away.

Said that, my proposal is to adopt the same approach as them. The relevant part of connectToStores would look roughly like this:

function connectToStores(Component, stores, getStateFromStores, options) {
  class StoreConnector extends React.Component {
    render() {
      const { fluxibleRef, ...props } = this.props;
      return <Component ref={fluxibleRef} {...props} {...this.state} />;
    }
  }

  if (options.forwardRef) {
    return React.forwardRef((props, ref) => (<StoreConnector fluxibleRef={ref} {...props} /> ));
  }

  return StoreConnector;
}

The user code could be:

// CustomInput.js
export default connectToStores(CustomInput, stores, getStateFromStores, { forwardRef: true })

// App.js
const App = () => {
  const ref = React.useRef(null);

  return (
    <div>
      <CustomInput ref={ref} />
      <button onClick={() => ref.current.focus()}></button>
    </div>
  );
};

As you can see, we wouldn't have any ref at all in connectToStores. We would only trigger the possibility to forward refs if the user explicitly asks for it (and the user would be responsible for complying with refs rules instead of us trying to figure out if we should create/forward a ref or not). Finally, the call to focus in the parent component would be just as if CustomInput was a regular input component: ref.current.focus().

By the way, I would only keep/fix the ref support on connectToStores since it's a hoc that is used in many "lower" components. Regarding provideContext, handleHistory and handleRoute, since they are all top level components that are created only once, I see no reason to keep ref support there.

@redonkulus what do you think? Does it make sense/was it clear?

@redonkulus
Copy link
Contributor

Thank you for the detailed analysis. Your recommendation makes sense to me and follows what other projects have done. I'm fine with this unless @mridgway or @lingyan have any reservations. I do not remember all the use cases for using ref's via connectToStore but making it an option and pass through prop makes sense to me.

As for provideContext, handleHistory and handleRoute I'm not sure what their use case was for refs.

@pablopalacios pablopalacios changed the title Remove wrappedElement and wrappedElementRef from addons-react and router Improve ref support on connectToStores (and removed refs from other components) May 16, 2021
@pablopalacios
Copy link
Contributor Author

Oh, I learned a lot during this analysis, so, no need to say thanks :)

I've updated the branch with my proposal so you can take a better look on how things could be.

@pablopalacios
Copy link
Contributor Author

@redonkulus we could also add it back later as a "bug fix" if we see the need. This would be faster than creating another major release.

@redonkulus
Copy link
Contributor

redonkulus commented May 20, 2021

@pablopalacios it looks like React has some documentation about optionally forwarding the ref, looks like we should always do it. Which means the extra option would not be necessary:

Conditionally applying React.forwardRef when it exists is also not recommended for the same reasons: it changes how your library behaves and can break your users’ apps when they upgrade React itself.

@pablopalacios
Copy link
Contributor Author

Sure! The only problem is that we must not insert the ref when it's a functional component but still insert refs in forwardref components. I think react-is can help here. I'll give it a try soon.

@pablopalacios
Copy link
Contributor Author

@redonkulus I tried it and meh... It didn't work.

The problem is that react-is does not work with components, only with elements. @mridgway even tried to raise this issue on react itself (facebook/react#12882) and @ljharb from enzyme tried to get a patch approved but it's been ignored since 2019 (facebook/react#15349).

This means that there is no clean way to check if the component that must be wrapped by connectToStores is "ref-able" or not. If we don't do this check and pass a ref unconditionally, React will throw a warning for every function component (which would be super annoying):

Warning: Function components cannot be given refs. Attempts to access this ref will fail. Did you mean to use React.forwardRef()?

My opinion is that React documentation is a little bit incomplete regarding HOCs and forward refs. It assumes that all wrapped components will be "ref-able" components.

If we don't want to play dirty, the only way that I see to circumvent this issue is by using some kind of flag that the user controls as proposed before. One thing that we could do differently is, instead of using options.forwardRef to determine if we should apply React.forwardRef or not, we could use it to check if we should forward the ref. Seems dumb and confusing but this is how it would roughly like:

function connectToStores(Component, stores, getStateFromStores, options) {
    class StoreConnector extends ReactComponent {
         // ...
         render() {
             const ref = options.forwardRef ? this.props.fluxibleRef : null;
             return <Component ref={ref} />
         }
    }
    // ...
    return React.forwardRef((props, ref) => <StoreConnector fluxibleRef={ref} />);
}

In this way we would always have a forwardRef component (dismissing the problem of conditionally applying it as discouraged by react docs) but we would leave to the user to decide if a ref should be forward or not (eliminating the need to introspect the component). One benefit that I see here is that the API will likely survive react updates: if they decide to create any other ref-able component, we don't have to update any thing on our side.

@pablopalacios pablopalacios changed the title Improve ref support on connectToStores (and removed refs from other components) Improve ref support on connectToStores (and remove refs from other components) May 25, 2021
@redonkulus
Copy link
Contributor

we could use it to check if we should forward the ref.

I like this approach and can future proof us against React changes.

@pablopalacios
Copy link
Contributor Author

@redonkulus updated :)

Copy link
Contributor

@redonkulus redonkulus left a comment

Choose a reason for hiding this comment

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

This is great, I like the changes, seems clear to me. Thanks for doing the hard work!

@redonkulus redonkulus merged commit 5ac6e0a into yahoo:fluxible-addons-react-1.x-dev Jun 1, 2021
@redonkulus
Copy link
Contributor

@pablopalacios new beta versions published to npm

@pablopalacios pablopalacios deleted the refs-1 branch June 1, 2021 19:35
pablopalacios added a commit to pablopalacios/fluxible that referenced this pull request Jul 17, 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.

2 participants