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

useAtomSelector doesn't work with useAtomContext instances #83

Open
Aleksion opened this issue Oct 5, 2023 · 14 comments · Fixed by #86
Open

useAtomSelector doesn't work with useAtomContext instances #83

Aleksion opened this issue Oct 5, 2023 · 14 comments · Fixed by #86

Comments

@Aleksion
Copy link

Aleksion commented Oct 5, 2023

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

@bowheart
Copy link
Collaborator

bowheart commented Oct 5, 2023

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.

@Aleksion
Copy link
Author

Aleksion commented Oct 5, 2023

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 🤯).

@Aleksion
Copy link
Author

any updates on this @bowheart?

@bowheart
Copy link
Collaborator

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.

@Aleksion
Copy link
Author

Aleksion commented Nov 8, 2023

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!

@bowheart
Copy link
Collaborator

bowheart commented Nov 8, 2023

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.

@bowheart
Copy link
Collaborator

@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

@bowheart
Copy link
Collaborator

@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.

@Aleksion
Copy link
Author

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

@bowheart
Copy link
Collaborator

@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.

@bowheart
Copy link
Collaborator

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

@Aleksion
Copy link
Author

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

@Aleksion
Copy link
Author

@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

@bowheart
Copy link
Collaborator

@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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging a pull request may close this issue.

2 participants