Skip to content

Commit 0716624

Browse files
committed
fix: full rewrite of ScopeProvider to address known issues
1 parent 3107601 commit 0716624

File tree

8 files changed

+249
-108
lines changed

8 files changed

+249
-108
lines changed

src/ScopeProvider/ScopeProvider.tsx

Lines changed: 4 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,10 @@
11
import { type PropsWithChildren, useEffect, useState } from 'react'
22
import { Provider, useStore } from 'jotai/react'
33
import { useHydrateAtoms } from 'jotai/utils'
4-
import {
5-
type AnyAtom,
6-
type AnyAtomFamily,
7-
type AtomDefault,
8-
SCOPE,
9-
ScopedStore,
10-
type Store,
11-
} from '../types'
4+
import type { INTERNAL_Store as Store } from 'jotai/vanilla/internals'
5+
import type { AnyAtom, AnyAtomFamily, AtomDefault, ScopedStore } from '../types'
6+
import { SCOPE } from '../types'
7+
import { isEqualSet } from '../utils'
128
import { createScope } from './scope'
139

1410
type ScopeProviderBaseProps = PropsWithChildren<{
@@ -84,7 +80,3 @@ export function ScopeProvider({
8480
useEffect(() => scopedStore[SCOPE].cleanup, [scopedStore])
8581
return <Provider store={scopedStore}>{children}</Provider>
8682
}
87-
88-
function isEqualSet(a: Set<unknown>, b: Set<unknown>) {
89-
return a === b || (a.size === b.size && Array.from(a).every(b.has.bind(b)))
90-
}

src/ScopeProvider/scope.ts

Lines changed: 143 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,23 @@
11
import { type Atom, atom } from 'jotai'
2+
import {
3+
INTERNAL_Mounted,
4+
INTERNAL_buildStoreRev1 as INTERNAL_buildStore,
5+
INTERNAL_getBuildingBlocksRev1 as INTERNAL_getBuildingBlocks,
6+
INTERNAL_isSelfAtom,
7+
type INTERNAL_Store as Store,
8+
} from 'jotai/vanilla/internals'
29
import { __DEV__ } from '../env'
310
import type {
411
AnyAtom,
512
AnyAtomFamily,
613
AnyWritableAtom,
14+
BuildingBlocks,
15+
CloneAtom,
716
Scope,
817
ScopedStore,
9-
Store,
10-
WithOriginal,
1118
} from '../types'
12-
import { SCOPE } from '../types'
19+
import { CONSUMER, EXPLICIT, SCOPE } from '../types'
20+
import { isCloneAtom, isEqualSet } from '../utils'
1321

1422
const globalScopeKey: { name?: string } = {}
1523
if (__DEV__) {
@@ -73,7 +81,10 @@ export function createScope({
7381

7482
// populate explicitly scoped atoms
7583
for (const anAtom of atomSet) {
76-
explicit.set(anAtom, [cloneAtom(anAtom, currentScope), currentScope])
84+
explicit.set(anAtom, [
85+
cloneAtom(anAtom, currentScope, EXPLICIT),
86+
currentScope,
87+
])
7788
}
7889

7990
const cleanupFamiliesSet = new Set<() => void>()
@@ -183,13 +194,18 @@ export function createScope({
183194
/**
184195
* @returns a scoped copy of the atom
185196
*/
186-
function cloneAtom<T>(originalAtom: Atom<T>, implicitScope?: Scope) {
187-
// avoid reading `init` to preserve lazy initialization
188-
const scopedAtom: WithOriginal<Atom<T>> = Object.create(
197+
function cloneAtom<T>(
198+
originalAtom: Atom<T>,
199+
implicitScope?: Scope,
200+
cloneType?: EXPLICIT | CONSUMER
201+
) {
202+
const scopedAtom: CloneAtom<Atom<T>> = Object.create(
203+
// avoid reading `init` to preserve lazy initialization
189204
Object.getPrototypeOf(originalAtom),
190205
Object.getOwnPropertyDescriptors(originalAtom)
191206
)
192-
scopedAtom.originalAtom = originalAtom
207+
scopedAtom.o = originalAtom
208+
scopedAtom.x = cloneType
193209

194210
if (scopedAtom.read !== defaultRead) {
195211
scopedAtom.read = createScopedRead<typeof scopedAtom>(
@@ -263,6 +279,125 @@ export function createScope({
263279

264280
const scopedStore = createPatchedStore(parentStore, currentScope)
265281
return scopedStore
282+
283+
/**
284+
* @returns a patched store that intercepts get and set calls to apply the scope
285+
*/
286+
function createPatchedStore(baseStore: Store, scope: Scope): ScopedStore {
287+
const baseBuildingBlocks = INTERNAL_getBuildingBlocks(baseStore)
288+
const [atomStateMap, mountedMap, invalidatedAtoms, changedAtoms] =
289+
baseBuildingBlocks
290+
const ensureAtomState = baseBuildingBlocks[11]
291+
const readAtomState = baseBuildingBlocks[14]
292+
const buildingBlocks: BuildingBlocks = [
293+
atomStateMap,
294+
undefined,
295+
invalidatedAtoms,
296+
changedAtoms,
297+
]
298+
const internalMountedMap = new WeakMap<AnyAtom, INTERNAL_Mounted>()
299+
buildingBlocks[1] = {
300+
get: (atom) => {
301+
if (!isCloneAtom(atom)) return mountedMap.get(atom)
302+
if (!checkConsumer(atom)) return mountedMap.get(atom.o)
303+
return internalMountedMap.get(atom)
304+
},
305+
set: (atom, mounted) => {
306+
if (!isCloneAtom(atom)) return mountedMap.set(atom, mounted)
307+
if (!checkConsumer(atom)) return mountedMap.set(atom.o, mounted)
308+
return internalMountedMap.set(atom, mounted)
309+
},
310+
has: (atom) => {
311+
if (!isCloneAtom(atom)) return mountedMap.has(atom)
312+
if (!checkConsumer(atom)) return mountedMap.has(atom.o)
313+
return internalMountedMap.has(atom)
314+
},
315+
delete: (atom) => {
316+
if (!isCloneAtom(atom)) return mountedMap.delete(atom)
317+
if (!checkConsumer(atom)) return mountedMap.delete(atom.o)
318+
return internalMountedMap.delete(atom)
319+
},
320+
}
321+
buildingBlocks[14] = (atom) => {
322+
checkConsumer(atom)
323+
const deps = new Set(ensureAtomState(atom).d.keys())
324+
if (isCloneAtom(atom) && atom.x === undefined) {
325+
const newAtomState = readAtomState(atom.o)
326+
// deps changed?
327+
const newDeps = new Set(newAtomState.d.keys())
328+
if (!isEqualSet(deps, newDeps)) {
329+
checkConsumer(atom)
330+
}
331+
return newAtomState
332+
}
333+
return readAtomState(atom)
334+
}
335+
const wrappedBaseStore = INTERNAL_buildStore(...buildingBlocks)
336+
const storeShim: ScopedStore = {
337+
get(anAtom, ...args) {
338+
const [scopedAtom] = scope.getAtom(anAtom)
339+
return wrappedBaseStore.get(scopedAtom, ...args)
340+
},
341+
set(anAtom, ...args) {
342+
const [scopedAtom, implicitScope] = scope.getAtom(anAtom)
343+
const restore = scope.prepareWriteAtom(
344+
scopedAtom,
345+
anAtom,
346+
implicitScope,
347+
scope
348+
)
349+
try {
350+
return wrappedBaseStore.set(scopedAtom, ...args)
351+
} finally {
352+
restore?.()
353+
}
354+
},
355+
sub(anAtom, ...args) {
356+
const [scopedAtom] = scope.getAtom(anAtom)
357+
return wrappedBaseStore.sub(scopedAtom, ...args)
358+
},
359+
[SCOPE]: scope,
360+
}
361+
return Object.assign(wrappedBaseStore, storeShim) as ScopedStore
362+
363+
/**
364+
* Check if the atom is a consumer.
365+
* Looks at the atom's dependencies to determine if it is a consumer.
366+
* Updates the atom's clone type with the new value if it changed.
367+
* Recursively checks the dependents if mounted.
368+
* @param atom
369+
* @returns true if the atom is a consumer
370+
*/
371+
function checkConsumer(atom: AnyAtom): boolean {
372+
let atomState = ensureAtomState(atom)
373+
const mountedState = mountedMap.get(atom)
374+
if (!isCloneAtom(atom) || atom.x === EXPLICIT) {
375+
return false
376+
}
377+
378+
if (!mountedState && mountedMap.has(atom.o)) {
379+
atomState = ensureAtomState(atom.o)
380+
}
381+
382+
const dependencies = Array.from(atomState.d.keys()).filter(
383+
(a) => !INTERNAL_isSelfAtom(atom, a)
384+
)
385+
386+
const isConsumer = dependencies.some(
387+
(atom) =>
388+
(isCloneAtom(atom) && (atom.x === CONSUMER || atom.x === EXPLICIT)) ||
389+
explicit.has(atom) // TODO: a consumer can also read consumers and inherited too.
390+
)
391+
if (atom.x === CONSUMER || atom.x === undefined) {
392+
const newValue = isConsumer ? CONSUMER : undefined
393+
if (atom.x !== newValue) {
394+
atom.x = newValue
395+
mountedState?.t.forEach(checkConsumer)
396+
}
397+
}
398+
return isConsumer
399+
}
400+
}
266401
}
267402

268403
function isWritableAtom(anAtom: AnyAtom): anAtom is AnyWritableAtom {
@@ -282,46 +417,3 @@ function combineVoidFunctions(...fns: (() => void)[]) {
282417
}
283418
}
284419
}
285-
286-
function PatchedStore() {}
287-
288-
/**
289-
* @returns a patched store that intercepts get and set calls to apply the scope
290-
*/
291-
function createPatchedStore(baseStore: Store, scope: Scope): ScopedStore {
292-
const store: ScopedStore = {
293-
...baseStore,
294-
get(anAtom, ...args) {
295-
const [scopedAtom] = scope.getAtom(anAtom)
296-
return baseStore.get(scopedAtom, ...args)
297-
},
298-
set(anAtom, ...args) {
299-
const [scopedAtom, implicitScope] = scope.getAtom(anAtom)
300-
const restore = scope.prepareWriteAtom(
301-
scopedAtom,
302-
anAtom,
303-
implicitScope,
304-
scope
305-
)
306-
try {
307-
return baseStore.set(scopedAtom, ...args)
308-
} finally {
309-
restore?.()
310-
}
311-
},
312-
sub(anAtom, ...args) {
313-
const [scopedAtom] = scope.getAtom(anAtom)
314-
return baseStore.sub(scopedAtom, ...args)
315-
},
316-
[SCOPE]: scope,
317-
// TODO: update this patch to support devtools
318-
}
319-
return Object.assign(Object.create(PatchedStore.prototype), store)
320-
}
321-
322-
/**
323-
* @returns true if the current scope is the first descendant scope under Provider
324-
*/
325-
export function isTopLevelScope(parentStore: Store) {
326-
return !(parentStore instanceof PatchedStore)
327-
}

src/createIsolation.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,8 @@ import {
88
} from 'jotai/react'
99
import { useHydrateAtoms } from 'jotai/react/utils'
1010
import { createStore } from 'jotai/vanilla'
11-
import type { AnyWritableAtom, Store } from './types'
11+
import { INTERNAL_Store as Store } from 'jotai/vanilla/internals'
12+
import type { AnyWritableAtom } from './types'
1213

1314
type CreateIsolationResult = {
1415
Provider: (props: {

src/types.ts

Lines changed: 23 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
1-
import type { Atom, WritableAtom, createStore } from 'jotai/vanilla'
1+
import type { Atom, WritableAtom } from 'jotai/vanilla'
2+
import {
3+
INTERNAL_getBuildingBlocksRev1 as INTERNAL_getBuildingBlocks,
4+
INTERNAL_Store as Store,
5+
} from 'jotai/vanilla/internals'
26
import type { AtomFamily } from 'jotai/vanilla/utils/atomFamily'
37

4-
export type Store = ReturnType<typeof createStore>
5-
6-
export type ScopedStore = Store & {
7-
[SCOPE]: Scope
8-
}
8+
export type ScopedStore = Store & { [SCOPE]: Scope }
99

1010
export type AnyAtom = Atom<any> | AnyWritableAtom
1111

@@ -53,6 +53,21 @@ export const SCOPE = Symbol('scope')
5353

5454
export type AtomDefault = readonly [AnyWritableAtom, unknown]
5555

56-
export type WithOriginal<T extends AnyAtom> = T & {
57-
originalAtom: T
56+
export type CloneAtom<T extends AnyAtom> = T & {
57+
/** original atom */
58+
o: T
59+
/** clone type */
60+
x: EXPLICIT | CONSUMER | undefined
61+
}
62+
63+
export const EXPLICIT = Symbol('explicit')
64+
export const CONSUMER = Symbol('consumer')
65+
export type EXPLICIT = typeof EXPLICIT
66+
export type CONSUMER = typeof CONSUMER
67+
68+
type Mutable<T> = {
69+
-readonly [K in keyof T]: T[K]
5870
}
71+
export type BuildingBlocks = Partial<
72+
Mutable<ReturnType<typeof INTERNAL_getBuildingBlocks>>
73+
>

src/utils.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import { AnyAtom, CloneAtom } from './types'
2+
3+
export function isCloneAtom<T extends AnyAtom>(atom: T): atom is CloneAtom<T> {
4+
return 'o' in atom && 'x' in atom
5+
}
6+
7+
export function isEqualSet(a: Set<unknown>, b: Set<unknown>) {
8+
return a === b || (a.size === b.size && Array.from(a).every(b.has.bind(b)))
9+
}

0 commit comments

Comments
 (0)