Omnistac / zedux

:zap: A Molecular State Engine for React

Home Page:https://Omnistac.github.io/zedux/

Geek Repo:Geek Repo

Github PK Tool:Github PK Tool

`useAtomSelector` doesn't work with `useAtomContext` instances

Aleksion opened this issue · comments

Zedux Version

"@zedux/react": "1.1.1",

Description

useAtomSelector doesn't trigger a re-render when used with useAtomContext.
But, any other re-render in the component will cause the selector to register and work as intended.

Try making a change in the code in the code sandbox, and you'll see it start to synchronize.

Reproduction

https://codesandbox.io/s/epic-snow-pxyvym?file=/src/App.js:0-1823

Thank you @Aleksion for reporting! It looks like this is due to StrictMode changes in React 18. You can see this in the sandbox by either removing StrictMode in index.js or switching to React v17.

Removing StrictMode is an easy fix. But obviously, Zedux is supposed to be compatible with it. This is a confirmed bug.

Initial findings: It looks like React no longer calls the subscribe function passed to useSyncExternalStore if the reference changes on a subsequent render. The useAtomSelector code relies on this happening. I remember hating that behavior in React 17, so I'm glad they made that change. Unfortunately, it broke useAtomSelector.

I'll spend some time today switching all the tests to use StrictMode. We should be doing that anyway. Looks like 3 tests fail in StrictMode that would have caught this.

As for the real fix, I can probably get something quick and dirty in pretty quickly. But I actually already have a branch where I switched useAtomSelector off of useSyncExternalStore completely, which would also fix this and may be better anyway. I'll explore it a bit and report back.

Thank you for the quick response!
Fascinating - I've seen multiple complaints about React 18 and strict mode on Xwitter as of late (useEffects with no dependencies firing twice 🤯).

any updates on this @bowheart?

Hey @Aleksion sorry I spent some time at Disney World a couple weeks ago and was busy getting back to work last week.

I've basically decided on a hybrid approach. Instead of switching off of useSyncExternalStore completely, we'll still use it for now, but won't rely on the passed onStoreChange function to rerender the component. This has a few advantages (for example, transition support) and only one disadvantage (an extra useState call is needed).

This is a pretty involved change. I'm actively working on it. Will probably take a couple days to get a new version published.

Hi,

Just wanted to check on this 🙈 Don't mean to apply pressure, but I'm moving into production and I like getting reactStrictMode back on 😂

(I also know that it's open source so it is what it is. Just wanted to understand if it's still on track).

Thank you for the amazing work you've done here!

Hey @Aleksion pressure away! It's good for me.

TL;DR this change has suffered some feature creep. But it's really a good thing, fixing a few outstanding issues.

Longer explanation:

I actually got all the tests passing about when I thought I would (a few days after my last message), but it's a big enough change that I wanted to add more tests and play with it in a test project for a bit. And it's a good thing I did because I discovered a new problem in strict mode that requires another rework to inline selectors passed to useAtomSelector.

The core of the change is that both useAtomSelector and useAtomInstance now add their graph edges during render. These operations need to be completely idempotent since they are impure. With atoms and externally-defined atom selectors, this works like a charm. But when inline selectors (selectors whose function reference changes across renders) are used and React double-renders a component unnecessarily in strict mode, the generated id for the first render doesn't match the generated id on the second render. In this case (and only in strict mode), effects aren't allowed to fire to clean up the unnecessary graph edge, leading to a memory leak. The leak isn't very aggressive - it would take a lot for it to lead to memory problems - but those leaked nodes would show up when inspecting the graph, potentially leading to wasted dev time trying to figure out why they're there.

This fault is only possible (or at least only possibly problematic) in dev strict mode as far as I can tell. But I'd still call it a must fix. So for the fix: I'm making useAtomSelector generate an id using a deterministic hash of the stringified function body. This generation shouldn't be too expensive and will only need to run once for non-inline selectors (unless Zedux has already encountered and cached the selector reference in another atom or selector before). For inline selectors, the hashing will need to run every render, but, again, that shouldn't be too expensive, especially since inline selectors are typically smaller functions. Still I'll want to run stress tests to verify.

Other outstanding issues that this fixes:

  • Transition support! Zedux state updates should now work with startTransition, though they may not play perfectly with suspense since Zedux is an external store that doesn't implement state versioning (currently), so it can't be in multiple states at once for full compatibility with React's fiber system. But React doesn't have any way for external stores to integrate with that anyway yet, nor are there any plans to ever support it as far as I know. In short, Zedux should be as compatible with transitions as any state manager can be.
  • ttl: 0 atoms can be destroyed synchronously by an unmounting component tree but captured by user code during initial render of a mounting component tree (e.g. by passing an atom instance to useRef()). These references need to be manually updated to use the new, non-destroyed atom instance (e.g. ref.current = ecosystem.getInstance(ref.current)). This isn't at all straightforward and has led to a few (rare) bugs that take a while to track down. Now that graph edges are added during render of the new component tree, the unmounting component tree doesn't destroy ttl: 0 atoms that are actually still in use.

Additionally, these changes do completely get rid of useSyncExternalStore. I've ditched the hybrid approach since it created as many edge cases as the original code. This means we'll no longer need to bundle the uSES shim, reducing Zedux's bundle size.

For time frame, I'm shooting to have all of this done and tested this week. If performance looks good, I'll publish a release candidate by the end of this week.

@Aleksion a preemptive heads up this time. I'm almost there. I'll try to snag a couple hours to finish this and get a release out tomorrow

@Aleksion The fix for this has been released in version 1.2.0-rc.0, which can be installed via npm i @zedux/react@next (or similar).

I've verified that it fixes your codesandbox here: https://codesandbox.io/s/useatomselector-react-context-t5dcps

If you get a minute, could you try out the release candidate version and verify that it fixes the issue in your app? I'm going to do some heavy testing with the release candidate version and if all goes well will probably release version 1.2.0 within a few days.

Hi @bowheart,

Just did a test, and it seems like useAtomSelector with dynamic selectors causes infinite loops:
Screenshot 2023-11-20 at 10 21 57 AM

@Aleksion Thank you! This is so helpful! I was getting a different error and thought I broke suspense support somehow. This clarified everything and I found the problem quickly and have put together #88 to fix. Publishing a new release candidate momentarily that I will test.

Version 1.2.0-rc.1 has been published with the fix for inline selectors that return referentially unstable results

ohhh... I think this might have done the trick.
It seems to work perfectly!

@bowheart I think I might have found another bug:

useAtomSelector re-renders subscribing components twice.

Screenshot 2023-11-21 at 12 12 53 PM Screenshot 2023-11-21 at 12 12 34 PM

@Aleksion I haven't been able to reproduce this. Are you perhaps seeing the double-renders from StrictMode?

const idAtom = atom('id', () => ({ _id: '123' }))
const selector = (
  { get }: AtomGetters,
  instance: AtomInstanceType<typeof idAtom>
) => get(instance)._id

function Test() {
  const [state, setState] = useState(1)
  const instance = useAtomInstance(idAtom)
  const val = useAtomSelector(selector, instance)

  console.log('rendered component', state)

  return (
    <div>
      Selector val: {val}, state: {state}{' '}
      <button onClick={() => setState(s => s + 1)}>Increment</button>
      <button onClick={() => instance.setState(s => ({ _id: s._id + 1 }))}>
        Update Atom
      </button>
    </div>
  )
}

This (perhaps over-simplified?) example renders the component once for each click of each button outside StrictMode, and twice for each click of each button when wrapped in StrictMode, as expected. If you could modify this code snippet to make it trigger excess renders beyond that, that would be a great help