facebookexperimental/Recoil

Async selectors mark downstream selectors modified when they're not, causing components to re-render when they don't need to

Open

#1937 opened on Aug 8, 2022

View on GitHub
 (1 comment) (1 reaction) (0 assignees)JavaScript (19,428 stars) (1,151 forks)batch import
help wantedperformance

Description

At Zapier, we were debugging excessive component updates when we noticed that when an async selector gets recomputed because a dependency has changed, the selector, and all that depend on it always get marked as modified, even when the initial dependency change doesn't produce any downstream effects. This also causes any component that subscribes to a selector that depends on this async selector to be updated when it doesn't need to.

I've created a reproducing CodeSandbox at https://codesandbox.io/s/recoil-downstream-wksim9?file=/pages/index.tsx

This features the follow data-flow graph:

atom<number>(counterState)
  ⌙> selector<Promise<boolean> | boolean>(hasIncrementedCounterBooleanState)
       ⌙> selector<'yes' | 'no'>(hasIncrementedCounterStringState)

In the view:

  • You can toggle hasIncrementedCounterBooleanState to be async using the "Disable Async" button.
  • You can increment counterSate using the "Increment Counter" button.
  • We only subscribe to the last hasIncrementedCounterStringState selector.
  • We log the number of times the component was rendered (its FC executed).

The console tab will show:

  • 💻 When a selector recomputes.
  • 🎨 When the main or Suspense LoadingFallback component renders (executes).
  • ⏰ When the Recoil snapshot updates and what values it has marked as isModified: true.

With hasIncrementedCounterBooleanState async (by default):

  • When you first increment, as expected both selectors compute and all 3 values are marked as modified.
  • When you increment again, as expected the hasIncrementedCounterBooleanState selector recomputes because it depends on the changed counterState, but hasIncrementedCounterStringState does not recompute since its dependency hasIncrementedCounterBooleanState resolves to the same value (true).
  • However, what is not expected is that the second time, snapshot logger does show hasIncrementedCounterBooleanState and hasIncrementedCounterStringState as modified, even though their values didn't change, and the latter didn't even recompute.
  • What is also not expected, but likely related to the above is that the component updates, even though it only subscribes to hasIncrementedCounterStringState which didn't get recomputed, let alone had its value change.

With hasIncrementedCounterBooleanState sync, for the second and following increments:

  • As expected show hasIncrementedCounterBooleanState as recomputed, but not as modified, since it resolves to the same value.
  • As a result and expected, only show counterState as modified.
  • As expected, show the component not re-rendering.

Here's a GIF showing the above:

Screen Cast 2022-08-08 at 2 31 46 PM

We suspect this is also related to #1698

Contributor guide