Skip to content

Commit b6fd022

Browse files
committed
fix: full rewrite of ScopeProvider to address known issues
1 parent 652fba2 commit b6fd022

27 files changed

+1883
-211
lines changed

eslint.config.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ export default tseslint.config(
2727
eqeqeq: 'error',
2828
'no-console': 'off',
2929
'no-inner-declarations': 'off',
30+
'no-sparse-arrays': 'off',
3031
'no-var': 'error',
3132
'prefer-const': 'error',
3233
'sort-imports': [

notes/new.md

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
## Goals
2+
3+
1. don't copy atoms
4+
2. remove writable hack
5+
6+
### explicitly scoped
7+
8+
```ts
9+
if (explicit.has(atom)) {
10+
// handle explicit
11+
return atom
12+
}
13+
```
14+
15+
### implicitly scoped
16+
17+
```ts
18+
19+
else if (implicit.has(atom)) {
20+
return currentScope.getAtom(atom)
21+
// handle implicit
22+
}
23+
```
24+
25+
### inherited scoped
26+
27+
```ts
28+
/** returns nearest ancestor scope if atom is explicitly scoped in any ancestor store */
29+
const scope = searchAncestorScopes(atom)
30+
else if (scope) {
31+
// handle inherited
32+
return scope.getAtom(atom)
33+
}
34+
```
35+
36+
### inherited implicitly scoped
37+
38+
else if the dependent atom is explicitly or implicitly scoped in an ancestor store, current atom is implicitly scoped in that ancestor store if it is not
39+
how: ???
40+
41+
```ts
42+
else if (searchAncestorScopes(dependentAtom)) {
43+
isScopedAtom(dependentAtom)
44+
// handle implicit
45+
}
46+
```
47+
48+
### unscoped
49+
50+
else atom is unscoped
51+
52+
#### unscoped derived
53+
54+
if atom is derived, it can access scoped atoms
55+
how: ???
56+
57+
### unscoped writable
58+
59+
if atom is writable, it can access scoped atoms
60+
how: ???

notes/notes

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
just intercept the getter of the store everywhere all the way up to the base store.
2+
3+
---
4+
5+
computed atoms are like implicit atoms but reverse
6+
computed atoms are not copied
7+
reverseImplicitSet is a set of computed atoms to indicate whether a computed atom should be treated as a reverse implicit
8+
the atom is removed from the set between recomputations
9+
by intercepting the readFn, if an atom is `get` that is either an explicit or reverse implicit,
10+
11+
- then the atom is added to the reverse implicit set
12+
13+
only the readFn determines if the atom is added to the reverse implicit set
14+
intercepting the readFn and writeFn is used to get the "correct" atom
15+
when a computed atom converts to reverse implicit,
16+
17+
- its atomState is created from scratch
18+
- this is because the atomState stores a different value for the scoped atom and can have different dependencies
19+
20+
**Special Case:** on first read, when a computed atom reads a scoped atom,
21+
22+
1. it is added to the reverse implicit set
23+
1. the atomState is copied from the unscoped atomState
24+
1. getAtomState points to the scoped atomState
25+
26+
the atomStateProxy is no longer needed.
27+
28+
# Implementation
29+
30+
readAtomTrap:
31+
getter:

notes/readAtomState.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
## Objectives
2+
3+
1. Derived atoms are copied even if they don’t depend on scoped atoms.
4+
2. If the derived atom has already mounted, don't call onMount again.
5+
Fixes:
6+
7+
- [Scope caused atomWithObservable to be out of sync](https://github.com/jotaijs/jotai-scope/issues/36)
8+
- [Computed atoms get needlessly triggered again](https://github.com/jotaijs/jotai-scope/issues/25)
9+
10+
## Requirements
11+
12+
1. Some way to get whether the atom has been mounted.
13+
2. Some way to bypass the onMount call if the atom is already mounted.

notes/unstable_derive.md

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
# Objectives
2+
3+
1. Derived atoms are not copied if they don’t depend on scoped atoms.
4+
2. When a derived atom starts depending on a scoped atom, a new atom state is created as the scoped atom state.
5+
3. When a derived atom stops depending on a scoped atom, it must be removed from the scope state and restored to the original atom state.
6+
a. When changing between scoped and unscoped, all subscibers must be notified.
7+
8+
Fixes:
9+
10+
- [Scope caused atomWithObservable to be out of sync](https://github.com/jotaijs/jotai-scope/issues/36)
11+
- [Computed atoms get needlessly triggered again](https://github.com/jotaijs/jotai-scope/issues/25)
12+
13+
# Requirements
14+
15+
1. Some way to track dependencies of computed atoms not in the scope without copying them.
16+
2. Some way to get whether the atom has been mounted.
17+
18+
# Problem Statement
19+
20+
A computed atom may or may not consume scoped atoms. This may also change as state changes.
21+
22+
```tsx
23+
const providerAtom = atom('unscoped')
24+
const scopedProviderAtom = atom('scoped')
25+
const shouldConsumeScopedAtom = atom(false)
26+
const consumerAtom = atom((get) => {
27+
if (get(shouldConsumeScopedAtom)) {
28+
return get(scopedProviderAtom)
29+
}
30+
return get(providerAtom)
31+
})
32+
33+
function Component() {
34+
const value = useAtomValue(consumerAtom)
35+
return value
36+
}
37+
38+
function App() {
39+
const setShouldConsumeScopedAtom = useSetAtom(shouldConsumeScopedAtom)
40+
useEffect(() => {
41+
const timeoutId = setTimeout(setShouldConsumeScopedAtom, 1000, true)
42+
return () => clearTimeout(timeoutId)
43+
}, [])
44+
45+
return (
46+
<ScopeProvider atoms={[scopedProviderAtom]}>
47+
<Component />
48+
</ScopeProvider>
49+
)
50+
}
51+
```
52+
53+
To properly handle `consumerAtom`, we need to track the dependencies of the computed atom.
54+
55+
# Proxy State
56+
57+
Atom state has the following shape;
58+
59+
```ts
60+
type AtomState = {
61+
d: Map<AnyAtom, number>; // map of atom consumers to their epoch number
62+
p: Set<AnyAtom>; // set of pending atom consumers
63+
n: number; // epoch number
64+
m?: {
65+
l: Set<() => void>; // set of listeners
66+
d: Set<AnyAtom>; // set of mounted atom consumers
67+
t: Set<AnyAtom>; // set of mounted atom providers
68+
u?: (setSelf: () => any) => (void | () => void); // unmount function
69+
};
70+
v?: any; // value
71+
e?: any; // error
72+
};
73+
```
74+
75+
All computed atoms (`atom.read !== defaultRead`) will have their base atomState converted to a proxy state. The proxy state will track dependencies and notify when they change.
76+
77+
0. Update all computed atoms with a proxy state in the parent store.
78+
1. If a computer atom does not depend on any scoped atoms, remove it from the unscopedComputed set
79+
2. If a computed atom starts depending on a scoped atom, add it to the scopedComputed set.
80+
a. If the scoped state does not already exist, create a new scoped atom state.
81+
3. If a computed atom stops depending on a scoped atom, remove it from the scopedComputed set.

pnpm-lock.yaml

Lines changed: 19 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/ScopeProvider/ScopeProvider.tsx

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ import {
1010
type Store,
1111
} from '../types'
1212
import { createScope } from './scope'
13+
import type { AnyAtom, AnyAtomFamily, Store } from './types'
14+
import { isEqualSet } from './utils'
1315

1416
type ScopeProviderBaseProps = PropsWithChildren<{
1517
atoms?: Iterable<AnyAtom | AtomDefault>
@@ -45,7 +47,22 @@ export function ScopeProvider({
4547

4648
const atoms = Array.from(atomsOrTuples, (a) => (Array.isArray(a) ? a[0] : a))
4749

48-
// atomSet is used to detect if the atoms prop has changed.
50+
export function ScopeProvider(
51+
props: { atoms: Iterable<Atom<unknown>> } & BaseScopeProviderProps
52+
): JSX.Element
53+
54+
export function ScopeProvider(
55+
props: { atomFamilies: Iterable<AnyAtomFamily> } & BaseScopeProviderProps
56+
): JSX.Element
57+
58+
export function ScopeProvider({
59+
atoms,
60+
atomFamilies,
61+
children,
62+
debugName,
63+
...options
64+
}: BaseScopeProviderProps) {
65+
const baseStore = useStore(options)
4966
const atomSet = new Set(atoms)
5067
const atomFamilySet = new Set(atomFamilies)
5168

0 commit comments

Comments
 (0)