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

Replace NodeResolver with Ref usage #4102

Closed
wants to merge 8 commits into from

Conversation

Rall3n
Copy link
Collaborator

@Rall3n Rall3n commented Jun 27, 2020

With React 16.x and StrictMode, the usage of findDOMNode has been deprecated.

This PR replaces the NodeResolver component, which used the deprecated function with an already existing Ref to the nearest DOM node of the MenuList component.

Related to #4094.

@changeset-bot
Copy link

changeset-bot bot commented Jun 27, 2020

⚠️ No Changeset found

Latest commit: 0aba924

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

This PR includes no changesets

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

Click here to learn what changesets are, and how to add one.

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

@codesandbox-ci
Copy link

codesandbox-ci bot commented Jun 27, 2020

This pull request is automatically built and testable in CodeSandbox.

To see build info of the built libraries, click here or the icon next to each commit SHA.

Latest deployment of this branch, based on commit 0aba924:

Sandbox Source
react-codesandboxer-example Configuration

@bladey
Copy link
Contributor

bladey commented Jun 29, 2020

Thank you so much for this PR @Rall3n.

@bladey bladey added pr/bug-fix PRs that are specifically addressing a bug pr/needs-review PRs that need to be reviewed to determine outcome pr/priority PRs that should be addressed sooner rather than later labels Jun 29, 2020
@bladey bladey added pr/in-review PRs currently in review by maintainers for the next release and removed pr/needs-review PRs that need to be reviewed to determine outcome labels Jun 30, 2020
@bladey
Copy link
Contributor

bladey commented Jun 30, 2020

Hey @Rall3n,

It looks like the tests are failing (9 errors):

