Bug: `onBlur` called in wrong order if another element receives focus

12
open
m4theushw
m4theushw
Posted 3 months ago

Bug: `onBlur` called in wrong order if another element receives focus #23165

React version: 17.0.2

Steps To Reproduce

  1. With the input focused, press TAB to focus the button
  2. Press Shift + TAB to go back to the input
  3. Observe the console

Link to code example: https://codesandbox.io/s/hardcore-pine-u4qn2?file=/src/App.js

The current behavior

When the button is blurred, the value of childRef is null inside the onBlur callback. From my investigation, I noticed that onBlur is being called after unmount (causing the ref to be null). If inputRef.current.focus() is commented it gets called before unmount, not causing the bug. Also, forwarding the ref to an DOM element instead of React.useImperativeHandle doesn't cause the bug.

The expected behavior

onBlur should be called before unmount while the ref still has a value.

For more context: https://github.com/mui-org/material-ui/issues/30285

sammy-SC
sammy-SC
Created 3 months ago

Hello @m4theushw,

I don't think that's a correct use of React.useImperativeHandle, I tried using ref={ref} and childRef was not null inside onBlur. This code was already in your example, it was just commented out. I also noticed the call to inputRef.current.focus() is unconditional, even when pressing TAB to focus from input to button, is this expected?

I'm just going to attach docs for React.useImperativeHandle, because that's why I think your example does not correctly uses React.useImperativeHandle

vkurchatkin
vkurchatkin
Created 3 months ago

I don't think that's a correct use of React.useImperativeHandle

@sammy-SC What's wrong with it?

@m4theushw it seems that you can work around this issue if you add an empty deps array to useImperativeHandle, which you probably should do anyway, if that is possible in your case

sammy-SC
sammy-SC
Created 3 months ago

Never mind :) I just re-read the question and I misunderstood it. My bad.

vkurchatkin
vkurchatkin
Created 3 months ago

It looks like a legitimate issue, for whatever reason the order of events is the following:

  • ref is cleared (set to null)
  • onBlur is called
  • new value for ref is created
  • ref is updated with this value

The same can be observed with <div ref={ref} />, but only if ref is a function

m4theushw
m4theushw
Created 3 months ago

I don't think that's a correct use of React.useImperativeHandle, I tried using ref={ref} and childRef was not null inside onBlur.

@sammy-SC This ref of this component is not forwarded to a DOM element but to a set of methods to imperatively control it. React.useImperativeHandle is necessary in this case.

This code was already in your example, it was just commented out.

Yes, I left there to show that the bug seems to occur only with React.useImperativeHandle + onBlur + React.useEffect.

it seems that you can work around this issue if you add an empty deps array to useImperativeHandle, which you probably should do anyway, if that is possible in your case

@vkurchatkin It turns out that we do have the deps array. It was only missing in the example. Even though having it, the bug still occurs in the real components that are being simulated in the demo. I'll try to see what is missing in the example to reproduce the bug.

eps1lon
eps1lon
Created 3 months ago
  1. Observe the console

Could you specify what the expected and intended output is?

I tried reproducing the issue described but in my console childRef.current is not null which seems to match your expectations.

If I just recorded a different behavior from the one you observed, please include browser and operating system.

Also: Is there still an issue when using React 18 ([email protected] and createRoot usage)?

m4theushw
m4theushw
Created 3 months ago

I updated the example to show the bug even if React.useImperativeHandle has a deps array. From what I found, the bug happens if on each rerender the ref is invalidated. In the demo, this is achieved by putting the classes prop as dependency of React.useCallback. This prop has a default value that changes on every render. In our components, we use this prop to allow to pass custom CSS classes to the component so it's normal for it to be an empty object as default. Memoizing this prop could be a solution but I don't know if this is not only a workaround and the bug will persist if the prop also changes during the blur event, invalidating the ref.

Could you specify what the expected and intended output is?

image

@eps1lon Above it's logging when the button receives a blur event. To the right, is the ref value of another component. I want this ref to not be null.

As explained in https://github.com/facebook/react/issues/23165#issuecomment-1020073728, before the blur it's cleaning the ref, then calling onBlur. It believe it should call onBlur before cleaning the ref.

Is there still an issue when using React 18 ([email protected] and createRoot usage)?

Yes, https://codesandbox.io/s/flamboyant-jones-d1mo2?file=/src/App.js

eps1lon
eps1lon
Created 3 months ago

I updated the example to show the bug even if React.useImperativeHandle has a deps array.

Do you remember what you changed? Yesterday I couldn't reproduce it, now I can.

Please "freeze" codesandboxes once you opened an issue referencing them. Otherwise it's really hard to reproduce issues later on.

eps1lon
eps1lon
Created 3 months ago

The issue is that before starting to commit new layout effects we cleanup every previously committed effect. Then we start committing the effects one by one. Once we reach <Input />, we focus thus triggering onBlur. But by that time we haven't reached Child yet.

So it breaks your expectation for

<Input hasFocus={focusIndex === 0} />
<Child ref={childRef} />

but not

<Child ref={childRef} />
<Input hasFocus={focusIndex === 0} />

I'd be surprised if this hasn't come up before.

Basically we probably want to assign all refs and then run all layout effects. Right now they're intertwined.

Simplified repro: https://codesandbox.io/s/inconsistent-refs-during-layout-effect-thqeg

m4theushw
m4theushw
Created 3 months ago

Two things are still unclear to me.

Once we reach , we focus thus triggering onBlur. But by that time we haven't reached Child yet.

@eps1lon The onBlur is called even if inputRef.current.focus is not called. But the ref is only null when the input is also explicitly focused inside the layout effect. If I log when the effect runs I see that onBlur is called after it, that's way the title of this issue. Shouldn't be the inverse: onBlur first, then the effect?

React.useLayoutEffect(() => {
  console.log("focusing");
  inputRef.current.focus();
}, [hasFocus]);

Why the ref is correctly assigned if, instead of React.useImperativeHandle, I forward it to a DOM element?

Previous