How can I prevent a React state update on an unmounted component in my integration testing?

1.3k views Asked by At

I'm using the testing-library to write my tests. I'm writing integration tests that load the component and then trying to walk through the UI in the test to mimic what a user might do and then testing the results of these steps. In my test output, I get the following warning when both tests run but do not get the following warning when only one test is run. All tests that run pass successfully.

  console.error node_modules/react-dom/cjs/react-dom.development.js:88
Warning: Can't perform a React state update on an unmounted component. This is a no-op, but it indicates a memory leak in your application. To fix, cancel all subscriptions and asynchronous tasks in a useEffect cleanup function.
    in Unknown (at Login.integration.test.js:12)

The following is my integration test written in jest. if I comment out either one of the tests, the warning goes away but if they both run then I get the warning.

import React from 'react';
import { render, screen, waitForElementToBeRemoved, waitFor } from '@testing-library/react';
import userEvent from "@testing-library/user-event";
import { login } from '../../../common/Constants';
import "@testing-library/jest-dom/extend-expect";
import { MemoryRouter } from 'react-router-dom';
import App from '../../root/App';
import { AuthProvider } from '../../../middleware/Auth/Auth';

function renderApp() {
  render(
    <AuthProvider>
      <MemoryRouter>
        <App />
      </MemoryRouter>
    </AuthProvider>
  );

  //Click the Login Menu Item
  const loginMenuItem = screen.getByRole('link', { name: /Login/i });
  userEvent.click(loginMenuItem);

  //It does not display a login failure alert
  const loginFailedAlert = screen.queryByRole('alert', { text: /Login Failed./i });
  expect(loginFailedAlert).not.toBeInTheDocument();

  const emailInput = screen.getByPlaceholderText(login.EMAIL);
  const passwordInput = screen.getByPlaceholderText(login.PASSWORD);
  const buttonInput = screen.getByRole('button', { text: /Submit/i });

  expect(emailInput).toBeInTheDocument();
  expect(passwordInput).toBeInTheDocument();
  expect(buttonInput).toBeInTheDocument();

  return { emailInput, passwordInput, buttonInput }
}

describe('<Login /> Integration tests:', () => {

  test('Successful Login', async () => {
    const { emailInput, passwordInput, buttonInput } = renderApp();

    Storage.prototype.getItem = jest.fn(() => {
      return JSON.stringify({ email: '[email protected]', password: 'asdf' });
    });

    // fill out and submit form with valid credentials
    userEvent.type(emailInput, '[email protected]');
    userEvent.type(passwordInput, 'asdf');
    userEvent.click(buttonInput);

    //It does not display a login failure alert
    const noLoginFailedAlert = screen.queryByRole('alert', { text: /Login Failed./i });
    expect(noLoginFailedAlert).not.toBeInTheDocument();

    // It hides form elements
    await waitForElementToBeRemoved(() => screen.getByPlaceholderText(login.EMAIL));
    expect(emailInput).not.toBeInTheDocument();
    expect(passwordInput).not.toBeInTheDocument();
    expect(buttonInput).not.toBeInTheDocument();
  });


  test('Failed Login - invalid password', async () => {
    const { emailInput, passwordInput, buttonInput } = renderApp();

    Storage.prototype.getItem = jest.fn(() => {
      return JSON.stringify({ email: '[email protected]', password: 'asdf' });
    });

    // fill out and submit form with invalid credentials
    userEvent.type(emailInput, '[email protected]');
    userEvent.type(passwordInput, 'invalidpw');
    userEvent.click(buttonInput);

    //It displays a login failure alert
    await waitFor(() => expect(screen.getByRole('alert', { text: /Login Failed./i })).toBeInTheDocument())

    // It still displays login form elements
    expect(emailInput).toBeInTheDocument();
    expect(passwordInput).toBeInTheDocument();
    expect(buttonInput).toBeInTheDocument();
  });
});

The following is the component:

import React, { useContext } from 'react';
import { Route, Switch, withRouter } from 'react-router-dom';
import Layout from '../../hoc/Layout/Layout';
import { paths } from '../../common/Constants';
import LandingPage from '../pages/landingPage/LandingPage';
import Dashboard from '../pages/dashboard/Dashboard';
import AddJob from '../pages/addJob/AddJob';
import Register from '../pages/register/Register';
import Login from '../pages/login/Login';
import NotFound from '../pages/notFound/NotFound';
import PrivateRoute from '../../middleware/Auth/PrivateRoute';
import { AuthContext } from '../../middleware/Auth/Auth';

function App() {

  let authenticatedRoutes = (
    <Switch>
      <PrivateRoute path={'/dashboard'} exact component={Dashboard} />
      <PrivateRoute path={'/add'} exact component={AddJob} />
      <PrivateRoute path={'/'} exact component={Dashboard} />
      <Route render={(props) => (<NotFound {...props} />)} />
    </Switch>
  )

  let publicRoutes = (
    <Switch>
      <Route path='/' exact component={LandingPage} />
      <Route path={paths.LOGIN} exact component={Login} />
      <Route path={paths.REGISTER} exact component={Register} />
      <Route render={(props) => (<NotFound {...props} />)} />
    </Switch>
  )

  const { currentUser } = useContext(AuthContext);
  let routes = currentUser ? authenticatedRoutes : publicRoutes;

  return (
    <Layout>{routes}</Layout>
  );
}

export default withRouter(App);

The following is the AuthProvider component that wraps in the renderApp() function. It takes advantadge of the React useContext hook to manage the state of a users authentication for the app:

import React, { useEffect, useState } from 'react'
import { AccountHandler } from '../Account/AccountHandler';

export const AuthContext = React.createContext();

export const AuthProvider = React.memo(({ children }) => {
  const [currentUser, setCurrentUser] = useState(null);
  const [pending, setPending] = useState(true);

  useEffect(() => {
    if (pending) {
      AccountHandler.getInstance().registerAuthStateChangeObserver((user) => {
        setCurrentUser(user);
        setPending(false);
      })
    };
  })

  if (pending) {
    return <>Loading ... </>
  }
  return (
    <AuthContext.Provider value={{ currentUser }}>
      {children}
    </AuthContext.Provider>
  )
});

It seems as though the first test mounts the component under test but that the second test is somehow trying to reference the first mounted component rather than the newly mounted component but I can't seem to figure out exactly what's happening here to correct these warnings. Any help would be greatly appreciated!

2

There are 2 answers

1
Brad Jones On BEST ANSWER

the AccountHandler isn't a singleton() the getInstance method name needs to be refactored to reflect this. So a new instance of AccountHandler is being created every time this is called. But, the register function adds an observer to an array that is iterated and each observer is called in that array when the authentication state changes. I wasn't clearing when new observers were added and thus the tests were calling both the old and unmounted observers as well as the new ones. By simply clearing that array, the issue was resolved. Here's the corrected code that has fixed the issue:

  private observers: Array<any> = [];

  /**
   * 
   * @param observer a function to call when the user authentication state changes
   * the value passed to this observer will either be the email address for the 
   * authenticated user or null for an unauthenticated user.
   */
  public registerAuthStateChangeObserver(observer: any): void {
    /**
     * NOTE:
     * * The observers array needs to be cleared so as to avoid the 
     * * situation where a reference to setState on an unmounted
     * * React component is called.  By clearing the observer we 
     * * ensure that all previous observers are garbage collected
     * * and only new observers are used.  This prevents memory
     * * leaks in the tests.
     */
    this.observers = [];

    this.observers.push(observer);
    this.initializeBackend();
  }

1
Gabriele Petrioli On

It looks like your AccountHandler is a singleton and you subscribe to changes to it.

This means that after you unmount the first component and mount the second instance, the first one is still registered there, and any updates to the AccountHandler will trigger the handler which calls setCurrentUser and setPending of the first component too.

You need to unsubscribe when the component is unmounted.

something like this

const handleUserChange = useCallback((user) => {
  setCurrentUser(user);
  setPending(false);
}, []);

useEffect(() => {
  if (pending) { 
    AccountHandler.getInstance().registerAuthStateChangeObserver(handleUserChange)
  };

  return () => {
    // here you need to unsubscribe
    AccountHandler.getInstance().unregisterAuthStateChangeObserver(handleUserChange);
  }
}, [])