Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Bug: useSyncExternalStore does not update internal value if a setState is also called, outside useEffect, causing store sets not re-render #25565

Closed
pandaiolo opened this issue Oct 26, 2022 · 5 comments · Fixed by #25578
Labels
Status: Unconfirmed A potential issue that we haven't yet confirmed as a bug

Comments

@pandaiolo
Copy link
Contributor

pandaiolo commented Oct 26, 2022

In a custom hook, I forgot to wrap my setState call in a useEffect, which did not pose any problems prior to React 18 but now this does not work anymore. The component does not re-render if the store is changed back to its initial value. I guess this might be expected, but there was no warning whatsoever, so it was hard to find out.

So this is more like a question:

  • Is that behavior a bug?
  • If it is not, would it be useful to have a warning for another developer making the same mistake?

React version: 18

Steps To Reproduce

  1. Have a component use useSyncExternalStore (for example redux 8)
  2. Call setState outside of useEffect or event handlers (in render)
  3. Trigger a change to the store back and forth

Link to code example: https://codesandbox.io/s/react-18-redux-8-reproduce-bug-ojdx43

The current behavior

  • The component does re-render if the store value is updated to something different than its initial value
  • The component does not re-render if the store is updated to its initial value again

The expected behavior

  • The component does re-render if the store value is updated to something different than its initial value
  • The component also does re-render if the store is updated to its initial value again

Further digging

When I tried to trace down the issue, I found that the setState called outside useEffect would mess up with the fiber update queue, in particular the prior pushEffect supposedly calling updateStoreInstance with the latest value to have the internal cached instance up to date. It does not get called at all (cancelled? overridden?) because of the unwrapped setState.

As a consequence, that cached instance never gets updated. Any other value but the initial value hence correctly triggers a re-render, as the checkIfSnapshotChanged ends up always comparing against that initial value.

@pandaiolo pandaiolo added the Status: Unconfirmed A potential issue that we haven't yet confirmed as a bug label Oct 26, 2022
@pandaiolo
Copy link
Contributor Author

pandaiolo commented Oct 26, 2022

In order to familiarize myself with react codebase, I wrote a small test that fails on this pattern. (even if it is expected so)

Here is it fwiw (in packages/react-reconciler/src/__tests__/useSyncExternalStore-test.js):

test('store value is correctly stored in current hook instance even with interleaved effects occurring', async () => {
    const store = createExternalStore('value:initial');

    function App() {
      const value = useSyncExternalStore(store.subscribe, store.getState);
      const [sameValue, setSameValue] = useState(value);
      if (value !== sameValue) setSameValue(value);
      return <Text text={value} />;
    }

    const root = ReactNoop.createRoot();
    act(() => {
      // Start a render that reads from the store and yields value
      root.render(<App />);
    });
    expect(Scheduler).toHaveYielded(['value:initial']);

    await act(() => {
      store.set('value:changed');
    });
    expect(Scheduler).toHaveYielded(['value:changed']);

    await act(() => {
      store.set('value:initial');
    });
    expect(Scheduler).toHaveYielded(['value:initial']); 
  });
});

The last assertion fails with the setSameValue line, and passes without.

@pandaiolo pandaiolo changed the title Bug: useSyncExternalStore update is not updating its cached value if setState is called outside useEffect, causing some store value to not cause a rerender Bug: useSyncExternalStore update is not updating its cached value if setState is called outside useEffect, causing store sets not re-render Oct 27, 2022
@pandaiolo pandaiolo changed the title Bug: useSyncExternalStore update is not updating its cached value if setState is called outside useEffect, causing store sets not re-render Bug: useSyncExternalStore does not update internal value if a setState is also called, outside useEffect, causing store sets not re-render Oct 27, 2022
@pandaiolo
Copy link
Contributor Author

What I don't understand is that in renderWithHooks, there is the following block:

// Check if there was a render phase update
  if (didScheduleRenderPhaseUpdateDuringThisPass) {

Which runs if setState was called in render. Then, it calls again component function - but to do so, it resets the workInProgress state, including updateQueue. IIUC this discards the effects pushed by previous hooks, without flushing them?

That's why useSyncExternalStore effect to update store value is not run, in that case.

The fact that there is code written to manage setState calls in render, seem to acknowledge it is a legit use case?

I must be missing something 😅 how to make sure those effects are run even if component function is called again before end of work?

@markerikson
Copy link
Contributor

markerikson commented Oct 27, 2022

pandaiolo added a commit to pandaiolo/react that referenced this issue Oct 28, 2022
If state is dispatched during render phase, then work in progress
`hook` will hold already updated memoized state, defeating previous
value comparison.

Using `currentHook` instead fixes that issue.
@pandaiolo
Copy link
Contributor Author

pandaiolo commented Oct 28, 2022

Ok, rel my previous comment, IIUC the discarding of work in progress hook is expected when state is dispatched in render phase. The work in progress queue should be recreated (to reflect new state).

useSyncExternalStore was using work in progress memoizedState to compare previous value, but in the case of a second call, this already contains the new value, defeating the purpose.

It works when using currentHook in priority. See attached PR with fix, let me know what you think!

@hawx1993
Copy link

a good solution to splitting UI and business logic, see: https://github.com/hawx1993/hooks-view-model

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Status: Unconfirmed A potential issue that we haven't yet confirmed as a bug
Projects
None yet
Development

Successfully merging a pull request may close this issue.

3 participants