├── .prettierignore ├── .codesandbox └── ci.json ├── .prettierrc ├── types ├── eslint-config-prettier.d.ts └── eslint-plugin-import.d.ts ├── src ├── index.ts ├── env.ts ├── utils.ts ├── types.ts ├── createIsolation.tsx └── ScopeProvider │ ├── ScopeProvider.tsx │ └── scope.ts ├── .gitignore ├── tsconfig.build.json ├── tsconfig.test.json ├── .github └── workflows │ ├── ci.yml │ └── cd.yml ├── tsconfig.json ├── tests ├── issues.test.ts ├── ScopeProvider │ ├── 10_atom_defaults.test.tsx │ ├── 05_derived_self.test.tsx │ ├── 09_mount.test.tsx │ ├── 06_implicit_parent.test.tsx │ ├── 02_removeScope.test.tsx │ ├── 03_nested.test.tsx │ ├── 07_writable.test.tsx │ ├── 08_family.test.tsx │ ├── 04_derived.test.tsx │ └── 01_basic_spec.test.tsx ├── effect.test.tsx ├── utils.ts └── createIsolation │ └── 01_basic_spec.test.tsx ├── LICENSE ├── vite.config.ts ├── package.json ├── eslint.config.ts ├── REQUIREMENTS.md └── README.md /.prettierignore: -------------------------------------------------------------------------------- 1 | /pnpm-lock.yaml 2 | /dist 3 | README.md 4 | /jotai 5 | /jotai-effect 6 | .prettierignore 7 | -------------------------------------------------------------------------------- /.codesandbox/ci.json: -------------------------------------------------------------------------------- 1 | { 2 | "buildCommand": "build", 3 | "sandboxes": ["new", "react-typescript-react-ts"], 4 | "node": "18" 5 | } 6 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": false, 3 | "trailingComma": "es5", 4 | "singleQuote": true, 5 | "bracketSameLine": true, 6 | "tabWidth": 2, 7 | "printWidth": 120 8 | } 9 | -------------------------------------------------------------------------------- /types/eslint-config-prettier.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'eslint-config-prettier' { 2 | const config: { 3 | rules: Record 4 | } 5 | export default config 6 | } 7 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export { createIsolation } from './createIsolation' 2 | export { ScopeProvider } from './ScopeProvider/ScopeProvider' 3 | export { createScope } from './ScopeProvider/scope' 4 | -------------------------------------------------------------------------------- /src/env.ts: -------------------------------------------------------------------------------- 1 | export const __DEV__ = 2 | typeof import.meta !== 'undefined' && import.meta.env !== undefined && import.meta.env.MODE === 'development' 3 | ? true 4 | : process.env.NODE_ENV !== 'production' 5 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *~ 2 | *.swp 3 | .vscode 4 | dist 5 | jotai 6 | jotai-effect 7 | node_modules 8 | .DS_Store 9 | 10 | # Generated JavaScript files from TypeScript compilation 11 | src/**/*.js 12 | src/**/*.js.map 13 | -------------------------------------------------------------------------------- /types/eslint-plugin-import.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'eslint-plugin-import' { 2 | const plugin: { 3 | flatConfigs: { 4 | recommended: { 5 | rules: Record 6 | } 7 | } 8 | } 9 | export default plugin 10 | } 11 | -------------------------------------------------------------------------------- /tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "dist", 5 | "declaration": true, 6 | "declarationMap": true, 7 | "emitDeclarationOnly": true, 8 | "paths": {}, 9 | "jsx": "preserve", 10 | "allowSyntheticDefaultImports": true, 11 | "isolatedModules": false 12 | }, 13 | "include": ["src"] 14 | } 15 | -------------------------------------------------------------------------------- /tsconfig.test.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "noEmit": true, 5 | "isolatedModules": true, 6 | "allowImportingTsExtensions": true, 7 | "paths": { 8 | "jotai": ["./jotai/src/index.ts"], 9 | "jotai/*": ["./jotai/src/*"], 10 | "jotai-scope": ["./src"] 11 | }, 12 | "types": ["vitest/globals"] 13 | }, 14 | "include": ["src", "jotai/src", "jotai-effect/src", "tests"] 15 | } 16 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | import type { AnyAtom, AnyWritableAtom } from './types' 2 | 3 | export function isEqualSize(aIter: Iterable, bIter: Iterable) { 4 | const a = new Set(aIter) 5 | const b = new Set(bIter) 6 | return aIter === bIter || (a.size === b.size && Array.from(a).every(b.has.bind(b))) 7 | } 8 | 9 | export function toNameString(this: { name: string }) { 10 | return this.name 11 | } 12 | 13 | export function isWritableAtom(atom: AnyAtom): atom is AnyWritableAtom { 14 | return 'write' in atom 15 | } 16 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | pull_request: 6 | 7 | jobs: 8 | test: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v3 12 | - uses: pnpm/action-setup@v2 13 | with: 14 | version: 8.15.0 15 | - uses: actions/setup-node@v3 16 | with: 17 | node-version: 20 18 | cache: 'pnpm' 19 | cache-dependency-path: '**/pnpm-lock.yaml' 20 | - run: pnpm install --frozen-lockfile 21 | - run: npm test 22 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "strict": true, 4 | "target": "esnext", 5 | "module": "esnext", 6 | "moduleResolution": "bundler", 7 | "allowJs": true, 8 | "jsx": "react-jsx", 9 | "skipLibCheck": true, 10 | "forceConsistentCasingInFileNames": true, 11 | "baseUrl": ".", 12 | "paths": { 13 | "jotai-scope": ["./src"] 14 | }, 15 | "types": ["vite/client", "node"] 16 | }, 17 | "include": ["src", "tests"], 18 | "exclude": ["dist", "jotai", "jotai-effect"] 19 | } 20 | -------------------------------------------------------------------------------- /.github/workflows/cd.yml: -------------------------------------------------------------------------------- 1 | name: CD 2 | 3 | on: 4 | push: 5 | tags: 6 | - v* 7 | 8 | jobs: 9 | publish: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v3 13 | - uses: pnpm/action-setup@v2 14 | with: 15 | version: 8.15.0 16 | - uses: actions/setup-node@v3 17 | with: 18 | node-version: 20 19 | registry-url: 'https://registry.npmjs.org' 20 | cache: 'pnpm' 21 | cache-dependency-path: '**/pnpm-lock.yaml' 22 | - run: pnpm install --frozen-lockfile 23 | - run: npm test 24 | - run: npm run build 25 | - run: npm publish 26 | env: 27 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 28 | -------------------------------------------------------------------------------- /tests/issues.test.ts: -------------------------------------------------------------------------------- 1 | import { atom, createStore } from 'jotai' 2 | import { describe, expect, it, vi } from 'vitest' 3 | import { createScope } from '../src/ScopeProvider/scope' 4 | 5 | describe('open issues', () => { 6 | // FIXME: 7 | it.skip('https://github.com/jotaijs/jotai-scope/issues/25', () => { 8 | const a = atom( 9 | vi.fn(() => { 10 | console.log('reading atomA') 11 | }), 12 | () => {} 13 | ) 14 | a.debugLabel = 'atomA' 15 | a.onMount = vi.fn(() => { 16 | console.log('mounting atomA') 17 | }) 18 | 19 | const s0 = createStore() 20 | s0.sub(a, () => { 21 | console.log('S0: atomA changed') 22 | }) 23 | 24 | const s1 = createScope({ 25 | atoms: [a], 26 | parentStore: s0, 27 | name: 's1', 28 | }) 29 | 30 | s1.sub(a, () => { 31 | console.log('S1: atomA changed') 32 | }) 33 | 34 | expect(a.read).toHaveBeenCalledTimes(1) 35 | expect(a.onMount).toHaveBeenCalledTimes(1) 36 | }) 37 | }) 38 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2023 Daishi Kato 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs' 2 | import path from 'path' 3 | import react from '@vitejs/plugin-react' 4 | import { defineConfig } from 'vitest/config' 5 | 6 | export default defineConfig(({ mode }) => { 7 | const alias = {} 8 | if (mode === 'development' || mode === 'test') { 9 | alias['jotai-scope'] = path.resolve(__dirname, 'src') 10 | const localJotai = path.resolve(__dirname, 'jotai/src') 11 | const hasLocalJotai = fs.existsSync(localJotai) 12 | if (hasLocalJotai) { 13 | alias['jotai'] = localJotai 14 | } 15 | const localJotaiEffect = path.resolve(__dirname, 'jotai-effect/src') 16 | const hasLocalJotaiEffect = fs.existsSync(localJotaiEffect) 17 | if (hasLocalJotaiEffect) { 18 | alias['jotai-effect'] = localJotaiEffect 19 | } 20 | } 21 | 22 | return { 23 | plugins: [react()], 24 | resolve: { alias }, 25 | build: { 26 | lib: { 27 | entry: path.resolve(__dirname, 'src/index.ts'), 28 | name: 'jotaiScope', 29 | formats: ['es', 'cjs'], 30 | fileName: (f) => (f === 'es' ? 'index.mjs' : 'index.cjs'), 31 | }, 32 | rollupOptions: { 33 | external: [ 34 | 'react', 35 | 'react/jsx-runtime', 36 | 'jotai', 37 | 'jotai/react', 38 | 'jotai/react/utils', 39 | 'jotai/vanilla', 40 | 'jotai/vanilla/utils', 41 | 'jotai/vanilla/internals', 42 | 'jotai/utils', 43 | ], 44 | }, 45 | sourcemap: true, 46 | }, 47 | test: { 48 | environment: 'happy-dom', 49 | globals: true, 50 | include: ['tests/**/*.test.{ts,tsx}'], 51 | exclude: ['jotai/**'], 52 | }, 53 | } 54 | }) 55 | -------------------------------------------------------------------------------- /tests/ScopeProvider/10_atom_defaults.test.tsx: -------------------------------------------------------------------------------- 1 | import { render } from '@testing-library/react' 2 | import { atom, useAtomValue } from 'jotai' 3 | import { describe, expect, test, vi } from 'vitest' 4 | import { ScopeProvider } from 'jotai-scope' 5 | 6 | describe('ScopeProvider atom defaults', () => { 7 | test('atoms defaults are applied without an extra render', () => { 8 | const a = atom(0) 9 | const b = atom(0) 10 | const c = atom(0) 11 | a.debugLabel = 'a' 12 | b.debugLabel = 'b' 13 | c.debugLabel = 'c' 14 | const Component = vi.fn(() => ( 15 | <> 16 |
{useAtomValue(a)}
17 |
{useAtomValue(b)}
18 |
{useAtomValue(c)}
19 | 20 | )) 21 | function getValues(container: HTMLElement) { 22 | return String(['a', 'b', 'c'].map((className) => container.querySelector(`.${className}`)!.textContent)) 23 | } 24 | { 25 | // without defaults 26 | const { container } = render( 27 | 28 | 29 | 30 | ) 31 | expect(getValues(container)).toBe('0,0,0') 32 | expect(Component).toHaveBeenCalledTimes(2) 33 | } 34 | vi.clearAllMocks() 35 | { 36 | // with defaults 37 | const { container } = render( 38 | 44 | 45 | 46 | ) 47 | expect(getValues(container)).toBe('1,2,0') 48 | // Component is normally rendered twice, 49 | // adding defaults does not add an extra render 50 | expect(Component).toHaveBeenCalledTimes(2) 51 | } 52 | }) 53 | }) 54 | -------------------------------------------------------------------------------- /tests/ScopeProvider/05_derived_self.test.tsx: -------------------------------------------------------------------------------- 1 | import { render } from '@testing-library/react' 2 | import { atom, useAtom } from 'jotai' 3 | import { useHydrateAtoms } from 'jotai/utils' 4 | import { describe, expect, test } from 'vitest' 5 | import { ScopeProvider } from 'jotai-scope' 6 | import { getTextContents } from '../utils' 7 | 8 | const baseAtom = atom(0) 9 | const derivedAtom1 = atom( 10 | (get) => get(baseAtom), 11 | (get): number => { 12 | return get(derivedAtom1) 13 | } 14 | ) 15 | 16 | function Component({ className, initialValue = 0 }: { className: string; initialValue?: number }) { 17 | useHydrateAtoms([[baseAtom, initialValue]]) 18 | const [atom1ReadValue, setAtom1Value] = useAtom(derivedAtom1) 19 | const atom1WriteValue = setAtom1Value() 20 | return ( 21 |
22 | {atom1ReadValue} 23 | {atom1WriteValue} 24 |
25 | ) 26 | } 27 | 28 | function App() { 29 | return ( 30 | <> 31 |

base component

32 |

derived1 should read itself from global scope

33 | 34 | 35 |

scoped component

36 |

derived1 should read itself from scoped scope

37 | 38 |
39 | 40 | ) 41 | } 42 | 43 | describe('Self', () => { 44 | /* 45 | baseA, derivedB(baseA, derivedB) 46 | S1[baseA]: baseA1, derivedB0(baseA1, derivedB0) 47 | */ 48 | test('derived dep scope is preserved in self reference', () => { 49 | const { container } = render() 50 | expect(getTextContents(container, ['.unscoped .read', '.unscoped .write'])).toEqual(['0', '0']) 51 | 52 | expect(getTextContents(container, ['.scoped .read', '.scoped .write'])).toEqual(['1', '1']) 53 | }) 54 | }) 55 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | import { type Atom, type WritableAtom } from 'jotai' 2 | import type { INTERNAL_StoreHooks, INTERNAL_Store as Store } from 'jotai/vanilla/internals' 3 | import type { AtomFamily } from 'jotai-family' 4 | 5 | export type AnyAtom = Atom | AnyWritableAtom 6 | 7 | export type AnyAtomFamily = AtomFamily 8 | 9 | export type AnyWritableAtom = WritableAtom 10 | 11 | /** WeakMap-like, but each value must be [same A as key, Scope?] */ 12 | export type AtomPairMap = { 13 | set(key: A, value: [A, Scope?]): AtomPairMap 14 | get(key: A): [A, Scope?] | undefined 15 | has(key: AnyAtom): boolean 16 | delete(key: AnyAtom): boolean 17 | } 18 | 19 | export interface ExplicitMap extends AtomPairMap {} 20 | 21 | export interface ImplicitMap extends AtomPairMap {} 22 | 23 | export type InheritedSource = WeakMap 24 | 25 | export type CleanupFamiliesSet = Set<() => void> 26 | 27 | export type Scope = [ 28 | explicitMap: ExplicitMap, 29 | implicitMap: ImplicitMap, 30 | inheritedSource: InheritedSource, 31 | baseStore: Store, 32 | parentScope: Scope | undefined, 33 | cleanupFamiliesSet: CleanupFamiliesSet, 34 | scopedStore: Store, 35 | ] & { 36 | /** @debug */ 37 | name?: string 38 | /** @debug */ 39 | toString?: () => string 40 | } 41 | 42 | export type AtomDefault = readonly [AnyWritableAtom, unknown] 43 | 44 | type Mutable = { -readonly [P in keyof T]: T[P] } 45 | 46 | export type StoreHooks = Mutable 47 | 48 | export type StoreHookForAtoms = NonNullable 49 | 50 | export type WeakMapLike = { 51 | get(key: K): V | undefined 52 | set(key: K, value: V): void 53 | has(key: K): boolean 54 | delete(key: K): boolean 55 | } 56 | export type SetLike = { 57 | readonly size: number 58 | add(value: T): void 59 | has(value: T): boolean 60 | delete(value: T): boolean 61 | clear(): void 62 | forEach(callback: (value: T) => void): void 63 | [Symbol.iterator](): IterableIterator 64 | } 65 | -------------------------------------------------------------------------------- /tests/effect.test.tsx: -------------------------------------------------------------------------------- 1 | import { act } from 'react' 2 | import { render } from '@testing-library/react' 3 | import { createStore, useAtomValue, useStore } from 'jotai' 4 | import { atomWithReducer } from 'jotai/utils' 5 | import type { INTERNAL_Store as Store } from 'jotai/vanilla/internals' 6 | import { atomEffect } from 'jotai-effect' 7 | import { ScopeProvider } from 'jotai-scope' 8 | // eslint-disable-next-line import/order 9 | import { describe, expect, test, vi } from 'vitest' 10 | 11 | describe('atomEffect', () => { 12 | test('should work with atomEffect', () => { 13 | const a = atomWithReducer(0, (v) => v + 1) 14 | a.debugLabel = 'atomA' 15 | const e = atomEffect((_get, set) => set(a)) 16 | e.debugLabel = 'effect' 17 | const s0 = createStore() 18 | function Component() { 19 | useAtomValue(e) 20 | const v = useAtomValue(a) 21 | return
{v}
22 | } 23 | const { container } = render( 24 | 25 | 26 | 27 | ) 28 | expect(container.querySelector('.value')!.textContent).toBe('1') 29 | expect(s0.get(a)).toBe(0) 30 | }) 31 | 32 | test('should work with atomEffect in a scope', async () => { 33 | const a = atomWithReducer(0, (v) => v + 1) 34 | a.debugLabel = 'atomA' 35 | const b = atomWithReducer(0, (v) => v + 1) 36 | b.debugLabel = 'atomB' 37 | const fn = vi.fn() 38 | const listener = atomEffect((get) => { 39 | fn(get(a)) 40 | }) 41 | listener.debugLabel = 'listener' 42 | const e = atomEffect((get, set) => { 43 | get(b) 44 | set(a) 45 | }) 46 | e.debugLabel = 'effect' 47 | let s1: Store | undefined 48 | function Component() { 49 | s1 = useStore() 50 | useAtomValue(listener) 51 | useAtomValue(e) 52 | const v = useAtomValue(a) 53 | return
{v}
54 | } 55 | 56 | render( 57 | 58 | 59 | 60 | ) 61 | expect(fn).toHaveBeenLastCalledWith(1) 62 | act(() => s1!.set(b)) 63 | expect(fn).toHaveBeenLastCalledWith(2) 64 | }) 65 | }) 66 | -------------------------------------------------------------------------------- /tests/ScopeProvider/09_mount.test.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from 'react' 2 | import { act, render } from '@testing-library/react' 3 | import { atom, useAtomValue } from 'jotai' 4 | import { describe, expect, it, vi } from 'vitest' 5 | import { ScopeProvider } from 'jotai-scope' 6 | import { clickButton } from '../utils' 7 | 8 | describe('ScopeProvider', () => { 9 | it('mounts and unmounts successfully', () => { 10 | const baseAtom = atom(0) 11 | function Component() { 12 | const base = useAtomValue(baseAtom) 13 | return
{base}
14 | } 15 | function App() { 16 | const [isMounted, setIsMounted] = useState(false) 17 | return ( 18 | <> 19 |
20 | 23 |
24 | {isMounted && ( 25 | 26 | 27 | 28 | )} 29 | 30 | ) 31 | } 32 | const { unmount, container } = render() 33 | const mountButton = '.mount' 34 | const base = '.base' 35 | 36 | act(() => clickButton(container, mountButton)) 37 | expect(container.querySelector(base)).not.toBe(null) 38 | act(() => clickButton(container, mountButton)) 39 | expect(container.querySelector(base)).toBe(null) 40 | act(() => clickButton(container, mountButton)) 41 | unmount() 42 | expect(container.querySelector(base)).toBe(null) 43 | }) 44 | }) 45 | 46 | it('computed atom mounts once for the unscoped and once for the scoped', () => { 47 | const baseAtom = atom(0) 48 | const deriveAtom = atom( 49 | (get) => get(baseAtom), 50 | () => {} 51 | ) 52 | const onUnmount = vi.fn() 53 | const onMount = vi.fn(() => onUnmount) 54 | deriveAtom.onMount = onMount 55 | function Component() { 56 | return useAtomValue(deriveAtom) 57 | } 58 | function App() { 59 | return ( 60 | <> 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | ) 69 | } 70 | const { unmount } = render() 71 | expect(onMount).toHaveBeenCalledTimes(2) 72 | unmount() 73 | expect(onUnmount).toHaveBeenCalledTimes(2) 74 | }) 75 | -------------------------------------------------------------------------------- /src/createIsolation.tsx: -------------------------------------------------------------------------------- 1 | import { createContext, createElement, useContext, useRef } from 'react' 2 | import type { ReactNode } from 'react' 3 | import { 4 | useAtom as useAtomOrig, 5 | useAtomValue as useAtomValueOrig, 6 | useSetAtom as useSetAtomOrig, 7 | useStore as useStoreOrig, 8 | } from 'jotai/react' 9 | import { useHydrateAtoms } from 'jotai/react/utils' 10 | import { createStore } from 'jotai/vanilla' 11 | import type { INTERNAL_Store as Store } from 'jotai/vanilla/internals' 12 | import { createScopeProvider } from './ScopeProvider/ScopeProvider' 13 | import type { AnyWritableAtom } from './types' 14 | 15 | type CreateIsolationResult = { 16 | Provider: (props: { 17 | store?: Store 18 | initialValues?: Iterable 19 | children: ReactNode 20 | }) => React.JSX.Element 21 | ScopeProvider: ReturnType 22 | useStore: typeof useStoreOrig 23 | useAtom: typeof useAtomOrig 24 | useAtomValue: typeof useAtomValueOrig 25 | useSetAtom: typeof useSetAtomOrig 26 | } 27 | 28 | export function createIsolation(): CreateIsolationResult { 29 | const StoreContext = createContext(null) 30 | 31 | function Provider({ 32 | store, 33 | initialValues = [], 34 | children, 35 | }: { 36 | store?: Store 37 | initialValues?: Iterable 38 | children: ReactNode 39 | }) { 40 | const storeRef = useRef(store) 41 | if (!storeRef.current) { 42 | storeRef.current = createStore() 43 | } 44 | useHydrateAtoms(initialValues as any, { store: storeRef.current }) 45 | return createElement(StoreContext.Provider, { 46 | value: storeRef.current, 47 | children, 48 | }) 49 | } 50 | 51 | const useStore = ((options?: any) => { 52 | const store = useContext(StoreContext) 53 | if (!store) throw new Error('Missing Provider from createIsolation') 54 | return useStoreOrig({ store, ...options }) 55 | }) as typeof useStoreOrig 56 | 57 | const useAtom = ((anAtom: any, options?: any) => { 58 | const store = useStore() 59 | return useAtomOrig(anAtom, { store, ...options }) 60 | }) as typeof useAtomOrig 61 | 62 | const useAtomValue = ((anAtom: any, options?: any) => { 63 | const store = useStore() 64 | return useAtomValueOrig(anAtom, { store, ...options }) 65 | }) as typeof useAtomValueOrig 66 | 67 | const useSetAtom = ((anAtom: any, options?: any) => { 68 | const store = useStore() 69 | return useSetAtomOrig(anAtom, { store, ...options }) 70 | }) as typeof useSetAtomOrig 71 | 72 | const ScopeProvider = createScopeProvider(Provider, useStore) 73 | 74 | return { 75 | Provider, 76 | ScopeProvider, 77 | useStore, 78 | useAtom, 79 | useAtomValue, 80 | useSetAtom, 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "jotai-scope", 3 | "version": "0.10.0", 4 | "description": "👻🔭", 5 | "type": "module", 6 | "author": "Daishi Kato", 7 | "contributors": [ 8 | "yf-yang (https://github.com/yf-yang)", 9 | "David Maskasky (https://github.com/dmaskasky)" 10 | ], 11 | "repository": { 12 | "type": "git", 13 | "url": "https://github.com/jotaijs/jotai-scope.git" 14 | }, 15 | "bugs": { 16 | "url": "https://github.com/jotaijs/jotai-scope/issues" 17 | }, 18 | "homepage": "https://jotai.org/docs/extensions/scope", 19 | "main": "./dist/index.cjs", 20 | "module": "./dist/index.mjs", 21 | "types": "./dist/index.d.ts", 22 | "exports": { 23 | ".": { 24 | "types": "./dist/index.d.ts", 25 | "import": "./dist/index.mjs", 26 | "require": "./dist/index.cjs" 27 | } 28 | }, 29 | "sideEffects": false, 30 | "files": [ 31 | "dist" 32 | ], 33 | "scripts": { 34 | "setup": "pnpm run '/^setup:.*/'", 35 | "setup:jotai": "git clone https://github.com/pmndrs/jotai.git && $(cd jotai && pnpm install && pnpm build)", 36 | "setup:jotai-effect": "git clone https://github.com/jotaijs/jotai-effect.git && $(cd jotai-effect && pnpm install && pnpm build)", 37 | "build": "vite build && tsc -p tsconfig.build.json", 38 | "fix": "pnpm run '/^fix:.*/'", 39 | "fix:format": "prettier --write .", 40 | "fix:lint": "eslint --config ./eslint.config.ts --fix .", 41 | "pretest": "pnpm build", 42 | "test": "pnpm run \"/^test:.*/\"", 43 | "test:format": "prettier --list-different .", 44 | "test:types": "tsc --noEmit", 45 | "test:lint": "eslint .", 46 | "test:spec": "vitest run" 47 | }, 48 | "keywords": [ 49 | "jotai", 50 | "react", 51 | "scope" 52 | ], 53 | "license": "MIT", 54 | "peerDependencies": { 55 | "jotai": ">=2.16.0", 56 | "react": ">=16.0.0" 57 | }, 58 | "devDependencies": { 59 | "@eslint/js": "^9.18.0", 60 | "@testing-library/react": "^16.2.0", 61 | "@types/node": "^22.10.2", 62 | "@types/react": "^19.0.2", 63 | "@types/react-dom": "^19.0.2", 64 | "@typescript-eslint/eslint-plugin": "^8.18.1", 65 | "@typescript-eslint/parser": "^8.18.1", 66 | "@vitejs/plugin-react": "^4.3.4", 67 | "dedent": "^1.7.0", 68 | "eslint": "^9.18.0", 69 | "eslint-config-prettier": "^9.1.0", 70 | "eslint-import-resolver-typescript": "^3.7.0", 71 | "eslint-plugin-import": "2.31.0", 72 | "eslint-plugin-prettier": "^5.2.3", 73 | "happy-dom": "^15.11.7", 74 | "jiti": "^2.4.2", 75 | "jotai": "2.16.0", 76 | "jotai-effect": "2.1.2", 77 | "jotai-family": "^1.0.0", 78 | "prettier": "^3.4.2", 79 | "typescript": "^5.7.2", 80 | "typescript-eslint": "^8.21.0", 81 | "vite": "^6.0.11", 82 | "vitest": "^3.0.3" 83 | }, 84 | "engines": { 85 | "node": ">=14.0.0" 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /src/ScopeProvider/ScopeProvider.tsx: -------------------------------------------------------------------------------- 1 | import type { ComponentType, PropsWithChildren, ReactNode } from 'react' 2 | import { createElement, useEffect, useState } from 'react' 3 | import { Provider, useStore } from 'jotai/react' 4 | import { useHydrateAtoms } from 'jotai/utils' 5 | import { INTERNAL_Store as Store } from 'jotai/vanilla/internals' 6 | import type { AnyAtom, AnyAtomFamily, AtomDefault } from '../types' 7 | import { isEqualSize } from '../utils' 8 | import { cleanup, createScope, storeScopeMap } from './scope' 9 | 10 | type BaseProps = PropsWithChildren<{ 11 | atoms?: Iterable 12 | atomFamilies?: Iterable 13 | name?: string 14 | }> 15 | 16 | type ProvidedScope = PropsWithChildren<{ scope: Store }> 17 | 18 | type ProviderProps = { store: Store; children: ReactNode } 19 | 20 | type ScopeProviderComponent = { 21 | ( 22 | props: { 23 | atoms: Iterable 24 | } & BaseProps 25 | ): React.JSX.Element 26 | ( 27 | props: { 28 | atomFamilies: Iterable 29 | } & BaseProps 30 | ): React.JSX.Element 31 | (props: PropsWithChildren<{ scope: Store }>): React.JSX.Element 32 | } 33 | 34 | export function createScopeProvider( 35 | ProviderComponent: ComponentType, 36 | useStoreHook: typeof useStore 37 | ): ScopeProviderComponent { 38 | return function ScopeProvider(props: BaseProps | ProvidedScope) { 39 | const { 40 | atoms: atomsOrTuples = [], 41 | atomFamilies = [], 42 | children, 43 | scope: providedScope, 44 | name, 45 | } = props as BaseProps & ProvidedScope 46 | const parentStore: Store = useStoreHook() 47 | const atoms = Array.from(atomsOrTuples, (a) => (Array.isArray(a) ? a[0] : a)) 48 | 49 | function initialize() { 50 | return [ 51 | providedScope ?? createScope({ atoms, atomFamilies, parentStore, name }), 52 | function hasChanged(current: { 53 | parentStore: Store 54 | atoms: Iterable 55 | atomFamilies: Iterable 56 | providedScope: Store | undefined 57 | }) { 58 | return ( 59 | parentStore !== current.parentStore || 60 | !isEqualSize(atoms, current.atoms) || 61 | !isEqualSize(atomFamilies, current.atomFamilies) || 62 | providedScope !== current.providedScope 63 | ) 64 | }, 65 | ] as const 66 | } 67 | 68 | const [[store, hasChanged], setState] = useState(initialize) 69 | if (hasChanged({ atoms, atomFamilies, parentStore, providedScope })) { 70 | const scope = storeScopeMap.get(store) 71 | if (scope) cleanup(scope) 72 | setState(initialize) 73 | } 74 | useHydrateAtoms(Array.from(atomsOrTuples).filter(Array.isArray) as AtomDefault[], { store }) 75 | useEffect(() => { 76 | const scope = storeScopeMap.get(store) 77 | return () => { 78 | if (scope) cleanup(scope) 79 | } 80 | }, [store]) 81 | return createElement(ProviderComponent, { store, children }) 82 | } as ScopeProviderComponent 83 | } 84 | 85 | export const ScopeProvider = createScopeProvider(Provider, useStore) 86 | -------------------------------------------------------------------------------- /tests/ScopeProvider/06_implicit_parent.test.tsx: -------------------------------------------------------------------------------- 1 | import type { FC } from 'react' 2 | import { render } from '@testing-library/react' 3 | import { atom, useAtom, useAtomValue } from 'jotai' 4 | import { atomWithReducer } from 'jotai/vanilla/utils' 5 | import { describe, expect, test } from 'vitest' 6 | import { ScopeProvider } from 'jotai-scope' 7 | import { clickButton, getTextContents } from '../utils' 8 | 9 | function renderWithOrder(level1: 'BD' | 'DB', level2: 'BD' | 'DB') { 10 | const baseAtom = atomWithReducer(0, (v) => v + 1) 11 | baseAtom.debugLabel = 'baseAtom' 12 | baseAtom.toString = function toString() { 13 | return this.debugLabel ?? 'Unknown Atom' 14 | } 15 | 16 | const derivedAtom = atom((get) => get(baseAtom)) 17 | derivedAtom.debugLabel = 'derivedAtom' 18 | derivedAtom.toString = function toString() { 19 | return this.debugLabel ?? 'Unknown Atom' 20 | } 21 | 22 | function BaseThenDerived({ level }: { level: string }) { 23 | const [base, increaseBase] = useAtom(baseAtom) 24 | const derived = useAtomValue(derivedAtom) 25 | return ( 26 | <> 27 |
28 | base: {base} 29 | 32 |
33 |
34 | derived:{derived} 35 |
36 | 37 | ) 38 | } 39 | 40 | function DerivedThenBase({ level }: { level: string }) { 41 | const derived = useAtomValue(derivedAtom) 42 | const [base, increaseBase] = useAtom(baseAtom) 43 | return ( 44 | <> 45 |
46 | base:{base} 47 | 50 |
51 |
52 | derived:{derived} 53 |
54 | 55 | ) 56 | } 57 | function App(props: { Level1Counter: FC<{ level: string }>; Level2Counter: FC<{ level: string }> }) { 58 | const { Level1Counter, Level2Counter } = props 59 | return ( 60 |
61 |

Layer 1: Scope derived

62 |

base should be globally shared

63 | 64 | 65 |

Layer 2: Scope base

66 |

base should be globally shared

67 | 68 | 69 | 70 |
71 |
72 | ) 73 | } 74 | function getCounter(order: 'BD' | 'DB') { 75 | return order === 'BD' ? BaseThenDerived : DerivedThenBase 76 | } 77 | return render() 78 | } 79 | 80 | /* 81 | b, D(b) 82 | S1[D]: b0, D1(b1) 83 | S2[ ]: b0, D1(b1) 84 | */ 85 | describe('Implicit parent does not affect unscoped', () => { 86 | const cases = [ 87 | ['BD', 'BD'], 88 | ['BD', 'DB'], 89 | ['DB', 'BD'], 90 | ['DB', 'DB'], 91 | ] as const 92 | test.each(cases)('level 1: %p and level 2: %p', (level1, level2) => { 93 | const { container } = renderWithOrder(level1, level2) 94 | const increaseLayer2Base = '.layer2.setBase' 95 | const selectors = ['.layer1.base', '.layer1.derived', '.layer2.base', '.layer2.derived'] 96 | 97 | expect(getTextContents(container, selectors).join('')).toEqual('0000') 98 | 99 | clickButton(container, increaseLayer2Base) 100 | expect(getTextContents(container, selectors).join('')).toEqual('1010') 101 | }) 102 | }) 103 | -------------------------------------------------------------------------------- /eslint.config.ts: -------------------------------------------------------------------------------- 1 | import js from '@eslint/js' 2 | import configPrettier from 'eslint-config-prettier' 3 | import importPlugin from 'eslint-plugin-import' 4 | import prettierPlugin from 'eslint-plugin-prettier' 5 | import * as tseslint from 'typescript-eslint' 6 | 7 | export default tseslint.config( 8 | // ───────────────────────────────────────────────────────────── 9 | // 1) Main config, merges "extends" equivalents + custom rules 10 | // ───────────────────────────────────────────────────────────── 11 | { 12 | files: ['**/*.{js,jsx,ts,tsx,json}'], 13 | }, 14 | { 15 | plugins: { 16 | prettier: prettierPlugin, 17 | '@typescript-eslint': tseslint.plugin, 18 | }, 19 | }, 20 | js.configs.recommended, 21 | tseslint.configs.recommended, 22 | configPrettier, 23 | importPlugin.flatConfigs.recommended, 24 | { 25 | rules: { 26 | curly: ['warn', 'multi-line', 'consistent'], 27 | eqeqeq: 'error', 28 | 'no-console': 'off', 29 | 'no-inner-declarations': 'off', 30 | 'no-var': 'error', 31 | 'prefer-const': 'error', 32 | 'sort-imports': [ 33 | 'error', 34 | { 35 | ignoreDeclarationSort: true, 36 | }, 37 | ], 38 | 'import/export': 'error', 39 | 'import/no-duplicates': ['error'], 40 | 'import/no-unresolved': ['error', { commonjs: true, amd: true }], 41 | 'import/order': [ 42 | 'error', 43 | { 44 | alphabetize: { order: 'asc', caseInsensitive: true }, 45 | groups: ['builtin', 'external', 'internal', 'parent', 'sibling', 'index', 'object'], 46 | 'newlines-between': 'never', 47 | pathGroups: [ 48 | { 49 | pattern: 'react', 50 | group: 'builtin', 51 | position: 'before', 52 | }, 53 | ], 54 | pathGroupsExcludedImportTypes: ['builtin'], 55 | }, 56 | ], 57 | 'prettier/prettier': 'error', 58 | '@typescript-eslint/explicit-module-boundary-types': 'off', 59 | '@typescript-eslint/no-empty-function': 'off', 60 | '@typescript-eslint/no-empty-object-type': 'off', 61 | '@typescript-eslint/no-explicit-any': 'off', 62 | '@typescript-eslint/no-unused-expressions': 'off', 63 | '@typescript-eslint/no-unused-vars': ['warn', { argsIgnorePattern: '^_', varsIgnorePattern: '^_' }], 64 | '@typescript-eslint/no-use-before-define': 'off', 65 | }, 66 | settings: { 67 | react: { version: 'detect' }, 68 | 'import/extensions': ['.js', '.jsx', '.ts', '.tsx'], 69 | 'import/parsers': { 70 | '@typescript-eslint/parser': ['.js', '.jsx', '.ts', '.tsx'], 71 | }, 72 | 'import/resolver': { 73 | typescript: true, 74 | node: { 75 | extensions: ['.js', '.jsx', '.ts', '.tsx', '.json'], 76 | paths: ['src'], 77 | }, 78 | }, 79 | }, 80 | }, 81 | { 82 | ignores: ['dist/', 'jotai/**', 'jotai-effect/**'], 83 | }, 84 | 85 | // ───────────────────────────────────────────────────────────── 86 | // 2) Overrides for "src" — specify TS project config 87 | // ───────────────────────────────────────────────────────────── 88 | { 89 | files: ['src'], 90 | languageOptions: { 91 | parserOptions: { 92 | project: './tsconfig.json', 93 | }, 94 | }, 95 | }, 96 | 97 | // ───────────────────────────────────────────────────────────── 98 | // 3) Overrides for test files (if needed) 99 | // ───────────────────────────────────────────────────────────── 100 | { 101 | files: ['tests/**/*.tsx', 'tests/**/*'], 102 | rules: {}, 103 | }, 104 | 105 | // ───────────────────────────────────────────────────────────── 106 | // 4) Overrides for JS config files in root 107 | // ───────────────────────────────────────────────────────────── 108 | { 109 | files: ['./*.js'], 110 | rules: {}, 111 | } 112 | ) 113 | -------------------------------------------------------------------------------- /tests/utils.ts: -------------------------------------------------------------------------------- 1 | import { fireEvent } from '@testing-library/react' 2 | import type { 3 | INTERNAL_AtomState as AtomState, 4 | INTERNAL_BuildingBlocks, 5 | INTERNAL_Store as Store, 6 | } from 'jotai/vanilla/internals' 7 | import { 8 | INTERNAL_buildStoreRev2 as buildStore, 9 | INTERNAL_getBuildingBlocksRev2 as getBuildingBlocks, 10 | INTERNAL_initializeStoreHooksRev2 as initializeStoreHooks, 11 | } from 'jotai/vanilla/internals' 12 | import { AnyAtom } from 'src/types' 13 | 14 | // 15 | // Debug Store 16 | // 17 | 18 | type Mutable = { -readonly [P in keyof T]: T[P] } 19 | 20 | type BuildingBlocks = Mutable 21 | 22 | type DebugStore = Store & { name: string } 23 | 24 | export function createDebugStore(name: string = `S0`) { 25 | const buildingBlocks: Partial = [ 26 | new Map(), // atomStateMap 27 | new Map(), // mountedMap 28 | new Map(), // invalidatedAtoms 29 | ] 30 | buildingBlocks[6] = initializeStoreHooks({}) 31 | const ensureAtomState = getBuildingBlocks(buildStore())[11] 32 | buildingBlocks[11] = (store, atom) => Object.assign(ensureAtomState(store, atom), { label: atom.debugLabel }) 33 | const debugStore = buildStore(...buildingBlocks) as DebugStore 34 | debugStore.name = name 35 | return debugStore 36 | } 37 | 38 | function getElements(container: HTMLElement, querySelectors: string[]): Element[] { 39 | return querySelectors.map((querySelector) => { 40 | const element = container.querySelector(querySelector) 41 | if (!element) { 42 | throw new Error(`Element not found: ${querySelector}`) 43 | } 44 | return element 45 | }) 46 | } 47 | 48 | export function getTextContents(container: HTMLElement, selectors: string[]): string[] { 49 | return getElements(container, selectors).map((element) => element.textContent!) 50 | } 51 | 52 | export function clickButton(container: HTMLElement, querySelector: string) { 53 | const button = container.querySelector(querySelector) 54 | if (!button) { 55 | throw new Error(`Button not found: ${querySelector}`) 56 | } 57 | fireEvent.click(button) 58 | } 59 | 60 | export function delay(ms: number) { 61 | return new Promise((resolve) => setTimeout(resolve, ms)) 62 | } 63 | 64 | /** 65 | * Order is `S0:A`,`S0:B`,`S1:A`,`S1:B` 66 | * ``` 67 | * [ 68 | * [S0:A, S0:B], 69 | * [S1:A, S1:B], 70 | * ] 71 | * ``` 72 | */ 73 | export function cross
( 74 | a: A, 75 | b: B, 76 | fn: (a: A[number], b: B[number]) => R 77 | ): { 78 | [a in keyof A]: { [b in keyof B]: R } 79 | } { 80 | return a.map((a) => b.map((b) => fn(a, b))) as any 81 | } 82 | 83 | export function storeGet(store: Store, atom: AnyAtom) { 84 | return store.get(atom) 85 | } 86 | 87 | export function printAtomState(store: Store) { 88 | const buildingBlocks = getBuildingBlocks(store) 89 | if (buildingBlocks[0] instanceof WeakMap) { 90 | throw new Error('Cannot print atomStateMap, store must be debug store') 91 | } 92 | const atomStateMap = buildingBlocks[0] as Map 93 | const result: string[] = [] 94 | function printAtom(atom: AnyAtom, indent = 0) { 95 | const atomState = atomStateMap.get(atom) 96 | if (!atomState) return 97 | const prefix = ' '.repeat(indent) 98 | const label = atom.debugLabel || String(atom) 99 | const value = atomState?.v ?? 'undefined' 100 | result.push(`${prefix}${label}: v=${value}`) 101 | if (atomState?.d) { 102 | const deps = [...atomState.d.keys()] 103 | if (deps.length > 0) { 104 | deps.forEach((depAtom) => printAtom(depAtom, indent + 1)) 105 | } 106 | } 107 | } 108 | Array.from(atomStateMap.keys(), (atom) => printAtom(atom)) 109 | return result.join('\n') + '\n' + '-'.repeat(20) 110 | } 111 | 112 | export function trackAtomStateMap(store: Store) { 113 | const buildingBlocks = getBuildingBlocks(store) 114 | if (buildingBlocks[0] instanceof WeakMap) { 115 | throw new Error('Cannot print atomStateMap, store must be debug store') 116 | } 117 | const storeHooks = buildingBlocks[6] 118 | storeHooks.c!.add(undefined, (atom) => { 119 | console.log('ATOM_CHANGED', atom.debugLabel) 120 | console.log(printAtomState(store)) 121 | }) 122 | console.log(printAtomState(store)) 123 | } 124 | -------------------------------------------------------------------------------- /tests/ScopeProvider/02_removeScope.test.tsx: -------------------------------------------------------------------------------- 1 | import type { PropsWithChildren } from 'react' 2 | import { render } from '@testing-library/react' 3 | import { atom, useAtom, useAtomValue } from 'jotai' 4 | import { atomWithReducer } from 'jotai/vanilla/utils' 5 | import { describe, expect, test } from 'vitest' 6 | import { ScopeProvider } from 'jotai-scope' 7 | import { clickButton, getTextContents } from '../utils' 8 | 9 | const baseAtom1 = atomWithReducer(0, (v) => v + 1) 10 | const baseAtom2 = atomWithReducer(0, (v) => v + 1) 11 | const shouldHaveScopeAtom = atom(true) 12 | 13 | function Counter({ counterClass }: { counterClass: string }) { 14 | const [base1, increaseBase1] = useAtom(baseAtom1) 15 | const [base2, increaseBase2] = useAtom(baseAtom2) 16 | return ( 17 | <> 18 |
19 | base1: {base1} 20 | 23 |
24 |
25 | base2: {base2} 26 | 29 |
30 | 31 | ) 32 | } 33 | 34 | function Wrapper({ children }: PropsWithChildren) { 35 | const shouldHaveScope = useAtomValue(shouldHaveScopeAtom) 36 | return shouldHaveScope ? {children} : children 37 | } 38 | 39 | function ScopeButton() { 40 | const [shouldHaveScope, setShouldHaveScope] = useAtom(shouldHaveScopeAtom) 41 | return ( 42 | 45 | ) 46 | } 47 | 48 | function App() { 49 | return ( 50 |
51 |

Unscoped

52 | 53 |

Scoped Provider

54 | 55 | 56 | 57 | 58 |
59 | ) 60 | } 61 | 62 | describe('Counter', () => { 63 | test('atom get correct value when ScopeProvider is added/removed', () => { 64 | const { container } = render() 65 | const increaseUnscopedBase1 = '.unscoped.setBase1' 66 | const increaseUnscopedBase2 = '.unscoped.setBase2' 67 | const increaseScopedBase1 = '.scoped.setBase1' 68 | const increaseScopedBase2 = '.scoped.setBase2' 69 | const toggleScope = '#toggleScope' 70 | 71 | const atomValueSelectors = ['.unscoped.base1', '.unscoped.base2', '.scoped.base1', '.scoped.base2'] 72 | 73 | expect(getTextContents(container, atomValueSelectors)).toEqual(['0', '0', '0', '0']) 74 | 75 | clickButton(container, increaseUnscopedBase1) 76 | expect(getTextContents(container, atomValueSelectors)).toEqual(['1', '0', '1', '0']) 77 | 78 | clickButton(container, increaseUnscopedBase2) 79 | expect(getTextContents(container, atomValueSelectors)).toEqual(['1', '1', '1', '0']) 80 | 81 | clickButton(container, increaseScopedBase1) 82 | expect(getTextContents(container, atomValueSelectors)).toEqual(['2', '1', '2', '0']) 83 | 84 | clickButton(container, increaseScopedBase2) 85 | clickButton(container, increaseScopedBase2) 86 | clickButton(container, increaseScopedBase2) 87 | 88 | expect(getTextContents(container, atomValueSelectors)).toEqual(['2', '1', '2', '3']) 89 | 90 | clickButton(container, toggleScope) 91 | expect(getTextContents(container, atomValueSelectors)).toEqual(['2', '1', '2', '1']) 92 | 93 | clickButton(container, increaseUnscopedBase1) 94 | expect(getTextContents(container, atomValueSelectors)).toEqual(['3', '1', '3', '1']) 95 | 96 | clickButton(container, increaseUnscopedBase2) 97 | expect(getTextContents(container, atomValueSelectors)).toEqual(['3', '2', '3', '2']) 98 | 99 | clickButton(container, increaseScopedBase1) 100 | expect(getTextContents(container, atomValueSelectors)).toEqual(['4', '2', '4', '2']) 101 | 102 | clickButton(container, increaseScopedBase2) 103 | expect(getTextContents(container, atomValueSelectors)).toEqual(['4', '3', '4', '3']) 104 | 105 | clickButton(container, toggleScope) 106 | expect(getTextContents(container, atomValueSelectors)).toEqual(['4', '3', '4', '0']) 107 | 108 | clickButton(container, increaseScopedBase2) 109 | expect(getTextContents(container, atomValueSelectors)).toEqual(['4', '3', '4', '1']) 110 | 111 | clickButton(container, increaseScopedBase2) 112 | expect(getTextContents(container, atomValueSelectors)).toEqual(['4', '3', '4', '2']) 113 | 114 | clickButton(container, increaseScopedBase2) 115 | expect(getTextContents(container, atomValueSelectors)).toEqual(['4', '3', '4', '3']) 116 | }) 117 | }) 118 | -------------------------------------------------------------------------------- /REQUIREMENTS.md: -------------------------------------------------------------------------------- 1 | 1. All scoped state must live inside the nearest ancestor Jotai Provider store (or the global default store), never in separate stores per scope. 2 | 2. Scoped behavior is implemented by cloning atom objects so that each scoped variant uses a distinct atom identity as the key into the shared store. 3 | 3. Scopes are modeled as levels S0, S1, S2, ... with notation Sx[explicitAtoms]: Aₖ, Bₖ, Cₖ(...) describing which atoms are explicit and at what depth their evaluations run. 4 | 4. The “influence depth” of a derived atom is the maximum scope level of any explicit scoped atom in its transitive dependency graph for that evaluation. 5 | 5. Atoms are classified into explicit scoped atoms, implicit scoped atoms, inherited/unscoped atoms, and dependent scoped derived atoms. 6 | 6. Dependent scoped atoms are derived atoms whose transitive dependencies include at least one explicit scoped atom in the current scope hierarchy. 7 | 7. Dependent scoped atoms must be cloned per influence depth (scope level) so each depth-specific variant has its own atom identity. 8 | 8. Inherited/unscoped atoms are atoms that are not explicitly scoped at any level relevant to the current evaluation and therefore use the global (depth 0) atom identity. 9 | 9. Explicit scoped atoms are those listed in a ScopeProvider’s atoms/atomFamilies props and must always get a cloned atom identity for that scope level. 10 | 10. Implicit scoped atoms are cloned atom identities created when an explicit scoped atom’s read function synchronously touches additional atoms during evaluation. 11 | 11. Dependent scoped atoms must not create new implicit scoped primitives; they simply read whichever explicit or unscoped primitives are visible at their scope level. 12 | 12. Dependent scoped atoms cannot read implicit scoped atoms, whereas explicit and implicit scoped atoms may share and read implicit scoped atoms. 13 | 13. For classification and scoping decisions, only synchronous get calls within the read function’s main (pre-await) execution are considered dependencies. 14 | 14. Late asynchronous get calls (after the synchronous phase) must not influence dependency tracking or classification but must still return the correct scoped value. 15 | 15. Each readAtomState invocation must be treated as a per-atom transaction that buffers writes to atomState, mounts, invalidated sets, and changed sets. 16 | 16. At the end of the synchronous part of readAtomState, the transaction must compute the atom’s classification (unscoped, explicit, implicit, or dependent scoped) based on the collected deps. 17 | 17. After classification, the transaction must commit all buffered changes into exactly one target atom identity (e.g. C₀, C₁, C₃), and never partially into multiple identities. 18 | 18. The commit for a readAtomState transaction must happen before Jotai’s recomputeDependents logic runs so dependents see the correct target atom identity and state. 19 | 19. Async atoms must write their pending promise and eventual resolved or rejected value into the classified target atom identity chosen at the end of the synchronous phase. 20 | 20. Classification must never be changed by asynchronous get calls, and we must not attempt to reclassify or migrate promises after they have been committed. 21 | 21. In development mode, calling get on an explicit scoped atom after classification in a way that would have changed the atom’s depth must throw an error. 22 | 22. In production mode, late get on explicit scoped atoms must still read the correct scoped value but must silently have no effect on classification. 23 | 23. Bi-stable classification is allowed: a dependent derived atom may evaluate as unscoped in one evaluation and dependent scoped in another, based purely on its synchronous dependencies. 24 | 24. Dependent scoped atoms are treated as derived-only and must not be directly writable by user code. 25 | 25. Writable derived atoms (with custom write functions) are treated like any other explicit or unscoped derived atom, with their get and set calls resolved through scope-aware mapping. 26 | 26. Scope-aware get must always resolve each dependency atom to the correct clone for the current scope (explicit clone at that level if available, otherwise inherited clone). 27 | 27. Scope-aware set must write to the correct underlying atom identity (scoped or unscoped) according to the same resolution rules used for get. 28 | 28. Dependent scoped clones for a given atom and scope level must be created lazily on the first evaluation where classification determines a nonzero influence depth at that level. 29 | 29. Once a dependent scoped clone exists for a given atom and scope level, that scope must resolve useAtom for that atom to the clone identity for any evaluation that classifies as dependent at that level. 30 | 30. For async atoms that semantically depend on scoped atoms, users are encouraged either to synchronously touch the scoped atoms at the top of the read or to factor that logic into an explicitly scoped helper atom. 31 | 31. A utility like markDependent(atom, [deps...]) may be provided to predeclare that an atom should be treated as dependent scoped in any scope where any of the listed deps are explicit, even if the sync read doesn’t touch them. 32 | -------------------------------------------------------------------------------- /tests/ScopeProvider/03_nested.test.tsx: -------------------------------------------------------------------------------- 1 | import { render } from '@testing-library/react' 2 | import { atom, useAtom, useAtomValue, useSetAtom } from 'jotai' 3 | import { atomWithReducer } from 'jotai/vanilla/utils' 4 | import { describe, expect, test } from 'vitest' 5 | import { ScopeProvider } from 'jotai-scope' 6 | import { clickButton, getTextContents } from '../utils' 7 | 8 | const baseAtom1 = atomWithReducer(0, (v) => v + 1) 9 | const baseAtom2 = atomWithReducer(0, (v) => v + 1) 10 | const baseAtom = atom(0) 11 | 12 | const writeProxyAtom = atom('unused', (get, set) => { 13 | set(baseAtom, get(baseAtom) + 1) 14 | set(baseAtom1) 15 | set(baseAtom2) 16 | }) 17 | 18 | function Counter({ counterClass }: { counterClass: string }) { 19 | const [base1, increaseBase1] = useAtom(baseAtom1) 20 | const [base2, increaseBase2] = useAtom(baseAtom2) 21 | const base = useAtomValue(baseAtom) 22 | const increaseAll = useSetAtom(writeProxyAtom) 23 | return ( 24 | <> 25 |
26 | base1: {base1} 27 | 30 |
31 |
32 | base2: {base2} 33 | 36 |
37 |
38 | base: {base} 39 |
40 | 43 | 44 | ) 45 | } 46 | 47 | function App() { 48 | return ( 49 |
50 |

Unscoped

51 | 52 |

Layer 1: Scope base 1

53 |

base 2 and base should be globally shared

54 | 55 | 56 |

Layer 2: Scope base 2

57 |

base 1 should be shared between layer 1 and layer 2, base should be globally shared

58 | 59 | 60 | 61 |
62 |
63 | ) 64 | } 65 | 66 | describe('Counter', () => { 67 | /* 68 | baseA, baseB, baseC 69 | S1[baseA]: baseA1 baseB0 baseC0 70 | S2[baseB]: baseA1 baseB2 baseC0 71 | */ 72 | test('nested primitive atoms are correctly scoped', () => { 73 | const { container } = render() 74 | const increaseUnscopedBase1 = '.unscoped.setBase1' 75 | const increaseUnscopedBase2 = '.unscoped.setBase2' 76 | const increaseAllUnscoped = '.unscoped.setAll' 77 | const increaseLayer1Base1 = '.layer1.setBase1' 78 | const increaseLayer1Base2 = '.layer1.setBase2' 79 | const increaseAllLayer1 = '.layer1.setAll' 80 | const increaseLayer2Base1 = '.layer2.setBase1' 81 | const increaseLayer2Base2 = '.layer2.setBase2' 82 | const increaseAllLayer2 = '.layer2.setAll' 83 | 84 | const atomValueSelectors = [ 85 | '.unscoped.base1', 86 | '.unscoped.base2', 87 | '.unscoped.base', 88 | '.layer1.base1', 89 | '.layer1.base2', 90 | '.layer1.base', 91 | '.layer2.base1', 92 | '.layer2.base2', 93 | '.layer2.base', 94 | ] 95 | 96 | expect(getTextContents(container, atomValueSelectors)).toEqual(['0', '0', '0', '0', '0', '0', '0', '0', '0']) 97 | 98 | clickButton(container, increaseUnscopedBase1) 99 | expect(getTextContents(container, atomValueSelectors)).toEqual(['1', '0', '0', '0', '0', '0', '0', '0', '0']) 100 | 101 | clickButton(container, increaseUnscopedBase2) 102 | expect(getTextContents(container, atomValueSelectors)).toEqual(['1', '1', '0', '0', '1', '0', '0', '0', '0']) 103 | 104 | clickButton(container, increaseAllUnscoped) 105 | expect(getTextContents(container, atomValueSelectors)).toEqual(['2', '2', '1', '0', '2', '1', '0', '0', '1']) 106 | 107 | clickButton(container, increaseLayer1Base1) 108 | expect(getTextContents(container, atomValueSelectors)).toEqual(['2', '2', '1', '1', '2', '1', '1', '0', '1']) 109 | 110 | clickButton(container, increaseLayer1Base2) 111 | expect(getTextContents(container, atomValueSelectors)).toEqual(['2', '3', '1', '1', '3', '1', '1', '0', '1']) 112 | 113 | clickButton(container, increaseAllLayer1) 114 | expect(getTextContents(container, atomValueSelectors)).toEqual(['2', '4', '2', '2', '4', '2', '2', '0', '2']) 115 | 116 | clickButton(container, increaseLayer2Base1) 117 | expect(getTextContents(container, atomValueSelectors)).toEqual(['2', '4', '2', '3', '4', '2', '3', '0', '2']) 118 | 119 | clickButton(container, increaseLayer2Base2) 120 | expect(getTextContents(container, atomValueSelectors)).toEqual(['2', '4', '2', '3', '4', '2', '3', '1', '2']) 121 | 122 | clickButton(container, increaseAllLayer2) 123 | expect(getTextContents(container, atomValueSelectors)).toEqual(['2', '4', '3', '4', '4', '3', '4', '2', '3']) 124 | }) 125 | }) 126 | -------------------------------------------------------------------------------- /tests/ScopeProvider/07_writable.test.tsx: -------------------------------------------------------------------------------- 1 | import { render } from '@testing-library/react' 2 | import type { PrimitiveAtom, WritableAtom } from 'jotai' 3 | import { atom, createStore, useAtom } from 'jotai' 4 | import { describe, expect, test } from 'vitest' 5 | import { ScopeProvider } from '../../src' 6 | import { createScope } from '../../src/ScopeProvider/scope' 7 | import { AnyAtom } from '../../src/types' 8 | import { clickButton, getTextContents } from '../utils' 9 | 10 | let baseAtom: PrimitiveAtom 11 | 12 | type WritableNumberAtom = WritableAtom 13 | 14 | const writableAtom: WritableNumberAtom = atom(0, (get, set, value = 0) => { 15 | const writableValue = get(writableAtom) 16 | const baseValue = get(baseAtom) 17 | set(writableAtom, writableValue + baseValue + value) 18 | }) 19 | writableAtom.debugLabel = 'writableAtom' 20 | 21 | const thisWritableAtom: WritableNumberAtom = atom(0, function write(this: WritableNumberAtom, get, set, value = 0) { 22 | set(this, get(this) + get(baseAtom) + value) 23 | }) 24 | 25 | function renderTest(targetAtom: WritableNumberAtom) { 26 | baseAtom = atom(0) 27 | baseAtom.debugLabel = 'baseAtom' 28 | 29 | function Component({ level }: { level: string }) { 30 | const [value, increaseWritable] = useAtom(targetAtom) 31 | const [baseValue, increaseBase] = useAtom(baseAtom) 32 | return ( 33 |
34 |
{value}
35 |
{baseValue}
36 | 44 | 47 |
48 | ) 49 | } 50 | 51 | function App() { 52 | return ( 53 | <> 54 |

unscoped

55 | 56 | 57 |

scoped

58 |

writable atom should update its value in both scoped and unscoped and read scoped atom

59 | 60 |
61 | 62 | ) 63 | } 64 | return render() 65 | } 66 | 67 | /* 68 | writable=w(,w + s), base=b 69 | S0[ ]: b0, w0(,w0 + b0) 70 | S1[b]: b1, w0(,w0 + b1) 71 | */ 72 | describe('Self', () => { 73 | test.each(['writableAtom', 'thisWritableAtom'])( 74 | '%p updates its value in both scoped and unscoped and read scoped atom', 75 | (atomKey) => { 76 | const target = atomKey === 'writableAtom' ? writableAtom : thisWritableAtom 77 | const { container } = renderTest(target) 78 | 79 | const increaseLevel0BaseAtom = '.level0 .writeBase' 80 | const increaseLevel0Writable = '.level0 .write' 81 | const increaseLevel1BaseAtom = '.level1 .writeBase' 82 | const increaseLevel1Writable = '.level1 .write' 83 | 84 | const selectors = ['.level0 .readBase', '.level0 .read', '.level1 .readBase', '.level1 .read'] 85 | 86 | // all initial values are zero 87 | expect(getTextContents(container, selectors)).toEqual([ 88 | '0', // level0 readBase 89 | '0', // level0 read 90 | '0', // level1 readBase 91 | '0', // level1 read 92 | ]) 93 | 94 | // level0 base atom updates its value to 1 95 | clickButton(container, increaseLevel0BaseAtom) 96 | expect(getTextContents(container, selectors)).toEqual([ 97 | '1', // level0 readBase 98 | '0', // level0 read 99 | '0', // level1 readBase 100 | '0', // level1 read 101 | ]) 102 | 103 | // level0 writable atom increases its value, level1 writable atom shares the same value 104 | clickButton(container, increaseLevel0Writable) 105 | expect(getTextContents(container, selectors)).toEqual([ 106 | '1', // level0 readBase 107 | '1', // level0 read 108 | '0', // level1 readBase 109 | '1', // level1 read 110 | ]) 111 | 112 | // level1 writable atom increases its value, 113 | // but since level1 base atom is zero, 114 | // level0 and level1 writable atoms value should not change 115 | clickButton(container, increaseLevel1Writable) 116 | expect(getTextContents(container, selectors)).toEqual([ 117 | '1', // level0 readBase 118 | '1', // level0 read 119 | '0', // level1 readBase 120 | '1', // level1 read 121 | ]) 122 | 123 | // level1 base atom updates its value to 10 124 | clickButton(container, increaseLevel1BaseAtom) 125 | expect(getTextContents(container, selectors)).toEqual([ 126 | '1', // level0 readBase 127 | '1', // level0 read 128 | '10', // level1 readBase 129 | '1', // level1 read 130 | ]) 131 | 132 | // level0 writable atom increases its value using level0 base atom 133 | clickButton(container, increaseLevel0Writable) 134 | expect(getTextContents(container, selectors)).toEqual([ 135 | '1', // level0 readBase 136 | '2', // level0 read 137 | '10', // level1 readBase 138 | '2', // level1 read 139 | ]) 140 | 141 | // level1 writable atom increases its value using level1 base atom 142 | clickButton(container, increaseLevel1Writable) 143 | const v = getTextContents(container, selectors) + '' 144 | expect(v).toEqual( 145 | [ 146 | '1', // level0 readBase 147 | '12', // level0 read 148 | '10', // level1 readBase 149 | '12', // level1 read 150 | ] + '' 151 | ) 152 | } 153 | ) 154 | }) 155 | 156 | describe('scope chains', () => { 157 | const a = atom(0) 158 | const b = atom(null, (_, set, v: number) => set(a, v)) 159 | const c = atom(null, (_, set, v: number) => set(b, v)) 160 | a.debugLabel = 'a' 161 | b.debugLabel = 'b' 162 | c.debugLabel = 'c' 163 | function createScopes(atoms: AnyAtom[] = []) { 164 | const s0 = createStore() 165 | const s1 = createScope({ atoms, parentStore: s0, name: 'S1' }) 166 | return { s0, s1 } 167 | } 168 | test('S1[a]: a1, b0(,a1), c0(,b0(,a1))', () => { 169 | { 170 | const { s0, s1 } = createScopes([a]) 171 | s0.set(c, 1) 172 | expect([s0.get(a), s1.get(a)] + '').toBe('1,0') 173 | } 174 | { 175 | const { s0, s1 } = createScopes([a]) 176 | s1.set(c, 1) 177 | expect([s0.get(a), s1.get(a)] + '').toBe('0,1') 178 | } 179 | }) 180 | }) 181 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # jotai-scope 2 | 3 | 👻🔭 *Isolate Jotai atoms with scope* 4 | 5 | ### Install 6 | 7 | ``` 8 | npm install jotai-scope 9 | ``` 10 | 11 | ## ScopeProvider 12 | 13 | While Jotai's Provider allows to scope Jotai's store under a subtree, we can't use the store above the tree within the subtree. 14 | 15 | A workaround is to use `store` option in useAtom and other hooks. 16 | 17 | Instead of specifying the `store` option, `` lets you reuse the *same* atoms in different parts of the React tree **without sharing state** while still being able to read other atoms from the parent store. 18 | 19 | ### At‑a‑glance 20 | 21 | * Scopes are opt‑in. Only atoms listed in `atoms` or `atomFamilies` are explicitly scoped. 22 | * **Unscoped derived** atoms can read both unscoped and scoped atoms. 23 | * **Scoped derived** atoms implicitly scope their atom dependencies. When you scope a derived atom, every atom it touches (recursively) is scoped automatically, but only when read by the derived atom. Outside the derived atom, it continues to be unscoped. 24 | * **Nested lookup.** If a scope can’t find the atom in the current scope, it inherits from the nearest parent scope, up to the nearest store. 25 | * Scoping works for both reading from atoms *and* writing to atoms. 26 | 27 | ### Quick Start 28 | 29 | ```tsx 30 | import { Provider, atom, useAtom, useAtomValue } from 'jotai' 31 | import { ScopeProvider } from 'jotai-scope' 32 | ``` 33 | 34 | **1 · Isolating a counter** 35 | 36 | ```tsx 37 | const countAtom = atom(0) 38 | const doubledAtom = atom((get) => get(countAtom) * 2) 39 | 40 | function Counter() { 41 | const [count, setCount] = useAtom(countAtom) 42 | const doubled = useAtomValue(doubledAtom) 43 | return ( 44 | <> 45 | 46 | {count} → {doubled} 47 | 48 | ) 49 | } 50 | 51 | export default function App() { 52 | return ( 53 | 54 | {/* doubledAtom uses the parent store */} 55 | 56 | {/* doubledAtom is scoped */} 57 | 58 | 59 | ) 60 | } 61 | ``` 62 | 63 | The second counter owns a private `doubledAtom` *and* a private `countAtom` because `doubledAtom` is scoped. 64 | 65 | **2 · Nested scopes** 66 | 67 | ```tsx 68 | 69 | {/* countAtom is read from S1 */} 70 | 71 | {/* countAtom is read from S1 & nameAtom is read from S2 */} 72 | 73 | 74 | ``` 75 | 76 | * Outer scope (S1) isolates `countAtom`. 77 | * Inner scope (S2) isolates `nameAtom`, then looks up the tree and finds `countAtom` in S1. 78 | 79 | **3 · Providing default values** 80 | 81 | ```tsx 82 | 83 | {/* starts at 42 inside this scope */} 84 | 85 | ``` 86 | 87 | Mix tuples and plain atoms as needed: `atoms={[[countAtom, 1], anotherAtom]}`. 88 | 89 | **4 · Scoping an atomFamily** 90 | 91 | ```tsx 92 | import { atom, atomFamily, useAtom } from 'jotai' 93 | import { ScopeProvider } from 'jotai-scope' 94 | 95 | const itemFamily = atomFamily((id: number) => atom(id)) 96 | 97 | {/* Unscoped items */} 98 | 99 | {/* Isolated items */} 100 | 101 | 102 | ``` 103 | 104 | Inside the `` every `itemFamily(id)` call resolves to a scoped copy, so items rendered inside the provider are independent from the global ones and from any sibling scopes. 105 | 106 | **A helpful syntax for describing nested scopes** 107 | 108 | ``` 109 | a, b, c(a + b), d(a + c) 110 | S1[a]: a1, b0, c0(a1 + b0), d0(a1 + c0(a1 + b0)) 111 | S2[c, d]: a1, b0, c2(a2 + b2), d2(a2 + c2(a2 + b2)) 112 | ``` 113 | Above: 114 | - Scope **S1** is the first scope under the store provider (**S0**). **S1** scopes **a**, so **a1** refers to the scoped **a** in **S1**. 115 | - **c** is a derived atom. **c** reads **a** and **b**. In **S1**, **c** is not scoped so it reads **a1** and **b0** from **S1**. 116 | - **c** is scoped in **S2**, so it reads **a** from **S2** and **b** from **S2**. This is because atom dependencies of scoped atoms are _implicitly scoped_. 117 | - Outside **c** and **d** in **S2**, **a** and **b** still inherit from **S1**. 118 | - **c** and **d** are both scoped in **S2**, so they both read **a2**. Implicit dependencies are shared across scoped atoms in the same scope so **a2** in **c2** and **a2** in **d2** are the same atom. 119 | 120 | ### API 121 | 122 | ```ts 123 | interface ScopeProviderProps { 124 | atoms?: (Atom | [WritableAtom, any])[] 125 | atomFamilies?: AtomFamily[] 126 | children: React.ReactNode 127 | name?: string 128 | } | { 129 | scope: Store 130 | children: React.ReactNode 131 | } 132 | ``` 133 | 134 | ### Caveats 135 | 136 | * Avoid side effects inside atom read—it may run multiple times per scope. For async atoms, use an abort controller. The extra renders are a known limitation and solutions are being researched. If you are interested in helping, please [join the discussion](https://github.com/jotaijs/jotai-scope/issues/25). 137 | 138 | 139 | 140 | 141 | ## createScope 142 | 143 | `createScope` is a low-level API that allows you to create a scoped store 144 | from a parent store. It is useful when you want to create a scope 145 | outside of React. 146 | 147 | ```tsx 148 | import { createScope, ScopeProvider } from 'jotai-scope' 149 | 150 | const parentStore = createStore() 151 | const scopedStore = createScope({ 152 | parentStore, 153 | atomSet: new Set([atomA, atomB]), 154 | atomFamilySet: new Set([atomFamilyA, atomFamilyB]), 155 | }) 156 | 157 | function Component() { 158 | return ( 159 | 160 | 161 | 162 | ) 163 | } 164 | ``` 165 | 166 | ### Nesting Scopes 167 | You can create a scope from another scope. 168 | ```tsx 169 | const parentStore = createStore() 170 | const scope1 = createScope({ 171 | parentStore, 172 | atomSet: new Set([atomA, atomB]), 173 | atomFamilySet: new Set([atomFamilyA, atomFamilyB]), 174 | scopeName: 'level1', 175 | }) 176 | const scope2 = createScope({ 177 | parentStore: scope1, 178 | atomSet: new Set([atomC, atomD]), 179 | scopeName: 'level2', 180 | }) 181 | ``` 182 | 183 | ## createIsolation 184 | 185 | Both Jotai's Provider and `jotai-scope`'s scoped provider 186 | are still using global contexts. 187 | 188 | If you are developing a library that depends on Jotai and 189 | the library user may use Jotai separately in their apps, 190 | they can share the same context. This can be troublesome 191 | because they point to unexpected Jotai stores. 192 | 193 | To avoid conflicting the contexts, a utility function called `createIsolation` is exported from `jotai-scope`. 194 | 195 | ```tsx 196 | import { createIsolation } from 'jotai-scope' 197 | 198 | const { Provider, ScopeProvider, useStore, useAtom, useAtomValue, useSetAtom } = 199 | createIsolation() 200 | 201 | function Library() { 202 | return ( 203 | 204 | 205 | 206 | ) 207 | } 208 | ``` 209 | -------------------------------------------------------------------------------- /tests/createIsolation/01_basic_spec.test.tsx: -------------------------------------------------------------------------------- 1 | import { act } from 'react' 2 | import { render } from '@testing-library/react' 3 | import { atom, useAtom, useAtomValue } from 'jotai' 4 | import { describe, expect, it } from 'vitest' 5 | import { createIsolation } from '../../src/index' 6 | import { clickButton } from '../utils' 7 | 8 | describe('basic spec', () => { 9 | it('should export functions', () => { 10 | expect(createIsolation).toBeDefined() 11 | }) 12 | }) 13 | 14 | describe('createIsolation ScopeProvider', () => { 15 | it('should scope atoms within isolated context', () => { 16 | const { Provider, ScopeProvider, useAtomValue: useIsolatedAtomValue } = createIsolation() 17 | const countAtom = atom(0) 18 | countAtom.debugLabel = 'count' 19 | 20 | function Counter({ className }: { className: string }) { 21 | const count = useIsolatedAtomValue(countAtom) 22 | return
{count}
23 | } 24 | 25 | const { container } = render( 26 | 27 | 28 | 29 | 30 | 31 | 32 | ) 33 | 34 | expect(container.querySelector('.unscoped')!.textContent).toBe('0') 35 | expect(container.querySelector('.scoped')!.textContent).toBe('10') 36 | }) 37 | 38 | it('should isolate scoped state from global jotai context', () => { 39 | const { Provider, ScopeProvider, useAtom: useIsolatedAtom } = createIsolation() 40 | const countAtom = atom(0) 41 | countAtom.debugLabel = 'count' 42 | 43 | // Component using isolated hooks 44 | function IsolatedCounter({ className }: { className: string }) { 45 | const [count, setCount] = useIsolatedAtom(countAtom) 46 | return ( 47 |
48 | {count} 49 | 52 |
53 | ) 54 | } 55 | 56 | // Component using global jotai hooks 57 | function GlobalCounter({ className }: { className: string }) { 58 | const [count, setCount] = useAtom(countAtom) 59 | return ( 60 |
61 | {count} 62 | 65 |
66 | ) 67 | } 68 | 69 | const { container } = render( 70 | <> 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | ) 80 | 81 | // All start at 0 82 | expect(container.querySelector('.global .value')!.textContent).toBe('0') 83 | expect(container.querySelector('.isolated-unscoped .value')!.textContent).toBe('0') 84 | expect(container.querySelector('.isolated-scoped .value')!.textContent).toBe('0') 85 | 86 | // Increment global - should not affect isolated 87 | clickButton(container, '.global button') 88 | expect(container.querySelector('.global .value')!.textContent).toBe('1') 89 | expect(container.querySelector('.isolated-unscoped .value')!.textContent).toBe('0') 90 | expect(container.querySelector('.isolated-scoped .value')!.textContent).toBe('0') 91 | 92 | // Increment isolated unscoped - should not affect scoped 93 | clickButton(container, '.isolated-unscoped button') 94 | expect(container.querySelector('.global .value')!.textContent).toBe('1') 95 | expect(container.querySelector('.isolated-unscoped .value')!.textContent).toBe('1') 96 | expect(container.querySelector('.isolated-scoped .value')!.textContent).toBe('0') 97 | 98 | // Increment isolated scoped - completely independent 99 | clickButton(container, '.isolated-scoped button') 100 | expect(container.querySelector('.global .value')!.textContent).toBe('1') 101 | expect(container.querySelector('.isolated-unscoped .value')!.textContent).toBe('1') 102 | expect(container.querySelector('.isolated-scoped .value')!.textContent).toBe('1') 103 | }) 104 | 105 | it('should work with derived atoms in isolated scope', () => { 106 | const { Provider, ScopeProvider, useAtomValue: useIsolatedAtomValue } = createIsolation() 107 | const baseAtom = atom(5) 108 | const derivedAtom = atom((get) => get(baseAtom) * 2) 109 | baseAtom.debugLabel = 'base' 110 | derivedAtom.debugLabel = 'derived' 111 | 112 | function Display({ className }: { className: string }) { 113 | const base = useIsolatedAtomValue(baseAtom) 114 | const derived = useIsolatedAtomValue(derivedAtom) 115 | return ( 116 |
117 | {base} 118 | {derived} 119 |
120 | ) 121 | } 122 | 123 | const { container } = render( 124 | 125 | 126 | 127 | 128 | 129 | 130 | ) 131 | 132 | // Unscoped: base=5, derived=10 133 | expect(container.querySelector('.unscoped .base')!.textContent).toBe('5') 134 | expect(container.querySelector('.unscoped .derived')!.textContent).toBe('10') 135 | 136 | // Scoped: base=10, derived reads scoped base=10, so derived=20 137 | expect(container.querySelector('.scoped .base')!.textContent).toBe('10') 138 | expect(container.querySelector('.scoped .derived')!.textContent).toBe('20') 139 | }) 140 | 141 | it('should differentiate scoped vs unscoped atoms within a scoped context', () => { 142 | const { Provider, ScopeProvider, useAtom: useIsolatedAtom } = createIsolation() 143 | const scopedAtom = atom(0) 144 | const unscopedAtom = atom(0) 145 | scopedAtom.debugLabel = 'scoped' 146 | unscopedAtom.debugLabel = 'unscoped' 147 | 148 | function Display({ className }: { className: string }) { 149 | const [scoped, setScoped] = useIsolatedAtom(scopedAtom) 150 | const [unscoped, setUnscoped] = useIsolatedAtom(unscopedAtom) 151 | return ( 152 |
153 | {scoped} 154 | {unscoped} 155 | 158 | 161 |
162 | ) 163 | } 164 | 165 | const { container } = render( 166 | 167 | 168 | 169 | 170 | 171 | 172 | ) 173 | function readValue(selector: string) { 174 | return container.querySelector(selector)!.textContent 175 | } 176 | 177 | expect(readValue('.outer .scoped-value')).toBe('0') 178 | expect(readValue('.outer .unscoped-value')).toBe('0') 179 | expect(readValue('.inner .scoped-value')).toBe('0') 180 | expect(readValue('.inner .unscoped-value')).toBe('0') 181 | 182 | act(() => clickButton(container, '.inner .inc-scoped')) 183 | expect(readValue('.outer .scoped-value')).toBe('0') 184 | expect(readValue('.inner .scoped-value')).toBe('1') 185 | 186 | act(() => clickButton(container, '.inner .inc-unscoped')) 187 | expect(readValue('.outer .unscoped-value')).toBe('1') 188 | expect(readValue('.inner .unscoped-value')).toBe('1') 189 | 190 | act(() => clickButton(container, '.outer .inc-scoped')) 191 | expect(readValue('.outer .scoped-value')).toBe('1') 192 | expect(readValue('.inner .scoped-value')).toBe('1') 193 | }) 194 | }) 195 | -------------------------------------------------------------------------------- /tests/ScopeProvider/08_family.test.tsx: -------------------------------------------------------------------------------- 1 | import { act, render } from '@testing-library/react' 2 | import { atom, useAtom, useSetAtom } from 'jotai' 3 | import { atomFamily, atomWithReducer } from 'jotai/utils' 4 | import { describe, expect, test } from 'vitest' 5 | import { ScopeProvider } from '../../src/index' 6 | import { clickButton, getTextContents } from '../utils' 7 | 8 | describe('AtomFamily with ScopeProvider', () => { 9 | /* 10 | a = aFamily('a'), b = aFamily('b') 11 | S0[]: a0 b0 12 | S1[aFamily]: a1 b1 13 | */ 14 | test('01. Scoped atom families provide isolated state', () => { 15 | const aFamily = atomFamily(() => atom(0)) 16 | const aAtom = aFamily('a') 17 | aAtom.debugLabel = 'aAtom' 18 | const bAtom = aFamily('b') 19 | bAtom.debugLabel = 'bAtom' 20 | function Counter({ level, param }: { level: string; param: string }) { 21 | const [value, setValue] = useAtom(aFamily(param)) 22 | return ( 23 |
24 | {param}:{value} 25 | 28 |
29 | ) 30 | } 31 | 32 | function App() { 33 | return ( 34 |
35 |

Unscoped

36 | 37 | 38 |

Scoped Provider

39 | 40 | 41 | 42 | 43 |
44 | ) 45 | } 46 | 47 | const { container } = render() 48 | const selectors = ['.level0.a', '.level0.b', '.level1.a', '.level1.b'] 49 | 50 | expect(getTextContents(container, selectors)).toEqual([ 51 | '0', // level0 a 52 | '0', // level0 b 53 | '0', // level1 a 54 | '0', // level1 b 55 | ]) 56 | 57 | clickButton(container, '.level0.set-a') 58 | expect(getTextContents(container, selectors)).toEqual([ 59 | '1', // level0 a 60 | '0', // level0 b 61 | '0', // level1 a 62 | '0', // level1 b 63 | ]) 64 | 65 | clickButton(container, '.level1.set-a') 66 | expect(getTextContents(container, selectors)).toEqual([ 67 | '1', // level0 a 68 | '0', // level0 b 69 | '1', // level1 a 70 | '0', // level1 b 71 | ]) 72 | 73 | clickButton(container, '.level1.set-b') 74 | expect(getTextContents(container, selectors)).toEqual([ 75 | '1', // level0 a 76 | '0', // level0 b 77 | '1', // level1 a 78 | '1', // level1 b 79 | ]) 80 | }) 81 | 82 | /* 83 | aFamily('a'), aFamily.remove('a') 84 | S0[aFamily('a')]: a0 -> removed 85 | S1[aFamily('a')]: a1 86 | */ 87 | // TODO: refactor atomFamily to support descoping removing atoms 88 | test.skip('02. Removing atom from atomFamily does not affect scoped state', () => { 89 | const aFamily = atomFamily(() => atom(0)) 90 | const atomA = aFamily('a') 91 | atomA.debugLabel = 'atomA' 92 | const rerenderAtom = atomWithReducer(0, (s) => s + 1) 93 | rerenderAtom.debugLabel = 'rerenderAtom' 94 | function Counter({ level, param }: { level: string; param: string }) { 95 | const [value, setValue] = useAtom(atomA) 96 | useAtom(rerenderAtom) 97 | return ( 98 |
99 | {param}:{value} 100 | 103 |
104 | ) 105 | } 106 | 107 | function App() { 108 | const rerender = useSetAtom(rerenderAtom) 109 | return ( 110 |
111 |

Unscoped

112 | 113 | 122 |

Scoped Provider

123 | 124 | 125 | 126 |
127 | ) 128 | } 129 | 130 | const { container } = render() 131 | const selectors = ['.level0.a', '.level1.a'] 132 | 133 | expect(getTextContents(container, selectors)).toEqual([ 134 | '0', // level0 a 135 | '0', // level1 a 136 | ]) 137 | 138 | clickButton(container, '.level0.set-a') 139 | expect(getTextContents(container, selectors)).toEqual([ 140 | '1', // level0 a 141 | '0', // level1 a 142 | ]) 143 | 144 | act(() => { 145 | clickButton(container, '.remove-atom') 146 | }) 147 | 148 | expect(getTextContents(container, ['.level0.a', '.level1.a'])).toEqual([ 149 | '1', // level0 a 150 | '1', // level1 a // atomA is now unscoped 151 | ]) 152 | 153 | clickButton(container, '.level1.set-a') 154 | expect(getTextContents(container, ['.level0.a', '.level1.a'])).toEqual([ 155 | '2', // level0 a 156 | '2', // level1 a 157 | ]) 158 | }) 159 | 160 | /* 161 | aFamily.setShouldRemove((createdAt, param) => param === 'b') 162 | S0[aFamily('a'), aFamily('b')]: a0 removed 163 | S1[aFamily('a'), aFamily('b')]: a1 b1 164 | */ 165 | // TODO: refactor atomFamily to support descoping removing atoms 166 | test.skip('03. Scoped atom families respect custom removal conditions', () => { 167 | const aFamily = atomFamily(() => atom(0)) 168 | const atomA = aFamily('a') 169 | atomA.debugLabel = 'atomA' 170 | const atomB = aFamily('b') 171 | atomB.debugLabel = 'atomB' 172 | const rerenderAtom = atomWithReducer(0, (s) => s + 1) 173 | rerenderAtom.debugLabel = 'rerenderAtom' 174 | 175 | function Counter({ level, param }: { level: string; param: string }) { 176 | const [value, setValue] = useAtom(aFamily(param)) 177 | useAtom(rerenderAtom) 178 | return ( 179 |
180 | {param}:{value} 181 | 184 |
185 | ) 186 | } 187 | 188 | function App() { 189 | const rerender = useSetAtom(rerenderAtom) 190 | return ( 191 |
192 | 201 |

Unscoped

202 | 203 | 204 |

Scoped Provider

205 | 206 | 207 | 208 | 209 |
210 | ) 211 | } 212 | 213 | const { container } = render() 214 | const removeBButton = '.remove-b' 215 | const selectors = ['.level0.a', '.level0.b', '.level1.a', '.level1.b'] 216 | 217 | expect(getTextContents(container, selectors)).toEqual([ 218 | '0', // level0 a 219 | '0', // level0 b 220 | '0', // level1 a 221 | '0', // level1 b 222 | ]) 223 | 224 | clickButton(container, '.level0.set-a') 225 | clickButton(container, '.level0.set-b') 226 | expect(getTextContents(container, selectors)).toEqual([ 227 | '1', // level0 a 228 | '1', // level0 b 229 | '0', // level1 a // a is scoped 230 | '0', // level1 b // b is scoped 231 | ]) 232 | 233 | act(() => { 234 | clickButton(container, removeBButton) 235 | }) 236 | 237 | expect(getTextContents(container, selectors)).toEqual([ 238 | '1', // level0 a 239 | '1', // level0 b 240 | '0', // level1 a // a is still scoped 241 | '1', // level1 b // b is no longer scoped 242 | ]) 243 | }) 244 | }) 245 | -------------------------------------------------------------------------------- /tests/ScopeProvider/04_derived.test.tsx: -------------------------------------------------------------------------------- 1 | import { render } from '@testing-library/react' 2 | import { atom, useAtom } from 'jotai' 3 | import { describe, expect, test } from 'vitest' 4 | import { ScopeProvider } from 'jotai-scope' 5 | import { clickButton, getTextContents } from '../utils' 6 | 7 | const atomValueSelectors = [ 8 | '.case1.base', 9 | '.case1.derivedA', 10 | '.case1.derivedB', 11 | '.case2.base', 12 | '.case2.derivedA', 13 | '.case2.derivedB', 14 | '.layer1.base', 15 | '.layer1.derivedA', 16 | '.layer1.derivedB', 17 | '.layer2.base', 18 | '.layer2.derivedA', 19 | '.layer2.derivedB', 20 | ] 21 | 22 | function clickButtonGetResults(buttonSelector: string) { 23 | const baseAtom = atom(0) 24 | const derivedAtomA = atom( 25 | (get) => get(baseAtom), 26 | (get, set) => { 27 | set(baseAtom, get(baseAtom) + 1) 28 | } 29 | ) 30 | 31 | const derivedAtomB = atom( 32 | (get) => get(baseAtom), 33 | (get, set) => { 34 | set(baseAtom, get(baseAtom) + 1) 35 | } 36 | ) 37 | 38 | function Counter({ counterClass }: { counterClass: string }) { 39 | const [base, setBase] = useAtom(baseAtom) 40 | const [derivedA, setDerivedA] = useAtom(derivedAtomA) 41 | const [derivedB, setDerivedB] = useAtom(derivedAtomB) 42 | return ( 43 | <> 44 |
45 | base:{base} 46 | 49 |
50 |
51 | derivedA: 52 | {derivedA} 53 | 56 |
57 |
58 | derivedB: 59 | {derivedB} 60 | 63 |
64 | 65 | ) 66 | } 67 | 68 | function App() { 69 | return ( 70 |
71 |

Only base is scoped

72 |

derivedA and derivedB should also be scoped

73 | 74 | 75 | 76 |

Both derivedA an derivedB are scoped

77 |

base should be global, derivedA and derivedB are shared

78 | 79 | 80 | 81 |

Layer1: Only derivedA is scoped

82 |

base and derivedB should be global

83 | 84 | 85 |

Layer2: Base and derivedB are scoped

86 |

derivedA should use layer2's atom, base and derivedB are layer 2 scoped

87 | 88 | 89 | 90 |
91 |
92 | ) 93 | } 94 | 95 | const { container } = render() 96 | expectAllZeroes(container) 97 | clickButton(container, buttonSelector) 98 | return getTextContents(container, atomValueSelectors) 99 | } 100 | 101 | function expectAllZeroes(container: HTMLElement) { 102 | expect(getTextContents(container, atomValueSelectors)).toEqual([ 103 | // case 1 104 | '0', // base 105 | '0', // derivedA 106 | '0', // derivedB 107 | 108 | // case 2 109 | '0', // base 110 | '0', // derivedA 111 | '0', // derivedB 112 | 113 | // layer 1 114 | '0', // base 115 | '0', // derivedA 116 | '0', // derivedB 117 | 118 | // layer 2 119 | '0', // base 120 | '0', // derivedA 121 | '0', // derivedB 122 | ]) 123 | } 124 | 125 | describe('Counter', () => { 126 | test("parent scope's derived atom is prior to nested scope's scoped base", () => { 127 | const increaseCase1Base = '.case1.setBase' 128 | const increaseCase1DerivedA = '.case1.setDerivedA' 129 | const increaseCase1DerivedB = '.case1.setDerivedB' 130 | const increaseCase2Base = '.case2.setBase' 131 | const increaseCase2DerivedA = '.case2.setDerivedA' 132 | const increaseCase2DerivedB = '.case2.setDerivedB' 133 | const increaseLayer1Base = '.layer1.setBase' 134 | const increaseLayer1DerivedA = '.layer1.setDerivedA' 135 | const increaseLayer1DerivedB = '.layer1.setDerivedB' 136 | const increaseLayer2Base = '.layer2.setBase' 137 | const increaseLayer2DerivedA = '.layer2.setDerivedA' 138 | const increaseLayer2DerivedB = '.layer2.setDerivedB' 139 | 140 | /* 141 | base, derivedA(base), derivedB(base) 142 | case1[base]: base1, derivedA0(base1), derivedB0(base1) 143 | case2[derivedA, derivedB]: base0, derivedA1(base1), derivedB1(base1) 144 | layer1[derivedA]: base0, derivedA1(base1), derivedB0(base0) 145 | layer2[base, derivedB]: base2, derivedA1(base2), derivedB2(base2) 146 | */ 147 | expect(clickButtonGetResults(increaseCase1Base)).toEqual([ 148 | // case 1 149 | '1', // base 150 | '1', // derivedA 151 | '1', // derivedB 152 | 153 | // case 2 154 | '0', // base 155 | '0', // derivedA 156 | '0', // derivedB 157 | 158 | // layer 1 159 | '0', // base 160 | '0', // derivedA 161 | '0', // derivedB 162 | 163 | // layer 2 164 | '0', // base 165 | '0', // derivedA 166 | '0', // derivedB 167 | ]) 168 | 169 | /* 170 | base, derivedA(base), derivedB(base) 171 | case1[base]: base1, derivedA0(base1), derivedB0(base1) 172 | case2[derivedA, derivedB]: base0, derivedA1(base1), derivedB1(base1) 173 | layer1[derivedA]: base0, derivedA1(base1), derivedB0(base0) 174 | layer2[base, derivedB]: base2, derivedA1(base2), derivedB2(base2) 175 | */ 176 | expect(clickButtonGetResults(increaseCase1DerivedA)).toEqual([ 177 | // case 1 178 | '1', // base 179 | '1', // derivedA 180 | '1', // derivedB 181 | 182 | // case 2 183 | '0', // base 184 | '0', // derivedA 185 | '0', // derivedB 186 | 187 | // layer 1 188 | '0', // base 189 | '0', // derivedA 190 | '0', // derivedB 191 | 192 | // layer 2 193 | '0', // base 194 | '0', // derivedA 195 | '0', // derivedB 196 | ]) 197 | 198 | /* 199 | base, derivedA(base), derivedB(base) 200 | case1[base]: base1, derivedA0(base1), derivedB0(base1) 201 | case2[derivedA, derivedB]: base0, derivedA1(base1), derivedB1(base1) 202 | layer1[derivedA]: base0, derivedA1(base1), derivedB0(base0) 203 | layer2[base, derivedB]: base2, derivedA1(base2), derivedB2(base2) 204 | */ 205 | expect(clickButtonGetResults(increaseCase1DerivedB)).toEqual([ 206 | // case 1 207 | '1', // base 208 | '1', // derivedA 209 | '1', // derivedB 210 | 211 | // case 2 212 | '0', // base 213 | '0', // derivedA 214 | '0', // derivedB 215 | 216 | // layer 1 217 | '0', // base 218 | '0', // derivedA 219 | '0', // derivedB 220 | 221 | // layer 2 222 | '0', // base 223 | '0', // derivedA 224 | '0', // derivedB 225 | ]) 226 | 227 | /* 228 | base, derivedA(base), derivedB(base) 229 | case1[base]: base1, derivedA0(base1), derivedB0(base1) 230 | case2[derivedA, derivedB]: base0, derivedA1(base1), derivedB1(base1) 231 | layer1[derivedA]: base0, derivedA1(base1), derivedB0(base0) 232 | layer2[base, derivedB]: base2, derivedA1(base2), derivedB2(base2) 233 | */ 234 | expect(clickButtonGetResults(increaseCase2Base)).toEqual([ 235 | // case 1 236 | '0', // base 237 | '0', // derivedA 238 | '0', // derivedB 239 | 240 | // case 2 241 | '1', // base 242 | '0', // derivedA 243 | '0', // derivedB 244 | 245 | // layer 1 246 | '1', // base 247 | '0', // derivedA 248 | '1', // derivedB 249 | 250 | // layer 2 251 | '0', // base 252 | '0', // derivedA 253 | '0', // derivedB 254 | ]) 255 | 256 | /* 257 | base, derivedA(base), derivedB(base) 258 | case1[base]: base1, derivedA0(base1), derivedB0(base1) 259 | case2[derivedA, derivedB]: base0, derivedA1(base1), derivedB1(base1) 260 | layer1[derivedA]: base0, derivedA1(base1), derivedB0(base0) 261 | layer2[base, derivedB]: base2, derivedA1(base2), derivedB2(base2) 262 | */ 263 | expect(clickButtonGetResults(increaseCase2DerivedA)).toEqual([ 264 | // case 1: case 1 265 | '0', // base actual: 0, 266 | '0', // derivedA actual: 0, 267 | '0', // derivedB actual: 0, 268 | 269 | // case 2 270 | '0', // base actual: 1, 271 | '1', // derivedA actual: 1, 272 | '1', // derivedB actual: 1, 273 | 274 | // layer 1 275 | '0', // base actual: 1, 276 | '0', // derivedA actual: 0, 277 | '0', // derivedB actual: 1, 278 | 279 | // layer 2 280 | '0', // base actual: 0, 281 | '0', // derivedA actual: 0, 282 | '0', // derivedB actual: 0 283 | ]) 284 | 285 | /* 286 | base, derivedA(base), derivedB(base) 287 | case1[base]: base1, derivedA0(base1), derivedB0(base1) 288 | case2[derivedA, derivedB]: base0, derivedA1(base1), derivedB1(base1) 289 | layer1[derivedA]: base0, derivedA1(base1), derivedB0(base0) 290 | layer2[base, derivedB]: base2, derivedA1(base2), derivedB2(base2) 291 | */ 292 | expect(clickButtonGetResults(increaseCase2DerivedB)).toEqual([ 293 | // case 1 294 | '0', // base 295 | '0', // derivedA 296 | '0', // derivedB 297 | 298 | // case 2 299 | '0', // base 300 | '1', // derivedA 301 | '1', // derivedB 302 | 303 | // layer 1 304 | '0', // base 305 | '0', // derivedA 306 | '0', // derivedB 307 | 308 | // layer 2 309 | '0', // base 310 | '0', // derivedA 311 | '0', // derivedB 312 | ]) 313 | 314 | /* 315 | base, derivedA(base), derivedB(base) 316 | case1[base]: base1, derivedA0(base1), derivedB0(base1) 317 | case2[derivedA, derivedB]: base0, derivedA1(base1), derivedB1(base1) 318 | layer1[derivedA]: base0, derivedA1(base1), derivedB0(base0) 319 | layer2[base, derivedB]: base2, derivedA1(base2), derivedB2(base2) 320 | */ 321 | expect(clickButtonGetResults(increaseLayer1Base)).toEqual([ 322 | // case 1 323 | '0', // base 324 | '0', // derivedA 325 | '0', // derivedB 326 | 327 | // case 2 328 | '1', // base 329 | '0', // derivedA 330 | '0', // derivedB 331 | 332 | // layer 1 333 | '1', // base 334 | '0', // derivedA 335 | '1', // derivedB 336 | 337 | // layer 2 338 | '0', // base 339 | '0', // derivedA 340 | '0', // derivedB 341 | ]) 342 | 343 | /* 344 | base, derivedA(base), derivedB(base) 345 | case1[base]: base1, derivedA0(base1), derivedB0(base1) 346 | case2[derivedA, derivedB]: base0, derivedA1(base1), derivedB1(base1) 347 | layer1[derivedA]: base0, derivedA1(base1), derivedB0(base0) 348 | layer2[base, derivedB]: base2, derivedA1(base2), derivedB2(base2) 349 | */ 350 | expect(clickButtonGetResults(increaseLayer1DerivedA)).toEqual([ 351 | // case 1 352 | '0', // base 353 | '0', // derivedA 354 | '0', // derivedB 355 | 356 | // case 2 357 | '0', // base 358 | '0', // derivedA 359 | '0', // derivedB 360 | 361 | // layer 1 362 | '0', // base 363 | '1', // derivedA 364 | '0', // derivedB 365 | 366 | // layer 2 367 | '0', // base 368 | '0', // derivedA 369 | '0', // derivedB 370 | ]) 371 | 372 | /* 373 | base, derivedA(base), derivedB(base) 374 | case1[base]: base1, derivedA0(base1), derivedB0(base1) 375 | case2[derivedA, derivedB]: base0, derivedA1(base1), derivedB1(base1) 376 | layer1[derivedA]: base0, derivedA1(base1), derivedB0(base0) 377 | layer2[base, derivedB]: base2, derivedA1(base2), derivedB2(base2) 378 | */ 379 | expect(clickButtonGetResults(increaseLayer1DerivedB)).toEqual([ 380 | // case 1 381 | '0', // base 382 | '0', // derivedA 383 | '0', // derivedB 384 | 385 | // case 2 386 | '1', // base 387 | '0', // derivedA 388 | '0', // derivedB 389 | 390 | // layer 1 391 | '1', // base 392 | '0', // derivedA 393 | '1', // derivedB 394 | 395 | // layer 2 396 | '0', // base 397 | '0', // derivedA 398 | '0', // derivedB 399 | ]) 400 | 401 | /* 402 | base, derivedA(base), derivedB(base) 403 | case1[base]: base1, derivedA0(base1), derivedB0(base1) 404 | case2[derivedA, derivedB]: base0, derivedA1(base1), derivedB1(base1) 405 | layer1[derivedA]: base0, derivedA1(base1), derivedB0(base0) 406 | layer2[base, derivedB]: base2, derivedA1(base2), derivedB2(base2) 407 | */ 408 | expect(clickButtonGetResults(increaseLayer2Base)).toEqual([ 409 | // case 1 410 | '0', // base 411 | '0', // derivedA 412 | '0', // derivedB 413 | 414 | // case 2 415 | '0', // base 416 | '0', // derivedA 417 | '0', // derivedB 418 | 419 | // layer 1 420 | '0', // base 421 | '0', // derivedA 422 | '0', // derivedB 423 | 424 | // layer 2 425 | '1', // base 426 | '1', // derivedA 427 | '1', // derivedB 428 | ]) 429 | 430 | /* 431 | base, derivedA(base), derivedB(base) 432 | case1[base]: base1, derivedA0(base1), derivedB0(base1) 433 | case2[derivedA, derivedB]: base0, derivedA1(base1), derivedB1(base1) 434 | layer1[derivedA]: base0, derivedA1(base1), derivedB0(base0) 435 | layer2[base, derivedB]: base2, derivedA1(base2), derivedB2(base2) 436 | */ 437 | expect(clickButtonGetResults(increaseLayer2DerivedA)).toEqual([ 438 | // case 1 439 | '0', // base 440 | '0', // derivedA 441 | '0', // derivedB 442 | 443 | // case 2 444 | '0', // base 445 | '0', // derivedA 446 | '0', // derivedB 447 | 448 | // layer 1 449 | '0', // base 450 | '0', // derivedA 451 | '0', // derivedB 452 | 453 | // layer 2 454 | '1', // base 455 | '1', // derivedA 456 | '1', // derivedB 457 | ]) 458 | 459 | /* 460 | base, derivedA(base), derivedB(base) 461 | case1[base]: base1, derivedA0(base1), derivedB0(base1) 462 | case2[derivedA, derivedB]: base0, derivedA1(base1), derivedB1(base1) 463 | layer1[derivedA]: base0, derivedA1(base1), derivedB0(base0) 464 | layer2[base, derivedB]: base2, derivedA1(base2), derivedB2(base2) 465 | */ 466 | expect(clickButtonGetResults(increaseLayer2DerivedB)).toEqual([ 467 | // case 1 468 | '0', // base 469 | '0', // derivedA 470 | '0', // derivedB 471 | 472 | // case 2 473 | '0', // base 474 | '0', // derivedA 475 | '0', // derivedB 476 | 477 | // layer 1 478 | '0', // base 479 | '0', // derivedA 480 | '0', // derivedB 481 | 482 | // layer 2 483 | '1', // base 484 | '1', // derivedA 485 | '1', // derivedB 486 | ]) 487 | }) 488 | }) 489 | -------------------------------------------------------------------------------- /src/ScopeProvider/scope.ts: -------------------------------------------------------------------------------- 1 | import type { Atom, WritableAtom } from 'jotai' 2 | import { atom as createAtom } from 'jotai' 3 | import { 4 | INTERNAL_buildStoreRev2 as buildStore, 5 | INTERNAL_getBuildingBlocksRev2 as getBuildingBlocks, 6 | } from 'jotai/vanilla/internals' 7 | import type { 8 | INTERNAL_AtomState as AtomState, 9 | INTERNAL_AtomStateMap as AtomStateMap, 10 | INTERNAL_BuildingBlocks as BuildingBlocks, 11 | INTERNAL_EnsureAtomState as EnsureAtomState, 12 | INTERNAL_Mounted as Mounted, 13 | INTERNAL_Store as Store, 14 | } from 'jotai/vanilla/internals' 15 | import { __DEV__ } from '../env' 16 | import type { 17 | AnyAtom, 18 | AnyAtomFamily, 19 | AnyWritableAtom, 20 | AtomPairMap, 21 | Scope, 22 | SetLike, 23 | StoreHookForAtoms, 24 | StoreHooks, 25 | WeakMapLike, 26 | } from '../types' 27 | import { isWritableAtom, toNameString } from '../utils' 28 | 29 | /** WeakMap to store the scope associated with each scoped store */ 30 | export const storeScopeMap = new WeakMap() 31 | 32 | const globalScopeKey: { name?: string } = {} 33 | if (__DEV__) { 34 | globalScopeKey.name = 'unscoped' 35 | globalScopeKey.toString = toNameString 36 | } 37 | 38 | type GlobalScopeKey = typeof globalScopeKey 39 | 40 | const { read: defaultRead, write: defaultWrite } = createAtom(null) 41 | 42 | export function getAtom(scope: Scope, atom: Atom, implicitScope?: Scope): [Atom, Scope?] { 43 | const [explicitMap, implicitMap, inheritedSource, , parentScope] = scope 44 | 45 | const explicitEntry = explicitMap.get(atom) 46 | if (explicitEntry) { 47 | return explicitEntry 48 | } 49 | 50 | if (implicitScope === scope) { 51 | // dependencies of explicitly scoped atoms are implicitly scoped 52 | // implicitly scoped atoms are only accessed by implicit and explicit scoped atoms 53 | let implicitEntry = implicitMap.get(atom) 54 | if (!implicitEntry) { 55 | implicitEntry = [cloneAtom(scope, atom, implicitScope), implicitScope] 56 | implicitMap.set(atom, implicitEntry) 57 | } 58 | return implicitEntry 59 | } 60 | 61 | // inherited atoms are copied so they can access scoped atoms 62 | // dependencies of inherited atoms first check if they are explicitly scoped 63 | // otherwise they use their original scope's atom 64 | const source = implicitScope ?? globalScopeKey 65 | let inheritedMap = inheritedSource.get(source) 66 | if (!inheritedMap) { 67 | inheritedMap = new WeakMap() as AtomPairMap 68 | inheritedSource.set(source, inheritedMap) 69 | } 70 | let inheritedEntry = inheritedMap.get(atom) 71 | if (!inheritedEntry) { 72 | const [ 73 | ancestorAtom, 74 | ancestorScope, // 75 | ] = parentScope ? getAtom(parentScope, atom, implicitScope) : [atom] 76 | const inheritedClone = atom.read === defaultRead ? ancestorAtom : cloneAtom(scope, atom, ancestorScope) 77 | inheritedEntry = [inheritedClone, ancestorScope] 78 | inheritedMap.set(atom, inheritedEntry) 79 | } 80 | return inheritedEntry 81 | } 82 | 83 | export function cleanup(scope: Scope): void { 84 | for (const cleanupFamilyListeners of scope[5]) { 85 | cleanupFamilyListeners() 86 | } 87 | } 88 | 89 | export function prepareWriteAtom( 90 | scope: Scope, 91 | atom: T, 92 | originalAtom: T, 93 | implicitScope: Scope | undefined, 94 | writeScope: Scope | undefined 95 | ): (() => void) | undefined { 96 | if ( 97 | originalAtom.read === defaultRead && 98 | isWritableAtom(originalAtom) && 99 | isWritableAtom(atom) && 100 | originalAtom.write !== defaultWrite && 101 | scope !== implicitScope 102 | ) { 103 | // atom is writable with init and holds a value 104 | // we need to preserve the value, so we don't want to copy the atom 105 | // instead, we need to override write until the write is finished 106 | const { write } = originalAtom 107 | atom.write = createScopedWrite( 108 | scope, 109 | originalAtom.write.bind(originalAtom) as (typeof originalAtom)['write'], 110 | implicitScope, 111 | writeScope 112 | ) 113 | return () => { 114 | atom.write = write 115 | } 116 | } 117 | return undefined 118 | } 119 | 120 | function createScopedRead>(scope: Scope, read: T['read'], implicitScope?: Scope): T['read'] { 121 | return function scopedRead(get, opts) { 122 | return read(function scopedGet(a) { 123 | const [scopedAtom] = getAtom(scope, a, implicitScope) 124 | return get(scopedAtom) 125 | }, opts) 126 | } 127 | } 128 | 129 | function createScopedWrite( 130 | scope: Scope, 131 | write: T['write'], 132 | implicitScope?: Scope, 133 | writeScope = implicitScope 134 | ): T['write'] { 135 | return function scopedWrite(get, set, ...args) { 136 | return write( 137 | function scopedGet(a) { 138 | const [scopedAtom] = getAtom(scope, a, implicitScope) 139 | return get(scopedAtom) 140 | }, 141 | function scopedSet(a, ...v) { 142 | const [scopedAtom] = getAtom(scope, a, implicitScope) 143 | const restore = prepareWriteAtom(scope, scopedAtom, a, implicitScope, writeScope) 144 | try { 145 | return set(scopedAtom as typeof a, ...v) 146 | } finally { 147 | restore?.() 148 | } 149 | }, 150 | ...args 151 | ) 152 | } 153 | } 154 | 155 | function cloneAtom(scope: Scope, originalAtom: Atom, implicitScope: Scope | undefined): Atom { 156 | // avoid reading `init` to preserve lazy initialization 157 | const propDesc = Object.getOwnPropertyDescriptors(originalAtom) 158 | Object.keys(propDesc) 159 | .filter((k) => ['read', 'write', 'debugLabel'].includes(k)) 160 | .forEach((k) => (propDesc[k].configurable = true)) 161 | const atomProto = Object.getPrototypeOf(originalAtom) 162 | const scopedAtom: Atom = Object.create(atomProto, propDesc) 163 | 164 | if (scopedAtom.read !== defaultRead) { 165 | scopedAtom.read = createScopedRead(scope, originalAtom.read.bind(originalAtom), implicitScope) 166 | } 167 | 168 | if (isWritableAtom(scopedAtom) && isWritableAtom(originalAtom) && scopedAtom.write !== defaultWrite) { 169 | scopedAtom.write = createScopedWrite(scope, originalAtom.write.bind(originalAtom), implicitScope) 170 | } 171 | if (__DEV__) { 172 | Object.defineProperty(scopedAtom, 'debugLabel', { 173 | get() { 174 | return `${originalAtom.debugLabel}@${scope.name}` 175 | }, 176 | configurable: true, 177 | enumerable: true, 178 | }) 179 | } 180 | 181 | return scopedAtom 182 | } 183 | 184 | type CreateScopeProps = { 185 | atoms?: Iterable 186 | atomFamilies?: Iterable 187 | parentStore: Store 188 | name?: string 189 | } 190 | 191 | export function createScope({ atoms = [], atomFamilies = [], parentStore, name: scopeName }: CreateScopeProps): Store { 192 | const atomsSet = new WeakSet(atoms) 193 | const parentScope = storeScopeMap.get(parentStore) 194 | const baseStore = parentScope?.[3] ?? parentStore 195 | 196 | // Create the scope as an array with data fields 197 | const scope: Scope = [ 198 | new WeakMap(), 199 | new WeakMap() as AtomPairMap, 200 | new WeakMap(), 201 | baseStore, 202 | parentScope, 203 | new Set<() => void>(), 204 | undefined!, // Store - will be set after creating patched store 205 | ] as Scope 206 | const explicitMap = scope[0] 207 | const cleanupFamiliesSet = scope[5] 208 | 209 | const scopedStore = createPatchedStore(scope) 210 | scope[6] = scopedStore 211 | Object.assign(scopedStore, { name: scopeName }) 212 | storeScopeMap.set(scopedStore, scope) 213 | 214 | if (scopeName && __DEV__) { 215 | scope.name = scopeName 216 | scope.toString = toNameString 217 | } 218 | 219 | // populate explicitly scoped atoms 220 | for (const atom of new Set(atoms)) { 221 | explicitMap.set(atom, [cloneAtom(scope, atom, scope), scope]) 222 | } 223 | 224 | for (const atomFamily of new Set(atomFamilies)) { 225 | for (const param of atomFamily.getParams()) { 226 | const atom = atomFamily(param) 227 | if (!explicitMap.has(atom)) { 228 | explicitMap.set(atom, [cloneAtom(scope, atom, scope), scope]) 229 | } 230 | } 231 | const cleanupFamily = atomFamily.unstable_listen(({ type, atom }) => { 232 | if (type === 'CREATE' && !explicitMap.has(atom)) { 233 | explicitMap.set(atom, [cloneAtom(scope, atom, scope), scope]) 234 | } else if (type === 'REMOVE' && !atomsSet.has(atom)) { 235 | explicitMap.delete(atom) 236 | } 237 | }) 238 | cleanupFamiliesSet.add(cleanupFamily) 239 | } 240 | 241 | return scopedStore 242 | } 243 | 244 | /** @returns a patched store that intercepts atom access to apply the scope */ 245 | function createPatchedStore(scope: Scope): Store { 246 | const baseStore = scope[3] 247 | const storeState: BuildingBlocks = [...getBuildingBlocks(baseStore)] 248 | const storeGet = storeState[21] 249 | const storeSet = storeState[22] 250 | const storeSub = storeState[23] 251 | const atomOnInit = storeState[9] 252 | const alreadyPatched: StoreHooks = {} 253 | 254 | storeState[9] = (_, atom) => atomOnInit(scopedStore, atom) 255 | storeState[21] = patchStoreFn(storeGet) 256 | storeState[22] = scopedSet 257 | storeState[23] = patchStoreFn(storeSub) 258 | storeState[24] = ([...buildingBlocks]) => { 259 | const patchedBuildingBlocks: BuildingBlocks = [ 260 | patchWeakMap(buildingBlocks[0], patchGetAtomState), // atomStateMap 261 | patchWeakMap(buildingBlocks[1], patchGetMounted), // mountedMap 262 | patchWeakMap(buildingBlocks[2]), // invalidatedAtoms 263 | patchSet(buildingBlocks[3]), // changedAtoms 264 | buildingBlocks[4], // mountCallbacks 265 | buildingBlocks[5], // unmountCallbacks 266 | patchStoreHooks(buildingBlocks[6]), // storeHooks 267 | patchStoreFn(buildingBlocks[7]), // atomRead 268 | patchStoreFn(buildingBlocks[8]), // atomWrite 269 | buildingBlocks[9], // atomOnInit 270 | patchStoreFn(buildingBlocks[10]), // atomOnMount 271 | patchStoreFn( 272 | buildingBlocks[11], // ensureAtomState 273 | (fn) => patchEnsureAtomState(patchedBuildingBlocks[0], fn) 274 | ), 275 | buildingBlocks[12], // flushCallbacks 276 | buildingBlocks[13], // recomputeInvalidatedAtoms 277 | patchStoreFn(buildingBlocks[14]), // readAtomState 278 | patchStoreFn(buildingBlocks[15]), // invalidateDependents 279 | patchStoreFn(buildingBlocks[16]), // writeAtomState 280 | patchStoreFn(buildingBlocks[17]), // mountDependencies 281 | patchStoreFn(buildingBlocks[18]), // mountAtom 282 | patchStoreFn(buildingBlocks[19]), // unmountAtom 283 | patchStoreFn(buildingBlocks[20]), // setAtomStateValueOrPromise 284 | patchStoreFn(buildingBlocks[21]), // getAtom 285 | patchStoreFn(buildingBlocks[22]), // setAtom 286 | patchStoreFn(buildingBlocks[23]), // subAtom 287 | () => buildingBlocks, // enhanceBuildingBlocks (raw) 288 | ] 289 | return patchedBuildingBlocks 290 | } 291 | const scopedStore = buildStore(...storeState) 292 | return scopedStore 293 | 294 | // --------------------------------------------------------------------------------- 295 | 296 | function patchGetAtomState(fn: T) { 297 | const patchedASM = new WeakMap() 298 | return function patchedGetAtomState(atom) { 299 | let patchedAtomState = patchedASM.get(atom) 300 | if (patchedAtomState) { 301 | return patchedAtomState 302 | } 303 | const atomState = fn(atom) 304 | if (!atomState) { 305 | return undefined 306 | } 307 | patchedAtomState = { 308 | ...atomState, 309 | d: patchWeakMap(atomState.d, function patchGetDependency(fn) { 310 | return (k) => fn(getAtom(scope, k)[0]) 311 | }), 312 | p: patchSet(atomState.p), 313 | get n() { 314 | return atomState.n 315 | }, 316 | set n(v) { 317 | atomState.n = v 318 | }, 319 | get v() { 320 | return atomState.v 321 | }, 322 | set v(v) { 323 | atomState.v = v 324 | }, 325 | get e() { 326 | return atomState.e 327 | }, 328 | set e(v) { 329 | atomState.e = v 330 | }, 331 | } as AtomState 332 | patchedASM.set(atom, patchedAtomState) 333 | return patchedAtomState 334 | } as T 335 | } 336 | 337 | function patchGetMounted(fn: T) { 338 | const patchedMM = new WeakMap() 339 | return function patchedGetMounted(atom: AnyAtom) { 340 | let patchedMounted = patchedMM.get(atom) 341 | if (patchedMounted) { 342 | return patchedMounted 343 | } 344 | const mounted = fn(atom) 345 | if (!mounted) { 346 | return undefined 347 | } 348 | patchedMounted = { 349 | ...mounted, 350 | d: patchSet(mounted.d), 351 | t: patchSet(mounted.t), 352 | get u() { 353 | return mounted.u 354 | }, 355 | set u(v) { 356 | mounted.u = v 357 | }, 358 | } as Mounted 359 | patchedMM.set(atom, patchedMounted) 360 | return patchedMounted 361 | } as T 362 | } 363 | 364 | function patchEnsureAtomState(patchedASM: AtomStateMap, fn: EnsureAtomState) { 365 | return function patchedEnsureAtomState(store, atom) { 366 | const patchedAtomState = patchedASM.get(atom) 367 | if (patchedAtomState) { 368 | return patchedAtomState 369 | } 370 | patchedASM.set(atom, fn(store, atom)) 371 | return patchedASM.get(atom) 372 | } as EnsureAtomState 373 | } 374 | 375 | function scopedSet( 376 | store: Store, 377 | atom: WritableAtom, 378 | ...args: Args 379 | ): Result { 380 | const [scopedAtom, implicitScope] = getAtom(scope, atom) 381 | const restore = prepareWriteAtom(scope, scopedAtom, atom, implicitScope, scope) 382 | try { 383 | return storeSet(store, scopedAtom as typeof atom, ...args) 384 | } finally { 385 | restore?.() 386 | } 387 | } 388 | 389 | function patchAtomFn any>(fn: T, patch?: (fn: T) => T) { 390 | return function scopedAtomFn(atom, ...args) { 391 | const [scopedAtom] = getAtom(scope, atom) 392 | const f = patch ? patch(fn) : fn 393 | return f(scopedAtom, ...args) 394 | } as T 395 | } 396 | 397 | function patchStoreFn any>(fn: T, patch?: (fn: T) => T) { 398 | return function scopedStoreFn(store, atom, ...args) { 399 | const [scopedAtom] = getAtom(scope, atom) 400 | const f = patch ? patch(fn) : fn 401 | return f(store, scopedAtom, ...args) 402 | } as T 403 | } 404 | 405 | function patchWeakMap>(wm: T, patch?: (fn: T['get']) => T['get']): T { 406 | const patchedWm: WeakMapLike = { 407 | get: patchAtomFn(wm.get.bind(wm), patch), 408 | set: patchAtomFn(wm.set.bind(wm)), 409 | has: patchAtomFn(wm.has.bind(wm)), 410 | delete: patchAtomFn(wm.delete.bind(wm)), 411 | } 412 | return patchedWm as T 413 | } 414 | 415 | function patchSet(s: SetLike) { 416 | return { 417 | get size() { 418 | return s.size 419 | }, 420 | add: patchAtomFn(s.add.bind(s)), 421 | has: patchAtomFn(s.has.bind(s)), 422 | delete: patchAtomFn(s.delete.bind(s)), 423 | clear: s.clear.bind(s), 424 | forEach: (cb) => s.forEach(patchAtomFn(cb)), 425 | *[Symbol.iterator](): IterableIterator { 426 | for (const atom of s) yield getAtom(scope, atom)[0] 427 | }, 428 | } satisfies SetLike 429 | } 430 | 431 | function patchStoreHook(fn: StoreHookForAtoms | undefined) { 432 | if (!fn) { 433 | return undefined 434 | } 435 | const storeHook = patchAtomFn(fn) 436 | storeHook.add = function patchAdd(atom, callback) { 437 | if (atom === undefined) { 438 | return fn.add(undefined, callback) 439 | } 440 | const [scopedAtom] = getAtom(scope, atom) 441 | return fn.add(scopedAtom, callback as () => void) 442 | } 443 | return storeHook 444 | } 445 | 446 | function patchStoreHooks(storeHooks: StoreHooks) { 447 | const patchedStoreHooks: StoreHooks = { 448 | get f() { 449 | return storeHooks.f 450 | }, 451 | set f(v) { 452 | storeHooks.f = v 453 | }, 454 | } 455 | Object.defineProperties( 456 | patchedStoreHooks, 457 | Object.fromEntries( 458 | (['r', 'c', 'm', 'u'] as const).map((hook) => [ 459 | hook, 460 | { 461 | get() { 462 | return (alreadyPatched[hook] ??= patchStoreHook(storeHooks[hook])) 463 | }, 464 | set(value: StoreHookForAtoms | undefined) { 465 | delete alreadyPatched[hook] 466 | storeHooks[hook] = value 467 | }, 468 | configurable: true, 469 | enumerable: true, 470 | }, 471 | ]) 472 | ) 473 | ) 474 | return Object.assign(patchedStoreHooks, storeHooks) 475 | } 476 | } 477 | -------------------------------------------------------------------------------- /tests/ScopeProvider/01_basic_spec.test.tsx: -------------------------------------------------------------------------------- 1 | import { render } from '@testing-library/react' 2 | import dedent from 'dedent' 3 | import { atom, createStore, useAtom, useAtomValue, useSetAtom } from 'jotai' 4 | import { INTERNAL_Store as Store } from 'jotai/vanilla/internals' 5 | import { atomWithReducer } from 'jotai/vanilla/utils' 6 | import { describe, expect, test } from 'vitest' 7 | import { ScopeProvider } from 'jotai-scope' 8 | import { createScope } from '../../src/ScopeProvider/scope' 9 | import { clickButton, createDebugStore, cross, getTextContents, printAtomState, storeGet } from '../utils' 10 | 11 | describe('Counter', () => { 12 | /* 13 | base 14 | S0[]: base0 15 | S1[]: base0 16 | */ 17 | test('01. ScopeProvider does not provide isolation for unscoped primitive atoms', () => { 18 | const baseAtom = atom(0) 19 | baseAtom.debugLabel = 'base' 20 | function Counter({ level }: { level: string }) { 21 | const [base, increaseBase] = useAtom(baseAtom) 22 | return ( 23 |
24 | base:{base} 25 | 28 |
29 | ) 30 | } 31 | 32 | function App() { 33 | return ( 34 |
35 |

Unscoped

36 | 37 |

Scoped Provider

38 | 39 | 40 | 41 |
42 | ) 43 | } 44 | const { container } = render() 45 | const increaseUnscopedBase = '.level0.setBase' 46 | const increaseScopedBase = '.level1.setBase' 47 | const atomValueSelectors = ['.level0.base', '.level1.base'] 48 | 49 | expect(getTextContents(container, atomValueSelectors)).toEqual([ 50 | '0', // level0 base 51 | '0', // level1 base 52 | ]) 53 | 54 | clickButton(container, increaseUnscopedBase) 55 | expect(getTextContents(container, atomValueSelectors)).toEqual([ 56 | '1', // level0 base 57 | '1', // level1 base 58 | ]) 59 | 60 | clickButton(container, increaseScopedBase) 61 | expect(getTextContents(container, atomValueSelectors)).toEqual([ 62 | '2', // level0 base 63 | '2', // level1 base 64 | ]) 65 | }) 66 | 67 | /* 68 | S0[]: a0 b0(a0) 69 | S1[]: a0 b0(a0) 70 | */ 71 | test('02. unscoped derived atoms are unaffected in ScopeProvider', () => { 72 | const a = atom(0) 73 | a.debugLabel = 'a' 74 | const b = atom( 75 | (get) => get(a), 76 | (_get, set) => set(a, (v) => v + 1) 77 | ) 78 | b.debugLabel = 'b' 79 | 80 | function createScopes() { 81 | const s0 = createStore() 82 | const s1 = createScope({ atoms: [], parentStore: s0, name: 'S1' }) 83 | return [s0, s1] as const 84 | } 85 | 86 | { 87 | const s = createScopes() 88 | s[0].set(a, (v) => v + 1) 89 | expect(s.map((s) => s.get(b)).join('')).toBe('11') 90 | } 91 | 92 | { 93 | const s = createScopes() 94 | s[1].set(a, (v) => v + 1) 95 | expect(s.map((s) => s.get(b)).join('')).toBe('11') 96 | } 97 | }) 98 | 99 | /* 100 | base 101 | S0[base]: base0 102 | S1[base]: base1 103 | */ 104 | test('03. ScopeProvider provides isolation for scoped primitive atoms', () => { 105 | const baseAtom = atom(0) 106 | baseAtom.debugLabel = 'base' 107 | function Counter({ level }: { level: string }) { 108 | const [base, increaseBase] = useAtom(baseAtom) 109 | return ( 110 |
111 | base:{base} 112 | 115 |
116 | ) 117 | } 118 | 119 | function App() { 120 | return ( 121 |
122 |

Unscoped

123 | 124 |

Scoped Provider

125 | 126 | 127 | 128 |
129 | ) 130 | } 131 | const { container } = render() 132 | const increaseUnscopedBase = '.level0.setBase' 133 | const increaseScopedBase = '.level1.setBase' 134 | const atomValueSelectors = ['.level0.base', '.level1.base'] 135 | 136 | expect(getTextContents(container, atomValueSelectors)).toEqual([ 137 | '0', // level0 base 138 | '0', // level1 base 139 | ]) 140 | 141 | clickButton(container, increaseUnscopedBase) 142 | expect(getTextContents(container, atomValueSelectors)).toEqual([ 143 | '1', // level0 base 144 | '0', // level1 base 145 | ]) 146 | 147 | clickButton(container, increaseScopedBase) 148 | expect(getTextContents(container, atomValueSelectors)).toEqual([ 149 | '1', // level0 base 150 | '1', // level1 base 151 | ]) 152 | }) 153 | 154 | /* 155 | S0[a]: b0(a0) 156 | S1[a]: b0(a1) 157 | */ 158 | test('04. unscoped derived can read and write to scoped primitive atoms', () => { 159 | const a = atom(0) 160 | a.debugLabel = 'a' 161 | const b = atom( 162 | (get) => get(a), 163 | (_get, set) => set(a, (v) => v + 1) 164 | ) 165 | b.debugLabel = 'b' 166 | 167 | function scopes() { 168 | const s0 = createStore() 169 | const s1 = createScope({ atoms: [a], parentStore: s0, name: 'S1' }) 170 | return [s0, s1] as const 171 | } 172 | function results(s: readonly [Store, Store]) { 173 | return cross(s, [a, b], storeGet).flat().join('') 174 | } 175 | 176 | { 177 | const s = scopes() 178 | s[0].set(b) 179 | expect(results(s)).toBe('1100') // Received '1101' 180 | } 181 | { 182 | const s = scopes() 183 | s[1].set(b) 184 | expect(results(s)).toBe('0011') // Received '0010' 185 | } 186 | }) 187 | 188 | /* 189 | base, notScoped, derived(base + notScoped) 190 | S0[base]: derived0(base0 + notScoped0) 191 | S1[base]: derived0(base1 + notScoped0) 192 | */ 193 | test('05. unscoped derived can read both scoped and unscoped atoms', () => { 194 | const baseAtom = atomWithReducer(0, (v) => v + 1) 195 | baseAtom.debugLabel = 'base' 196 | const notScopedAtom = atomWithReducer(0, (v) => v + 1) 197 | notScopedAtom.debugLabel = 'notScoped' 198 | const derivedAtom = atom((get) => ({ 199 | base: get(baseAtom), 200 | notScoped: get(notScopedAtom), 201 | })) 202 | derivedAtom.debugLabel = 'derived' 203 | 204 | function Counter({ level }: { level: string }) { 205 | const increaseBase = useSetAtom(baseAtom) 206 | const derived = useAtomValue(derivedAtom) 207 | return ( 208 |
209 | base:{derived.base} 210 | not scoped: 211 | {derived.notScoped} 212 | 215 |
216 | ) 217 | } 218 | 219 | function IncreaseUnscoped() { 220 | const increaseNotScoped = useSetAtom(notScopedAtom) 221 | return ( 222 | 225 | ) 226 | } 227 | 228 | function App() { 229 | return ( 230 |
231 |

Unscoped

232 | 233 | 234 |

Scoped Provider

235 | 236 | 237 | 238 |
239 | ) 240 | } 241 | const { container } = render() 242 | const increaseUnscopedBase = '.level0.setBase' 243 | const increaseScopedBase = '.level1.setBase' 244 | const increaseNotScoped = '.increaseNotScoped' 245 | const atomValueSelectors = ['.level0.base', '.level1.base', '.level1.notScoped'] 246 | 247 | expect(getTextContents(container, atomValueSelectors)).toEqual([ 248 | '0', // level0 base 249 | '0', // level1 base 250 | '0', // level1 notScoped 251 | ]) 252 | 253 | clickButton(container, increaseUnscopedBase) 254 | expect(getTextContents(container, atomValueSelectors)).toEqual([ 255 | '1', // level0 base 256 | '0', // level1 base 257 | '0', // level1 notScoped 258 | ]) 259 | 260 | clickButton(container, increaseScopedBase) 261 | expect(getTextContents(container, atomValueSelectors)).toEqual([ 262 | '1', // level0 base 263 | '1', // level1 base 264 | '0', // level1 notScoped 265 | ]) 266 | 267 | clickButton(container, increaseNotScoped) 268 | expect(getTextContents(container, atomValueSelectors)).toEqual([ 269 | '1', // level0 base 270 | '1', // level1 base 271 | '1', // level1 notScoped 272 | ]) 273 | }) 274 | 275 | /* 276 | S0[ ]: a0, b0(a0) 277 | S1[b]: a0, b1(a1) 278 | */ 279 | test('06. dependencies of scoped derived are implicitly scoped', () => { 280 | const a = atom(0) 281 | a.debugLabel = 'a' 282 | 283 | const b = atom( 284 | (get) => get(a), 285 | (_get, set) => set(a, (v) => v + 1) 286 | ) 287 | b.debugLabel = 'b' 288 | 289 | function getScopes() { 290 | const s0 = createStore() 291 | const s1 = createScope({ atoms: [b], parentStore: s0, name: 'S1' }) 292 | return [s0, s1] as const 293 | } 294 | 295 | { 296 | const s = getScopes() 297 | s[0].set(a, (v) => v + 1) 298 | expect(s.map((s) => s.get(b)).join('')).toBe('10') // Received '11' <=========== 299 | } 300 | { 301 | const s = getScopes() 302 | s[1].set(a, (v) => v + 1) 303 | expect(s.map((s) => s.get(b)).join('')).toBe('10') // Received '11' 304 | } 305 | { 306 | const s = getScopes() 307 | s[1].set(b) 308 | expect(s.map((s) => s.get(b)).join('')).toBe('01') // Received '00' 309 | } 310 | }) 311 | 312 | /* 313 | S0[b,c]: a0, b0(a0), c0(a0) 314 | S1[b,c]: a0, b1(a1), c1(a1) 315 | */ 316 | test('07. scoped derived atoms can share implicitly scoped dependencies', () => { 317 | const a = atom(0) 318 | a.debugLabel = 'a' 319 | const b = atom( 320 | (get) => get(a), 321 | (_get, set) => set(a, (v) => v + 1) 322 | ) 323 | b.debugLabel = 'b' 324 | const c = atom( 325 | (get) => get(a), 326 | (_get, set) => set(a, (v) => v + 1) 327 | ) 328 | c.debugLabel = 'c' 329 | 330 | function getScopes() { 331 | const s0 = createStore() 332 | const s1 = createScope({ atoms: [b, c], parentStore: s0, name: 'S1' }) 333 | s0.sub(b, () => {}) 334 | return [s0, s1] as const 335 | } 336 | { 337 | const s = getScopes() 338 | s[0].set(a, (v) => v + 1) 339 | expect([s[0].get(b), s[1].get(b), s[1].get(c)].join('')).toBe('100') 340 | } 341 | { 342 | const s = getScopes() 343 | s[1].set(a, (v) => v + 1) 344 | expect([s[0].get(b), s[1].get(b), s[1].get(c)].join('')).toBe('100') 345 | } 346 | { 347 | const s = getScopes() 348 | s[1].set(b) 349 | expect([s[0].get(b), s[1].get(b), s[1].get(c)].join('')).toBe('011') 350 | } 351 | { 352 | const s = getScopes() 353 | s[1].set(c) 354 | expect([s[0].get(b), s[1].get(b), s[1].get(c)].join('')).toBe('011') 355 | } 356 | }) 357 | 358 | /* 359 | base, derivedA(base), derivedB(base) 360 | S0[base]: base0 361 | S1[base]: base1 362 | S2[base]: base2 363 | S3[base]: base3 364 | */ 365 | test('08. nested scopes provide isolation for primitive atoms at every level', () => { 366 | const baseAtom = atomWithReducer(0, (v) => v + 1) 367 | 368 | function Counter({ level }: { level: string }) { 369 | const [base, increaseBase] = useAtom(baseAtom) 370 | return ( 371 |
372 | base:{base} 373 | 376 |
377 | ) 378 | } 379 | 380 | function App() { 381 | return ( 382 |
383 |

Unscoped

384 | 385 |

Scoped Provider

386 | 387 | 388 | 389 | 390 | 391 | 392 |
393 | ) 394 | } 395 | const { container } = render() 396 | const increaseUnscopedBase = '.level0.setBase' 397 | const increaseScopedBase = '.level1.setBase' 398 | const increaseDoubleScopedBase = '.level2.setBase' 399 | const atomValueSelectors = ['.level0.base', '.level1.base', '.level2.base'] 400 | 401 | expect(getTextContents(container, atomValueSelectors)).toEqual(['0', '0', '0']) 402 | 403 | clickButton(container, increaseUnscopedBase) 404 | expect(getTextContents(container, atomValueSelectors)).toEqual(['1', '0', '0']) 405 | 406 | clickButton(container, increaseScopedBase) 407 | expect(getTextContents(container, atomValueSelectors)).toEqual(['1', '1', '0']) 408 | 409 | clickButton(container, increaseDoubleScopedBase) 410 | expect(getTextContents(container, atomValueSelectors)).toEqual(['1', '1', '1']) 411 | }) 412 | 413 | /* 414 | S0[]: b0, c0, d0(b0 + c0) 415 | S1[b]: b1, c0, d0(b1 + c0) 416 | */ 417 | test('09. implicitly scoped atoms are scoped', () => { 418 | const b = atom(0) 419 | b.debugLabel = 'b' 420 | const c = atom(0) 421 | c.debugLabel = 'c' 422 | const d = atom( 423 | (get) => '' + get(b) + get(c), 424 | (_, set) => [set(b, (v) => v + 1), set(c, (v) => v + 1)] 425 | ) 426 | d.debugLabel = 'd' 427 | function getScopes() { 428 | const s0 = createDebugStore() 429 | const s1 = createScope({ 430 | atoms: [b], 431 | parentStore: s0, 432 | name: 'S1', 433 | }) 434 | const s = [s0, s1] as const 435 | cross(s, [b, c, d], (sx, ax) => sx.sub(ax as any, () => {})) 436 | return s 437 | } 438 | 439 | const s = getScopes() 440 | /* 441 | S0[]: b0, c0, d0(b0 + c0) 442 | S1[b]: b1, c0, d0(b1 + c0) 443 | */ 444 | expect(printAtomState(s[0])).toBe(dedent` 445 | b: v=0 446 | c: v=0 447 | d: v=00 448 | b: v=0 449 | c: v=0 450 | b@S1: v=0 451 | d@S1: v=00 452 | b@S1: v=0 453 | c: v=0 454 | -------------------- 455 | `) 456 | s[0].set(d) 457 | /* 458 | 1. set d 459 | 2. set b to 1 460 | 3. set c to 1 461 | 4. changedAtoms: [b, c, d] 462 | 5. invalidatedAtoms: [d, d@S1] 463 | 6. changedAtoms: [b, c, d] 464 | */ 465 | expect(printAtomState(s[0])).toBe(dedent` 466 | b: v=1 467 | c: v=1 468 | d: v=11 469 | b: v=1 470 | c: v=1 471 | b@S1: v=0 472 | d@S1: v=01 473 | b@S1: v=0 474 | c: v=1 475 | -------------------- 476 | `) 477 | }) 478 | 479 | /* 480 | baseA, baseB, baseC, derived(baseA + baseB + baseC) 481 | S0[ ]: derived(baseA0 + baseB0 + baseC0) 482 | S1[baseB]: derived(baseA0 + baseB1 + baseC0) 483 | S2[baseC]: derived(baseA0 + baseB1 + baseC2) 484 | */ 485 | test('10. unscoped derived atoms in nested scoped can read and write to scoped primitive atoms at every level (vanilla)', () => { 486 | const a = atomWithReducer(0, (v) => v + 1) 487 | const b = atomWithReducer(0, (v) => v + 1) 488 | const c = atomWithReducer(0, (v) => v + 1) 489 | const d = atom( 490 | (get) => [get(a), get(b), get(c)], 491 | (_, set) => [set(a), set(b), set(c)] 492 | ) 493 | function when(fn?: (s: readonly [Store, Store, Store]) => void) { 494 | const s0 = createStore() 495 | const s1 = createScope({ 496 | atoms: [b], 497 | parentStore: s0, 498 | name: 'S1', 499 | }) 500 | const s2 = createScope({ 501 | atoms: [c], 502 | parentStore: s1, 503 | name: 'S2', 504 | }) 505 | const s = [s0, s1, s2] as const 506 | s0.sub(a, () => {}) 507 | s0.sub(d, () => {}) 508 | s1.sub(b, () => {}) 509 | s1.sub(d, () => {}) 510 | s2.sub(c, () => {}) 511 | s2.sub(d, () => {}) 512 | fn?.(s) 513 | return s.map((sx) => [sx.get(a), sx.get(b), sx.get(c), ...sx.get(d)].join('')).join('|') 514 | } 515 | expect(when((s) => s[0].set(a))).toBe('100100|100100|100100') 516 | expect(when((s) => s[1].set(b))).toBe('000000|010010|010010') 517 | expect(when((s) => s[2].set(c))).toBe('000000|000000|001001') 518 | expect(when((s) => s[0].set(d))).toBe('111111|101101|100100') 519 | expect(when((s) => s[1].set(d))).toBe('101101|111111|110110') 520 | expect(when((s) => s[2].set(d))).toBe('100100|110110|111111') 521 | }) 522 | 523 | /* 524 | baseA, baseB, derived(baseA + baseB) 525 | S1[baseB, derived]: derived1(baseA1 + baseB1) 526 | S2[baseB]: derived1(baseA1 + baseB2) 527 | */ 528 | test('11. inherited scoped derived atoms can read and write to scoped primitive atoms at every nested level', () => { 529 | const baseAAtom = atomWithReducer(0, (v) => v + 1) 530 | baseAAtom.debugLabel = 'baseA' 531 | 532 | const baseBAtom = atomWithReducer(0, (v) => v + 1) 533 | baseBAtom.debugLabel = 'baseB' 534 | 535 | const derivedAtom = atom( 536 | (get) => ({ 537 | baseA: get(baseAAtom), 538 | baseB: get(baseBAtom), 539 | }), 540 | (_get, set) => { 541 | set(baseAAtom) 542 | set(baseBAtom) 543 | } 544 | ) 545 | derivedAtom.debugLabel = 'derived' 546 | 547 | function Counter({ level }: { level: string }) { 548 | const [{ baseA, baseB }, increaseAll] = useAtom(derivedAtom) 549 | return ( 550 |
551 | baseA:{baseA} 552 | baseB:{baseB} 553 | 556 |
557 | ) 558 | } 559 | 560 | function App() { 561 | return ( 562 |
563 |

Unscoped

564 |

Scoped Provider

565 | 566 | 567 | 568 | 569 | 570 | 571 |
572 | ) 573 | } 574 | const { container } = render() 575 | 576 | const increaseLevel1All = '.level1.increaseAll' 577 | const increaseLevel2All = '.level2.increaseAll' 578 | const atomValueSelectors = ['.level1.baseA', '.level1.baseB', '.level2.baseA', '.level2.baseB'] 579 | 580 | /* 581 | baseA, baseB, derived(baseA + baseB) 582 | S1[baseB, derived]: derived1(baseA1 + baseB1) 583 | S2[baseB]: derived1(baseA1 + baseB2) 584 | */ 585 | expect(getTextContents(container, atomValueSelectors)).toEqual([ 586 | '0', // level1 baseA1 587 | '0', // level1 baseB1 588 | '0', // level2 baseA1 589 | '0', // level2 baseB2 590 | ]) 591 | 592 | /* 593 | baseA, baseB, derived(baseA + baseB) 594 | S1[baseB, derived]: derived1(baseA1 + baseB1) 595 | S2[baseB]: derived1(baseA1 + baseB2) 596 | */ 597 | clickButton(container, increaseLevel1All) 598 | expect(getTextContents(container, atomValueSelectors)).toEqual([ 599 | '1', // level1 baseA1 600 | '1', // level1 baseB1 601 | '1', // level2 baseA1 602 | '0', // level2 baseB2 603 | ]) 604 | 605 | /* 606 | baseA, baseB, derived(baseA + baseB) 607 | S1[baseB, derived]: derived1(baseA1 + baseB1) 608 | S2[baseB]: derived1(baseA1 + baseB2) 609 | */ 610 | clickButton(container, increaseLevel2All) 611 | expect(getTextContents(container, atomValueSelectors)).toEqual([ 612 | '2', // level1 baseA1 613 | '1', // level1 baseB1 614 | '2', // level2 baseA1 615 | '1', // level2 baseB2 616 | ]) 617 | }) 618 | }) 619 | --------------------------------------------------------------------------------