-
Notifications
You must be signed in to change notification settings - Fork 4
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
Nested Scope Consistency Question #32
Comments
Go back to this case: #28 (comment) So, when
There is no conflict between those two possibilities, so they can both happen. Therefore, we need to define the priority. In current implementation, we check I'm not sure if we can offer a better rule. For example, <ScopeProvider atoms={[derivedAtom1]}>
<Counter counterClass="layer1" />
<ScopeProvider atoms={[baseAtom, derivedAtom2]}>
<Counter counterClass="layer2" />
</ScopeProvider>
</ScopeProvider> You think in layer 2 derived1 should access nested one? Then what about this case? <ScopeProvider atoms={[derivedAtom1]}>
<Counter counterClass="layer1" />
<ScopeProvider atoms={[derivedAtom2]}>
<Counter counterClass="layer2" />
</ScopeProvider>
</ScopeProvider> This case is actually part of the previous mentioned comment. I wonder if you want some rule like "if the dependency is explicitly scoped, then it has higher priority". |
Case StudyConsider these two cases. How are they similar? How are they different? const baseAtom = atom(0)
const derivedAtom1 = atom((get) => get(baseAtom))
function Counter() {
const [count, setCount] = useAtom(derivedAtom1)
...
}
// Case 1
<Provider>
<Counter counterClass="layer1" />
<ScopeProvider atoms={[baseAtom]}>
<Counter counterClass="layer2" />
</ScopeProvider>
</Provider>
// Case 2
<ScopeProvider atoms={[derivedAtom1]}>
<Counter counterClass="layer1" />
<ScopeProvider atoms={[baseAtom]}>
<Counter counterClass="layer2" />
</ScopeProvider>
</ScopeProvider> How are they similar?For both Case 1 and Case 2, How are they different?In Case 1, In Case 2, SummaryWith existing implementation we can make the following statement: My thoughts on thisWe should not make that statement in the Summary above. It artificially restricts usage and goes against purpose of using ScopeProvider. And currently, there is no workaround for this specialness property of scoped derived atoms. The ScopeProvider lets applications mix global atoms with scoped ones, but fixing the scope of atom dependencies of scoped atoms prevents mixing scopes of atom dependencies for nested scopes.
For nested scopes, this makes derived atoms all-or-nothing, and the benefits of using ScopedProvider are lost. ProposalThe priority should be: The closest scoped atom is the winner. getAtom Algo
To get dep1:
To get derived1:
What about the case you mentioned?
<ScopeProvider atoms={[derivedAtom1]}>
<Counter counterClass="layer1" />
<ScopeProvider atoms={[derivedAtom2]}>
<Counter counterClass="layer2" />
</ScopeProvider>
</ScopeProvider> For this case, in layer2
Related: |
OK, so the resolution direction is
I'm still unclear what's the boundary of 1, when we will consider 1 as finishes and start to try 2? Let's dive deeper into details. Suppose some of atomA's dependencies are scoped, then atomA is scoped, so we finishes in 1. That's the conceptual question. Consider those two cases. You can ignore derived2. Which base would derived1 in layer3 access? <ScopeProvider atoms={[derived1]}> // layer1
<ScopeProvider atoms={[base]}> // layer2
<ScopeProvider atoms={[]}> // layer3
<Counter />
</ScopeProvider>
</ScopeProvider>
</ScopeProvider> <ScopeProvider atoms={[base]}> // layer1
<ScopeProvider atoms={[derived1]}> // layer2
<ScopeProvider atoms={[]}> // layer3
<Counter />
</ScopeProvider>
</ScopeProvider>
</ScopeProvider> |
Language convention: nested scope is colloquially greater than parent scope (ie layer2 > layer1). Q: should we check the dependencies' ancestor scope atom first? Example 1<ScopeProvider atoms={[derived1]}> // layer1
<ScopeProvider atoms={[base]}> // layer2
<ScopeProvider atoms={[]}> // layer3
<Counter />
</ScopeProvider>
</ScopeProvider>
</ScopeProvider> Q: Which base would derived1 in layer3 access? Example 2<ScopeProvider atoms={[base]}> // layer1
<ScopeProvider atoms={[derived1]}> // layer2
<ScopeProvider atoms={[]}> // layer3
<Counter />
</ScopeProvider>
</ScopeProvider>
</ScopeProvider> Q: Which base would derived1 in layer3 access? |
OK, so the rule would be: Conceptually, it makes sense, but when it comes to implementation, since dependency graph is dynamically built, we need to traverse the whole graph to know if any of the dependency is scoped, then go upward to the ancestor scope. In worst case, if a derived atom and its base atom is global, if it is deeply nested, then the computational complexity would be O(Number of nest scopes * Number of atoms in the dependency). |
base -> derived1 -> derived2 <ScopeProvider atoms={[derived1]}> // layer1
<ScopeProvider atoms={[base]}> // layer2
<ScopeProvider atoms={[]}> // layer3
<Counter />
</ScopeProvider>
</ScopeProvider>
</ScopeProvider> When calling useAtomValue(derived2):
Example 2: <ScopeProvider atoms={[]}> // layer1
<ScopeProvider atoms={[]}> // layer2
<ScopeProvider atoms={[]}> // layer3
<Counter />
</ScopeProvider>
</ScopeProvider>
</ScopeProvider> When calling useAtomValue(derived2):
|
@dai-shi Do you have any comments on different implementations?
|
@dmaskasky After checking the code, I think it is impossible to tell derived atom from base atom that base atom is scoped or not. The only stuff that can be passed to derived atom is the base atom's state value. |
Example 1: <ScopeProvider atoms={[derived1]}> // layer1
<ScopeProvider atoms={[base]}> // layer2
<ScopeProvider atoms={[]}> // layer3
<Counter />
</ScopeProvider>
</ScopeProvider>
</ScopeProvider>
this should use base (layer2) because in layer3, derived1 -> base (layer2). Example 2: <ScopeProvider atoms={[]}> // layer1
<ScopeProvider atoms={[]}> // layer2
<ScopeProvider atoms={[]}> // layer3
<Counter />
</ScopeProvider>
</ScopeProvider>
</ScopeProvider>
what does invalid mean? since none are scoped, it should use the closest Provider store otherwise default global store.
I don't think atom.read should get called on every level. Call once, then look up the chain for the atom dependency in the weakSet. Also, I looked at implementation and it looks like it is copying the atom at every level. I think it would be more performant to copy atom config only for atoms in the atomSet and its dependencies.
I think the code will need to be changed to allow for atoms of parent scopes to "lexical scope" their nested scopes. I recommend a data structure to make this easier to accomplish while staying in the current scope. |
write can have side effects. by contract, read should be idempotent, but as optimization, it's not invoked unnecessarily. both can be expensive technically. |
yes, read should be called only once. |
is this necessary? I think an atom's membership to a weakSet should be all that is needed to indicate scope. Derived atom dependencies are implicitly scoped.
Write can have any number of side-effects before and after zero to many calls to get/set, and a value may be returned from write. |
Though, it's not a guaranteed feature for the future. Our mental model should be "read" is no side effect. |
Yes, for instance atomEffect does not assume read is called only once, but unnecessary calls should be avoided for performance. In this case, I don't think read needs to be called more than once since the mechanism for resolving atoms is built into the overrided getter and setter functions. |
Let me try to explain in details how this one should be implemented. At layer 3
At layer 2
Now, the information that "no atom is scoped in derived2's dependency chain at layer 3" should be sent to derived2, but there is no easy way to achieve that. |
Emmm... I've come up with some ugly hack. When store.set is called,
A bit drawback is that, dependency could vary based on different value at different scope. So, dependency resolution stage" should be called at every scope until we find the correct one. So the computational efficiency problem still exist. |
The root problem: dependency is dynamically computed. The only way to figure out the actual dependency is calling atom.read/atom.write. So, each time a horizontal arrow of the graph is traversed, an atom.read/atom.write is called. Be aware that dependency may not even be the same in different scope. DerivedAtom could access base1 or base2 given different value of base3. Only one dependency resolution is not enough. |
This is looking closer to what I'm imagining. The only change I would make is to synchronously get the atom from the nearest scope first before calling its read fn. That way dynamic deps edge case is handled correctly. So for each dep recursive, always look starting at the nearest scope. I believe only one read should suffice. 😌 |
I might be wrong but I think the requisite change should also address #24 |
I'd be happy to review a PR if you have an idea on how to fix. |
We can't do it. Consider this case:
We need base3's value to get the correct dependency atom of derived. |
const derived = atom(get => get(base3 > 0 ? base1 : base2));
<ScopeProvider atoms={[derived]}> // layer1
<ScopeProvider atoms={[base3]}> // layer2
<ScopeProvider atoms={[base2]}> // layer3
<ScopeProvider atoms={[base1]}> // layer4
<Counter />
</ScopeProvider>
</ScopeProvider>
</ScopeProvider>
</ScopeProvider> |
Emmm, maybe I can come up with another example that breaks the rule.
<ScopeProvider atoms={[derived]}> // layer1
<ScopeProvider atoms={[atom1]}> // layer2
<Counter />
</ScopeProvider>
</ScopeProvider> So from the rule, in layer2, derived should access atom1 of layer2. However, which atom2 will it access? |
I'm not sure I follow. In your example above, base1 vs base2 vs base3 are different atoms - not atoms of different scopes. derived1 can only declare that it needs the value from baseAtom. Remember atoms are just definitions and scopes are tied to the store and not the atom definition. derived1 would not be able to dynamically switch to a different baseAtom scope. Even if it were possible, the strategy I have in mind would still be able to handle this case efficiently. I might put something together when I find the time. |
Hmmm, the same atom in different scopes are implemented as different atoms (that's aligned with the behavior that the same atom has different independent value in different scope). Before we further discuss that, maybe we can first check this example #32 (comment). I realize this rule may be harder to understand. I need to figure out if this example makes sense before discussing implementations. |
Ok, I understand your concern.
const derived = atom(get => get(base3 > 0 ? base1 : base2));
<ScopeProvider atoms={[derived]}> // layer1
<ScopeProvider atoms={[base3]}> // layer2
<ScopeProvider atoms={[base2]}> // layer3
<ScopeProvider atoms={[base1]}> // layer4
<Counter />
</ScopeProvider>
</ScopeProvider>
</ScopeProvider>
</ScopeProvider> There's actually two ways to handle this.
Both read and insert must be synchronous. Since insert is a one-time cost on the average case, we should optimize for read making (2) the better option. |
We already maintain the weakmap as you said in 2. The key here is "we don't know which atoms are in the dependencies", so even we know which atoms are scoped, we don't know if the atom being used is scoped or not. Shall we discuss #32 (comment) first? |
Yes. Another challenge is that if a derived atom subscribes to changes to a scoped atom. If that scoped atom gets removed from the atoms prop of the ScopeProvider, then the derived atom now points to the global atom, but isn't subscribed to it. I haven't checked if this is solved by router atom or not. |
It is already taken into considerations. https://github.com/jotaijs/jotai-scope/tree/main/examples/02_removeScope To make it work, we need to dynamically recompute everything when useAtom is called. |
I'm working on a refactor to close this issue and also #24. Are you on Discord? It would be great if we could offline some of this conversation not relevant to this issue. I have some questions specific to the current implementation. You can find me on the Poimandres server as dmaskasky (previously dmaskasky#3642). |
I'm wondering about the current implementation for nested scope. Is it strange that derivedAtom1 does NOT use the baseAtom scoped to layer2?
For the single layer case, derivedAtom1 would use baseAtom inside Counter below, instead of the global baseAtom.
I think it would be more consistent if derivedAtom1 uses layer2 baseAtom in layer2 Counter.
Do you see any pitfalls with this proposed behavior?
Are there any advantages in keeping the current behavior?
The text was updated successfully, but these errors were encountered: