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

Sometimes items is an empty array when clicking an item #186

Closed
ferdinandsalis opened this issue Sep 12, 2017 · 41 comments
Closed

Sometimes items is an empty array when clicking an item #186

ferdinandsalis opened this issue Sep 12, 2017 · 41 comments

Comments

@ferdinandsalis
Copy link
Contributor

  • downshift version: latest
  • node version: 8.4
  • npm (or yarn) version: 5.3.0

What you did:
Select an item per left mouse click

What happened:
Most of the time it would select the item as expected. However sometimes it will not.

kapture 2017-09-12 at 17 37 48

Reproduction repository:
https://codesandbox.io/s/l2yk1qqqrm

Problem description:*
This is due to the fact that at the point of time the item is clicked it has not yet been added to the items array.

Suggested solution:
No idea yet how to solve this.

@kentcdodds
Copy link
Member

Thanks for the issue @ferdinandsalis!

Yeah, looking into this I've realized something kinda tricky about the way that downshift is implemented. We make the assumption that you'll render all items you need synchronously on every render. And you can make this work if the component you wrap is the whole downshift component rather than just the list (see this example and the Apollo example).

If for some reason you'd rather connect your menu, then maybe there's something we can do...

What if we exposed a clearItems method for you to call when the items load? Here's a demo of what that might look like: https://codesandbox.io/s/rwrjzyqm7q

In that example, I do some magic to simulate the built-in API, but it really would be as easy as calling clearItems anytime your list is rendered.

If you like that, then I welcome you to make a PR to add it. Here's what you'd need to do:

  1. Document the new method in the README (under actions)
  2. Test the new functionality (in the misc tests)
  3. Add clearItems to getStateAndHelpers
  4. Implement clearItems (somewhere around here maybe?)

Let me know what you think :)

@tansongyang
Copy link
Collaborator

You could also add TypeScript types.

@kentcdodds
Copy link
Member

Yeah, that'd be great! Should hopefully be pretty straightforward given the existing types and the simplicity of this API. It's just a function with no args and no return :)

One other thing, you could replace this with a call to clearItems

@ferdinandsalis
Copy link
Contributor Author

ferdinandsalis commented Sep 12, 2017

I am bit confused since I am doing the same thing as in your Apollo example, or am I missing something? Also could you elaborate more on the problem, I dont yet understand it and how clearItems solves the problem?

@kentcdodds
Copy link
Member

The Apollo example wraps the component that renders <Downshift />, yours renders the items within the downshift component.

Downshift keeps track of all the items on its own instance. It clears them every render and adds them all to the list as they're rendered. The problem is that you're rerendering the menu yourself (or rather Apollo is), so the items don't get cleared between renders, leading to unpredictable contents in the items array.

So if you want to be able to dynamically render items without rerendering all of downshift, you'll have to clear the items yourself.

Of course, an alternative would be to wrap the whole component in Apollo (rather than just the menu) like the existing example... :)

@ferdinandsalis
Copy link
Contributor Author

@kentcdodds thanks for explaining! I will take a look.

@ferdinandsalis
Copy link
Contributor Author

Implemented in #187

@ferdinandsalis
Copy link
Contributor Author

That landed quickly. Thanks @kentcdodds. I guess I can close this.

@kentcdodds
Copy link
Member

Thank you! 🎉

@ferdinandsalis
Copy link
Contributor Author

@kentcdodds sorry for being a pest but sadly this did not fix the issue. One more detail I noticed about the issue is that when using the cursor keys and enter to select an item it always works. I think it must have to do with the mouse over and the re-renders. Every little movement will lead to a re-render and clearing of items and adding of new items. Sometimes a click will then stumble upon an empty items array. I hope this is clear. Happy to chat about it.

@kentcdodds
Copy link
Member

Yeah, that would be an issue... Do you not have any control over what props result in a new network request? You should only make a request when the input value prop changes... Does the higher order component have something for that?

@ferdinandsalis
Copy link
Contributor Author

Yes that is what I am doing. Only if inputValue changes a new network request is made. I dont see how this is related to the issue though. I am doing exactly same as your apollo example but also I am skipping the hoc if the inputValue is empty.

const withData = graphql(LIST_ITEM_SEARCH, {
  skip: ({ inputValue }) => {
    return inputValue == null || inputValue === '' ? true : false;
  },
  options: ({ inputValue, listId }) => ({
    variables: { searchString: inputValue, listId },
    fetchPolicy: 'cache-and-network'
  })
});

@kentcdodds
Copy link
Member

That logic isn't preventing a rerender when the input doesn't change. It's preventing rerender when the input is falsy.

@ferdinandsalis
Copy link
Contributor Author

Right, but why does this matter here. I have typed my search and received my results and now I want to select an item either by keyboard or mouse. Apollo wont do a network request at that point. I fear we are talking past each other. Sorry if my explanation is inadequate. Feel free to ignore this issue if you wish.

@kentcdodds
Copy link
Member

Oh, I think I understand now! What if you only clear the items when it's loading?

@kentcdodds
Copy link
Member

Or maybe only clear them after it's loaded?

@ferdinandsalis
Copy link
Contributor Author

ferdinandsalis commented Sep 13, 2017

Would you be open to quickly discussing this via skype or similar?

Anyway will try your suggestions and report back.

@ferdinandsalis
Copy link
Contributor Author

No, that did not work either. I am lost ☺️

@kentcdodds
Copy link
Member

Sorry, we just had our 4th baby, so I'm afraid I'm a bit limited with what I can do to help at the moment 😅

@pbomb
Copy link
Collaborator

pbomb commented Sep 13, 2017

I'm using the new clearItems API since I'm fetching results for autocomplete in my scenario. I believe that we should reset the highlightedIndex back to the default when clearItems is set. Otherwise, it gets maintained and can have a value larger than the number of items. Thoughts? If there is agreement, I can submit a PR.

@kentcdodds
Copy link
Member

Hmmm.... I'm not certain that'd be best. I can't think of all use cases, but I think what would be better is for you to just call setHighlightedIndex(0) or something 🤔

Otherwise we may make the wrong choice for people wanting to use the clearItems API in the future.

@pbomb
Copy link
Collaborator

pbomb commented Sep 13, 2017

Oh yeah, good call. Didn't even realize setHighlightedIndex was exposed. Thanks (and congratulations! 😉)

@ferdinandsalis
Copy link
Contributor Author

No worries and congratulations! I am still at number one 😊

@ferdinandsalis
Copy link
Contributor Author

Hi @kentcdodds, I looked at my problem again and I now understand that the complexity of the rendered children influenced wether I could select something with the mouse or not (apollo and the asynchronicity nature of the task was not an issue) .

For example I am using styled-components right now and I have few of those styled components that make up an item (see below for code). Apparently this was enough that sometimes when clicking the item the internal item array was not yet populated.

const Menu = ({
  highlightedIndex,
  selectedItem,
  getItemProps,
  data
}) => {
  const { items, loading } = data;
  if (loading || items.length === 0) {
    return null;
  }
  return (
    <List>
      {items.map((item, index) => (
        <Item
          {...getItemProps({
            item,
            index,
            key: item.id,
            disabled: item.isIncluded,
            active: R.equals(highlightedIndex, index),
            selected: R.equals(selectedItem, item)
          })}
        >
          <Work {...item.work} />
        </Item>
      ))}
    </List>
  );
};
const Work = pure(({ cover, title, artist }) => (
  <Flex>
    <Cover width={48} height={48} image={cover} />
    <Box ml={3}>
      <Text mb={1} bold>
        {title}
      </Text>
      <Text>{artist && artist.name}</Text>
    </Box>
  </Flex>
));

To mitigate this problem I have wrapped the Work component in pure hoc from recompose which implements shouldComponentUpdate ☺️ with a shallow compare of the props it gets passed. This has solved my problem. I can now select an item any time.

Clearly this was not a problem of downshift. However I assume a few other people will encounter this problem. What do you think? What could downshift do? For example to reduce the probability of this issue couldn’t we debounce the mouseover event?

Anyhow that all. I just wanted to give you a follow up.

@kentcdodds
Copy link
Member

Thanks for following up. I'm a little confused by this:

Apparently this was enough that sometimes when clicking the item the internal item array was not yet populated.

What actually makes it so the list of items is not yet populated?

@ferdinandsalis
Copy link
Contributor Author

Sorry my understanding of react internals and js are still limited. However my understanding is that on every render — which happens a lot when moving the mouse over the items — it also newly renders the children, thus there is quite a bit of work happening. So it should be possible that at the point of clicking an item this.items is not yet fully populated. Anyway since implementing shouldComponentUpdate solves the problem it seems to support my argument, no?

@kentcdodds
Copy link
Member

I think I understand. So if items have a slow render method, then the user can click before things have finished rendering? Hmm... If you could dig a little deeper then that'd be great, but I think the best we can/should do is add documentation that says folks should implement shouldComponentUpdate on their items.

Another thing you might consider is implementing windowing with react-virtualized.

@ferdinandsalis
Copy link
Contributor Author

Yes will dig deeper. Will be a great way to learn more. Thanks for the feedback.

@dylanmoz
Copy link

@kentcdodds I'm dealing with the same issue. For me, from what I can tell, what happens is that when I click the first search result, it causes a new downshift render (which calls clearItems), and then subsequently rerenders my search results**. After that, the onClick handler executes and calls selectItemAtIndex.

** However, react-apollo has a shouldComponentUpdate, and it returns false for this rerender, so the items are cleared inside downshift, but there is no subsequent rerender so the items are left empty. I believe this theory to be the case because when I set my react-apollo component to have a prop such as <Component forcerender={() => {}} />, the problem is fixed.

@kentcdodds
Copy link
Member

Hi @dylanmoz,
Sorry, I'm afraid I don't have much time to dedicate to looking into this for you. If you could dig deeper and come up with a suggested solution (whether that be code or documentation) I'd be happy to look at it. Good luck!

@ibrahima
Copy link

Huh, I think I ran into this same issue in a different context.

In our case, we have a react Component inside our <Downshift> element that's responsible for actually rendering the menu, and it extends React.PureComponent. By sticking console.log statements inside Downshift itself, I found the same thing that others have found: under some set of circumstances, the items array gets cleared during a mouse enter. This alone might be okay, but in combination with setting defaultHighlightedIndex={0}, this means that when I mouse over the first item, because none of the props changed, the menu doesn't re-render (thanks, PureComponent), but if I then click on that first item, the items array is still empty, so it fails to find the item and the event handling ends.

In our case, I guess the solution is to not use PureComponent, but this kind of makes me think that making getItemProps populate internal state is a little bit of a footgun. I certainly like the behavior of Downshift and the API is nice overall, but I wonder if there's a better way of doing it. For my specific case, I suppose it could be solved by implementing something that prevents Downshift from re-rendering itself if none of its props/state changed (nothing should have changed because the hovered item was the defaultHighlightedIndex) but there might be other cases where that doesn't work. I don't fully understand the other issues because I haven't used react-apollo, but it sounds like it could be a similar problem with shouldComponentUpdate. Is it enough for Downshift itself to implement shouldComponentUpdate?

@kentcdodds
Copy link
Member

I guess the solution is to not use PureComponent

Bingo, that's your solution. Please don't use PureComponent without an associated benchmark showing that it's necessary because otherwise it causes more problems than it's worth.

making getItemProps populate internal state is a little bit of a footgun

To be clear, it's not populating state in the React sense, but an instance property. And you can control that via the itemCount prop (set via parent) or setItemCount (set via child) if you need to.

Seriously, check if you need the PureComponent implementation. You probably don't and it'll make your life easier to not use it. I'm really hesitant to add complexity to Downshift to support usage of PureComponent unnecessarily.

@ibrahima
Copy link

That's a fair point, I do agree with that (wasn't my decision to use PureComponent there, I was just helping a coworker debug this issue). Premature optimization and all that ;)

I do still feel like it can be confusing if you don't know how getItemProps works, because even if you're not using PureComponent any wrapper that implements shouldComponentUpdate (sounds like react-apollo does?) could have this issue. But it certainly is documented that it's an impure function so users should be careful about that. I was just idly wondering if there's a reason that items isn't just passed in as a prop like other similar libraries do, but I'm sure you thought this through long ago, and I won't bother you to reiterate your reasoning for me (I'm guessing it's along the lines of what you've outlined in How to give rendering control to users with prop getters. I appreciate that your time is limited, so thanks for the response!

My only suggestion then is that perhaps it's worth adding some kind of warning in the README about implementing shouldComponentUpdate and how it could interfere with getItemProps - seems like you just shouldn't do it, ever, with the component that needs to call getItemProps, whether that's done explicitly or implicitly by extending something that does this. Not sure if that's necessary though, maybe it's clear enough by saying that getItemProps is impure?

Perhaps just adding something under the "What do you mean by impure function?" section like:

You should also avoid using shouldComponentUpdate, or extending React.PureComponent, because this will interfere with Downshift's operation.

@kentcdodds
Copy link
Member

Yeah, I'd love an addition to the docs for that 👍

It could also say:

If you do want to use shouldComponentUpdate or React.PureComponent then you should make sure to set the itemCount prop.

@arackaf
Copy link

arackaf commented Oct 11, 2018

Just ran into a gnarly bug related to this. Posting this to hopefully help the next person.

Honestly I don't think it has much to do with asynchronous filling of your items. No matter how the items are fetched, this will work fine ... IF, (and only if) the items re-render after the main Downshift component.

Since these items are rendered in a child component of Downshift, you might be wondering how they could not. The answer is: if shouldComponentUpdate blocks a re-render of said child components. PureComponent, as mentioned above, is one way this can sneak up on you. For MobX users out there, like me, @observer is another. Basically, make certain any child components rendered inside of <Downshift do NOT have the @observer decorator. Remove them all, and simply pass whatever props you need down. The component that renders <Downshift CAN have @observer, and may very well need it. But children rendering the items cannot have it (or extend PureComponent, for that matter)

@gaearon
Copy link

gaearon commented Oct 11, 2018

Just a heads-up that code like #186 (comment) is pretty fragile and can indeed break in the future.

@arackaf
Copy link

arackaf commented Oct 11, 2018

@kentcdodds - I think the solution here may be simple. Instead of a clearItems method, what about a setItems method that we could call in the Downshift render method.

That would turn items into a controlled prop, so getItemProps would no longer need to be called after every parent render.

Let me know if you'd like a PR for that.

@gaearon
Copy link

gaearon commented Oct 11, 2018

The whole notion of calling methods during render sounds off to me.

Why does the component need to store items at all if it's supposed to be "out of scope" of its knowledge? Either component truly doesn't need items (and then you shouldn't need an instance property), or it does (and then it should be a prop).

@arackaf
Copy link

arackaf commented Oct 11, 2018

Think about things like highlighted index. As you hit the down arrow key, the next item in the list gets highlighted. But once you hit the end of the array, down key keeps the last item highlighted.

But, thinking about it a bit more, surely items could be a regular prop, and then added to the render props, allowing devs to render whatever they want with them, right? getItemProps() might still be needed on each rendered item, since I'm sure you might need to know which dom node is attached to which item, but that should be fine.

@kentcdodds
Copy link
Member

We're going to look into fixing this. Tracking here: #599

@arackaf
Copy link

arackaf commented Oct 11, 2018

Yeah I saw - thanks a ton for the amazing library.

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

No branches or pull requests

8 participants