简体   繁体   中英

React memory leak - when updating state in context provider via a function passed to a child of the provider

After some debugging I understand the issue and I know roughly why it's happening, so I will show as much code as I can.

The Error

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 the componentWillUnmount method.
    in ProductsDisplay (created by ConnectFunction)
    in ConnectFunction (created by Context.Consumer)
    in Route (created by SiteRouter)
    in Switch (created by SiteRouter)
    in SiteRouter (created by ConnectFunction)
    in ConnectFunction (created by TLORouter)
    in Route (created by TLORouter)
    in Switch (created by TLORouter)

So to give you context, the React structure looks a bit like so

Simplified version

App.jsx > Router > GlobalLayoutProvider > Route > Page

Within the GlobalLayoutProvider I pass six functions down via the new react context, the code looks like so. All these functions provide is the ability to modify the state of the layout component, so that if child elements have more complex requirements they can send the information up after performing fetchs etc or they could on mount set the values of the layout.

GlobalLayoutRedux.jsx

class GlobalLayoutProvider extends React.Component {
  constructor(props) {
    super(props);
    this.state = { routeConfig: null };
    this.getRouteData = this.getRouteData.bind(this);
    this.setLoaderOptions = this.setLoaderOptions.bind(this);
  }

  componentDidMount() {
    this.getRouteData();
  }

  componentDidUpdate(prevProps) {
    const { urlParams, user, layoutSettings } = this.props;

    if (
      urlParams.pathname !== prevProps.urlParams.pathname
      || user.permissions !== prevProps.user.permissions
    ) {
      this.getRouteData();
    }
  }

  getRouteData() {
    const { user, urlParams } = this.props;
    const { tlo, site, pathname } = urlParams;

    this.setState({
      routeConfig: pageConfigs().find(
        (c) => c.pageContext(tlo, site, user) === pathname,
      ),
    });
  }

  setLoaderOptions(data) {
    this.setState((prevState) => ({
      routeConfig: {
        ...prevState.routeConfig,
        loader: {
          display: data?.display || initialState.loader.display,
          message: data?.message || initialState.loader.message,
        },
      },
    }));
  }

  render() {
    const { routeConfig } = this.state;
    const { children, user } = this.props;

    return (
      <GlobalLayoutContext.Provider
        value={{
          setLoaderOptions: this.setLoaderOptions,
        }}
      >
        <PageContainer
          title={routeConfig?.pageContainer?.title}
          breadcrumbs={[routeConfig?.pageContainer?.title]}
        >
          <ActionsBar
            actionsBarProperties={{ actions: routeConfig?.actionBar?.actions }}
            pageTitle={routeConfig?.actionBar?.title}
          />
          <SideNav items={routeConfig?.sideNav?.options} selected={routeConfig?.sideNav?.pageNavKey}>
            <div id={routeConfig?.sideNav?.pageNavKey} className="Content__body page-margin">
              <div id="loader-instance" className={`${routeConfig?.loader?.display ? '' : 'd-none'}`}>
                <Loader message={routeConfig?.loader?.message} />
              </div>
              <div id="children-instance" className={`${routeConfig?.loader?.display ? 'd-none' : ''}`}>
                {children}
              </div>
            </div>
          </SideNav>
        </PageContainer>
      </GlobalLayoutContext.Provider>
    );
  }
}


export default GlobalLayoutProvider;

Inside the Page.jsx we have a componentDidMount and a componentDidUpdate. The issue seems to stem from calling the parent function and setting the state pretty much at any point prior to updating the state of the child component.

Page.jsx

export default class Page extends Component {
  static contextType = GlobalLayoutContext;

  constructor(props) {
    super(props);
    this.state = {
      someState: 'stuff'
    };
  }

  componentDidMount() {
    this.setActionBarButtons();
    this.fetchOrganisationsProducts();
  }

  async componentDidUpdate(prevProps) {
    const { shouldProductsRefresh, selectedOrganisation, permissions } = this.props;

    if (
      selectedOrganisation?.id !== prevProps.selectedOrganisation?.id
      || shouldProductsRefresh !== prevProps.shouldProductsRefresh
    ) {
      await this.fetchOrganisationsProducts();
    }

    if (
      selectedOrganisation?.id !== prevProps.selectedOrganisation?.id
      || shouldProductsRefresh !== prevProps.shouldProductsRefresh
      || permissions !== prevProps.permissions
    ) {
      this.setActionBarButtons();
    }
  }

  setActionBarButtons() {
    const { setActionBarOptions } = this.context;

    const actions = [
      ActionButtons.Custom(
        () => this.setState({ exportTemplateModalIsOpen: true }),
        { title: 'Button', icon: 'button' },
      ),
    ];

    setActionBarOptions({ actions, title: 'Products', display: true });
  }


  async fetchOrganisationsProducts() {
    const { selectedOrganisation } = this.props;
    const { setLoaderOptions } = this.context;
    setLoaderOptions({ display: true, message: 'Loading Products In Organisation' });

    (await productStoreService.getProducts(selectedOrganisation.id))
      .handleError(() => setLoaderOptions({ display: false }))
      .handleOk((products) => {
        this.setState({ products }, () => {
          setLoaderOptions({ display: false });
          products.forEach(this.fetchAdditionalInformation)
        });
      });
  }

  render() {
    return (<p>Something</p>)
  }
}

What's odd the memory leak will disappear if I add this suggestion I seen on stack overflow suggesting to track the state of the components interacting with the higher-level component.

export default class Page extends Component {
  static contextType = GlobalLayoutContext;

  constructor(props) {
    super(props);
    this.state = {
      someState: 'stuff'
    };
  }
        
  // ADDITION HERE
  _isMounted = false;

  componentDidMount() {
    // ADDITION HERE
    this._isMounted = true;
    this.setActionBarButtons();
    this.fetchOrganisationsProducts();
  }

  // ADDITION HERE
  componentWillUnmount() {
    this._isMounted = false;
  }

  async fetchOrganisationsProducts() {
    const { selectedOrganisation } = this.props;
    const { setLoaderOptions } = this.context;
    setLoaderOptions({ display: true, message: 'Loading Products In Organisation' });

    (await productStoreService.getProducts(selectedOrganisation.id))
      .handleError(() => setLoaderOptions({ display: false }))
      .handleOk((products) => {

        // ADDITION HERE
        if (this._isMounted) {
          this.setState({ products }, () => {
            setLoaderOptions({ display: false });
            products.forEach(this.fetchAdditionalInformation)
          });
        }
      });
  }

  render() {
    return (<p>Something</p>)
  }
}

Personally, I don't see this as a solution if I was building my own thing I wouldn't be too fussed but I can't ask an entire company to start adding this addition everywhere.

My gut is telling me that because the component is firing up an object to configure the state of the parent which is for a fraction of a second unmounting as the component did mount is still processing due to the async network fetch when that is returned it is saving to the state before the parent has managed to render the function call state change.

What was odd if I pass the callbacks into the parent and call them once the setState has been actioned the issue is resolved like so

  setOnMountOptions(data) {
    this.setState((prevState) => ({
      routeConfig: {
        ...prevState.routeConfig,
        ...data?.loader ? { loader: data.loader } : {},
      },
    }), async () => { await data.callbacks(); });
  }

but again this causes havoc on the testing side as you are abstracting the componentDidmount functionality out and calling it after a set state is actioned elsewhere.

I have tried adapting what I have to Redux but I had the exact same result everything from a viewing perspective in the browser was fine but still getting the same memory leak using the Redux calls to try to populate all the data from top to bottom.

I can't think of any way of handling this gracefully where we don't need to ask the company to add that fix everywhere.

So to save people time and effort it turns out our memory leak was actually being cause by a bad set state in the routers of our application.

The technical post webpages of this site follow the CC BY-SA 4.0 protocol. If you need to reprint, please indicate the site URL or the original address.Any question please contact:yoyou2525@163.com.

 
粤ICP备18138465号  © 2020-2024 STACKOOM.COM