Skip to content

Commit b98f865

Browse files
authored
refactor(core,utils,devtools): add dev methods in store (#717)
* refactor(core,utils,devtools): add dev methods in store * fix: state listener should be invoked in flush * simplify debug atom init
1 parent 5839dbe commit b98f865

File tree

9 files changed

+106
-120
lines changed

9 files changed

+106
-120
lines changed

src/core/Provider.ts

Lines changed: 16 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -7,14 +7,14 @@ import {
77
} from 'react'
88
import type { PropsWithChildren } from 'react'
99
import type { Atom, Scope } from './atom'
10+
import { createScopeContainer, getScopeContext } from './contexts'
11+
import type { ScopeContainer } from './contexts'
1012
import {
11-
ScopeContainer,
12-
createScopeContainer,
13-
getScopeContext,
14-
isDevScopeContainer,
15-
} from './contexts'
16-
import type { ScopeContainerForDevelopment } from './contexts'
17-
import { DEV_GET_ATOM_STATE, DEV_GET_MOUNTED } from './store'
13+
DEV_GET_ATOM_STATE,
14+
DEV_GET_MOUNTED,
15+
DEV_GET_MOUNTED_ATOMS,
16+
DEV_SUBSCRIBE_STATE,
17+
} from './store'
1818
import type { AtomState, Store } from './store'
1919

2020
export const Provider = ({
@@ -34,8 +34,7 @@ export const Provider = ({
3434
if (
3535
typeof process === 'object' &&
3636
process.env.NODE_ENV !== 'production' &&
37-
process.env.NODE_ENV !== 'test' &&
38-
isDevScopeContainer(scopeContainerRef.current)
37+
process.env.NODE_ENV !== 'test'
3938
) {
4039
// eslint-disable-next-line react-hooks/rules-of-hooks
4140
useDebugState(scopeContainerRef.current)
@@ -77,15 +76,16 @@ const stateToPrintable = ([store, atoms]: [Store, Atom<unknown>[]]) =>
7776

7877
// We keep a reference to the atoms in Provider's registeredAtoms in dev mode,
7978
// so atoms aren't garbage collected by the WeakMap of mounted atoms
80-
const useDebugState = (scopeContainer: ScopeContainerForDevelopment) => {
81-
const [store, devStore] = scopeContainer
82-
const [atoms, setAtoms] = useState(devStore.atoms)
79+
const useDebugState = (scopeContainer: ScopeContainer) => {
80+
const store = scopeContainer.s
81+
const [atoms, setAtoms] = useState<Atom<unknown>[]>([])
8382
useEffect(() => {
84-
// HACK creating a new reference for useDebugValue to update
85-
const callback = () => setAtoms([...devStore.atoms])
86-
const unsubscribe = devStore.subscribe(callback)
83+
const callback = () => {
84+
setAtoms(Array.from(store[DEV_GET_MOUNTED_ATOMS]?.() || []))
85+
}
86+
const unsubscribe = store[DEV_SUBSCRIBE_STATE]?.(callback)
8787
callback()
8888
return unsubscribe
89-
}, [devStore])
89+
}, [store])
9090
useDebugValue([store, atoms], stateToPrintable)
9191
}

src/core/contexts.ts

Lines changed: 7 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -2,66 +2,19 @@ import { createContext } from 'react'
22
import type { Context } from 'react'
33
import type { Atom, Scope } from './atom'
44
import { createStore } from './store'
5+
import type { Store } from './store'
56

6-
const createScopeContainerForProduction = (
7-
initialValues?: Iterable<readonly [Atom<unknown>, unknown]>
8-
) => {
9-
const store = createStore(initialValues)
10-
return [store] as const
7+
export type ScopeContainer = {
8+
s: Store
119
}
1210

13-
const createScopeContainerForDevelopment = (
11+
export const createScopeContainer = (
1412
initialValues?: Iterable<readonly [Atom<unknown>, unknown]>
15-
) => {
16-
const devStore = {
17-
listeners: new Set<() => void>(),
18-
subscribe: (callback: () => void) => {
19-
devStore.listeners.add(callback)
20-
return () => {
21-
devStore.listeners.delete(callback)
22-
}
23-
},
24-
atoms: Array.from(initialValues ?? []).map(([a]) => a),
25-
}
26-
const stateListener = (updatedAtom: Atom<unknown>, isNewAtom: boolean) => {
27-
if (isNewAtom) {
28-
// FIXME memory leak
29-
// we should probably remove unmounted atoms eventually
30-
devStore.atoms = [...devStore.atoms, updatedAtom]
31-
}
32-
Promise.resolve().then(() => {
33-
devStore.listeners.forEach((listener) => listener())
34-
})
35-
}
36-
const store = createStore(initialValues, stateListener)
37-
return [store, devStore] as const
38-
}
39-
40-
export const isDevScopeContainer = (
41-
scopeContainer: ScopeContainer
42-
): scopeContainer is ScopeContainerForDevelopment => {
43-
return scopeContainer.length > 1
13+
): ScopeContainer => {
14+
const store = createStore(initialValues)
15+
return { s: store }
4416
}
4517

46-
type ScopeContainerForProduction = ReturnType<
47-
typeof createScopeContainerForProduction
48-
>
49-
export type ScopeContainerForDevelopment = ReturnType<
50-
typeof createScopeContainerForDevelopment
51-
>
52-
export type ScopeContainer =
53-
| ScopeContainerForProduction
54-
| ScopeContainerForDevelopment
55-
56-
type CreateScopeContainer = (
57-
initialValues?: Iterable<readonly [Atom<unknown>, unknown]>
58-
) => ScopeContainer
59-
60-
export const createScopeContainer: CreateScopeContainer =
61-
typeof process === 'object' && process.env.NODE_ENV !== 'production'
62-
? createScopeContainerForDevelopment
63-
: createScopeContainerForProduction
64-
6518
type ScopeContext = Context<ScopeContainer>
6619

6720
const ScopeContextMap = new Map<Scope | undefined, ScopeContext>()

src/core/store.ts

Lines changed: 45 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -64,23 +64,36 @@ type Mounted = {
6464

6565
// for debugging purpose only
6666
type StateListener = (updatedAtom: AnyAtom, isNewAtom: boolean) => void
67+
type MountedAtoms = Set<AnyAtom>
6768

6869
// store methods
6970
export const READ_ATOM = 'r'
7071
export const WRITE_ATOM = 'w'
7172
export const FLUSH_PENDING = 'f'
7273
export const SUBSCRIBE_ATOM = 's'
7374
export const RESTORE_ATOMS = 'h'
75+
76+
// store dev methods (these are tentative and subject to change)
77+
export const DEV_SUBSCRIBE_STATE = 'n'
78+
export const DEV_GET_MOUNTED_ATOMS = 'l'
7479
export const DEV_GET_ATOM_STATE = 'a'
7580
export const DEV_GET_MOUNTED = 'm'
7681

7782
export const createStore = (
78-
initialValues?: Iterable<readonly [AnyAtom, unknown]>,
79-
stateListener?: StateListener
83+
initialValues?: Iterable<readonly [AnyAtom, unknown]>
8084
) => {
8185
const atomStateMap = new WeakMap<AnyAtom, AtomState>()
8286
const mountedMap = new WeakMap<AnyAtom, Mounted>()
83-
const pendingMap = new Map<AnyAtom, ReadDependencies | undefined>()
87+
const pendingMap = new Map<
88+
AnyAtom,
89+
[dependencies: ReadDependencies | undefined, isNewAtom: boolean]
90+
>()
91+
let stateListeners: Set<StateListener>
92+
let mountedAtoms: MountedAtoms
93+
if (typeof process === 'object' && process.env.NODE_ENV !== 'production') {
94+
stateListeners = new Set()
95+
mountedAtoms = new Set()
96+
}
8497

8598
if (initialValues) {
8699
for (const [atom, value] of initialValues) {
@@ -144,7 +157,7 @@ export const createStore = (
144157
atomState.d.set(atom, atomState.r)
145158
}
146159
}
147-
commitAtomState(atom, atomState, dependencies && prevDependencies)
160+
setAtomState(atom, atomState, dependencies && prevDependencies)
148161
}
149162

150163
const setAtomReadError = <Value>(
@@ -163,7 +176,7 @@ export const createStore = (
163176
delete atomState.c // cancel read promise
164177
delete atomState.i // invalidated revision
165178
atomState.e = error // read error
166-
commitAtomState(atom, atomState, prevDependencies)
179+
setAtomState(atom, atomState, prevDependencies)
167180
}
168181

169182
const setAtomReadPromise = <Value>(
@@ -186,13 +199,13 @@ export const createStore = (
186199
atomState.p = interruptablePromise // read promise
187200
atomState.c = interruptablePromise[INTERRUPT_PROMISE]
188201
}
189-
commitAtomState(atom, atomState, prevDependencies)
202+
setAtomState(atom, atomState, prevDependencies)
190203
}
191204

192205
const setAtomInvalidated = <Value>(atom: Atom<Value>): void => {
193206
const [atomState] = wipAtomState(atom)
194207
atomState.i = atomState.r // invalidated revision
195-
commitAtomState(atom, atomState)
208+
setAtomState(atom, atomState)
196209
}
197210

198211
const setAtomWritePromise = <Value>(
@@ -207,7 +220,7 @@ export const createStore = (
207220
// delete it only if it's not overwritten
208221
delete atomState.w // write promise
209222
}
210-
commitAtomState(atom, atomState)
223+
setAtomState(atom, atomState)
211224
}
212225

213226
const scheduleReadAtomState = <Value>(
@@ -483,6 +496,9 @@ export const createStore = (
483496
u: undefined,
484497
}
485498
mountedMap.set(atom, mounted)
499+
if (typeof process === 'object' && process.env.NODE_ENV !== 'production') {
500+
mountedAtoms.add(atom)
501+
}
486502
if (isActuallyWritableAtom(atom) && atom.onMount) {
487503
const setAtom = (update: unknown) => writeAtom(atom, update)
488504
mounted.u = atom.onMount(setAtom)
@@ -497,6 +513,9 @@ export const createStore = (
497513
onUnmount()
498514
}
499515
mountedMap.delete(atom)
516+
if (typeof process === 'object' && process.env.NODE_ENV !== 'production') {
517+
mountedAtoms.delete(atom)
518+
}
500519
// unmount read dependencies afterward
501520
const atomState = getAtomState(atom)
502521
if (atomState) {
@@ -550,7 +569,7 @@ export const createStore = (
550569
})
551570
}
552571

553-
const commitAtomState = <Value>(
572+
const setAtomState = <Value>(
554573
atom: Atom<Value>,
555574
atomState: AtomState<Value>,
556575
prevDependencies?: ReadDependencies
@@ -560,31 +579,29 @@ export const createStore = (
560579
}
561580
const isNewAtom = !atomStateMap.has(atom)
562581
atomStateMap.set(atom, atomState)
563-
if (stateListener) {
564-
stateListener(atom, isNewAtom)
565-
}
566582
if (!pendingMap.has(atom)) {
567-
pendingMap.set(atom, prevDependencies)
583+
pendingMap.set(atom, [prevDependencies, isNewAtom])
568584
}
569585
}
570586

571587
const flushPending = (): void => {
572588
const pending = Array.from(pendingMap)
573589
pendingMap.clear()
574-
pending.forEach(([atom, prevDependencies]) => {
575-
const atomState = getAtomState(atom)
576-
if (atomState) {
577-
if (prevDependencies) {
590+
pending.forEach(([atom, [prevDependencies, isNewAtom]]) => {
591+
if (prevDependencies) {
592+
const atomState = getAtomState(atom)
593+
if (atomState) {
578594
mountDependencies(atom, atomState, prevDependencies)
579595
}
580-
} else if (
596+
}
597+
const mounted = mountedMap.get(atom)
598+
mounted?.l.forEach((listener) => listener())
599+
if (
581600
typeof process === 'object' &&
582601
process.env.NODE_ENV !== 'production'
583602
) {
584-
console.warn('[Bug] atom state not found in flush', atom)
603+
stateListeners.forEach((l) => l(atom, isNewAtom))
585604
}
586-
const mounted = mountedMap.get(atom)
587-
mounted?.l.forEach((listener) => listener())
588605
})
589606
}
590607

@@ -617,6 +634,13 @@ export const createStore = (
617634
[FLUSH_PENDING]: flushPending,
618635
[SUBSCRIBE_ATOM]: subscribeAtom,
619636
[RESTORE_ATOMS]: restoreAtoms,
637+
[DEV_SUBSCRIBE_STATE]: (l: StateListener) => {
638+
stateListeners.add(l)
639+
return () => {
640+
stateListeners.delete(l)
641+
}
642+
},
643+
[DEV_GET_MOUNTED_ATOMS]: () => mountedAtoms.values(),
620644
[DEV_GET_ATOM_STATE]: (a: AnyAtom) => atomStateMap.get(a),
621645
[DEV_GET_MOUNTED]: (a: AnyAtom) => mountedMap.get(a),
622646
}

src/core/useAtom.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ export function useAtom<Value, Update>(
5353
}
5454

5555
const ScopeContext = getScopeContext(scope)
56-
const [store] = useContext(ScopeContext)
56+
const store = useContext(ScopeContext).s
5757

5858
const getAtomValue = useCallback(() => {
5959
const atomState = store[READ_ATOM](atom)

src/devtools/useAtomsSnapshot.ts

Lines changed: 31 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,40 +1,51 @@
11
import { useContext, useEffect, useState } from 'react'
22
import { SECRET_INTERNAL_getScopeContext as getScopeContext } from 'jotai'
33
import type { Atom, Scope } from '../core/atom'
4-
// NOTE importing from '../core/contexts' is across bundles and actually copying code
5-
import { isDevScopeContainer } from '../core/contexts'
6-
import { DEV_GET_ATOM_STATE, DEV_GET_MOUNTED } from '../core/store'
7-
import type { AtomState } from '../core/store'
4+
import {
5+
DEV_GET_ATOM_STATE,
6+
DEV_GET_MOUNTED_ATOMS,
7+
DEV_SUBSCRIBE_STATE,
8+
} from '../core/store'
9+
import type { AtomState, Store } from '../core/store'
810

911
type AtomsSnapshot = Map<Atom<unknown>, unknown>
1012

13+
const createAtomsSnapshot = (
14+
store: Store,
15+
atoms: Atom<unknown>[]
16+
): AtomsSnapshot => {
17+
const tuples = atoms.map<[Atom<unknown>, unknown]>((atom) => {
18+
const atomState = store[DEV_GET_ATOM_STATE]?.(atom) ?? ({} as AtomState)
19+
return [atom, atomState.v]
20+
})
21+
return new Map(tuples)
22+
}
23+
1124
export function useAtomsSnapshot(scope?: Scope): AtomsSnapshot {
1225
const ScopeContext = getScopeContext(scope)
1326
const scopeContainer = useContext(ScopeContext)
27+
const store = scopeContainer.s
1428

15-
if (!isDevScopeContainer(scopeContainer)) {
16-
throw Error('useAtomsSnapshot can only be used in dev mode.')
29+
if (!store[DEV_SUBSCRIBE_STATE]) {
30+
throw new Error('useAtomsSnapshot can only be used in dev mode.')
1731
}
1832

19-
const [store, devStore] = scopeContainer
33+
const [atomsSnapshot, setAtomsSnapshot] = useState<AtomsSnapshot>(
34+
() => new Map()
35+
)
2036

21-
const [atomsSnapshot, setAtomsSnapshot] = useState<AtomsSnapshot>(new Map())
2237
useEffect(() => {
23-
const callback = () => {
24-
const { atoms } = devStore
25-
const atomToAtomValueTuples = atoms
26-
.filter((atom) => !!store[DEV_GET_MOUNTED]?.(atom))
27-
.map<[Atom<unknown>, unknown]>((atom) => {
28-
const atomState =
29-
store[DEV_GET_ATOM_STATE]?.(atom) ?? ({} as AtomState)
30-
return [atom, atomState.v]
31-
})
32-
setAtomsSnapshot(new Map(atomToAtomValueTuples))
38+
const callback = (updatedAtom?: Atom<unknown>, isNewAtom?: boolean) => {
39+
const atoms = Array.from(store[DEV_GET_MOUNTED_ATOMS]?.() || [])
40+
if (updatedAtom && isNewAtom && !atoms.includes(updatedAtom)) {
41+
atoms.push(updatedAtom)
42+
}
43+
setAtomsSnapshot(createAtomsSnapshot(store, atoms))
3344
}
34-
const unsubscribe = devStore.subscribe(callback)
45+
const unsubscribe = store[DEV_SUBSCRIBE_STATE]?.(callback)
3546
callback()
3647
return unsubscribe
37-
}, [store, devStore])
48+
}, [store])
3849

3950
return atomsSnapshot
4051
}

0 commit comments

Comments
 (0)