Cannot call this.startListening with this.props.targetRef bound to el because null [1] is
incompatible with HTMLElement [2].

 [1] 11│   targetRef: HTMLElement | null,
       :
     17│   touchStart: number;
     18│
     19│   componentDidMount() {
     20│     this.startListening(this.props.targetRef);
     21│   }
     22│   componentDidUpdate(prevProps) {
     23│     if (prevProps.targetRef != this.props.targetRef) {
       :
 [2] 31│   startListening(el: HTMLElement) {

If I remove null from targetRef:

IBLczp

It eliminates all errors but one:

Missing type annotation for prevProps.

     19│   componentDidMount() {
     20│     this.startListening(this.props.targetRef);
     21│   }
     22│   componentDidUpdate(prevProps) {
     23│     if (prevProps.targetRef != this.props.targetRef) {
     24│       if (prevProps.targetRef) this.stopListening(prevProps.targetRef);
     25│       this.startListening(this.props.targetRef);

Which seems to be a missing type for the new prevProps argument you've added.

I may be completely off the mark here for all of the above, but I wanted to give it a go.

Let me know!

@bladey bladey added the awaiting-author-response Issues or PRs waiting for more information from the author label Jun 30, 2020
@Rall3n
Copy link
Collaborator Author

Rall3n commented Jun 30, 2020

Hey @bladey,

I was not sure how to type a legacy ref.
The prop targetRef could be null on component mount. That is why I added the componentDidUpdate lifecycle and the null typing to the prop.
If it is not necessary, then it can be removed.

The prevProps typing is simple. I can add a commit right away for that.

@bladey bladey removed the awaiting-author-response Issues or PRs waiting for more information from the author label Jul 1, 2020
Copy link
Contributor

@bladey bladey left a comment

Choose a reason for hiding this comment

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

To be checked by @JedWatson

packages/react-select/src/internal/ScrollCaptor.js Outdated Show resolved Hide resolved
packages/react-select/src/internal/ScrollBlock.js Outdated Show resolved Hide resolved
@bladey
Copy link
Contributor

bladey commented Jul 1, 2020

Thanks @Rall3n! Looks good, I'll leave it to @JedWatson to check the two comments above for the tests to start passing or if we need further work.

@bladey bladey requested review from JedWatson and bladey July 1, 2020 02:16
@Rall3n
Copy link
Collaborator Author

Rall3n commented Aug 28, 2020

@bladey @JedWatson Any information regarding this pr / the failed checks for the reference typing?

@bladey bladey added this to the 3.2 milestone Dec 2, 2020
}
componentWillUnmount() {
this.stopListening(this.scrollTarget);
this.stopListening(this.props.targetRef);
Copy link
Collaborator

@Methuselah96 Methuselah96 Dec 9, 2020

Choose a reason for hiding this comment

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

Can we be sure that this.props.targetRef is not null here? To be honest, I've never quite understood when a ref can or can't be null, but based on the documentation I'm wondering if it could be null in this scenario:

React will call the ref callback with the DOM element when the component mounts, and call it with null when it unmounts. Refs are guaranteed to be up-to-date before componentDidMount or componentDidUpdate fires.

I'd be happy to get the Flow types working with | null if that's helpful. I have it working on a local branch.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Well, componentWillUnmount is neither componentDidMount or componentDidUpdate.

componentWillUnmount() is invoked immediately before a component is unmounted and destroyed. Perform any necessary cleanup in this method, such as invalidating timers, canceling network requests, or cleaning up any subscriptions that were created in componentDidMount().

This lifecycle is made for such things, so I am pretty confident that the reference will never be null at this point.

Copy link
Collaborator

@Methuselah96 Methuselah96 Dec 9, 2020

Choose a reason for hiding this comment

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

Yeah, I agree that componentWillUnmount is usually a good place to remove event listeners from refs, etc. However, this is not a normal ref situation since the grandparent has a ref to the grandchild and is passing it into the child as a prop. Therefore, I'm more wary than usual.

I made a CodeSandbox to see how this situation would behave, and it is possible to have a null value in componentWillUnmount if the top-level parent doesn't re-render (if you press "Toggle mount status of ReactSelect" immediately after loading the page). However, if the component is re-rendered (by pressing "Set state on ReactSelect") the ref will be set in the props in componentWillUnmount.

It looks like the Select component in react-select is always re-rendered by something pretty quickly, but I don't think it's worth relying on that being the case. That's why I'd rather be safe and just check for null where possible.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

The example seems very specific, but the reasoning is understandable.

I will push a commit with a null-check.

Copy link
Collaborator

@Methuselah96 Methuselah96 Dec 11, 2020

Choose a reason for hiding this comment

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

Thanks, I'm sorry if my concerns are coming off as pedantic, just trying to make sure we catch the edge cases.

After thinking about this some more, I realized that this means that if ScrollBlock and ScrollCaptor fail to re-render after the menuListRef in Select has been set, then the scroll blocking and capturing won't work correctly. I've been trying to figure out whether we know for sure if those re-renders will happen or not (they seem to re-render in every case I've tried), but I'm having trouble proving it one way or the other. Do you have any insight?

If we can't know for sure that those components will re-render, I was thinking that a pretty safe way to make sure they re-render would be to introduce a ScrollManager component where the ref is in state, so it will re-render on state change. Something like this:

type Props = {
  captureMenuScroll: boolean,
  onMenuScrollToBottom?: (event: SyntheticEvent<HTMLElement>) => void,
  onMenuScrollToTop?: (event: SyntheticEvent<HTMLElement>) => void,
  menuShouldBlockScroll: boolean,
  children: Element<*>,
};

export default function ScrollManger({
  captureMenuScroll,
  onMenuScrollToBottom,
  onMenuScrollToTop,
  menuShouldBlockScroll,
  children,
}: Props) {
  const childElement = Children.only(children);
  const { innerRef } = childElement.props;

  // must be in state to trigger a re-render, only runs once per instance
  const [scrollTarget, setScrollTarget] = useState();
  const refCallback = useCallback(
    element => {
      innerRef(element);
      if (element === scrollTarget) return;
      setScrollTarget(element);
    },
    [innerRef, scrollTarget]
  );

  return (
    <ScrollCaptor
      isEnabled={captureMenuScroll}
      onTopArrive={onMenuScrollToTop}
      onBottomArrive={onMenuScrollToBottom}
      scrollTarget={scrollTarget}
    >
      <ScrollBlock
        isEnabled={menuShouldBlockScroll}
        scrollTarget={scrollTarget}
      >
        {React.cloneElement(childElement, { innerRef: refCallback })}
      </ScrollBlock>
    </ScrollCaptor>
  );
}

Still looking into it, let me know if you have any ideas.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

I think your code snippet would be too over engineered, just simple overkill. I believe a better way would be to let the components have their own reference to the DOM Element, and not through props.

But I also think this should be part of another discussion/PR. I already have something in mind and will get to it.

The sole focus of this PR is to remove deprecated methods without changing current functionality, and I think this has been accomplished. Everything else is just preventing this from being merged.

Copy link
Collaborator

@Methuselah96 Methuselah96 Dec 11, 2020

Choose a reason for hiding this comment

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

I completely agree that my proposed solution is not ideal and that letting the components have their own reference to the DOM element would be much better (I would be interested to see how these components could get a ref to the DOM node without getting it through innerRef). And I also completely agree on what the focus of this PR should be (although I still think having a technical discussion on how to accomplish the goal is still appropriate).

My reasons for preferring a solution that explicitly causes a re-render are:

  1. The current solution in this PR relies on implicit behavior of the MenuPlacer component working a certain way. This means if these components are either no longer a child of the MenuPlacer or the functionality of MenuPlacer changes that it would be very easy to miss the fact that that would cause the ScrollBlock and ScrollCaptor to no longer work (since it's not obvious that these components depend on the functionality of Menu). Making the re-render explicit would make it more likely that we won't break this functionality by mistake when working with parts of the code that aren't related to the scroll components.
  2. It's hard to predict how long a certain piece of code is going to live for or in what context someone's going to try to use your code (unless there's an active PR that is about to get merged). Therefore, if there's an easy solution that is a better long-term solution, I usually prefer the better long-term solution.
  3. This functionality is not tested, so it would be easy for it to break without anyone noticing.
  4. My code snippet was completely based on logic that was already in ScrollBlock (putting the ref in state in order to force a re-render), I just put it in a higher component so that it would apply to both ScrollBlock and ScrollCaptor. (I tried having each component put the ref in state, but it would be hard to make sure that the innerRef passed into MenuList isn't called multiple times in that situation.)

As you noted, if both of us think our approach is the better solution, then this discussion is holding up the merging of this PR. Thanks for bearing with me while I figure out how some of these internal components work.

Copy link
Collaborator

@Methuselah96 Methuselah96 Dec 11, 2020

Choose a reason for hiding this comment

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

I think I gave up too easily on making ScrollBlock and ScrollCaptor responsible for getting the DOM node themselves. I came up with a solution that doesn't rely on a ScrollManager, is a minimal code change, and handles the ref internally inside of ScrollBlock and ScrollCaptor (which means that we're no longer relying on the Menu state change to trigger a re-render). Can you take a look and see what you think?

Copy link
Collaborator

Choose a reason for hiding this comment

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

It seems like there ought to be some HOC or React hooks way to do this generically, but I can't seem to figure it out.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

I´m not a fan of using cloneElement to force extra props onto a component. And I´m guaranteed not a fan of storing refs in the state.

I agree that the components should handle the references by themselves, but not like this. Instead I would suggest a solution using React.createRef and using a function as a child (much like the MenuPlacer component) to forward the ref to the DOM element.

<ScrollCaptor>{scrollTargetRef => (
    <ScrollBlock>{blockTargetRef => (
        <div ref={instance => {
            this.getMenuListRef(instance)
            scrollTargetRef.current = instance;
            blockTargetRef.current = instance;
        }}
        >
            { ... }
        </div>)}
    </ScrollBlock>)}
</ScrollCaptor>

This makes the components independent of the menuListRef reference and React guarantees that the references are set before the lifecycle methods are called (componentDidMount, componentDidUpdate).


Tested it without the MenuPlacer and Menu components to prevent possible re-renders. Both elements worked as expected.


But as I already said and will say again: This is a matter for another PR. Get this merged to resolve StrictMode errors as soon as possible.

@Methuselah96 Methuselah96 modified the milestones: 3.2, 4.0 Dec 12, 2020
@Methuselah96
Copy link
Collaborator

Replaced by #4330.

@Rall3n Rall3n deleted the replace-findDOMNode branch February 2, 2022 08:11
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
pr/bug-fix PRs that are specifically addressing a bug pr/in-review PRs currently in review by maintainers for the next release pr/priority PRs that should be addressed sooner rather than later
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants