casl: Race condition in unsubcription of useEffect in useAbility

Describe the bug

Sporadically I get the following error from useAbility:

react_devtools_backend.js:2842 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.
    at Inner (https://localhost:3000/src/components/Control.tsx?t=1628859377104:22:3)
    at UiPathContextProvider (https://localhost:3000/src/contexts/UiPathContextProvider.tsx:19:3)
    at div
    at Control (https://localhost:3000/src/components/Control.tsx?t=1628859377104:43:3)
    at InfoField (https://localhost:3000/src/components/numeric/InfoField.tsx?t=1628859377104:22:3)
    at EmptyField
    at Loading (https://localhost:3000/src/components/RenderTag.tsx?t=1628859377104:41:3)

To Reproduce

When I trigger an ability change via an AbilityContext upstream in the UI tree, every 5 to 10 re-renders the error occurs. useAbility is used by a primitive UI control wrapper component used a lot in my project ( I use CASL ability for controlling write access, which re-renders UI controls to a read-only state when use level is not high enough).

Expected behavior No race condition

Interactive example (optional, but highly desirable) I have a large hierarchy and large parts of the UI tree gets sometimes unmounted as a response of an ability change. So it is really difficult to reproduce this error in a minimal project.

As an alternative I tried already a bug fix which seems to work. I replace the original implementation of the useAbility:

import React from 'react';
import { AnyAbility } from '@casl/ability';

export function useAbility<T extends AnyAbility>(context: React.Context<T>): T {
  if (process.env.NODE_ENV !== 'production' && typeof React.useContext !== 'function') {
    /* istanbul ignore next */
    throw new Error('You must use React >= 16.8 in order to use useAbility()');
  }

  const ability = React.useContext<T>(context);
  const [rules, setRules] = React.useState<T['rules']>();

  React.useEffect(() => ability.on('updated', (event) => {
    if (event.rules !== rules) {
      setRules(event.rules);
    }
  }), []);

  return ability;
}

with this:

import { AnyAbility } from '@casl/ability';
import React, { useContext, useEffect, useRef, useState } from 'react';

function useAbility<T extends AnyAbility>(context: React.Context<T>): T {
  if (process.env.NODE_ENV !== 'production' && typeof useContext !== 'function') {
    /* istanbul ignore next */
    throw new Error('You must use React >= 16.8 in order to use useAbility()');
  }

  const ability = useContext<T>(context);
  const [rules, setRules] = useState<T['rules']>();
  const subscribed = useRef(false);

  useEffect(() => {
    const unsubscribe = ability.on('updated', event => {
      if (subscribed.current && event.rules !== rules) {
        setRules(event.rules);
      }
    });
    subscribed.current = true;
    return function () {
      subscribed.current = false;
      unsubscribe();
    };
  }, [ability, rules]);

  return ability;
}

export default useAbility;

Basically I currently suspect the race condition somewhere inside the unsubscribe function returned from ability.on and just don’t call setRules (which triggers a setState and the bug) anymore as soon as the component is unmounted.

This fixes the racing condition 100%

CASL Version@casl/ability”: “^5.4.0”, “@casl/react”: “^2.3.0”,

Environment: NodeJS on Windows, newest Chrome

About this issue

  • Original URL
  • State: closed
  • Created 3 years ago
  • Comments: 22 (11 by maintainers)

Most upvoted comments

import { defineAbility } from '@casl/ability';

const ability = defineAbility((can, cannot) => {
  can('manage', 'all');
  cannot('delete', 'User');
});

ability.on('updated', event => {
  console.log('1');
});
ability.on('updated', event => {
  console.log('2');
});
ability.on('updated', event => {
  console.log('3');
});
ability.on('updated', event => {
  console.log('4');
});

const unsubscribeHead = ability.on('updated', event => {
  console.log('5');
});

ability.update(ability.rules);

unsubscribeHead();

ability.update(ability.rules);

Output is:

5
4
3
2
1
5

After unsubscribing the head element the other event handlers are lost

If you unsubscribe any other elements except the head its working