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

@react-aria/focus - Failed to execute 'createTreeWalker' on 'Document' #3877

Closed
filipw01 opened this issue Jan 2, 2023 · 15 comments · Fixed by #3908
Closed

@react-aria/focus - Failed to execute 'createTreeWalker' on 'Document' #3877

filipw01 opened this issue Jan 2, 2023 · 15 comments · Fixed by #3908

Comments

@filipw01
Copy link
Contributor

filipw01 commented Jan 2, 2023

🐛 Bug Report

I was unable to reproduce it with a minimal code but this error is making some of the tests in my project flaky.
I suspect this is caused in my example by some kind of race condition between unmounting the element and calling onBlur in requestAnimationFrame

In a private repo it is flaky, for one set of tests it works fine if I add an artificial delay after each test (I assume during this time requestAnimationFrame has time to fire before test env is destroyed)

🤔 Expected Behavior

No error thrown
As far as I know it can only happen when scope[0] is no longer part of the DOM, it is equal to document or root of shadow DOM

😯 Current Behavior

FocusScope's onBlur throws because scope[0].parentElement is null

TypeError: Failed to execute 'createTreeWalker' on 'Document': parameter 1 is not of type 'Node'.
      at Object.exports.convert (../../node_modules/jsdom/lib/jsdom/living/generated/Node.js:25:9)
      at Document.createTreeWalker (../../node_modules/jsdom/lib/jsdom/living/generated/Document.js:692:23)
      at $a7a032acae3ddda9$export$2d6ec8fc375ceafa (../../node_modules/@react-aria/focus/dist/packages/@react-aria/focus/src/FocusScope.tsx:649:8)
      at $a7a032acae3ddda9$var$focusFirstInScope (../../node_modules/@react-aria/focus/dist/packages/@react-aria/focus/src/FocusScope.tsx:419:14)
      at ../../node_modules/@react-aria/focus/dist/packages/@react-aria/focus/src/FocusScope.tsx:339:19
      at invokeTheCallbackFunction (../../node_modules/jsdom/lib/jsdom/living/generated/Function.js:19:26)
      at runAnimationFrameCallbacks (../../node_modules/jsdom/lib/jsdom/browser/Window.js:603:13)
      at Timeout._onTimeout (../../node_modules/jsdom/lib/jsdom/browser/Window.js:581:11)

a quick investigation simplifies the stack trace to

at Object.exports.convert (../../node_modules/jsdom/lib/jsdom/living/generated/Node.js:25:9) 
at Document.createTreeWalker (../../node_modules/jsdom/lib/jsdom/living/generated/Document.js:692:23)
at getFocusableTreeWalker (../../node_modules/@react-aria/focus/dist/packages/@react-aria/focus/src/FocusScope.tsx:649:8)      at focusFirstInScope (../../node_modules/@react-aria/focus/dist/packages/@react-aria/focus/src/FocusScope.tsx:419:14)
at onBlur ../../node_modules/@react-aria/focus/dist/packages/@react-aria/focus/src/FocusScope.tsx:339:19
...

💁 Possible Solution

Early return in onBlur or focusFirstInScope and possibly other functions that might suffer from the same bug

🔦 Context

I'm trying to use react-aria and test the components I create with it without nasty workarounds
(at the moment I have overridden document.createTreeWalker in jest so it doesn't throw)

💻 Code Sample

This is what fails in the private repo, but doesn't fail in isolation

const PopoverOverlay = ({ onClose, popoverRef, children, ...otherProps }) => {
  const { overlayProps } = useOverlay(
    {
      isOpen: true,
      onClose,
      isDismissable: true,
    },
    popoverRef
  )
  const { dialogProps } = useDialog({}, popoverRef)
  return (
    <FocusScope restoreFocus contain>
      <div ref={popoverRef} {...mergeProps(dialogProps, otherProps, overlayProps)}>
        {children}
      </div>
      <DismissButton onDismiss={onClose} />
    </FocusScope>
  )
}

export const Popover = ({ testId, children, isOpen, onOpenChange }: Props) => {
  const triggerRef = useRef<HTMLButtonElement>(null)
  const popoverRef = useRef<HTMLDivElement>(null)
  const state = useOverlayTriggerState({ isOpen, onOpenChange })
  const { triggerProps, overlayProps } = useOverlayTrigger({ type: 'dialog' }, state, triggerRef)
  return (
    <>
      <Button data-test-id={testId} {...{ ...triggerProps, buttonRef: triggerRef }}>
        trigger
      </Button>
      {state.isOpen && (
        <PopoverOverlay {...overlayProps} onClose={state.close} popoverRef={popoverRef}>
          {children}
        </PopoverOverlay>
      )}
    </>
  )
}

const Button = ({children, buttonRef, ...props}) => {
  const {buttonProps} = useButton(props, buttonRef);
  return (
    <button {...buttonProps} ref={buttonRef}>
      {children}
    </button>);
};

and at least two tests like

    render(
      <Popover testId='root'>
        <Popover testId='nested'>
          <div>content</div>
        </Popover>
      </Popover>
    )

    await userEvent.click(screen.getByTestId('root'))

    expect(screen.queryByText('content')).not.toBeInTheDocument()

    await userEvent.click(screen.getByTestId('nested'))

    expect(screen.getByText('content')).toBeInTheDocument()
    expect(screen.getByTestId('root')).toHaveAttribute('aria-expanded', 'true')
    expect(screen.getByTestId('nested')).toHaveAttribute('aria-expanded', 'true')

🌍 Your Environment

Software Version(s)
react-spectrum 3.22.0
Browser Node 16
Operating System macOS

🧢 Your Company/Team

ChiliPiper

@snowystinger
Copy link
Member

This could be difficult on our end. I suspect you are correct that it's an issue with unmounting and calling blur. Unfortunately, there is code here that we do want to run after unmount. In a browser, this won't matter because the page will be gone. In a test, however, the code keeps running. If you were using fakeTimers, I'd say run them out. But it looks like you are not. So you might try an async sleep to run out the request animation frame that is probably driving this.

function sleep(ms) {
    return new Promise(resolve => setTimeout(resolve, ms));
}

then just await it in the afterEach of your test suite to allow time for these things to finish.

afterEach(async () => {
  await sleep(50); // anything over 16 *should* work, though there are a couple double rafs around due to some FF bugs we work around, so 50 to be safe
});

otherwise, we'd need some way of knowing that the entire react tree/app is unmounting, not just the focus scope.

it's also possible that this is related to #3393. I don't think it is, but linking them just in case since that issue was a huge pain to figure out.

@filipw01
Copy link
Contributor Author

filipw01 commented Jan 3, 2023

What code does need to run even after unmount? Other possible solution is to explicitly handle this case calling whatever's needed but respecting that getScopeRoot can return null

From my understanding changing this should be fine because the only change is that now instead of throwing we are skipping the part that focuses first in scope

    let onBlur = (e) => {
      // Firefox doesn't shift focus back to the Dialog properly without this
      raf.current = requestAnimationFrame(() => {
        // Use document.activeElement instead of e.relatedTarget so we can tell if user clicked into iframe
        if (shouldContainFocus(scopeRef) && !isElementInChildScope(document.activeElement, scopeRef)) {
          activeScope = scopeRef;
          if (document.body.contains(e.target)) {
            focusedNode.current = e.target;
            focusedNode.current.focus();
-          } else if (activeScope) {
+          } else if (activeScope && getScopeRoot(activeScope.current) !== null) {
            focusFirstInScope(activeScope.current);
          }
        }
      });
    };

@filipw01
Copy link
Contributor Author

filipw01 commented Jan 3, 2023

In the meantime I found a nicer workaround. If I change in my PopoverOverlay

+<div>
	<FocusScope restoreFocus contain>
		<div ref={popoverRef} {...mergeProps(dialogProps, otherProps, overlayProps)}>
			{children}
		</div>
		<DismissButton onDismiss={onClose} />
	</FocusScope>
+</div>

It works because scope[0] is equal to popoverRef so now even if it unmounts it has parentElement to call document.createTreeWalker on

@snowystinger
Copy link
Member

Maybe I didn't quite follow, I'll have a look again tomorrow. Thanks for all the extra info.

@filipw01
Copy link
Contributor Author

filipw01 commented Jan 9, 2023

@snowystinger just a reminder that you wanted to take a look at this

@snowystinger
Copy link
Member

snowystinger commented Jan 12, 2023

Thanks for the ping, you're right, that's not failing in isolation https://codesandbox.io/s/fancy-moon-siqtm0?file=/src/App.test.js

I think in the example you showed where you placed a div around the FocusScope, scope[0] should actually be the scope sentinel that FocusScope renders, so it won't be popoverRef. Not something that really matters, just pointing it out. The parent element of that is still the new div you've added.

I'm not sure how you could have a FocusScope that doesn't have a parent if it's rendered into the DOM though. It looks like the blur raf you're looking at is also cleaned up during unmount and that is when it would lose the parent element. So I don't quite understand how we're getting into that section of code at all.

race condition between unmounting the element and calling onBlur in requestAnimationFrame

just to clarify, is this a typo? I think it's that the raf is called in onBlur, not the other way around. onBlur should be synchronous to whenever the element loses focus, though React has some bugs around onBlur handling of unmounted components facebook/react#12363

Can you provide more information about your test environment? Maybe you can use whatever your project settings are in the codesandbox I started above. React version may matter as well.

this check you've proposed does seem safe enough, so I'm happy to open a PR with it, but I want to try to make sure we aren't covering something up
} else if (activeScope && getScopeRoot(activeScope.current) !== null) {

@filipw01
Copy link
Contributor Author

filipw01 commented Jan 12, 2023

just to clarify, is this a typo? I think it's that the raf is called in onBlur

Yes, I meant to say "race condition between unmounting the element and calling requestAnimationFrame in onBlur"
I suspect it works roughly like this

  1. react starts unmounting the component
  2. react calls onBlur because it had focus
  3. RAF is scheduled
  4. react unmounts from the DOM
  5. RAF is executed

And usually it calls RAF before unmounting so it works, but when it doesn't for some reason the above bug occurs

I will work on the reproduction this or next week to confirm that or find another thesis

@snowystinger
Copy link
Member

So this is where the bug I linked to earlier comes into play a bit. See this codesandbox in each of the browsers https://codesandbox.io/s/jovial-benji-e3rsml?file=/src/App.js
Steps are:

  1. open console
  2. refresh page
  3. focus button
  4. wait until button unmounts
  5. repeat 2 - 4 as much as you like if you missed the chance to focus the button

You'll notice that Safari and Firefox don't fire any blur events. Only Chrome does.
This is also present in JS DOM, as you can see by going to the tests tab in this codesandbox. It passes with no console logs to blur.

Both React and the browsers have bugs logged against them for not firing blur on unmount of a focused element.

So I don't think that the steps outlined in your comment are what is happening. That said, I still don't have an idea of what IS happening. Luckily, you can reproduce the issue though, so I'll be relying on you to determine what's happening. Could be I'm doing something wrong and your order is correct, but we'll need to prove it.

@filipw01
Copy link
Contributor Author

I got it 🥳 will post a reproduction soon

@filipw01
Copy link
Contributor Author

@filipw01
Copy link
Contributor Author

filipw01 commented Jan 13, 2023

I played a bit with this and established that

  • it only happens if the popovers are nested (otherwise onBlur is not called at all, I guess it's handled elsewhere in react-aria)
  • onBlur is always called with popover open, but RAF is called with popover already removed from the DOM. Here you can see document.body.innerHTML logged in onBlur and RAF in the test Screenshot 2023-01-13 at 13 29 48
  • now it fails consistently for me, I though it was flaky because wallaby is running only changed tests so it executed only one of two making it pass (because they pass in isolation) It passed for me on clean run so I can confirm it sometimes works fine

Keep in mind that in order to see the error you need at least 2 tests because error from the first test is visible only in the second test

@filipw01
Copy link
Contributor Author

filipw01 commented Jan 13, 2023

I noticed there's some mechanizm to cancelAnimationFrame on unmount so added some console log to check execution order

    let onBlur = (e) => {
+      console.log('blur')
      // Firefox doesn't shift focus back to the Dialog properly without this
      raf.current = requestAnimationFrame(() => {
+        console.log('RAF')
        // Use document.activeElement instead of e.relatedTarget so we can tell if user clicked into iframe
        if (shouldContainFocus(scopeRef) && !isElementInChildScope(document.activeElement, scopeRef)) {
          activeScope = scopeRef;
          if (document.body.contains(e.target)) {
            focusedNode.current = e.target;
            focusedNode.current.focus();
          } else if (activeScope) {
            focusFirstInScope(activeScope.current);
          }
        }
      });
    };

    document.addEventListener('keydown', onKeyDown, false);
    document.addEventListener('focusin', onFocus, false);
    scope.forEach(element => element.addEventListener('focusin', onFocus, false));
    scope.forEach(element => element.addEventListener('focusout', onBlur, false));
    return () => {
+      console.log('unmount - remove onBlur handler')
      document.removeEventListener('keydown', onKeyDown, false);
      document.removeEventListener('focusin', onFocus, false);
      scope.forEach(element => element.removeEventListener('focusin', onFocus, false));
      scope.forEach(element => element.removeEventListener('focusout', onBlur, false));
    };
  }, [scopeRef, contain]);

  // eslint-disable-next-line arrow-body-style
  useEffect(() => {
    return () => {
+      console.log('unmount - cancel RAF')
      if (raf.current) {
        cancelAnimationFrame(raf.current);
      }
    };
  }, [raf]);
}

When the tests passed it resulted in

unmount - cancel RAF
blur
unmount - cancel RAF
blur
RAF
RAF
unmount - remove onBlur handler
unmount - remove onBlur handler
-- here probably starts the second test --
unmount - cancel RAF
blur
unmount - cancel RAF
blur
RAF
RAF
unmount - remove onBlur handler
unmount - remove onBlur handler

When the test failed it resulted in

unmount - cancel RAF
blur
unmount - cancel RAF
blur
unmount - remove onBlur handler
unmount - remove onBlur handler
RAF

There seems to be some race condition here

@filipw01
Copy link
Contributor Author

And now I can also confirm that when the tests pass RAF is called with overlay open
Screenshot 2023-01-13 at 14 20 12

@snowystinger
Copy link
Member

snowystinger commented Jan 14, 2023

Thanks for all the great information and, most importantly, the reproduction.

Here's a way to reproduce it every time, regardless of the machine you're on, no more race condition and only one test needed.


describe('Unmounting cleanup', () => {
  beforeAll(() => {
    jest.useFakeTimers();
  });
  afterAll(() => {
    jest.runAllTimers();
  });

  it('should open nested popover',  () => {
    render(
      <Popover testId="root">
        <Popover testId="nested">
          <div>content</div>
        </Popover>
      </Popover>
    );

    const rootTrigger = screen.getByTestId('root');
    userEvent.click(rootTrigger);

    expect(screen.queryByText('content')).not.toBeInTheDocument();

    const nestedTrigger = screen.getByTestId('nested');
    userEvent.click(nestedTrigger);

    expect(screen.getByText('content')).toBeInTheDocument();
  });
});

While doing this, I was able to determine that the root cause is that we added the same onBlur event listener to multiple elements. When any of them receives a blur event, they create a request animation frame on the same ref object. We don't clear them out before overwriting them. This meant we were leaking a RAF unintentionally. I've adjusted the code to fix the underlying issue.

I've updated my PR with a much more minimal test reproducing the behavior.

@filipw01
Copy link
Contributor Author

Great job finding the root cause 👏 Glad I was able to help

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

Successfully merging a pull request may close this issue.

2 participants