Skip Main Navigation
Ben IlegboduBen Ilegbodu

Avoiding React act warning when accessibility testing next/link with jest-axe

A journey explaining how digging into 3rd-party code helped provide clarity & find a workaround to a tricky problem

Sunday, January 30, 2022 ยท 11 min read

Recently when adding accessibility tests with jest-axe to a React component, I ran into the dreaded act() warning:

 FAIL  src/components/Link.test.tsx
  โœ• is accessible (70 ms)

  โ— is accessible

    Expected test not to call console.error().

    If the warning is expected, test for it explicitly by mocking it
    out using jest.spyOn(console, 'error') and test that the warning occurs.

    Warning: An update to Link inside a test was not wrapped in act(...).

    When testing, code that causes React state updates should be wrapped
    into act(...):

    act(() => {
      /* fire events that update state */
    });
    /* assert on the output */

    This ensures that you're testing the behavior the user would see in the
    browser. Learn more at https://reactjs.org/link/wrap-tests-with-act
      at Link (.../node_modules/next/client/link.tsx:131:19)
      at Link (.../src/components/Link.tsx:15:5)

This specific test was testing a React component that wrapped next/link.

import { render, screen } from '@testing-library/react'
import { axe } from 'jest-axe'
import Link from './Link'

it('is accessible', async () => {
  const { container } = render(
    <Link href="https://www.benmvp.com">contents</Link>,
  )

  expect(screen.getByRole('link')).toHaveAttribute(
    'href',
    'https://www.benmvp.com',
  )
  expect(await axe(container)).toHaveNoViolations()
})

But it turns out that any component that rendered a next/link had this exact same problem. I googled around for some combination of "jest-axe", "next/link", and "act()" and surprisingly found very little. But there was nothing that was helpful or provided a solution.

If I were to use act() the way the warning message suggested, it would look something like:

import { act, render, screen } from '@testing-library/react'
import { axe } from 'jest-axe'
import Link from './Link'

it('is accessible', async () => {
  const { container } = render(<Link href="/">contents</Link>)

  expect(screen.getByRole('link')).toHaveAttribute(
    'href',
    'https://www.benmvp.com',
  )

  await act(async () => {
    expect(await axe(container)).toHaveNoViolations()
  })
})

But I didn't want to have to write this act() code every time I decided to use jest-axe with a component that rendered a next/link (either directly or indirectly). Plus it would be highly likely that my teammates would get tripped up by it even if I did document the problem.

React Testing Library wraps all of its user events with act() so that we don't have to. But even still, the act() warning usually happens when we perform some non-UI action in a test that causes the component to re-render. All I did was render and add an accessibility assertion. The component shouldn't have been re-rendering.

Well, after lots of debugging and sleuthing (more details below if you're interested), I finally came up with a workaround that avoided using act() everywhere. It requires installing react-intersection-observer and using its test utils in the Jest configuration.

// jest.config.js
module.exports = {
  setupFiles: ['<rootDir>/jest.setupFiles.js'],
  setupFilesAfterEnv: [
    '@testing-library/jest-dom',
    'jest-axe/extend-expect',
    'react-intersection-observer/test-utils',
    '<rootDir>/jest.setupFilesAfterEnv.ts',
  ],
}

// jest.setupFiles.js
import { defaultFallbackInView } from 'react-intersection-observer'

global.IntersectionObserver = jest.fn()
defaultFallbackInView(false)

These few lines solve the problem! It seems so simple, but it took me hours of struggling and wondering if I'd even find a workaround. The solution is definitely not straightforward. So if you're having this problem right now and just need an answer so you can move on, there you have it! ๐Ÿ˜ƒ

But if you'd like to know why it works and why it was even failing in the first place, feel free to read on! I want to take you through (a condensed version of) my debugging process so you can learn how to debug 3rd-party code, learn some things about JavaScript, and hopefully add some tools to your DivOps setup.


FYI on helpful tools

Actually, before we get into the problem, I want to highlight some testing tools that you totally should be using (if you're not already).

First, the act() warning typically doesn't cause Jest tests to fail. It's just a warning. But it, along with other warnings (such as prop type failures), can fill up the test logs unless developers notice and care enough to fix the warnings. In my opinion, test logs filled with warnings are a sign of an unhealthy codebase. So one of the tools I always add to my Jest setup is jest-fail-on-console. It makes Jest tests fail when console.error() or console.warn() are used. For more on it and other tools like it, read 5 tips for a healthier DivOps setup.

Second, jest-axe is a Jest wrapper around axe for accessibility testing in React and other UI libraries. It's similar to code linters like ESLint, but it runs on the generated HTML code instead of the source JavaScript code. So it can catch lots of errors around the structure of the React component's HTML. Read Tools to catch accessibility errors in JavaScript applications for more tools like it.

Lastly, the custom Link component that wrapped next/link was to connect next/link with MUI Link. That way I could get the functionality of next/link with the look-and-feel of MUI Link. Check out Wrapping next/link to use with a custom UI Link component for how to set that up.

Now let's go on a journey...


Problem 1: Find the source of the act() warning

Let's take a quick look at the test code again:

import { render, screen } from '@testing-library/react'
import { axe } from 'jest-axe'
import Link from './Link'

it('is accessible', async () => {
  const { container } = render(
    <Link href="https://www.benmvp.com">contents</Link>,
  )

  expect(screen.getByRole('link')).toHaveAttribute(
    'href',
    'https://www.benmvp.com',
  )
  expect(await axe(container)).toHaveNoViolations()
})

When I removed the jest-axe assertion, the test passed. When I added it back, the test failed because of the act() warning. So my first goal was to figure out why the next/link component was re-rendering when seemingly no interaction was happening. This re-render is what was triggering the act() warning.

The stack trace of the warning pointed to a line in next/link code as the culprit (next/client/link.tsx). But when I checked that specific line in the next/client/link.tsx, it didn't really point to anything. So I started throwing console.log statements everywhere in the generated file within node_modules and re-running the tests. The source code was light on comments so I needed to figure out how it worked.

I used to be afraid of digging into 3rd-party node_modules code. But now after doing it so many times, I've found that it's really no different than looking at unfamiliar code from my teammates. Sometimes I find bugs in 3rd-party code and I'll file an issue on GitHub. But usually what happens is that I better understand how the code works to know what I need to change in my code to work around whatever problem I'm dealing with.

After a lot of debugging (and I mean a lot), I narrowed down the cause of the re-render to lines of code using of a useIntersection Hook:

const [setIntersectionRef, isVisible] = useIntersection({
  rootMargin: '200px',
})

Without the jest-axe assertion, isVisible would always remain false. But once the jest-axe was added, isVisible would start off as false. But then the useIntersection Hook would cause the component to re-render with a true value. The isVisible value is used to pre-fetch routes (which is a pretty cool feature).

next/link calls React.cloneElement to render the contents.

return React.cloneElement(child, childProps)

A re-render makes it look like the UI has changed because the child is re-cloned, thus triggering the act() warning.

The first problem was answered, but it created a new one. How can I stop useIntersection from causing a re-render?


Problem 2: Prevent re-render

The useIntersection Hook was defined in next/client/use-intersection.tsx. I was going even deeper into unfamiliar code. But I quickly noticed in the useIntersection Hook that it maintained a visible state.

const [visible, setVisible] = useState(false)

And it returned that state from the Hook.

return [setRef, visible]

So the next goal was to figure out where setVisible was being set to true. There were 2 such places. The first, through several layers of abstraction, set up an IntersectionObserver between the link and the browser viewport. It called setVisible if there was an intersection.

unobserve.current = observe(
  el,
  (isVisible) => isVisible && setVisible(isVisible),
  { root, rootMargin },
)

But I knew that IntersectionObserver didn't exist in the Jest environment, even with jest-environment-jsdom-global. With additional console.log statements, I verified that this code was never calling setVisible.

So onto the second place, which was setting setVisible to true on requestIdleCallback(), but only when IntersectionObserver didn't exist (and visible wasn't already true).

useEffect(() => {
  if (!hasIntersectionObserver) {
    if (!visible) {
      const idleCallback = requestIdleCallback(() => setVisible(true))
      return () => cancelIdleCallback(idleCallback)
    }
  }
}, [visible])

Thanks to more console.log statements (I use them liberally), I verified that this code was being executed. I wish I could tell you that I immediately realized why it was being called when the jest-axe assertion was added, but I didn't. I didn't figure out that wrinkle until I had solved the whole thing. But in order to make this journey somewhat coherent, let's pretend that revelation came next.


Problem 3: Prevent requestIdleCallback()

So requestIdleCallback() was what was updating the visible state to true, causing a re-render, and ultimately causing the act() warning. So my thought was to prevent the requestIdleCallback() from happening. The requestIdleCallback() callback function seemed to only get called when adding the jest-axe assertion.

it('is accessible', async () => {
  const { container } = render(
    <Link href="https://www.benmvp.com">contents</Link>,
  )

  expect(screen.getByRole('link')).toHaveAttribute(
    'href',
    'https://www.benmvp.com',
  )
  expect(await axe(container)).toHaveNoViolations()
})

It turns out that it had nothing specifically to do with jest-axe, but because the operation is async. The await gives time for requestIdleCallback() to happen. It could've been any async call after rendering <Link> that would've caused requestIdleCallback() to fire. None of my other tests were async so it just seemed like it was caused by jest-axe.

I had a quick thought about maybe mocking requestIdleCallback() in the Jest configuration to never call its callback. But I was afraid of those implications. I knew React internals make use of requestIdleCallback(), and who knows what other code in my codebase requires it for code to work properly.

So after breaks to clear my head (and question the meaning of life), I remembered that the requestIdleCallback() code only happened in useIntersection when IntersectionObserver didn't exist. If it did, then requestIdleCallback() wouldn't get registered.


Problem 4: Polyfill IntersectionObserver

Because our app supports iOS Safari 12.1, but IntersectionObserver wasn't natively supported until iOS Safari 12.2, the app polyfills IntersectionObserver using intersection-observer. So I added a polyfill for Jest as well.

// jest.config.js
module.exports = {
  setupFiles: [
    'intersection-observer',
    '<rootDir>/jest.setupFiles.js',
  ],
  setupFilesAfterEnv: [
    '@testing-library/jest-dom',
    'jest-axe/extend-expect',
    '<rootDir>/jest.setupFilesAfterEnv.ts',
  ],
}

My thinking was that the useIntersection code would set up a handler with IntersectionObserver, but because there's no browser, the link would have 0 dimensions. As a result, an intersection would never happen and visible would never be set to true.

unobserve.current = observe(
  el,
  (isVisible) => isVisible && setVisible(isVisible),
  { root, rootMargin },
)

Frustratingly this didn't work. The act() warning still happened because I was only half right. The link did have 0 dimensions, but the rootMargin was set to '200px' in the call to useIntersection from next/link.

const [setIntersectionRef, isVisible] = useIntersection({
  rootMargin: '200px',
})

Even though both the browser viewport and the link had 0 dimensions, the rootMargin caused an intersection. The rootMargin causes the intersection "zone" to go outside its bounds 200 pixels in every direction. ๐Ÿ˜ญ

It was at this point, that I thought I'd have to give in and just use act() everywhere. If IntersectionObserver exists, there's still an intersection and a re-render happens. If IntersectionObserver doesn't exist, requestIdleCallback() gets called because of the async operation. I lose either way!

Oh! But what if instead of polyfilling IntersectionObserver... I mocked it?


Problem 5: Mock IntersectionObserver

I really felt that my best chance at a workaround was by manipulating IntersectionObserver somehow. I needed it to exist, but never intersect. And then I remembered react-intersection-observer. Its main use provides a useInView Hook that is a wrapper around IntersectionObserver's API. But what I was most interested in was its testing utilities.

react-intersection-observer/test-utils adds a beforeEach that creates a custom IntersectionObserver mock that intercepts all observer and unobserve calls allowing me to control intersections.

// jest.config.js
module.exports = {
  setupFiles: ['<rootDir>/jest.setupFiles.js'],
  setupFilesAfterEnv: [
    '@testing-library/jest-dom',
    'jest-axe/extend-expect',
    'react-intersection-observer/test-utils',
    '<rootDir>/jest.setupFilesAfterEnv.ts',
  ],
}

Here was my thinking. Now useIntersection would be using the mock of IntersectionObserver. So IntersectionObserver would be defined, but no intersections would happen. Therefore visible would never be set to true.

unobserve.current = observe(
  el,
  (isVisible) => isVisible && setVisible(isVisible),
  { root, rootMargin },
)

But again frustratingly, this didn't work! And moreover it was the requestIdleCallback() that was triggering the visible change, not the IntersectionObserver code. Somehow IntersectionObserver still wasn't defined. Let's look at the requestIdleCallback() code again.

useEffect(() => {
  if (!hasIntersectionObserver) {
    if (!visible) {
      const idleCallback = requestIdleCallback(() => setVisible(true))
      return () => cancelIdleCallback(idleCallback)
    }
  }
}, [visible])

There's a check against hasIntersectionObserver. The problem was that hasIntersectionObserver is defined in module scope outside of the definition of useIntersection().

const hasIntersectionObserver = typeof IntersectionObserver !== 'undefined'

export function useIntersection<T extends Element>({

So even though the react-intersection-observer/test-utils created a mock IntersectionObserver, it was getting created in the beforeEach(). And that was too late. When next/client/use-intersection.tsx is executed is when it needed to know if IntersectionObserver existed. If I moved the definition of hasIntersectionObserver down two lines into useIntersection, everything worked fine.

I contemplated filing an issue in the next repo to make that 2-line switch, but if I were the maintainers I wouldn't accept it. For the main browser use case, it doesn't make sense to recalculate that value all the time. Getting it to work in my edge case for running tests wouldn't seem worth it. Plus, even they did accept it, it could be weeks before it'd be released in a new version.

So this is where things got hacky (if they hadn't already). All hasIntersectionObserver needs to know in module scope is that IntersectionObserver exists. It's not actually trying to use it then. So in the Jest setupFiles, I set global.IntersectionObserver to a dummy value.

import { defaultFallbackInView } from 'react-intersection-observer'

global.IntersectionObserver = jest.fn()
defaultFallbackInView(false)

This way when hasIntersectionObserver is evaluated IntersectionObserver will be defined and hasIntersectionObserver will be true. Then the mock IntersectionObserver from react-intersection-observer/test-utils in the beforeEach kicks in when the tests actually run. And it worked! ๐Ÿ˜… The act() warning went away because the component didn't re-render. The isVisible is no longer updated to be true.

Although the tests work without it, I also added defaultFallbackInView(false) for clarity. This just says that by default the IntersectionObserver will always return false value for an intersection.


Final workaround solution

So after that long circuitous route filled with console.log statements and self-doubt, the final solution is only a few lines of Jest configuration.

// jest.config.js
module.exports = {
  setupFiles: ['<rootDir>/jest.setupFiles.js'],
  setupFilesAfterEnv: [
    '@testing-library/jest-dom',
    'jest-axe/extend-expect',

    // In order to run `jest-axe` assertions for components containing
    // `next/link`, we need `IntersectionObserver` to always exist,
    // but to be mocked so that we can set it to *never* intersect
    // in `jest.setupFiles.js`
    'react-intersection-observer/test-utils',

    '<rootDir>/jest.setupFilesAfterEnv.ts',
  ],
}

// jest.setupFiles.js
import { defaultFallbackInView } from 'react-intersection-observer'

// `react-intersection-observer/test-utils` added in
// `setupFilesAfterEnv` will add a mock `IntersectionObserver`
// automatically in every `beforeEach()`. But `next/link`
// checks to see if `IntersectionObserver` exists at module
// scope. So we add the bare minimum to get that check to pass.
global.IntersectionObserver = jest.fn()

// Then when the tests actually run, we default intersection to
// `false` so that `jest-axe` assertions will pass without needing
// `act()`.
defaultFallbackInView(false)

I wrote this post primarily to provide an answer that I spent countless hours looking for. But I also hope that it encourages you when you're struggling to persevere to find an answer. It may look like those that write blog posts are super smart and found the answer immediately, but that's rarely the case. And lastly, I hope it motivates you to dig into node_modules when you need help debugging issues with a 3rd-party library. I learn so much from code spelunking.

Feel free to reach out to me on Twitter at @benmvp with any comments or questions you have!

Keep learning my friends. ๐Ÿค“

Subscribe to the Newsletter

Get notified about new blog posts, minishops & other goodies

โ€‹
โ€‹

Hi, I'm Ben Ilegbodu. ๐Ÿ‘‹๐Ÿพ

I'm a Christian, husband, and father of 3, with 15+ years of professional experience developing user interfaces for the Web. I'm a Google Developer Expert Frontend Architect at Stitch Fix, and frontend development teacher. I love helping developers level up their frontend skills.

Discuss on Twitter // Edit on GitHub