Open RaffaeleCanale opened 5 months ago
Thank you for submitting the issue, I know what happens but I am not sure what is the correct recipe. Let me first explain some design considerations, they are mainly about derived atoms:
get(anotherAtom)
call, then such atoms are derived atoms. Otherwise, it becomes a primitive atom.
Note that get(anotherAtom)
is an expression within the read
function, which means we have no idea which atom it depends on until we call anAtom.read()
. That's what we mean by dynamically computed
.OK, here comes the issue: In a ScopeProvider's nested children component, when calling useAtom(anAtom)
, we are not aware whether the atom is scoped or not. We need to first find its dependency chain (by calling anAtom.read
), then check if any of the dependency atom is scoped.
So, to make it work, we PESSIMISTICALLY creates a copy of all the atoms called within a ScopeProvider (including atoms depended by them), then calls their read function to detect their dependencies. If they are actually not scoped, what we do is like, making the copy subscribe the original atom, so they look like the same. However, internally, all the atoms within a ScopeProvider are copied. They are different instances.
Comming back to your original case, I have two ideas. The first one would be add another API to explicitly tells ScopeProvider that I don't want the atom to be scoped, but I am not in favor of it because that would lead to additional stuff to learn and I am not sure if that's a good implementation. The second one would be decoupling the cache with your atom. You can implement the "cache fetch" code outside of read function and determines if the cache is available in your read function. Would you like to share a piece of code of your async cache? I'm interested in if it is only initialized once and never changes then, or it could be invalid and retrieved at some time. Anyway, I think the recipe is not so difficult.
I am not so clear if some deeper assumptions are behind. Async functions are not pure. "read functions only trigger when dependencies change" is true, but unfortunately a scoped atom is not the original atom, so multiple initializations occur for each ScopeProvider.
I see there are some implications that are more complex than what I thought. For example, what if a derived atom is not scoped, but one of its dependent atoms is, it leads to confusing situations indeed.
Would you like to share a piece of code of your async cache?
Sorry I might have mislead you with my language. What I meant by "cache" was really just the atom itself and it how it buffers the result of a derived atom. For example, it could look like this:
import { Suspense } from "react";
import { atom, useAtomValue } from "jotai";
import { ScopeProvider } from "jotai-scope";
const MyObjAtom = atom(async () => {
// Expecting this to only be called once (unless someone explicitly scopes it)
const response = await fetch('/api/myobj');
return response.json();
});
function MyObjView() {
const myObj = useAtomValue(MyObjAtom);
return <div>{myObj}</div>;
}
export default function App() {
return (
<div className="App">
<Suspense>
<MyObjView />
<ScopeProvider atoms={[]}>
<Suspense>
<MyObjView />
</Suspense>
</ScopeProvider>
</Suspense>
</div>
);
}
The problem we have is that we constructed most our API requests using plain async atoms like these, which works well enough for our current stage, but whenever someone uses a ScopeProvider
, even if it's for a set of unrelated atoms, if they read from any of those API call atoms, they will re-fetch again.
One workaround I found is manually cache those atoms, a bit like so:
const MyObjCacheAtom = atom();
const MyObjAtom = atom(async (get) => {
if (get(MyObjCacheAtom)) {
return get(MyObjCacheAtom);
}
const response = await fetch('/api/myobj');
return response.json();
});
This actually works well as MyObjCacheAtom
will not be scoped. I guess we could also use jotai-cache
for the same effect. My issue with that solution is that we would need to change all our API calls and take the habit of caching them all, not because we want/need the cache, but because some components might potentially use an unrelated ScopeProvider
.
I was prioritizing simplicity with this approach for as long as we don't need more advanced features (e.g. invalidation), but it seems that this simplicity won't work with jotai-scope
and I either need to drop it, or bring an additional general layer of complexity to our API interactions (e.g. jotai-cache, jotai-tanstack-query, custom caching, ...) which I was hoping to avoid.
Yes yes your workaround is exactly correct, I can understand what a more intuitive API would like, but unfortunately I don't know how to do that (maybe that's a wont-fix) :/
The key takeaway is One should not expect the read function being called exactly once, even though the atom is not scoped.
I'll consider documenting it instead of patching it if that is a rare case.
Updated readme, close it for now.
apologies but i don't think i understand the work around. Are you writing to this cache atom somewhere?
const MyObjCacheAtom = atom();
const MyObjAtom = atom(async (get) => {
if (get(MyObjCacheAtom)) {
return get(MyObjCacheAtom);
}
const response = await fetch('/api/myobj');
return response.json();
});
@yf-yang @RaffaeleCanale curious if you have more detail on how you're using the cache.
For context, we have two separate bugs when using jotai-scope
with atomWithObservable
(at first via jotai-urql but it also manifests when used directly).
One involves a combination of unwrap
and the observable atom creating a loop. The other manifests as a permanently hung suspense boundary.
I'm not sure how to use a caching solution with the observable since I specifically need it to recalculate on updates from the URQL cache.
Part of my difficulty here is that this also only manifests in our production nextjs build. I have tried hard to create a repro here: https://codesandbox.io/p/sandbox/urql-plus-suspense-plus-scope-mzkct6?file=%2Fsrc%2FApp.tsx and haven't been able to create a consistent one. I have seen an infinite loop intermittently but not often. Whereas, the prod bugs I mentioned happen reliably every time.
Any thoughts here would be very appreciated.
@scamden Is the sandbox public? Following that link tells me the sandbox wasn't found.
In the workaround I suggested, the atom MyObjCacheAtom
is the "cache". My issue was that when using jotai-scope
, all derived atoms (even the ones not listed in the scope) will be sort of invalidated and recompute. In other words, the derivation function (in my case, the one making an API call) would get called again if that atom was accessed within the scoped tree.
So by copying the response of the API in a separate atom (which I also don't scope), then even if my derived atom gets computed again, it's fine as it will just return the value from the MyObjCacheAtom
.
So in the end, my problem was really just about derived atoms getting recomputed again despite not being in the scope list, which caused unnecessary API calls.
Full disclosure: In the end it was too cumbersome and error prone to cache all our API derived atoms into simple value holders atoms, so we went an alternative route and stopped using jotai-scope
altogether unfortunately.
Is this still an issue? I just merged a full ground-up rewite of jotai-scope. We will create a new release version shortly.
In the new implementation derived atoms still get copied (because they may access scoped atoms), but the way they get copied doesn't call their read or init functions. This also fixes atomWithLazy from jotai/utils.
Is this still an issue? I just merged a full ground-up rewite of jotai-scope. We will create a new release version shortly.
In the new implementation derived atoms still get copied (because they may access scoped atoms), but the way they get copied doesn't call their read or init functions. This also fixes atomWithLazy from jotai/utils.
This sounds exciting! I'm happy to give it a drive when the new release is pushed. It might take me a few days to find the time to test but I'm happy to report back afterwards :+1:
@scamden Is the sandbox public? Following that link tells me the sandbox wasn't found.
Whoops it wasn't! Now it is.
Full disclosure: In the end it was too cumbersome and error prone to cache all our API derived atoms into simple value holders atoms, so we went an alternative route and stopped using
jotai-scope
altogether unfortunately.
If you stopped, what alternative did you choose? I experimented briefly with bunshi personally
If you stopped, what alternative did you choose? I experimented briefly with bunshi personally
None. For the atoms we intended to scope, we kept them global and manually reset them when relevant. We wanted to scope them to avoid unnecessarily holding onto global memory longer than needed, so for now we just accepted to keep them global nevertheless.
I'm sorry I can't be of more help, but I have hopes that this latest feature could truly help.
Problem statement
Let's consider this example:
Note that I purposely do not pass
AtomA
in the scope provider, so I would intuitively assume that the inner child would read from the same "scope instance" as the parent one. As the read function normally only triggers when dependencies change, I would except it not to trigger again but just return the same cached value as for the parent component.However that turns out not to be the case here. "Computing AtomA" gets logged twice in the console almost as if the atom was scoped.
Additional context
Perhaps this was implemented with the idea that read functions are pure, so even if we compute them again they'll return the same result. And if they depend on other atoms, the value for those would match the parent scope, so it would again return the same value as the parent scope.
However I find that hinders the assumption that read functions only trigger when dependencies change. For example I have use cases with async read functions that fetch data from an API into a cached atom. So if I were to consume those atoms inside unrelated ScopeProviders, then they'd trigger the API fetch again which is an undesired and unpredictable behavior.