From 096076af41ec9e6d0c6d34f096b86e77a6dbd998 Mon Sep 17 00:00:00 2001 From: David Maskasky Date: Thu, 16 May 2024 19:05:48 -0700 Subject: [PATCH 01/12] move some files around --- __tests__/{ => ScopeProvider}/02_removeScope.tsx | 4 ++-- __tests__/{ => ScopeProvider}/03_nested.tsx | 4 ++-- __tests__/{ => ScopeProvider}/04_derived.tsx | 4 ++-- __tests__/{ => createIsolation}/01_basic_spec.tsx | 0 src/{ => ScopeProvider}/ScopeProvider.tsx | 0 src/ScopeProvider/index.ts | 1 + 6 files changed, 7 insertions(+), 6 deletions(-) rename __tests__/{ => ScopeProvider}/02_removeScope.tsx (97%) rename __tests__/{ => ScopeProvider}/03_nested.tsx (98%) rename __tests__/{ => ScopeProvider}/04_derived.tsx (98%) rename __tests__/{ => createIsolation}/01_basic_spec.tsx (100%) rename src/{ => ScopeProvider}/ScopeProvider.tsx (100%) create mode 100644 src/ScopeProvider/index.ts diff --git a/__tests__/02_removeScope.tsx b/__tests__/ScopeProvider/02_removeScope.tsx similarity index 97% rename from __tests__/02_removeScope.tsx rename to __tests__/ScopeProvider/02_removeScope.tsx index e36128f..3db3d87 100644 --- a/__tests__/02_removeScope.tsx +++ b/__tests__/ScopeProvider/02_removeScope.tsx @@ -3,8 +3,8 @@ import { render } from '@testing-library/react'; import { atom, useAtom, useAtomValue } from 'jotai'; import { atomWithReducer } from 'jotai/vanilla/utils'; import { PropsWithChildren } from 'react'; -import { ScopeProvider } from '../src/index'; -import { clickButton, getTextContents } from './utils'; +import { ScopeProvider } from '../../src/index'; +import { clickButton, getTextContents } from '../utils'; const baseAtom1 = atomWithReducer(0, (v) => v + 1); const baseAtom2 = atomWithReducer(0, (v) => v + 1); diff --git a/__tests__/03_nested.tsx b/__tests__/ScopeProvider/03_nested.tsx similarity index 98% rename from __tests__/03_nested.tsx rename to __tests__/ScopeProvider/03_nested.tsx index 3d5a88f..c1fb122 100644 --- a/__tests__/03_nested.tsx +++ b/__tests__/ScopeProvider/03_nested.tsx @@ -1,9 +1,9 @@ import { render } from '@testing-library/react'; import { atom, useAtom, useAtomValue, useSetAtom } from 'jotai'; import { atomWithReducer } from 'jotai/vanilla/utils'; -import { clickButton, getTextContents } from './utils'; +import { clickButton, getTextContents } from '../utils'; -import { ScopeProvider } from '../src/index'; +import { ScopeProvider } from '../../src/index'; const baseAtom1 = atomWithReducer(0, (v) => v + 1); const baseAtom2 = atomWithReducer(0, (v) => v + 1); diff --git a/__tests__/04_derived.tsx b/__tests__/ScopeProvider/04_derived.tsx similarity index 98% rename from __tests__/04_derived.tsx rename to __tests__/ScopeProvider/04_derived.tsx index f85b1ad..8947903 100644 --- a/__tests__/04_derived.tsx +++ b/__tests__/ScopeProvider/04_derived.tsx @@ -1,7 +1,7 @@ import { render } from '@testing-library/react'; import { atom, useAtom } from 'jotai'; -import { clickButton, getTextContents } from './utils'; -import { ScopeProvider } from '../src/index'; +import { clickButton, getTextContents } from '../utils'; +import { ScopeProvider } from '../../src/index'; const baseAtom = atom(0); const derivedAtom1 = atom( diff --git a/__tests__/01_basic_spec.tsx b/__tests__/createIsolation/01_basic_spec.tsx similarity index 100% rename from __tests__/01_basic_spec.tsx rename to __tests__/createIsolation/01_basic_spec.tsx diff --git a/src/ScopeProvider.tsx b/src/ScopeProvider/ScopeProvider.tsx similarity index 100% rename from src/ScopeProvider.tsx rename to src/ScopeProvider/ScopeProvider.tsx diff --git a/src/ScopeProvider/index.ts b/src/ScopeProvider/index.ts new file mode 100644 index 0000000..2ec43f8 --- /dev/null +++ b/src/ScopeProvider/index.ts @@ -0,0 +1 @@ +export { ScopeContext, ScopeProvider } from './ScopeProvider'; From 82703b97e2959e94ccef20068612386f9a45590f Mon Sep 17 00:00:00 2001 From: David Maskasky Date: Sat, 11 May 2024 13:58:55 -0700 Subject: [PATCH 02/12] add failing tests for - scoped derived uses nested scope dep - derived dep scope is preserved in self reference --- __tests__/05_derived_self.tsx | 58 ++++ __tests__/ScopeProvider/04_derived.tsx | 405 +++++++++++++++---------- 2 files changed, 306 insertions(+), 157 deletions(-) create mode 100644 __tests__/05_derived_self.tsx diff --git a/__tests__/05_derived_self.tsx b/__tests__/05_derived_self.tsx new file mode 100644 index 0000000..d787326 --- /dev/null +++ b/__tests__/05_derived_self.tsx @@ -0,0 +1,58 @@ +import { render } from '@testing-library/react'; +import { atom, useAtom } from 'jotai'; +import { useHydrateAtoms } from 'jotai/utils'; +import { getTextContents } from './utils'; +import { ScopeProvider } from '../src/index'; + +const baseAtom = atom(0); +const derivedAtom1 = atom( + (get) => get(baseAtom), + (get): number => { + return get(derivedAtom1); + }, +); + +const Component = ({ + className, + initialValue = 0, +}: { + className: string; + initialValue?: number; +}) => { + useHydrateAtoms([[baseAtom, initialValue]]); + const [atom1ReadValue, setAtom1Value] = useAtom(derivedAtom1); + const atom1WriteValue = setAtom1Value(); + return ( +
+ {atom1ReadValue} + {atom1WriteValue} +
+ ); +}; + +const App = () => { + return ( + <> +

base component

+

derived1 should read itself from global scope

+ + +

scoped component

+

derived1 should read itself from scoped scope

+ +
+ + ); +}; + +describe('Self', () => { + test('derived dep scope is preserved in self reference', () => { + const { container } = render(); + expect(getTextContents(container, ['.base .read', '.base .write'])).toEqual( + ['0', '0'], + ); + expect( + getTextContents(container, ['.scoped .read', '.scoped .write']), + ).toEqual(['1', '1']); + }); +}); diff --git a/__tests__/ScopeProvider/04_derived.tsx b/__tests__/ScopeProvider/04_derived.tsx index 8947903..747b0ed 100644 --- a/__tests__/ScopeProvider/04_derived.tsx +++ b/__tests__/ScopeProvider/04_derived.tsx @@ -77,7 +77,7 @@ const App = () => {

Layer2: Base and derived2 are scoped

- derived1 should use layer1's atom, base and derived2 are layer 2 + derived1 should use layer2's atom, base and derived2 are layer 2 scoped

@@ -121,210 +121,301 @@ describe('Counter', () => { const { container } = render(); expect(getTextContents(container, atomValueSelectors)).toEqual([ - '0', - '0', - '0', - '0', - '0', - '0', - '0', - '0', - '0', - '0', - '0', - '0', + // case 1: baseAtom scoped + '0', // .case1.base + '0', // .case1.derived1 + '0', // .case1.derived2 + + // case 2: derivedAtom1 and derivedAtom2 scoped + '0', // .case2.base + '0', // .case2.derived1 + '0', // .case2.derived2 + + // layer1: derivedAtom1 scoped + '0', // .layer1.base + '0', // .layer1.derived1 + '0', // .layer1.derived2 + + // layer2: baseAtom and derivedAtom2 scoped (nested in layer1) + '0', // .layer2.base + '0', // .layer2.derived1 + '0', // .layer2.derived2 ]); clickButton(container, increaseCase1Base); expect(getTextContents(container, atomValueSelectors)).toEqual([ - '1', - '1', - '1', - '0', - '0', - '0', - '0', - '0', - '0', - '0', - '0', - '0', + // case 1: baseAtom scoped + '1', // .case1.base + '1', // .case1.derived1 + '1', // .case1.derived2 + + // case 2: derivedAtom1 and derivedAtom2 scoped + '0', // .case2.base + '0', // .case2.derived1 + '0', // .case2.derived2 + + // layer1: derivedAtom1 scoped + '0', // .layer1.base + '0', // .layer1.derived1 + '0', // .layer1.derived2 + + // layer2: baseAtom and derivedAtom2 scoped (nested in layer1) + '0', // .layer2.base + '0', // .layer2.derived1 + '0', // .layer2.derived2 ]); clickButton(container, increaseCase1Derived1); expect(getTextContents(container, atomValueSelectors)).toEqual([ - '2', - '2', - '2', - '0', - '0', - '0', - '0', - '0', - '0', - '0', - '0', - '0', + // case 1: baseAtom scoped + '2', // .case1.base + '2', // .case1.derived1 + '2', // .case1.derived2 + + // case 2: derivedAtom1 and derivedAtom2 scoped + '0', // .case2.base + '0', // .case2.derived1 + '0', // .case2.derived2 + + // layer1: derivedAtom1 scoped + '0', // .layer1.base + '0', // .layer1.derived1 + '0', // .layer1.derived2 + + // layer2: baseAtom and derivedAtom2 scoped (nested in layer1) + '0', // .layer2.base + '0', // .layer2.derived1 + '0', // .layer2.derived2 ]); clickButton(container, increaseCase1Derived2); expect(getTextContents(container, atomValueSelectors)).toEqual([ - '3', - '3', - '3', - '0', - '0', - '0', - '0', - '0', - '0', - '0', - '0', - '0', + // case 1: baseAtom scoped + '3', // .case1.base + '3', // .case1.derived1 + '3', // .case1.derived2 + + // case 2: derivedAtom1 and derivedAtom2 scoped + '0', // .case2.base + '0', // .case2.derived1 + '0', // .case2.derived2 + + // layer1: derivedAtom1 scoped + '0', // .layer1.base + '0', // .layer1.derived1 + '0', // .layer1.derived2 + + // layer2: baseAtom and derivedAtom2 scoped (nested in layer1) + '0', // .layer2.base + '0', // .layer2.derived1 + '0', // .layer2.derived2 ]); clickButton(container, increaseCase2Base); expect(getTextContents(container, atomValueSelectors)).toEqual([ - '3', - '3', - '3', - '1', - '0', - '0', - '1', - '0', - '1', - '0', - '0', - '0', + // case 1: baseAtom scoped + '3', // .case1.base + '3', // .case1.derived1 + '3', // .case1.derived2 + + // case 2: derivedAtom1 and derivedAtom2 scoped + '1', // .case2.base + '0', // .case2.derived1 + '0', // .case2.derived2 + + // layer1: derivedAtom1 scoped + '1', // .layer1.base + '0', // .layer1.derived1 + '1', // .layer1.derived2 + + // layer2: baseAtom and derivedAtom2 scoped (nested in layer1) + '0', // .layer2.base + '0', // .layer2.derived1 + '0', // .layer2.derived2 ]); clickButton(container, increaseCase2Derived1); expect(getTextContents(container, atomValueSelectors)).toEqual([ - '3', - '3', - '3', - '1', - '1', - '1', - '1', - '0', - '1', - '0', - '0', - '0', + // case 1: baseAtom scoped + '3', // .case1.base + '3', // .case1.derived1 + '3', // .case1.derived2 + + // case 2: derivedAtom1 and derivedAtom2 scoped + '1', // .case2.base + '1', // .case2.derived1 + '1', // .case2.derived2 + + // layer1: derivedAtom1 scoped + '1', // .layer1.base + '0', // .layer1.derived1 + '1', // .layer1.derived2 + + // layer2: baseAtom and derivedAtom2 scoped (nested in layer1) + '0', // .layer2.base + '0', // .layer2.derived1 + '0', // .layer2.derived2 ]); clickButton(container, increaseCase2Derived2); expect(getTextContents(container, atomValueSelectors)).toEqual([ - '3', - '3', - '3', - '1', - '2', - '2', - '1', - '0', - '1', - '0', - '0', - '0', + // case 1: baseAtom scoped + '3', // .case1.base + '3', // .case1.derived1 + '3', // .case1.derived2 + + // case 2: derivedAtom1 and derivedAtom2 scoped + '1', // .case2.base + '2', // .case2.derived1 + '2', // .case2.derived2 + + // layer1: derivedAtom1 scoped + '1', // .layer1.base + '0', // .layer1.derived1 + '1', // .layer1.derived2 + + // layer2: baseAtom and derivedAtom2 scoped (nested in layer1) + '0', // .layer2.base + '0', // .layer2.derived1 + '0', // .layer2.derived2 ]); clickButton(container, increaseLayer1Base); expect(getTextContents(container, atomValueSelectors)).toEqual([ - '3', - '3', - '3', - '2', - '2', - '2', - '2', - '0', - '2', - '0', - '0', - '0', + // case 1: baseAtom scoped + '3', // .case1.base + '3', // .case1.derived1 + '3', // .case1.derived2 + + // case 2: derivedAtom1 and derivedAtom2 scoped + '2', // .case2.base + '2', // .case2.derived1 + '2', // .case2.derived2 + + // layer1: derivedAtom1 scoped + '2', // .layer1.base + '0', // .layer1.derived1 + '2', // .layer1.derived2 + + // layer2: baseAtom and derivedAtom2 scoped (nested in layer1) + '0', // .layer2.base + '0', // .layer2.derived1 + '0', // .layer2.derived2 ]); clickButton(container, increaseLayer1Derived1); expect(getTextContents(container, atomValueSelectors)).toEqual([ - '3', - '3', - '3', - '2', - '2', - '2', - '2', - '1', - '2', - '0', - '1', - '0', + // case 1: baseAtom scoped + '3', // .case1.base + '3', // .case1.derived1 + '3', // .case1.derived2 + + // case 2: derivedAtom1 and derivedAtom2 scoped + '2', // .case2.base + '2', // .case2.derived1 + '2', // .case2.derived2 + + // layer1: derivedAtom1 scoped + '2', // .layer1.base + '1', // .layer1.derived1 + '2', // .layer1.derived2 + + // layer2: baseAtom and derivedAtom2 scoped (nested in layer1) + '0', // .layer2.base + '0', // .layer2.derived1 + '0', // .layer2.derived2 ]); clickButton(container, increaseLayer1Derived2); expect(getTextContents(container, atomValueSelectors)).toEqual([ - '3', - '3', - '3', - '3', - '2', - '2', - '3', - '1', - '3', - '0', - '1', - '0', + // case 1: baseAtom scoped + '3', // .case1.base + '3', // .case1.derived1 + '3', // .case1.derived2 + + // case 2: derivedAtom1 and derivedAtom2 scoped + '3', // .case2.base + '2', // .case2.derived1 + '2', // .case2.derived2 + + // layer1: derivedAtom1 scoped + '3', // .layer1.base + '1', // .layer1.derived1 + '3', // .layer1.derived2 + + // layer2: baseAtom and derivedAtom2 scoped (nested in layer1) + '0', // .layer2.base + '0', // .layer2.derived1 + '0', // .layer2.derived2 ]); clickButton(container, increaseLayer2Base); expect(getTextContents(container, atomValueSelectors)).toEqual([ - '3', - '3', - '3', - '3', - '2', - '2', - '3', - '1', - '3', - '1', - '1', - '1', + // case 1: baseAtom scoped + '3', // .case1.base + '3', // .case1.derived1 + '3', // .case1.derived2 + + // case 2: derivedAtom1 and derivedAtom2 scoped + '3', // .case2.base + '2', // .case2.derived1 + '2', // .case2.derived2 + + // layer1: derivedAtom1 scoped + '3', // .layer1.base + '1', // .layer1.derived1 + '3', // .layer1.derived2 + + // layer2: baseAtom and derivedAtom2 scoped (nested in layer1) + '1', // .layer2.base + '1', // .layer2.derived1 + '1', // .layer2.derived2 ]); clickButton(container, increaseLayer2Derived1); expect(getTextContents(container, atomValueSelectors)).toEqual([ - '3', - '3', - '3', - '3', - '2', - '2', - '3', - '2', - '3', - '1', - '2', - '1', + // case 1: baseAtom scoped + '3', // .case1.base + '3', // .case1.derived1 + '3', // .case1.derived2 + + // case 2: derivedAtom1 and derivedAtom2 scoped + '3', // .case2.base + '2', // .case2.derived1 + '2', // .case2.derived2 + + // layer1: derivedAtom1 scoped + '3', // .layer1.base + '2', // .layer1.derived1 + '3', // .layer1.derived2 + + // layer2: baseAtom and derivedAtom2 scoped (nested in layer1) + '2', // .layer2.base + '2', // .layer2.derived1 + '2', // .layer2.derived2 ]); clickButton(container, increaseLayer2Derived2); expect(getTextContents(container, atomValueSelectors)).toEqual([ - '3', - '3', - '3', - '3', - '2', - '2', - '3', - '2', - '3', - '2', - '2', - '2', + // case 1: baseAtom scoped + '3', // .case1.base + '3', // .case1.derived1 + '3', // .case1.derived2 + + // case 2: derivedAtom1 and derivedAtom2 scoped + '3', // .case2.base + '2', // .case2.derived1 + '2', // .case2.derived2 + + // layer1: derivedAtom1 scoped + '3', // .layer1.base + '2', // .layer1.derived1 + '3', // .layer1.derived2 + + // layer2: baseAtom and derivedAtom2 scoped (nested in layer1) + '3', // .layer2.base + '3', // .layer2.derived1 + '3', // .layer2.derived2 ]); }); }); From 7c527790ab89f5c4f68c9fa046e81b66bcbb7905 Mon Sep 17 00:00:00 2001 From: David Maskasky Date: Thu, 16 May 2024 13:14:48 -0700 Subject: [PATCH 03/12] feat: rewrite jotai-scope to support order of influence --- .eslintrc.json | 2 +- __tests__/ScopeProvider/01_basic_spec.tsx | 658 ++++++++++++++++++ __tests__/ScopeProvider/02_removeScope.tsx | 17 +- __tests__/ScopeProvider/03_nested.tsx | 8 +- __tests__/ScopeProvider/04_derived.tsx | 8 +- .../{ => ScopeProvider}/05_derived_self.tsx | 14 +- __tests__/createIsolation/01_basic_spec.tsx | 2 +- examples/01_isolation/src/App.tsx | 12 +- examples/02_removeScope/src/App.tsx | 16 +- examples/03_nested/src/App.tsx | 8 +- examples/04_derived/src/App.tsx | 8 +- notes | 76 ++ src/ScopeProvider/ScopeProvider.tsx | 304 +++----- src/ScopeProvider/index.ts | 2 +- src/ScopeProvider/scope.ts | 151 ++++ src/ScopeProvider/types.ts | 5 + src/createIsolation.tsx | 6 +- src/index.ts | 5 +- 18 files changed, 1032 insertions(+), 270 deletions(-) create mode 100644 __tests__/ScopeProvider/01_basic_spec.tsx rename __tests__/{ => ScopeProvider}/05_derived_self.tsx (90%) create mode 100644 notes create mode 100644 src/ScopeProvider/scope.ts create mode 100644 src/ScopeProvider/types.ts diff --git a/.eslintrc.json b/.eslintrc.json index 3800f8a..31e37fb 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -46,7 +46,7 @@ "camelcase": ["error", { "allow": ["^INTERNAL_"] }], "react/function-component-definition": [ "error", - { "namedComponents": "arrow-function" } + { "namedComponents": "function-declaration" } ], "react/require-default-props": "off", "react/destructuring-assignment": "off", diff --git a/__tests__/ScopeProvider/01_basic_spec.tsx b/__tests__/ScopeProvider/01_basic_spec.tsx new file mode 100644 index 0000000..9cf8874 --- /dev/null +++ b/__tests__/ScopeProvider/01_basic_spec.tsx @@ -0,0 +1,658 @@ +import { render } from '@testing-library/react'; +import { + useAtom, + useSetAtom, + useAtomValue, + atom, + WritableAtom, + SetStateAction, +} from 'jotai'; +import { atomWithReducer } from 'jotai/vanilla/utils'; +import { ScopeProvider } from '../../src/index'; +import { clickButton, getTextContents } from '../utils'; + +describe('Counter', () => { + test('ScopeProvider provides isolation for scoped primitive atoms', () => { + const baseAtom = atomWithReducer(0, (v) => v + 1); + + function Counter({ counterClass }: { counterClass: string }) { + const [base, increaseBase] = useAtom(baseAtom); + return ( +
+ base: {base} + +
+ ); + } + + function App() { + return ( +
+

Unscoped

+ +

Scoped Provider

+ + + +
+ ); + } + const { container } = render(); + const increaseUnscopedBase = '.unscoped.setBase'; + const increaseScopedBase = '.scoped.setBase'; + + const atomValueSelectors = ['.unscoped.base', '.scoped.base']; + + expect(getTextContents(container, atomValueSelectors)).toEqual(['0', '0']); + + clickButton(container, increaseUnscopedBase); + expect(getTextContents(container, atomValueSelectors)).toEqual(['1', '0']); + + clickButton(container, increaseScopedBase); + expect(getTextContents(container, atomValueSelectors)).toEqual(['1', '1']); + }); + + test('unscoped derived can read and write to scoped primitive atoms', () => { + const baseAtom = atomWithReducer(0, (v) => v + 1); + const derivedAtom = atom( + (get) => get(baseAtom), + (get, set) => { + set(baseAtom, get(baseAtom) + 1); + }, + ); + + function Counter({ counterClass }: { counterClass: string }) { + const [derived, increaseFromDerived] = useAtom(derivedAtom); + return ( +
+ base: {derived} + +
+ ); + } + + function App() { + return ( +
+

Unscoped

+ +

Scoped Provider

+ + + +
+ ); + } + const { container } = render(); + const increaseUnscopedBase = '.unscoped.setBase'; + const increaseScopedBase = '.scoped.setBase'; + + const atomValueSelectors = ['.unscoped.base', '.scoped.base']; + + expect(getTextContents(container, atomValueSelectors)).toEqual(['0', '0']); + + clickButton(container, increaseUnscopedBase); + expect(getTextContents(container, atomValueSelectors)).toEqual(['1', '0']); + + clickButton(container, increaseScopedBase); + expect(getTextContents(container, atomValueSelectors)).toEqual(['1', '1']); + }); + + test('unscoped derived can read both scoped and unscoped atoms', () => { + const baseAtom = atomWithReducer(0, (v) => v + 1); + const notScopedAtom = atom(0); + const derivedAtom = atom((get) => ({ + base: get(baseAtom), + notScoped: get(notScopedAtom), + })); + + function Counter({ counterClass }: { counterClass: string }) { + const increaseBase = useSetAtom(baseAtom); + const derived = useAtomValue(derivedAtom); + return ( +
+ base: {derived.base} + not scoped:{' '} + + {derived.notScoped} + + +
+ ); + } + + function IncreaseUnscoped() { + const setNotScoped = useSetAtom(notScopedAtom); + return ( + + ); + } + + function App() { + return ( +
+

Unscoped

+ + +

Scoped Provider

+ + + +
+ ); + } + const { container } = render(); + const increaseUnscopedBase = '.unscoped.setBase'; + const increaseScopedBase = '.scoped.setBase'; + const increaseNotScoped = '.increaseNotScoped'; + + const atomValueSelectors = [ + '.unscoped.base', + '.scoped.base', + '.scoped.notScoped', + ]; + + expect(getTextContents(container, atomValueSelectors)).toEqual([ + '0', + '0', + '0', + ]); + + clickButton(container, increaseUnscopedBase); + expect(getTextContents(container, atomValueSelectors)).toEqual([ + '1', + '0', + '0', + ]); + + clickButton(container, increaseScopedBase); + expect(getTextContents(container, atomValueSelectors)).toEqual([ + '1', + '1', + '0', + ]); + + clickButton(container, increaseNotScoped); + expect(getTextContents(container, atomValueSelectors)).toEqual([ + '1', + '1', + '1', + ]); + }); + + test('dependencies of scoped derived are implicitly scoped', () => { + const baseAtom = atomWithReducer(0, (v) => v + 1); + const derivedAtom = atom((get) => get(baseAtom)); + + function Counter({ counterClass }: { counterClass: string }) { + const increaseBase = useSetAtom(baseAtom); + const derived = useAtomValue(derivedAtom); + return ( +
+ base: {derived} + +
+ ); + } + + function App() { + return ( +
+

Unscoped

+ +

Scoped Provider

+ + + +
+ ); + } + const { container } = render(); + const increaseUnscopedBase = '.unscoped.setBase'; + const increaseScopedBase = '.scoped.setBase'; + + const atomValueSelectors = ['.unscoped.base', '.scoped.base']; + + expect(getTextContents(container, atomValueSelectors)).toEqual(['0', '0']); + + clickButton(container, increaseUnscopedBase); + expect(getTextContents(container, atomValueSelectors)).toEqual(['1', '0']); + + clickButton(container, increaseScopedBase); + expect(getTextContents(container, atomValueSelectors)).toEqual(['1', '1']); + }); + + test('scoped derived atoms can share implicitly scoped dependencies', () => { + const baseAtom = atomWithReducer(0, (v) => v + 1); + const derivedAtom1 = atom((get) => get(baseAtom)); + const derivedAtom2 = atom((get) => get(baseAtom)); + + function Counter({ counterClass }: { counterClass: string }) { + const increaseBase = useSetAtom(baseAtom); + const derived = useAtomValue(derivedAtom1); + const derived2 = useAtomValue(derivedAtom2); + return ( +
+ base: {derived} + base2: {derived2} + +
+ ); + } + + function App() { + return ( +
+

Unscoped

+ +

Scoped Provider

+ + + +
+ ); + } + const { container } = render(); + const increaseUnscopedBase = '.unscoped.setBase'; + const increaseScopedBase = '.scoped.setBase'; + + const atomValueSelectors = [ + '.unscoped.base', + '.scoped.base', + '.scoped.base2', + ]; + + expect(getTextContents(container, atomValueSelectors)).toEqual([ + '0', + '0', + '0', + ]); + + clickButton(container, increaseUnscopedBase); + expect(getTextContents(container, atomValueSelectors)).toEqual([ + '1', + '0', + '0', + ]); + + clickButton(container, increaseScopedBase); + expect(getTextContents(container, atomValueSelectors)).toEqual([ + '1', + '1', + '1', + ]); + }); + + test('nested scopes provide isolation for primitive atoms at every level', () => { + const baseAtom = atomWithReducer(0, (v) => v + 1); + + function Counter({ counterClass }: { counterClass: string }) { + const [base, increaseBase] = useAtom(baseAtom); + return ( +
+ base: {base} + +
+ ); + } + + function App() { + return ( +
+

Unscoped

+ +

Scoped Provider

+ + + + + + +
+ ); + } + const { container } = render(); + const increaseUnscopedBase = '.level0.setBase'; + const increaseScopedBase = '.level1.setBase'; + const increaseDoubleScopedBase = '.level2.setBase'; + + const atomValueSelectors = ['.level0.base', '.level1.base', '.level2.base']; + + expect(getTextContents(container, atomValueSelectors)).toEqual([ + '0', + '0', + '0', + ]); + + clickButton(container, increaseUnscopedBase); + expect(getTextContents(container, atomValueSelectors)).toEqual([ + '1', + '0', + '0', + ]); + + clickButton(container, increaseScopedBase); + expect(getTextContents(container, atomValueSelectors)).toEqual([ + '1', + '1', + '0', + ]); + + clickButton(container, increaseDoubleScopedBase); + expect(getTextContents(container, atomValueSelectors)).toEqual([ + '1', + '1', + '1', + ]); + }); + + /* + FIXME: The remaining tests are failing because of an infinite loop on unstable_is + */ + test.skip('unscoped derived atoms in nested scoped can read and write to scoped primitive atoms at every level', () => { + const base0Atom = atom(0); + const base1Atom = atom(0); + const base2Atom = atom(0); + const derivedAtom = atom( + (get) => ({ + base0: get(base0Atom), + base1: get(base1Atom), + base2: get(base2Atom), + }), + (get, set) => { + set(base0Atom, get(base0Atom) + 1); + set(base1Atom, get(base1Atom) + 1); + set(base2Atom, get(base2Atom) + 1); + }, + ); + + function Counter({ + counterClass, + baseAtom, + }: { + counterClass: string; + baseAtom: WritableAtom], void>; + }) { + const setBase = useSetAtom(baseAtom); + const [{ base0, base1, base2 }, increaseAll] = useAtom(derivedAtom); + return ( +
+ base0: {base0} + level1: {base1} + level2: {base2} + + +
+ ); + } + + function App() { + return ( +
+

Unscoped

+ +

Scoped Provider

+ + + + + + +
+ ); + } + const { container } = render(); + // const increaseLevel0Base = '.level0.increaseBase'; + // const increaseLevel1Base = '.level1.increaseBase'; + // const increaseLevel2Base = '.level2.increaseBase'; + // const increaseLevel0All = '.level0.increaseAll'; + // const increaseLevel1All = '.level1.increaseAll'; + // const increaseLevel2All = '.level2.increaseAll'; + + const atomValueSelectors = [ + '.level0.base0', + '.level0.base1', + '.level0.base2', + '.level1.base0', + '.level1.base1', + '.level1.base2', + '.level2.base0', + '.level2.base1', + '.level2.base2', + ]; + + expect(getTextContents(container, atomValueSelectors)).toEqual([ + '0', // level0 base0 + '0', // level0 base1 + '0', // level0 base2 + '0', // level1 base0 + '0', // level1 base1 + '0', // level1 base2 + '0', // level2 base0 + '0', // level2 base1 + '0', // level2 base2 + ]); + + // clickButton(container, increaseLevel0Base); + // expect(getTextContents(container, atomValueSelectors)).toEqual([ + // '1', // level0 base0 + // '0', // level0 base1 + // '0', // level0 base2 + // '1', // level1 base0 + // '0', // level1 base1 + // '0', // level1 base2 + // '1', // level2 base0 + // '0', // level2 base1 + // '0', // level2 base2 + // ]); + + // clickButton(container, increaseLevel1Base); + // expect(getTextContents(container, atomValueSelectors)).toEqual([ + // '1', // level0 base0 + // '0', // level0 base1 + // '0', // level0 base2 + // '1', // level1 base0 + // '1', // level1 base1 + // '0', // level1 base2 + // '1', // level2 base0 + // '1', // level2 base1 + // '0', // level2 base2 + // ]); + + // clickButton(container, increaseLevel2Base); + // expect(getTextContents(container, atomValueSelectors)).toEqual([ + // '1', // level0 base0 + // '0', // level0 base1 + // '0', // level0 base2 + // '1', // level1 base0 + // '1', // level1 base1 + // '0', // level1 base2 + // '1', // level2 base0 + // '1', // level2 base1 + // '1', // level2 base2 + // ]); + + // clickButton(container, increaseLevel0All); + // expect(getTextContents(container, atomValueSelectors)).toEqual([ + // '2', // level0 base0 + // '1', // level0 base1 + // '1', // level0 base2 + // '2', // level1 base0 + // '1', // level1 base1 + // '0', // level1 base2 + // '2', // level2 base0 + // '1', // level2 base1 + // '1', // level2 base2 + // ]); + + // clickButton(container, increaseLevel1All); + // expect(getTextContents(container, atomValueSelectors)).toEqual([ + // '3', // level0 base0 + // '1', // level0 base1 + // '1', // level0 base2 + // '3', // level1 base0 + // '2', // level1 base1 + // '1', // level1 base2 + // '3', // level2 base0 + // '2', // level2 base1 + // '1', // level2 base2 + // ]); + + // clickButton(container, increaseLevel2All); + // expect(getTextContents(container, atomValueSelectors)).toEqual([ + // '4', // level0 base0 + // '1', // level0 base1 + // '1', // level0 base2 + // '4', // level1 base0 + // '3', // level1 base1 + // '1', // level1 base2 + // '4', // level2 base0 + // '3', // level2 base1 + // '2', // level2 base2 + // ]); + }); + + test.skip('inherited scoped derived atoms can read and write to scoped primitive atoms at every nested level', () => { + const base1Atom = atom(0); + base1Atom.debugLabel = 'base1Atom'; + const base2Atom = atom(0); + base2Atom.debugLabel = 'base2Atom'; + const derivedAtom = atom( + (get) => ({ + base1: get(base1Atom), + base2: get(base2Atom), + }), + (get, set) => { + set(base1Atom, get(base1Atom) + 1); + set(base2Atom, get(base2Atom) + 1); + }, + ); + derivedAtom.debugLabel = 'derivedAtom'; + + let unscopedCount = 0; + let scopedCount = 0; + function Counter({ counterClass }: { counterClass: string }) { + console.log( + counterClass, + 'Counter render', + counterClass === 'level1' ? unscopedCount++ : scopedCount++, + ); + const [{ base1, base2 }, increaseAll] = useAtom(derivedAtom); + console.log(counterClass, 'Counter after useAtom'); + return ( +
+ level1: {base1} + level2: {base2} + +
+ ); + } + + let appCount = 0; + function App() { + console.log('App render', appCount++); + return ( +
+

Unscoped

+

Scoped Provider

+ + + + + + +
+ ); + } + const { container } = render(); + const increaseLevel1All = '.level1.increaseAll'; + const increaseLevel2All = '.level2.increaseAll'; + + const atomValueSelectors = [ + '.level1.base1', + '.level1.base2', + '.level2.base1', + '.level2.base2', + ]; + + expect(getTextContents(container, atomValueSelectors)).toEqual([ + '0', // level1 base1 + '0', // level1 base2 + '0', // level2 base1 + '0', // level2 base2 + ]); + + clickButton(container, increaseLevel1All); + expect(getTextContents(container, atomValueSelectors).join('')).toEqual( + '1110', // level1 base1, level1 base2, level2 base1, level2 base2 + ); + + clickButton(container, increaseLevel2All); + expect(getTextContents(container, atomValueSelectors)).toEqual([ + '2', // level1 base1 + '1', // level1 base2 + '2', // level2 base1 + '1', // level2 base2 + ]); + }); +}); diff --git a/__tests__/ScopeProvider/02_removeScope.tsx b/__tests__/ScopeProvider/02_removeScope.tsx index 3db3d87..6d70393 100644 --- a/__tests__/ScopeProvider/02_removeScope.tsx +++ b/__tests__/ScopeProvider/02_removeScope.tsx @@ -1,5 +1,4 @@ import { render } from '@testing-library/react'; - import { atom, useAtom, useAtomValue } from 'jotai'; import { atomWithReducer } from 'jotai/vanilla/utils'; import { PropsWithChildren } from 'react'; @@ -10,7 +9,7 @@ const baseAtom1 = atomWithReducer(0, (v) => v + 1); const baseAtom2 = atomWithReducer(0, (v) => v + 1); const shouldHaveScopeAtom = atom(true); -const Counter = ({ counterClass }: { counterClass: string }) => { +function Counter({ counterClass }: { counterClass: string }) { const [base1, increaseBase1] = useAtom(baseAtom1); const [base2, increaseBase2] = useAtom(baseAtom2); return ( @@ -37,18 +36,18 @@ const Counter = ({ counterClass }: { counterClass: string }) => { ); -}; +} -const Wrapper = ({ children }: PropsWithChildren) => { +function Wrapper({ children }: PropsWithChildren) { const shouldHaveScope = useAtomValue(shouldHaveScopeAtom); return shouldHaveScope ? ( {children} ) : ( children ); -}; +} -const ScopeButton = () => { +function ScopeButton() { const [shouldHaveScope, setShouldHaveScope] = useAtom(shouldHaveScopeAtom); return ( ); -}; +} -const App = () => { +function App() { return (

Unscoped

@@ -73,7 +72,7 @@ const App = () => {
); -}; +} describe('Counter', () => { test('atom get correct value when ScopeProvider is added/removed', () => { diff --git a/__tests__/ScopeProvider/03_nested.tsx b/__tests__/ScopeProvider/03_nested.tsx index c1fb122..7632089 100644 --- a/__tests__/ScopeProvider/03_nested.tsx +++ b/__tests__/ScopeProvider/03_nested.tsx @@ -15,7 +15,7 @@ const writeProxyAtom = atom('unused', (get, set) => { set(baseAtom2); }); -const Counter = ({ counterClass }: { counterClass: string }) => { +function Counter({ counterClass }: { counterClass: string }) { const [base1, increaseBase1] = useAtom(baseAtom1); const [base2, increaseBase2] = useAtom(baseAtom2); const base = useAtomValue(baseAtom); @@ -54,9 +54,9 @@ const Counter = ({ counterClass }: { counterClass: string }) => { ); -}; +} -const App = () => { +function App() { return (

Unscoped

@@ -76,7 +76,7 @@ const App = () => {
); -}; +} describe('Counter', () => { test('nested primitive atoms are correctly scoped', () => { diff --git a/__tests__/ScopeProvider/04_derived.tsx b/__tests__/ScopeProvider/04_derived.tsx index 747b0ed..37b1a71 100644 --- a/__tests__/ScopeProvider/04_derived.tsx +++ b/__tests__/ScopeProvider/04_derived.tsx @@ -18,7 +18,7 @@ const derivedAtom2 = atom( }, ); -const Counter = ({ counterClass }: { counterClass: string }) => { +function Counter({ counterClass }: { counterClass: string }) { const [base, setBase] = useAtom(baseAtom); const [derived1, setDerived1] = useAtom(derivedAtom1); const [derived2, setDerived2] = useAtom(derivedAtom2); @@ -56,9 +56,9 @@ const Counter = ({ counterClass }: { counterClass: string }) => { ); -}; +} -const App = () => { +function App() { return (

Only base is scoped

@@ -86,7 +86,7 @@ const App = () => {
); -}; +} describe('Counter', () => { test("parent scope's derived atom is prior to nested scope's scoped base", () => { diff --git a/__tests__/05_derived_self.tsx b/__tests__/ScopeProvider/05_derived_self.tsx similarity index 90% rename from __tests__/05_derived_self.tsx rename to __tests__/ScopeProvider/05_derived_self.tsx index d787326..4776f29 100644 --- a/__tests__/05_derived_self.tsx +++ b/__tests__/ScopeProvider/05_derived_self.tsx @@ -1,8 +1,8 @@ import { render } from '@testing-library/react'; import { atom, useAtom } from 'jotai'; import { useHydrateAtoms } from 'jotai/utils'; -import { getTextContents } from './utils'; -import { ScopeProvider } from '../src/index'; +import { getTextContents } from '../utils'; +import { ScopeProvider } from '../../src/index'; const baseAtom = atom(0); const derivedAtom1 = atom( @@ -12,13 +12,13 @@ const derivedAtom1 = atom( }, ); -const Component = ({ +function Component({ className, initialValue = 0, }: { className: string; initialValue?: number; -}) => { +}) { useHydrateAtoms([[baseAtom, initialValue]]); const [atom1ReadValue, setAtom1Value] = useAtom(derivedAtom1); const atom1WriteValue = setAtom1Value(); @@ -28,9 +28,9 @@ const Component = ({ {atom1WriteValue} ); -}; +} -const App = () => { +function App() { return ( <>

base component

@@ -43,7 +43,7 @@ const App = () => {
); -}; +} describe('Self', () => { test('derived dep scope is preserved in self reference', () => { diff --git a/__tests__/createIsolation/01_basic_spec.tsx b/__tests__/createIsolation/01_basic_spec.tsx index bbea641..db63706 100644 --- a/__tests__/createIsolation/01_basic_spec.tsx +++ b/__tests__/createIsolation/01_basic_spec.tsx @@ -1,4 +1,4 @@ -import { createIsolation } from '../src/index'; +import { createIsolation } from '../../src/index'; describe('basic spec', () => { it('should export functions', () => { diff --git a/examples/01_isolation/src/App.tsx b/examples/01_isolation/src/App.tsx index 8fc03e8..6480734 100644 --- a/examples/01_isolation/src/App.tsx +++ b/examples/01_isolation/src/App.tsx @@ -5,7 +5,7 @@ const { Provider: MyProvider, useAtom: useMyAtom } = createIsolation(); const countAtom = atom(0); -const Counter = () => { +function Counter() { const [count, setCount] = useAtom(countAtom); return (
@@ -15,9 +15,9 @@ const Counter = () => {
); -}; +} -const MyCounter = () => { +function MyCounter() { const [count, setCount] = useMyAtom(countAtom); return (
@@ -27,9 +27,9 @@ const MyCounter = () => {
); -}; +} -const App = () => { +function App() { return (

First Provider

@@ -44,6 +44,6 @@ const App = () => {
); -}; +} export default App; diff --git a/examples/02_removeScope/src/App.tsx b/examples/02_removeScope/src/App.tsx index 8283ef0..b8c4df1 100644 --- a/examples/02_removeScope/src/App.tsx +++ b/examples/02_removeScope/src/App.tsx @@ -7,7 +7,7 @@ const baseAtom1 = atomWithReducer(0, (v) => v + 1); const baseAtom2 = atomWithReducer(0, (v) => v + 1); const shouldHaveScopeAtom = atom(true); -const Counter = ({ counterClass }: { counterClass: string }) => { +function Counter({ counterClass }: { counterClass: string }) { const [base1, increaseBase1] = useAtom(baseAtom1); const [base2, increaseBase2] = useAtom(baseAtom2); return ( @@ -34,18 +34,18 @@ const Counter = ({ counterClass }: { counterClass: string }) => { ); -}; +} -const Wrapper = ({ children }: PropsWithChildren) => { +function Wrapper({ children }: PropsWithChildren) { const shouldHaveScope = useAtomValue(shouldHaveScopeAtom); return shouldHaveScope ? ( {children} ) : ( children ); -}; +} -const ScopeButton = () => { +function ScopeButton() { const [shouldHaveScope, setShouldHaveScope] = useAtom(shouldHaveScopeAtom); return ( ); -}; +} -const App = () => { +function App() { return (

Unscoped

@@ -70,6 +70,6 @@ const App = () => {
); -}; +} export default App; diff --git a/examples/03_nested/src/App.tsx b/examples/03_nested/src/App.tsx index 4eccedb..858fcb2 100644 --- a/examples/03_nested/src/App.tsx +++ b/examples/03_nested/src/App.tsx @@ -12,7 +12,7 @@ const writeProxyAtom = atom('unused', (get, set) => { set(baseAtom2); }); -const Counter = ({ counterClass }: { counterClass: string }) => { +function Counter({ counterClass }: { counterClass: string }) { const [base1, increaseBase1] = useAtom(baseAtom1); const [base2, increaseBase2] = useAtom(baseAtom2); const base = useAtomValue(baseAtom); @@ -51,9 +51,9 @@ const Counter = ({ counterClass }: { counterClass: string }) => { ); -}; +} -const App = () => { +function App() { return (

Unscoped

@@ -73,6 +73,6 @@ const App = () => {
); -}; +} export default App; diff --git a/examples/04_derived/src/App.tsx b/examples/04_derived/src/App.tsx index c59419a..fe7f52f 100644 --- a/examples/04_derived/src/App.tsx +++ b/examples/04_derived/src/App.tsx @@ -16,7 +16,7 @@ const derivedAtom2 = atom( }, ); -const Counter = ({ counterClass }: { counterClass: string }) => { +function Counter({ counterClass }: { counterClass: string }) { const [base, setBase] = useAtom(baseAtom); const [derived1, setDerived1] = useAtom(derivedAtom1); const [derived2, setDerived2] = useAtom(derivedAtom2); @@ -54,9 +54,9 @@ const Counter = ({ counterClass }: { counterClass: string }) => { ); -}; +} -const App = () => { +function App() { return (

Only base is scoped

@@ -84,6 +84,6 @@ const App = () => {
); -}; +} export default App; diff --git a/notes b/notes new file mode 100644 index 0000000..89814bb --- /dev/null +++ b/notes @@ -0,0 +1,76 @@ +/** + * Implicit Scope: a scoped derived atom has implicitly scoped dependencies. + * Explicit Scope: atoms passed to the ScopeProvider are explicitly scoped. + * Explicit overrides implicit scoped. + * Nested scope overrides parent scope. + * + * Scope is a copy of the atom and the getter is to look at the current scope + * and up the scope chain for x + * + * Does implicit vs explicit scope matter? No. + */ + +/** + * Thes scopeMaps are used to map the original atom to the scoped atom. + * explicit - atom is passed to ScopeProvider atoms prop at that scope level (Highest priority) + * implicit - atom is scoped because it is a dependency of a scoped derived atom (Medium priority) + * inherited - atom is scoped because it is scoped in the parent scope (Lowest priority) + */ + +/* + explicit > implicit > inherited > unscoped derived > original + _if they are explicitly scoped, then return explicitly scoped_ + + ** inherited scoped atoms preserve their value from the parent scope ** + + + a -> b -> c + { + explicit: a + implicit: b,c + inherited: none + derived: none + original: none + } + + { + explicit: none + implicit: none + inherited: a,b,c + derived: none + original: none + } + a1 -> b1 -> c1 + + { + explicit: c + implicit: none + inherited: a,b + derived: none + original: none + } + + + + + + + l2: a1 -> b0 + -> b1 + -> b2 + a2 -> b0 + -> b1 + -> b2 + + if the atom is explicitly scoped + if the dependency is already explicitly scoped, return the explicitly scoped atom + if the dependency is already implicitly scoped, return the implicitly scoped atom + if the dependency is already inherited scoped, return the inherited scoped atom + if the atom is explicitly scoped, its dependencies are implicitly scoped + +*/ + +/** + * When an atom is accessed via useAtomValue/useSetAtom, the access should + * be handled by getAtom. + */ diff --git a/src/ScopeProvider/ScopeProvider.tsx b/src/ScopeProvider/ScopeProvider.tsx index 99a3d73..12eb89f 100644 --- a/src/ScopeProvider/ScopeProvider.tsx +++ b/src/ScopeProvider/ScopeProvider.tsx @@ -1,236 +1,112 @@ -import { createContext, useContext, useState } from 'react'; -import type { ReactNode } from 'react'; import { Provider, useStore } from 'jotai/react'; -import { getDefaultStore } from 'jotai/vanilla'; -import type { Atom, WritableAtom } from 'jotai/vanilla'; - -type AnyAtom = Atom; -type AnyWritableAtom = WritableAtom; -type GetRouterAtom = (anAtom: T) => T; -type TryGetScopedRouterAtomInCurrentScope = ( - anAtom: T, -) => [anAtomOrScoped: T, isScoped: boolean]; - -const isSelfAtom = (atom: AnyAtom, a: AnyAtom) => - atom.unstable_is ? atom.unstable_is(a) : a === atom; -const isEqualSet = (a: Set, b: Set) => - a === b || (a.size === b.size && Array.from(a).every((v) => b.has(v))); -type Store = ReturnType; - -export const ScopeContext = createContext< - readonly [TryGetScopedRouterAtomInCurrentScope, Store | undefined] ->([(a) => [a, false], undefined]); -export const ScopeProvider = ({ +import { + type ReactNode, + createContext, + useContext, + useState, + useRef, +} from 'react'; +import { createScope, type Scope } from './scope'; +import { AnyAtom, Store } from './types'; + +const ScopeContext = createContext<{ + scope: Scope | undefined; + baseStore: Store | undefined; +}>({ + scope: undefined, + baseStore: undefined, +}); + +const patchedStoreSymbol = Symbol(); + +export function ScopeProvider({ atoms, children, + className, }: { atoms: Iterable; children: ReactNode; -}) => { - const parentScopeContext = useContext(ScopeContext); - const atomSet = new Set(atoms); - const [tryGetScopedRouterAtomInParentScope, storeOrUndefined] = - parentScopeContext; + className?: string; +}) { + const log = console.log.bind(console, className); + const renderCountRef = useRef(0); + log('ScopeProvider render', renderCountRef.current++); const parentStore = useStore(); - const store = storeOrUndefined ?? parentStore; - - const initialize = () => { - const commonRouterAtoms = new WeakMap(); - const scopedRouterAtoms = new WeakMap(); - - /** - * Create a CommonRouterAtom copy of originalAtom. CommonRouterAtom will NEVER act as a store - * key to access states, but it intercepts originalAtom's read/write function. If some of - * originalAtom's dependencies are scoped, the read/write function will be intercepted to access the correct - * ScopedRouterAtom copy. - * - * NOTE: CommonRouterAtom is only used when originalAtom is not scoped in any scope. That logic - * is guaranteed by `getRouterAtom`. - * - * @see getRouterAtom - * @param originalAtom - * @returns A CommonRouterAtom of originalAtom. - */ - const createCommonRouterAtom = ( - originalAtom: T, - ): T => { - /** - * This is the core mechanism of how a router atom finds the correct atom to read/write. - * - * First, we know that originalAtom is not scoped in any scope, the logic is guaranteed by - * `getRouterAtom`. - * - * Then, when the CommonRouterAtom call get(targetAtom) or set(targetAtom, value), this - * function is called to route `targetAtom` to another atom. That atom would be used as - * store key to access the globally shared / scoped state. - * @param target The `targetAtom` that this atom is accessing. - * @returns The actual atom to access. If the atom is originalAtom itself, return - * originalAtom. If the atom is scoped, return a ScopedRouterAtom copy. Otherwise, return the - * unscoped CommonRouterAtom copy. - * - * Check the example below, when calling useAtomValue, jotai-scope will first - * find its router copy (lets call it `rAtom`). Then, - * `anAtom.read(get => get(dependencyAtom))` becomes - * `rAtom.read(get => get(routeAtom(anAtom, dependencyAtom)))` - * @example - * const anAtom = atom(get => get(dependencyAtom)) - * const Component = () => { - * useAtomValue(anAtom); - * } - * const App = () => { - * return ( - * - * - * - * ); - * } - */ - const routeAtom = (target: A): A => { - // The target is got/set by itself. Since we know originalAtom is not scoped in any scope, - // directly return the originalAtom itself. - if (target === (originalAtom as unknown as A)) { - return target; - } - - // The target is got/set by another atom, access the target's router atom. - return getRouterAtom(target); - }; - - const routerAtom: typeof originalAtom = { - ...originalAtom, - ...('read' in originalAtom && { - read(get, opts) { - return originalAtom.read((a) => get(routeAtom(a)), opts); - }, - }), - ...('write' in originalAtom && { - write(get, set, ...args) { - return originalAtom.write( - (a) => get(routeAtom(a)), - (a, ...v) => set(routeAtom(a), ...v), - ...args, - ); - }, - }), - // eslint-disable-next-line camelcase - unstable_is: (a: AnyAtom) => isSelfAtom(a, originalAtom), - }; - return routerAtom; - }; - - /** - * Create a ScopedRouterAtom copy of originalAtom. The ScopedRouterAtom will act as a store key - * to access its own state. All of a ScopedRouterAtom's dependencies are also scoped, so their - * read/write functions will be intercepted to access ScopedRouterAtom copy, too. - * @param originalAtom - * @returns A ScopedRouterAtom copy of originalAtom. - */ - const createScopedRouterAtom = ( - originalAtom: T, - ): T => { - const scopedRouterAtom: typeof originalAtom = { - ...originalAtom, - ...('read' in originalAtom && { - read(get, opts) { - return originalAtom.read((a) => get(getScopedRouterAtom(a)), opts); - }, - }), - ...('write' in originalAtom && { - write(get, set, ...args) { - return originalAtom.write( - (a) => get(getScopedRouterAtom(a)), - (a, ...v) => set(getScopedRouterAtom(a), ...v), - ...args, - ); - }, - }), - // eslint-disable-next-line camelcase - unstable_is: (a: AnyAtom) => isSelfAtom(a, originalAtom), - }; - return scopedRouterAtom; - }; - - /** - * For EVERY `useAtomValue` and `useSetAtom` call, since we don't know if the atom is scoped - * or not, a router atom copy is always created to intercept the read/write function. - * - * It first check if originalAtom is scoped in any scope. If so, then route to - * `ScopedRouterAtom`. All of the atom's dependency will be scoped in that scope as well. - * If not, then the atom is globally unique, route to `CommonRouterAtom`. The atom's - * dependencies' scope status is not determined yet. - */ - const getRouterAtom: GetRouterAtom = (originalAtom) => { - // Step 1: Check if the atom is scoped in current scope. - const [possiblyScoped, isScoped] = - tryGetScopedRouterAtomInCurrentScope(originalAtom); + let { scope: parentScope, baseStore = parentStore } = + useContext(ScopeContext); + if (!(patchedStoreSymbol in parentStore)) { + parentScope = undefined; + baseStore = parentStore; + } - // Step 2: If the atom is scoped, return the ScopedRouterAtom copy. - if (isScoped) { - return possiblyScoped; - } + /** + * atomSet is used to detect if the atoms prop has changed. + */ + const atomSet = new Set(atoms); - // Step 3: If the atom is not scoped, return the CommonRouterAtom copy. - let commonRouterAtom = commonRouterAtoms.get(originalAtom); - if (!commonRouterAtom) { - commonRouterAtom = createCommonRouterAtom( - originalAtom as unknown as AnyWritableAtom, - ); - commonRouterAtoms.set(originalAtom, commonRouterAtom); - } - return commonRouterAtom as typeof originalAtom; + const initializeCountRef = useRef(0); + function initialize() { + console.log( + className, + 'ScopeProvider initialize', + initializeCountRef.current++, + ); + const scope = createScope(atoms, parentScope, className); + const patchedStore: Store & { [patchedStoreSymbol]: true } & { + name: string | undefined; + } = { + // TODO: update this patch to support devtools + ...baseStore, + get(anAtom) { + log(this.name, 'get', anAtom.debugLabel); + return baseStore.get(scope.getAtom(anAtom)); + }, + set(anAtom, ...args) { + log(this.name, 'set', anAtom.debugLabel); + return baseStore.set(scope.getAtom(anAtom), ...args); + }, + sub(anAtom, ...args) { + log(this.name, 'sub', anAtom.debugLabel); + return baseStore.sub(scope.getAtom(anAtom), ...args); + }, + [patchedStoreSymbol]: true, + name: `${className}:store`, }; - const getScopedRouterAtom: GetRouterAtom = (originalAtom) => { - let scopedRouterAtom = scopedRouterAtoms.get(originalAtom); - if (!scopedRouterAtom) { - scopedRouterAtom = createScopedRouterAtom( - originalAtom as unknown as AnyWritableAtom, + return { + patchedStore, + scopeContext: { scope, baseStore }, + hasChanged(current: { + baseStore: Store; + parentScope: Scope | undefined; + atomSet: Set; + }) { + return ( + parentScope !== current.parentScope || + !isEqualSet(atomSet, current.atomSet) || + current.baseStore !== baseStore ); - scopedRouterAtoms.set(originalAtom, scopedRouterAtom); - } - return scopedRouterAtom as typeof originalAtom; - }; - - /** - * If originalAtom is scoped in current scope, returns ScopedRouterAtom copy. - * Otherwise, recursively check if originalAtom is scoped in parent scope. - * - * If the atom is not scoped in any scope, return the originalAtom itself. - * The second return value indicates whether the atom is scoped in any scope. - */ - const tryGetScopedRouterAtomInCurrentScope: TryGetScopedRouterAtomInCurrentScope = - (originalAtom) => { - if (atomSet.has(originalAtom)) { - return [getScopedRouterAtom(originalAtom), true]; - } - return tryGetScopedRouterAtomInParentScope(originalAtom); - }; - - /** - * When an atom is accessed via useAtomValue/useSetAtom, the access should - * be handled by a router atom copy. - */ - const patchedStore: typeof store = { - ...store, - get: (anAtom, ...args) => store.get(getRouterAtom(anAtom), ...args), - set: (anAtom, ...args) => store.set(getRouterAtom(anAtom), ...args), - sub: (anAtom, ...args) => store.sub(getRouterAtom(anAtom), ...args), + }, }; - - const scopeContext = [tryGetScopedRouterAtomInCurrentScope, store] as const; - - return [patchedStore, scopeContext, parentScopeContext, atomSet] as const; - }; + } const [state, setState] = useState(initialize); - if (parentScopeContext !== state[2] || !isEqualSet(atomSet, state[3])) { + const { hasChanged, scopeContext, patchedStore } = state; + if (hasChanged({ parentScope, atomSet, baseStore })) { setState(initialize); } - const [patchedStore, scopeContext] = state; - + console.log( + className, + 'ScopeProvider return', + Array.from(atoms).map((a) => a.debugLabel), + ); return ( {children} ); -}; +} + +function isEqualSet(a: Set, b: Set) { + return a === b || (a.size === b.size && Array.from(a).every((v) => b.has(v))); +} diff --git a/src/ScopeProvider/index.ts b/src/ScopeProvider/index.ts index 2ec43f8..64f0423 100644 --- a/src/ScopeProvider/index.ts +++ b/src/ScopeProvider/index.ts @@ -1 +1 @@ -export { ScopeContext, ScopeProvider } from './ScopeProvider'; +export { ScopeProvider } from './ScopeProvider'; diff --git a/src/ScopeProvider/scope.ts b/src/ScopeProvider/scope.ts new file mode 100644 index 0000000..f2cf276 --- /dev/null +++ b/src/ScopeProvider/scope.ts @@ -0,0 +1,151 @@ +import { atom, type Atom } from 'jotai'; +import { type AnyAtom, type AnyWritableAtom } from './types'; + +export type Scope = { + getAtom: (originalAtom: T, fromScoped?: boolean) => T; +}; + +// let i = 0; + +export function createScope( + atoms: Iterable, + parentScope: Scope | undefined, + name?: string, +): Scope { + const log = console.log.bind(console, name); + const explicit = new WeakMap(); + const implicit = new WeakMap(); + const inherited = new WeakMap(); + const unscoped = new WeakMap(); + + // populate explicit scope map + for (const anAtom of atoms) { + const scopedCopy = copyAtom(anAtom, true); + explicit.set(anAtom, scopedCopy); + } + + /** + * Creates a scoped atom from the original atom. + * All dependencies of the original atom will be implicitly scoped. + * All scoped atoms will be inherited scoped in nested scopes. + * @param anAtom + * @param isScoped - the atom is a dependency of an explicitly scoped atom + * @returns + */ + function copyAtom(anAtom: Atom, isScoped = false) { + log('copyAtom', anAtom.debugLabel, { isScoped }); + // avoid reading `init` to preserve lazy initialization + const scopedAtom: Atom = Object.create( + Object.getPrototypeOf(anAtom), + Object.getOwnPropertyDescriptors(anAtom), + ); + scopedAtom.debugLabel = `${name}:${anAtom.debugLabel}`; + + if ('read' in scopedAtom) { + // inherited atoms should preserve their value + if (scopedAtom.read === defaultRead) { + const self = isScoped ? scopedAtom : anAtom; + scopedAtom.read = defaultRead.bind(self) as Atom['read']; + } else { + scopedAtom.read = (get, opts) => { + log(scopedAtom.debugLabel, 'scopedAtom.read outer'); + return anAtom.read((a) => { + log( + scopedAtom.debugLabel, + 'scopedAtom.read inner', + anAtom.debugLabel, + ); + return get(getAtom(a, isScoped)); + }, opts); + }; + } + } + + if ('write' in scopedAtom) { + if (scopedAtom.write === defaultWrite) { + const self = isScoped ? scopedAtom : anAtom; + scopedAtom.write = defaultWrite.bind(self); + } else { + (scopedAtom as AnyWritableAtom).write = (get, set, ...args) => { + return (anAtom as AnyWritableAtom).write( + (a) => get(getAtom(a, isScoped)), + (a, ...v) => set(getAtom(a, isScoped), ...v), + ...args, + ); + }; + } + } + + // // eslint-disable-next-line camelcase + // // @ts-ignore + // scopedAtom.unstable_is = (a: AnyAtom, from: any) => { + // if (i++ > 10) throw new Error('unstable_is'); + // log(scopedAtom.debugLabel, 'unstable_is', { + // a: a.debugLabel, + // anAtom: anAtom.debugLabel, + // from, + // }); + // const result = + // a === scopedAtom || + // // @ts-ignore + // (a.unstable_is?.(anAtom, scopedAtom.debugLabel) ?? a === anAtom); + // log(scopedAtom.debugLabel, 'unstable_is', { + // result, + // 'a === scopedAtom': a === scopedAtom, + // unstable_is: !!a.unstable_is, + // }); + // return result; + // }; + + return scopedAtom; + } + + function getAtom(anAtom: T, fromScoped = false): T { + if (explicit.has(anAtom)) { + log('getAtom-explicit', anAtom.debugLabel); + return explicit.get(anAtom) as T; + } + // atom is a dependency of an explicitly scoped atom + if (fromScoped && !implicit.has(anAtom)) { + log('getAtom-implicit-create', anAtom.debugLabel); + implicit.set(anAtom, copyAtom(anAtom, true)); + } + if (implicit.has(anAtom)) { + log('getAtom-implicit', anAtom.debugLabel); + return implicit.get(anAtom) as T; + } + if (inherited.has(anAtom)) { + log('getAtom-inherited', anAtom.debugLabel); + return inherited.get(anAtom) as T; + } + if (parentScope) { + const inheritedAtom = copyAtom(parentScope.getAtom(anAtom)); + inherited.set(anAtom, inheritedAtom); + log('getAtom-fromParent', anAtom.debugLabel); + return inherited.get(anAtom) as T; + } + // derived atoms should may need to access scoped atoms + if (!isPrimitiveAtom(anAtom) && !unscoped.has(anAtom)) { + log('getAtom-unscoped-create', anAtom.debugLabel); + unscoped.set(anAtom, copyAtom(anAtom)); + } + if (unscoped.has(anAtom)) { + log('getAtom-unscoped', anAtom.debugLabel); + return unscoped.get(anAtom) as T; + } + log('getAtom-original', anAtom.debugLabel); + return anAtom; + } + + return { getAtom }; +} + +function isPrimitiveAtom(anAtom: AnyAtom) { + return ( + anAtom.read === defaultRead && + 'write' in anAtom && + anAtom.write === defaultWrite + ); +} + +const { read: defaultRead, write: defaultWrite } = atom(null); diff --git a/src/ScopeProvider/types.ts b/src/ScopeProvider/types.ts new file mode 100644 index 0000000..27952a0 --- /dev/null +++ b/src/ScopeProvider/types.ts @@ -0,0 +1,5 @@ +import type { Atom, WritableAtom, getDefaultStore } from 'jotai'; + +export type AnyAtom = Atom | WritableAtom; +export type AnyWritableAtom = WritableAtom; +export type Store = ReturnType; diff --git a/src/createIsolation.tsx b/src/createIsolation.tsx index 784905f..c464524 100644 --- a/src/createIsolation.tsx +++ b/src/createIsolation.tsx @@ -16,7 +16,7 @@ type AnyWritableAtom = WritableAtom; export function createIsolation() { const StoreContext = createContext(null); - const Provider = ({ + function Provider({ store, initialValues = [], children, @@ -24,7 +24,7 @@ export function createIsolation() { store?: Store; initialValues?: Iterable; children: ReactNode; - }) => { + }) { const storeRef = useRef(store); if (!storeRef.current) { storeRef.current = createStore(); @@ -35,7 +35,7 @@ export function createIsolation() { {children} ); - }; + } const useStore = ((options?: any) => { const store = useContext(StoreContext); diff --git a/src/index.ts b/src/index.ts index d89ec43..0da2644 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,2 @@ export { createIsolation } from './createIsolation'; -export { - ScopeContext as INTERNAL_ScopeContext, - ScopeProvider, -} from './ScopeProvider'; +export { ScopeProvider } from './ScopeProvider'; From 059f3c5275a376722c0fa7b12a10b85892e18622 Mon Sep 17 00:00:00 2001 From: David Maskasky Date: Thu, 16 May 2024 18:54:52 -0700 Subject: [PATCH 04/12] remove debug statements --- __tests__/ScopeProvider/01_basic_spec.tsx | 176 ++++++++++------------ src/ScopeProvider/ScopeProvider.tsx | 34 +---- src/ScopeProvider/scope.ts | 44 +----- 3 files changed, 90 insertions(+), 164 deletions(-) diff --git a/__tests__/ScopeProvider/01_basic_spec.tsx b/__tests__/ScopeProvider/01_basic_spec.tsx index 9cf8874..e4309c8 100644 --- a/__tests__/ScopeProvider/01_basic_spec.tsx +++ b/__tests__/ScopeProvider/01_basic_spec.tsx @@ -456,12 +456,12 @@ describe('Counter', () => { ); } const { container } = render(); - // const increaseLevel0Base = '.level0.increaseBase'; - // const increaseLevel1Base = '.level1.increaseBase'; - // const increaseLevel2Base = '.level2.increaseBase'; - // const increaseLevel0All = '.level0.increaseAll'; - // const increaseLevel1All = '.level1.increaseAll'; - // const increaseLevel2All = '.level2.increaseAll'; + const increaseLevel0Base = '.level0.increaseBase'; + const increaseLevel1Base = '.level1.increaseBase'; + const increaseLevel2Base = '.level2.increaseBase'; + const increaseLevel0All = '.level0.increaseAll'; + const increaseLevel1All = '.level1.increaseAll'; + const increaseLevel2All = '.level2.increaseAll'; const atomValueSelectors = [ '.level0.base0', @@ -487,83 +487,83 @@ describe('Counter', () => { '0', // level2 base2 ]); - // clickButton(container, increaseLevel0Base); - // expect(getTextContents(container, atomValueSelectors)).toEqual([ - // '1', // level0 base0 - // '0', // level0 base1 - // '0', // level0 base2 - // '1', // level1 base0 - // '0', // level1 base1 - // '0', // level1 base2 - // '1', // level2 base0 - // '0', // level2 base1 - // '0', // level2 base2 - // ]); - - // clickButton(container, increaseLevel1Base); - // expect(getTextContents(container, atomValueSelectors)).toEqual([ - // '1', // level0 base0 - // '0', // level0 base1 - // '0', // level0 base2 - // '1', // level1 base0 - // '1', // level1 base1 - // '0', // level1 base2 - // '1', // level2 base0 - // '1', // level2 base1 - // '0', // level2 base2 - // ]); - - // clickButton(container, increaseLevel2Base); - // expect(getTextContents(container, atomValueSelectors)).toEqual([ - // '1', // level0 base0 - // '0', // level0 base1 - // '0', // level0 base2 - // '1', // level1 base0 - // '1', // level1 base1 - // '0', // level1 base2 - // '1', // level2 base0 - // '1', // level2 base1 - // '1', // level2 base2 - // ]); - - // clickButton(container, increaseLevel0All); - // expect(getTextContents(container, atomValueSelectors)).toEqual([ - // '2', // level0 base0 - // '1', // level0 base1 - // '1', // level0 base2 - // '2', // level1 base0 - // '1', // level1 base1 - // '0', // level1 base2 - // '2', // level2 base0 - // '1', // level2 base1 - // '1', // level2 base2 - // ]); - - // clickButton(container, increaseLevel1All); - // expect(getTextContents(container, atomValueSelectors)).toEqual([ - // '3', // level0 base0 - // '1', // level0 base1 - // '1', // level0 base2 - // '3', // level1 base0 - // '2', // level1 base1 - // '1', // level1 base2 - // '3', // level2 base0 - // '2', // level2 base1 - // '1', // level2 base2 - // ]); - - // clickButton(container, increaseLevel2All); - // expect(getTextContents(container, atomValueSelectors)).toEqual([ - // '4', // level0 base0 - // '1', // level0 base1 - // '1', // level0 base2 - // '4', // level1 base0 - // '3', // level1 base1 - // '1', // level1 base2 - // '4', // level2 base0 - // '3', // level2 base1 - // '2', // level2 base2 - // ]); + clickButton(container, increaseLevel0Base); + expect(getTextContents(container, atomValueSelectors)).toEqual([ + '1', // level0 base0 + '0', // level0 base1 + '0', // level0 base2 + '1', // level1 base0 + '0', // level1 base1 + '0', // level1 base2 + '1', // level2 base0 + '0', // level2 base1 + '0', // level2 base2 + ]); + + clickButton(container, increaseLevel1Base); + expect(getTextContents(container, atomValueSelectors)).toEqual([ + '1', // level0 base0 + '0', // level0 base1 + '0', // level0 base2 + '1', // level1 base0 + '1', // level1 base1 + '0', // level1 base2 + '1', // level2 base0 + '1', // level2 base1 + '0', // level2 base2 + ]); + + clickButton(container, increaseLevel2Base); + expect(getTextContents(container, atomValueSelectors)).toEqual([ + '1', // level0 base0 + '0', // level0 base1 + '0', // level0 base2 + '1', // level1 base0 + '1', // level1 base1 + '0', // level1 base2 + '1', // level2 base0 + '1', // level2 base1 + '1', // level2 base2 + ]); + + clickButton(container, increaseLevel0All); + expect(getTextContents(container, atomValueSelectors)).toEqual([ + '2', // level0 base0 + '1', // level0 base1 + '1', // level0 base2 + '2', // level1 base0 + '1', // level1 base1 + '0', // level1 base2 + '2', // level2 base0 + '1', // level2 base1 + '1', // level2 base2 + ]); + + clickButton(container, increaseLevel1All); + expect(getTextContents(container, atomValueSelectors)).toEqual([ + '3', // level0 base0 + '1', // level0 base1 + '1', // level0 base2 + '3', // level1 base0 + '2', // level1 base1 + '1', // level1 base2 + '3', // level2 base0 + '2', // level2 base1 + '1', // level2 base2 + ]); + + clickButton(container, increaseLevel2All); + expect(getTextContents(container, atomValueSelectors)).toEqual([ + '4', // level0 base0 + '1', // level0 base1 + '1', // level0 base2 + '4', // level1 base0 + '3', // level1 base1 + '1', // level1 base2 + '4', // level2 base0 + '3', // level2 base1 + '2', // level2 base2 + ]); }); test.skip('inherited scoped derived atoms can read and write to scoped primitive atoms at every nested level', () => { @@ -583,16 +583,8 @@ describe('Counter', () => { ); derivedAtom.debugLabel = 'derivedAtom'; - let unscopedCount = 0; - let scopedCount = 0; function Counter({ counterClass }: { counterClass: string }) { - console.log( - counterClass, - 'Counter render', - counterClass === 'level1' ? unscopedCount++ : scopedCount++, - ); const [{ base1, base2 }, increaseAll] = useAtom(derivedAtom); - console.log(counterClass, 'Counter after useAtom'); return (
level1: {base1} @@ -608,9 +600,7 @@ describe('Counter', () => { ); } - let appCount = 0; function App() { - console.log('App render', appCount++); return (

Unscoped

diff --git a/src/ScopeProvider/ScopeProvider.tsx b/src/ScopeProvider/ScopeProvider.tsx index 12eb89f..a5ec922 100644 --- a/src/ScopeProvider/ScopeProvider.tsx +++ b/src/ScopeProvider/ScopeProvider.tsx @@ -1,11 +1,5 @@ import { Provider, useStore } from 'jotai/react'; -import { - type ReactNode, - createContext, - useContext, - useState, - useRef, -} from 'react'; +import { type ReactNode, createContext, useContext, useState } from 'react'; import { createScope, type Scope } from './scope'; import { AnyAtom, Store } from './types'; @@ -22,15 +16,10 @@ const patchedStoreSymbol = Symbol(); export function ScopeProvider({ atoms, children, - className, }: { atoms: Iterable; children: ReactNode; - className?: string; }) { - const log = console.log.bind(console, className); - const renderCountRef = useRef(0); - log('ScopeProvider render', renderCountRef.current++); const parentStore = useStore(); let { scope: parentScope, baseStore = parentStore } = useContext(ScopeContext); @@ -44,33 +33,21 @@ export function ScopeProvider({ */ const atomSet = new Set(atoms); - const initializeCountRef = useRef(0); function initialize() { - console.log( - className, - 'ScopeProvider initialize', - initializeCountRef.current++, - ); - const scope = createScope(atoms, parentScope, className); - const patchedStore: Store & { [patchedStoreSymbol]: true } & { - name: string | undefined; - } = { + const scope = createScope(atoms, parentScope); + const patchedStore: Store & { [patchedStoreSymbol]: true } = { // TODO: update this patch to support devtools ...baseStore, get(anAtom) { - log(this.name, 'get', anAtom.debugLabel); return baseStore.get(scope.getAtom(anAtom)); }, set(anAtom, ...args) { - log(this.name, 'set', anAtom.debugLabel); return baseStore.set(scope.getAtom(anAtom), ...args); }, sub(anAtom, ...args) { - log(this.name, 'sub', anAtom.debugLabel); return baseStore.sub(scope.getAtom(anAtom), ...args); }, [patchedStoreSymbol]: true, - name: `${className}:store`, }; return { @@ -95,11 +72,6 @@ export function ScopeProvider({ if (hasChanged({ parentScope, atomSet, baseStore })) { setState(initialize); } - console.log( - className, - 'ScopeProvider return', - Array.from(atoms).map((a) => a.debugLabel), - ); return ( {children} diff --git a/src/ScopeProvider/scope.ts b/src/ScopeProvider/scope.ts index f2cf276..590e2b9 100644 --- a/src/ScopeProvider/scope.ts +++ b/src/ScopeProvider/scope.ts @@ -5,14 +5,10 @@ export type Scope = { getAtom: (originalAtom: T, fromScoped?: boolean) => T; }; -// let i = 0; - export function createScope( atoms: Iterable, parentScope: Scope | undefined, - name?: string, ): Scope { - const log = console.log.bind(console, name); const explicit = new WeakMap(); const implicit = new WeakMap(); const inherited = new WeakMap(); @@ -33,13 +29,11 @@ export function createScope( * @returns */ function copyAtom(anAtom: Atom, isScoped = false) { - log('copyAtom', anAtom.debugLabel, { isScoped }); // avoid reading `init` to preserve lazy initialization const scopedAtom: Atom = Object.create( Object.getPrototypeOf(anAtom), Object.getOwnPropertyDescriptors(anAtom), ); - scopedAtom.debugLabel = `${name}:${anAtom.debugLabel}`; if ('read' in scopedAtom) { // inherited atoms should preserve their value @@ -48,13 +42,7 @@ export function createScope( scopedAtom.read = defaultRead.bind(self) as Atom['read']; } else { scopedAtom.read = (get, opts) => { - log(scopedAtom.debugLabel, 'scopedAtom.read outer'); return anAtom.read((a) => { - log( - scopedAtom.debugLabel, - 'scopedAtom.read inner', - anAtom.debugLabel, - ); return get(getAtom(a, isScoped)); }, opts); }; @@ -76,64 +64,40 @@ export function createScope( } } - // // eslint-disable-next-line camelcase - // // @ts-ignore - // scopedAtom.unstable_is = (a: AnyAtom, from: any) => { - // if (i++ > 10) throw new Error('unstable_is'); - // log(scopedAtom.debugLabel, 'unstable_is', { - // a: a.debugLabel, - // anAtom: anAtom.debugLabel, - // from, - // }); - // const result = - // a === scopedAtom || - // // @ts-ignore - // (a.unstable_is?.(anAtom, scopedAtom.debugLabel) ?? a === anAtom); - // log(scopedAtom.debugLabel, 'unstable_is', { - // result, - // 'a === scopedAtom': a === scopedAtom, - // unstable_is: !!a.unstable_is, - // }); - // return result; - // }; + // eslint-disable-next-line camelcase + scopedAtom.unstable_is = (a: AnyAtom) => { + return a === scopedAtom || (a.unstable_is?.(anAtom) ?? a === anAtom); + }; return scopedAtom; } function getAtom(anAtom: T, fromScoped = false): T { if (explicit.has(anAtom)) { - log('getAtom-explicit', anAtom.debugLabel); return explicit.get(anAtom) as T; } // atom is a dependency of an explicitly scoped atom if (fromScoped && !implicit.has(anAtom)) { - log('getAtom-implicit-create', anAtom.debugLabel); implicit.set(anAtom, copyAtom(anAtom, true)); } if (implicit.has(anAtom)) { - log('getAtom-implicit', anAtom.debugLabel); return implicit.get(anAtom) as T; } if (inherited.has(anAtom)) { - log('getAtom-inherited', anAtom.debugLabel); return inherited.get(anAtom) as T; } if (parentScope) { const inheritedAtom = copyAtom(parentScope.getAtom(anAtom)); inherited.set(anAtom, inheritedAtom); - log('getAtom-fromParent', anAtom.debugLabel); return inherited.get(anAtom) as T; } // derived atoms should may need to access scoped atoms if (!isPrimitiveAtom(anAtom) && !unscoped.has(anAtom)) { - log('getAtom-unscoped-create', anAtom.debugLabel); unscoped.set(anAtom, copyAtom(anAtom)); } if (unscoped.has(anAtom)) { - log('getAtom-unscoped', anAtom.debugLabel); return unscoped.get(anAtom) as T; } - log('getAtom-original', anAtom.debugLabel); return anAtom; } From 0169dee9b8327dccd30fc7d7b70b34f4912e6d5d Mon Sep 17 00:00:00 2001 From: David Maskasky Date: Thu, 16 May 2024 18:58:46 -0700 Subject: [PATCH 05/12] remove notes --- notes | 76 -------------------------------------- src/ScopeProvider/scope.ts | 2 +- 2 files changed, 1 insertion(+), 77 deletions(-) delete mode 100644 notes diff --git a/notes b/notes deleted file mode 100644 index 89814bb..0000000 --- a/notes +++ /dev/null @@ -1,76 +0,0 @@ -/** - * Implicit Scope: a scoped derived atom has implicitly scoped dependencies. - * Explicit Scope: atoms passed to the ScopeProvider are explicitly scoped. - * Explicit overrides implicit scoped. - * Nested scope overrides parent scope. - * - * Scope is a copy of the atom and the getter is to look at the current scope - * and up the scope chain for x - * - * Does implicit vs explicit scope matter? No. - */ - -/** - * Thes scopeMaps are used to map the original atom to the scoped atom. - * explicit - atom is passed to ScopeProvider atoms prop at that scope level (Highest priority) - * implicit - atom is scoped because it is a dependency of a scoped derived atom (Medium priority) - * inherited - atom is scoped because it is scoped in the parent scope (Lowest priority) - */ - -/* - explicit > implicit > inherited > unscoped derived > original - _if they are explicitly scoped, then return explicitly scoped_ - - ** inherited scoped atoms preserve their value from the parent scope ** - - - a -> b -> c - { - explicit: a - implicit: b,c - inherited: none - derived: none - original: none - } - - { - explicit: none - implicit: none - inherited: a,b,c - derived: none - original: none - } - a1 -> b1 -> c1 - - { - explicit: c - implicit: none - inherited: a,b - derived: none - original: none - } - - - - - - - l2: a1 -> b0 - -> b1 - -> b2 - a2 -> b0 - -> b1 - -> b2 - - if the atom is explicitly scoped - if the dependency is already explicitly scoped, return the explicitly scoped atom - if the dependency is already implicitly scoped, return the implicitly scoped atom - if the dependency is already inherited scoped, return the inherited scoped atom - if the atom is explicitly scoped, its dependencies are implicitly scoped - -*/ - -/** - * When an atom is accessed via useAtomValue/useSetAtom, the access should - * be handled by getAtom. - */ diff --git a/src/ScopeProvider/scope.ts b/src/ScopeProvider/scope.ts index 590e2b9..1b8fbc3 100644 --- a/src/ScopeProvider/scope.ts +++ b/src/ScopeProvider/scope.ts @@ -91,7 +91,7 @@ export function createScope( inherited.set(anAtom, inheritedAtom); return inherited.get(anAtom) as T; } - // derived atoms should may need to access scoped atoms + // derived atoms may need to access scoped atoms if (!isPrimitiveAtom(anAtom) && !unscoped.has(anAtom)) { unscoped.set(anAtom, copyAtom(anAtom)); } From cb07c66c58f4eb884769abb28ce1169e022c180f Mon Sep 17 00:00:00 2001 From: David Maskasky Date: Fri, 17 May 2024 10:05:58 -0700 Subject: [PATCH 06/12] fix 05_derived_self test --- __tests__/ScopeProvider/05_derived_self.tsx | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/__tests__/ScopeProvider/05_derived_self.tsx b/__tests__/ScopeProvider/05_derived_self.tsx index 4776f29..e63953b 100644 --- a/__tests__/ScopeProvider/05_derived_self.tsx +++ b/__tests__/ScopeProvider/05_derived_self.tsx @@ -35,11 +35,11 @@ function App() { <>

base component

derived1 should read itself from global scope

- +

scoped component

derived1 should read itself from scoped scope

- +
); @@ -48,9 +48,10 @@ function App() { describe('Self', () => { test('derived dep scope is preserved in self reference', () => { const { container } = render(); - expect(getTextContents(container, ['.base .read', '.base .write'])).toEqual( - ['0', '0'], - ); + expect( + getTextContents(container, ['.unscoped .read', '.unscoped .write']), + ).toEqual(['0', '0']); + expect( getTextContents(container, ['.scoped .read', '.scoped .write']), ).toEqual(['1', '1']); From 1dbacad8d297dedae749548e5f16de6f54ae5ae5 Mon Sep 17 00:00:00 2001 From: David Maskasky Date: Fri, 17 May 2024 10:08:44 -0700 Subject: [PATCH 07/12] add back exporting ScopeContext --- src/ScopeProvider/ScopeProvider.tsx | 2 +- src/ScopeProvider/index.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/ScopeProvider/ScopeProvider.tsx b/src/ScopeProvider/ScopeProvider.tsx index a5ec922..8952f24 100644 --- a/src/ScopeProvider/ScopeProvider.tsx +++ b/src/ScopeProvider/ScopeProvider.tsx @@ -3,7 +3,7 @@ import { type ReactNode, createContext, useContext, useState } from 'react'; import { createScope, type Scope } from './scope'; import { AnyAtom, Store } from './types'; -const ScopeContext = createContext<{ +export const ScopeContext = createContext<{ scope: Scope | undefined; baseStore: Store | undefined; }>({ diff --git a/src/ScopeProvider/index.ts b/src/ScopeProvider/index.ts index 64f0423..2ec43f8 100644 --- a/src/ScopeProvider/index.ts +++ b/src/ScopeProvider/index.ts @@ -1 +1 @@ -export { ScopeProvider } from './ScopeProvider'; +export { ScopeContext, ScopeProvider } from './ScopeProvider'; From e7738abd2be912d23ada43d7efb8bb31fced817c Mon Sep 17 00:00:00 2001 From: David Maskasky Date: Fri, 17 May 2024 10:10:03 -0700 Subject: [PATCH 08/12] revert changing eslint configuration --- .eslintrc.json | 2 +- __tests__/ScopeProvider/01_basic_spec.tsx | 70 ++++++++++----------- __tests__/ScopeProvider/02_removeScope.tsx | 16 ++--- __tests__/ScopeProvider/03_nested.tsx | 8 +-- __tests__/ScopeProvider/04_derived.tsx | 8 +-- __tests__/ScopeProvider/05_derived_self.tsx | 10 +-- examples/01_isolation/src/App.tsx | 12 ++-- examples/02_removeScope/src/App.tsx | 16 ++--- examples/03_nested/src/App.tsx | 8 +-- examples/04_derived/src/App.tsx | 8 +-- src/ScopeProvider/ScopeProvider.tsx | 6 +- src/createIsolation.tsx | 6 +- src/index.ts | 5 +- 13 files changed, 89 insertions(+), 86 deletions(-) diff --git a/.eslintrc.json b/.eslintrc.json index 31e37fb..3800f8a 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -46,7 +46,7 @@ "camelcase": ["error", { "allow": ["^INTERNAL_"] }], "react/function-component-definition": [ "error", - { "namedComponents": "function-declaration" } + { "namedComponents": "arrow-function" } ], "react/require-default-props": "off", "react/destructuring-assignment": "off", diff --git a/__tests__/ScopeProvider/01_basic_spec.tsx b/__tests__/ScopeProvider/01_basic_spec.tsx index e4309c8..e716f72 100644 --- a/__tests__/ScopeProvider/01_basic_spec.tsx +++ b/__tests__/ScopeProvider/01_basic_spec.tsx @@ -15,7 +15,7 @@ describe('Counter', () => { test('ScopeProvider provides isolation for scoped primitive atoms', () => { const baseAtom = atomWithReducer(0, (v) => v + 1); - function Counter({ counterClass }: { counterClass: string }) { + const Counter = ({ counterClass }: { counterClass: string }) => { const [base, increaseBase] = useAtom(baseAtom); return (
@@ -29,9 +29,9 @@ describe('Counter', () => {
); - } + }; - function App() { + const App = () => { return (

Unscoped

@@ -42,7 +42,7 @@ describe('Counter', () => {
); - } + }; const { container } = render(); const increaseUnscopedBase = '.unscoped.setBase'; const increaseScopedBase = '.scoped.setBase'; @@ -67,7 +67,7 @@ describe('Counter', () => { }, ); - function Counter({ counterClass }: { counterClass: string }) { + const Counter = ({ counterClass }: { counterClass: string }) => { const [derived, increaseFromDerived] = useAtom(derivedAtom); return (
@@ -81,9 +81,9 @@ describe('Counter', () => {
); - } + }; - function App() { + const App = () => { return (

Unscoped

@@ -94,7 +94,7 @@ describe('Counter', () => {
); - } + }; const { container } = render(); const increaseUnscopedBase = '.unscoped.setBase'; const increaseScopedBase = '.scoped.setBase'; @@ -118,7 +118,7 @@ describe('Counter', () => { notScoped: get(notScopedAtom), })); - function Counter({ counterClass }: { counterClass: string }) { + const Counter = ({ counterClass }: { counterClass: string }) => { const increaseBase = useSetAtom(baseAtom); const derived = useAtomValue(derivedAtom); return ( @@ -137,9 +137,9 @@ describe('Counter', () => {
); - } + }; - function IncreaseUnscoped() { + const IncreaseUnscoped = () => { const setNotScoped = useSetAtom(notScopedAtom); return ( ); - } + }; - function App() { + const App = () => { return (

Unscoped

@@ -164,7 +164,7 @@ describe('Counter', () => {
); - } + }; const { container } = render(); const increaseUnscopedBase = '.unscoped.setBase'; const increaseScopedBase = '.scoped.setBase'; @@ -208,7 +208,7 @@ describe('Counter', () => { const baseAtom = atomWithReducer(0, (v) => v + 1); const derivedAtom = atom((get) => get(baseAtom)); - function Counter({ counterClass }: { counterClass: string }) { + const Counter = ({ counterClass }: { counterClass: string }) => { const increaseBase = useSetAtom(baseAtom); const derived = useAtomValue(derivedAtom); return ( @@ -223,9 +223,9 @@ describe('Counter', () => {
); - } + }; - function App() { + const App = () => { return (

Unscoped

@@ -236,7 +236,7 @@ describe('Counter', () => {
); - } + }; const { container } = render(); const increaseUnscopedBase = '.unscoped.setBase'; const increaseScopedBase = '.scoped.setBase'; @@ -257,7 +257,7 @@ describe('Counter', () => { const derivedAtom1 = atom((get) => get(baseAtom)); const derivedAtom2 = atom((get) => get(baseAtom)); - function Counter({ counterClass }: { counterClass: string }) { + const Counter = ({ counterClass }: { counterClass: string }) => { const increaseBase = useSetAtom(baseAtom); const derived = useAtomValue(derivedAtom1); const derived2 = useAtomValue(derivedAtom2); @@ -274,9 +274,9 @@ describe('Counter', () => { ); - } + }; - function App() { + const App = () => { return (

Unscoped

@@ -287,7 +287,7 @@ describe('Counter', () => {
); - } + }; const { container } = render(); const increaseUnscopedBase = '.unscoped.setBase'; const increaseScopedBase = '.scoped.setBase'; @@ -322,7 +322,7 @@ describe('Counter', () => { test('nested scopes provide isolation for primitive atoms at every level', () => { const baseAtom = atomWithReducer(0, (v) => v + 1); - function Counter({ counterClass }: { counterClass: string }) { + const Counter = ({ counterClass }: { counterClass: string }) => { const [base, increaseBase] = useAtom(baseAtom); return (
@@ -336,9 +336,9 @@ describe('Counter', () => {
); - } + }; - function App() { + const App = () => { return (

Unscoped

@@ -352,7 +352,7 @@ describe('Counter', () => {
); - } + }; const { container } = render(); const increaseUnscopedBase = '.level0.setBase'; const increaseScopedBase = '.level1.setBase'; @@ -408,13 +408,13 @@ describe('Counter', () => { }, ); - function Counter({ + const Counter = ({ counterClass, baseAtom, }: { counterClass: string; baseAtom: WritableAtom], void>; - }) { + }) => { const setBase = useSetAtom(baseAtom); const [{ base0, base1, base2 }, increaseAll] = useAtom(derivedAtom); return ( @@ -438,9 +438,9 @@ describe('Counter', () => { ); - } + }; - function App() { + const App = () => { return (

Unscoped

@@ -454,7 +454,7 @@ describe('Counter', () => {
); - } + }; const { container } = render(); const increaseLevel0Base = '.level0.increaseBase'; const increaseLevel1Base = '.level1.increaseBase'; @@ -583,7 +583,7 @@ describe('Counter', () => { ); derivedAtom.debugLabel = 'derivedAtom'; - function Counter({ counterClass }: { counterClass: string }) { + const Counter = ({ counterClass }: { counterClass: string }) => { const [{ base1, base2 }, increaseAll] = useAtom(derivedAtom); return (
@@ -598,9 +598,9 @@ describe('Counter', () => {
); - } + }; - function App() { + const App = () => { return (

Unscoped

@@ -613,7 +613,7 @@ describe('Counter', () => {
); - } + }; const { container } = render(); const increaseLevel1All = '.level1.increaseAll'; const increaseLevel2All = '.level2.increaseAll'; diff --git a/__tests__/ScopeProvider/02_removeScope.tsx b/__tests__/ScopeProvider/02_removeScope.tsx index 6d70393..a9ae4a9 100644 --- a/__tests__/ScopeProvider/02_removeScope.tsx +++ b/__tests__/ScopeProvider/02_removeScope.tsx @@ -9,7 +9,7 @@ const baseAtom1 = atomWithReducer(0, (v) => v + 1); const baseAtom2 = atomWithReducer(0, (v) => v + 1); const shouldHaveScopeAtom = atom(true); -function Counter({ counterClass }: { counterClass: string }) { +const Counter = ({ counterClass }: { counterClass: string }) => { const [base1, increaseBase1] = useAtom(baseAtom1); const [base2, increaseBase2] = useAtom(baseAtom2); return ( @@ -36,18 +36,18 @@ function Counter({ counterClass }: { counterClass: string }) { ); -} +}; -function Wrapper({ children }: PropsWithChildren) { +const Wrapper = ({ children }: PropsWithChildren) => { const shouldHaveScope = useAtomValue(shouldHaveScopeAtom); return shouldHaveScope ? ( {children} ) : ( children ); -} +}; -function ScopeButton() { +const ScopeButton = () => { const [shouldHaveScope, setShouldHaveScope] = useAtom(shouldHaveScopeAtom); return ( ); -} +}; -function App() { +const App = () => { return (

Unscoped

@@ -76,7 +76,7 @@ function App() {
); -} +}; describe('Counter', () => { test('nested primitive atoms are correctly scoped', () => { diff --git a/__tests__/ScopeProvider/04_derived.tsx b/__tests__/ScopeProvider/04_derived.tsx index 37b1a71..747b0ed 100644 --- a/__tests__/ScopeProvider/04_derived.tsx +++ b/__tests__/ScopeProvider/04_derived.tsx @@ -18,7 +18,7 @@ const derivedAtom2 = atom( }, ); -function Counter({ counterClass }: { counterClass: string }) { +const Counter = ({ counterClass }: { counterClass: string }) => { const [base, setBase] = useAtom(baseAtom); const [derived1, setDerived1] = useAtom(derivedAtom1); const [derived2, setDerived2] = useAtom(derivedAtom2); @@ -56,9 +56,9 @@ function Counter({ counterClass }: { counterClass: string }) { ); -} +}; -function App() { +const App = () => { return (

Only base is scoped

@@ -86,7 +86,7 @@ function App() {
); -} +}; describe('Counter', () => { test("parent scope's derived atom is prior to nested scope's scoped base", () => { diff --git a/__tests__/ScopeProvider/05_derived_self.tsx b/__tests__/ScopeProvider/05_derived_self.tsx index e63953b..669a78a 100644 --- a/__tests__/ScopeProvider/05_derived_self.tsx +++ b/__tests__/ScopeProvider/05_derived_self.tsx @@ -12,13 +12,13 @@ const derivedAtom1 = atom( }, ); -function Component({ +const Component = ({ className, initialValue = 0, }: { className: string; initialValue?: number; -}) { +}) => { useHydrateAtoms([[baseAtom, initialValue]]); const [atom1ReadValue, setAtom1Value] = useAtom(derivedAtom1); const atom1WriteValue = setAtom1Value(); @@ -28,9 +28,9 @@ function Component({ {atom1WriteValue} ); -} +}; -function App() { +const App = () => { return ( <>

base component

@@ -43,7 +43,7 @@ function App() { ); -} +}; describe('Self', () => { test('derived dep scope is preserved in self reference', () => { diff --git a/examples/01_isolation/src/App.tsx b/examples/01_isolation/src/App.tsx index 6480734..8fc03e8 100644 --- a/examples/01_isolation/src/App.tsx +++ b/examples/01_isolation/src/App.tsx @@ -5,7 +5,7 @@ const { Provider: MyProvider, useAtom: useMyAtom } = createIsolation(); const countAtom = atom(0); -function Counter() { +const Counter = () => { const [count, setCount] = useAtom(countAtom); return (
@@ -15,9 +15,9 @@ function Counter() {
); -} +}; -function MyCounter() { +const MyCounter = () => { const [count, setCount] = useMyAtom(countAtom); return (
@@ -27,9 +27,9 @@ function MyCounter() {
); -} +}; -function App() { +const App = () => { return (

First Provider

@@ -44,6 +44,6 @@ function App() {
); -} +}; export default App; diff --git a/examples/02_removeScope/src/App.tsx b/examples/02_removeScope/src/App.tsx index b8c4df1..8283ef0 100644 --- a/examples/02_removeScope/src/App.tsx +++ b/examples/02_removeScope/src/App.tsx @@ -7,7 +7,7 @@ const baseAtom1 = atomWithReducer(0, (v) => v + 1); const baseAtom2 = atomWithReducer(0, (v) => v + 1); const shouldHaveScopeAtom = atom(true); -function Counter({ counterClass }: { counterClass: string }) { +const Counter = ({ counterClass }: { counterClass: string }) => { const [base1, increaseBase1] = useAtom(baseAtom1); const [base2, increaseBase2] = useAtom(baseAtom2); return ( @@ -34,18 +34,18 @@ function Counter({ counterClass }: { counterClass: string }) { ); -} +}; -function Wrapper({ children }: PropsWithChildren) { +const Wrapper = ({ children }: PropsWithChildren) => { const shouldHaveScope = useAtomValue(shouldHaveScopeAtom); return shouldHaveScope ? ( {children} ) : ( children ); -} +}; -function ScopeButton() { +const ScopeButton = () => { const [shouldHaveScope, setShouldHaveScope] = useAtom(shouldHaveScopeAtom); return ( ); -} +}; -function App() { +const App = () => { return (

Unscoped

@@ -73,6 +73,6 @@ function App() {
); -} +}; export default App; diff --git a/examples/04_derived/src/App.tsx b/examples/04_derived/src/App.tsx index fe7f52f..c59419a 100644 --- a/examples/04_derived/src/App.tsx +++ b/examples/04_derived/src/App.tsx @@ -16,7 +16,7 @@ const derivedAtom2 = atom( }, ); -function Counter({ counterClass }: { counterClass: string }) { +const Counter = ({ counterClass }: { counterClass: string }) => { const [base, setBase] = useAtom(baseAtom); const [derived1, setDerived1] = useAtom(derivedAtom1); const [derived2, setDerived2] = useAtom(derivedAtom2); @@ -54,9 +54,9 @@ function Counter({ counterClass }: { counterClass: string }) { ); -} +}; -function App() { +const App = () => { return (

Only base is scoped

@@ -84,6 +84,6 @@ function App() {
); -} +}; export default App; diff --git a/src/ScopeProvider/ScopeProvider.tsx b/src/ScopeProvider/ScopeProvider.tsx index 8952f24..a313017 100644 --- a/src/ScopeProvider/ScopeProvider.tsx +++ b/src/ScopeProvider/ScopeProvider.tsx @@ -13,13 +13,13 @@ export const ScopeContext = createContext<{ const patchedStoreSymbol = Symbol(); -export function ScopeProvider({ +export const ScopeProvider = ({ atoms, children, }: { atoms: Iterable; children: ReactNode; -}) { +}) => { const parentStore = useStore(); let { scope: parentScope, baseStore = parentStore } = useContext(ScopeContext); @@ -77,7 +77,7 @@ export function ScopeProvider({ {children} ); -} +}; function isEqualSet(a: Set, b: Set) { return a === b || (a.size === b.size && Array.from(a).every((v) => b.has(v))); diff --git a/src/createIsolation.tsx b/src/createIsolation.tsx index c464524..784905f 100644 --- a/src/createIsolation.tsx +++ b/src/createIsolation.tsx @@ -16,7 +16,7 @@ type AnyWritableAtom = WritableAtom; export function createIsolation() { const StoreContext = createContext(null); - function Provider({ + const Provider = ({ store, initialValues = [], children, @@ -24,7 +24,7 @@ export function createIsolation() { store?: Store; initialValues?: Iterable; children: ReactNode; - }) { + }) => { const storeRef = useRef(store); if (!storeRef.current) { storeRef.current = createStore(); @@ -35,7 +35,7 @@ export function createIsolation() { {children} ); - } + }; const useStore = ((options?: any) => { const store = useContext(StoreContext); diff --git a/src/index.ts b/src/index.ts index 0da2644..d89ec43 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,2 +1,5 @@ export { createIsolation } from './createIsolation'; -export { ScopeProvider } from './ScopeProvider'; +export { + ScopeContext as INTERNAL_ScopeContext, + ScopeProvider, +} from './ScopeProvider'; From f6cf9907412b7a8e4804fa83dd435bdc98c97ef8 Mon Sep 17 00:00:00 2001 From: David Maskasky Date: Fri, 17 May 2024 19:41:09 -0700 Subject: [PATCH 09/12] refactor --- .gitignore | 1 + __tests__/ScopeProvider/01_basic_spec.tsx | 619 +++++++++------- __tests__/ScopeProvider/04_derived.tsx | 845 ++++++++++++---------- src/ScopeProvider/ScopeProvider.tsx | 71 +- src/ScopeProvider/scope.ts | 250 +++++-- 5 files changed, 1056 insertions(+), 730 deletions(-) diff --git a/.gitignore b/.gitignore index 66cc957..f1041cd 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ *.swp node_modules /dist +.vscode diff --git a/__tests__/ScopeProvider/01_basic_spec.tsx b/__tests__/ScopeProvider/01_basic_spec.tsx index e716f72..b69a9b3 100644 --- a/__tests__/ScopeProvider/01_basic_spec.tsx +++ b/__tests__/ScopeProvider/01_basic_spec.tsx @@ -12,18 +12,23 @@ import { ScopeProvider } from '../../src/index'; import { clickButton, getTextContents } from '../utils'; describe('Counter', () => { + /* + base + S0[base]: base0 + S1[base]: base1 + */ test('ScopeProvider provides isolation for scoped primitive atoms', () => { const baseAtom = atomWithReducer(0, (v) => v + 1); - - const Counter = ({ counterClass }: { counterClass: string }) => { + baseAtom.debugLabel = 'base'; + const Counter = ({ level }: { level: string }) => { const [base, increaseBase] = useAtom(baseAtom); return (
- base: {base} + base:{base} @@ -35,19 +40,18 @@ describe('Counter', () => { return (

Unscoped

- +

Scoped Provider

- - + +
); }; const { container } = render(); - const increaseUnscopedBase = '.unscoped.setBase'; - const increaseScopedBase = '.scoped.setBase'; - - const atomValueSelectors = ['.unscoped.base', '.scoped.base']; + const increaseUnscopedBase = '.level0.setBase'; + const increaseScopedBase = '.level1.setBase'; + const atomValueSelectors = ['.level0.base', '.level1.base']; expect(getTextContents(container, atomValueSelectors)).toEqual(['0', '0']); @@ -58,22 +62,25 @@ describe('Counter', () => { expect(getTextContents(container, atomValueSelectors)).toEqual(['1', '1']); }); + /* + base, derived(base) + S0[base]: derived0(base0) + S1[base]: derived0(base1) + */ test('unscoped derived can read and write to scoped primitive atoms', () => { const baseAtom = atomWithReducer(0, (v) => v + 1); const derivedAtom = atom( (get) => get(baseAtom), - (get, set) => { - set(baseAtom, get(baseAtom) + 1); - }, + (get, set) => set(baseAtom, get(baseAtom) + 1), ); - const Counter = ({ counterClass }: { counterClass: string }) => { + const Counter = ({ level }: { level: string }) => { const [derived, increaseFromDerived] = useAtom(derivedAtom); return (
- base: {derived} + base:{derived} @@ -140,11 +152,11 @@ describe('Counter', () => { }; const IncreaseUnscoped = () => { - const setNotScoped = useSetAtom(notScopedAtom); + const increaseNotScoped = useSetAtom(notScopedAtom); return ( +
); @@ -229,19 +258,19 @@ describe('Counter', () => { return (

Unscoped

- +

Scoped Provider

- - + +
); }; const { container } = render(); - const increaseUnscopedBase = '.unscoped.setBase'; - const increaseScopedBase = '.scoped.setBase'; - - const atomValueSelectors = ['.unscoped.base', '.scoped.base']; + const increaseUnscopedBase = '.level0.setBase'; + const increaseScopedBase = '.level1.setBase'; + const increaseScopedDerived = '.level1.setDerived'; + const atomValueSelectors = ['.level0.base', '.level1.base']; expect(getTextContents(container, atomValueSelectors)).toEqual(['0', '0']); @@ -249,28 +278,62 @@ describe('Counter', () => { expect(getTextContents(container, atomValueSelectors)).toEqual(['1', '0']); clickButton(container, increaseScopedBase); - expect(getTextContents(container, atomValueSelectors)).toEqual(['1', '1']); + expect(getTextContents(container, atomValueSelectors)).toEqual(['2', '0']); + + clickButton(container, increaseScopedDerived); + expect(getTextContents(container, atomValueSelectors)).toEqual(['2', '1']); }); + /* + base, derivedA(base), derivemB(base) + S0[derivedA, derivedB]: derivedA0(base0), derivedB0(base0) + S1[derivedA, derivedB]: derivedA1(base1), derivedB1(base1) + */ test('scoped derived atoms can share implicitly scoped dependencies', () => { const baseAtom = atomWithReducer(0, (v) => v + 1); - const derivedAtom1 = atom((get) => get(baseAtom)); - const derivedAtom2 = atom((get) => get(baseAtom)); + baseAtom.debugLabel = 'base'; + const derivedAtomA = atom( + (get) => get(baseAtom), + (_get, set) => set(baseAtom), + ); + derivedAtomA.debugLabel = 'derivedAtomA'; + const derivedAtomB = atom( + (get) => get(baseAtom), + (_get, set) => set(baseAtom), + ); + derivedAtomB.debugLabel = 'derivedAtomB'; - const Counter = ({ counterClass }: { counterClass: string }) => { - const increaseBase = useSetAtom(baseAtom); - const derived = useAtomValue(derivedAtom1); - const derived2 = useAtomValue(derivedAtom2); + const Counter = ({ level }: { level: string }) => { + const setBase = useSetAtom(baseAtom); + const [derivedA, setDerivedA] = useAtom(derivedAtomA); + const [derivedB, setDerivedB] = useAtom(derivedAtomB); return (
- base: {derived} - base2: {derived2} + base:{derivedA} + derivedA: + {derivedA} + derivedB: + {derivedB} + +
); @@ -280,55 +343,77 @@ describe('Counter', () => { return (

Unscoped

- +

Scoped Provider

- - + +
); }; const { container } = render(); - const increaseUnscopedBase = '.unscoped.setBase'; - const increaseScopedBase = '.scoped.setBase'; - + const increaseLevel0Base = '.level0.setBase'; + const increaseLevel1Base = '.level1.setBase'; + const increaseLevel1DerivedA = '.level1.setDerivedA'; + const increaseLevel1DerivedB = '.level1.setDerivedB'; const atomValueSelectors = [ - '.unscoped.base', - '.scoped.base', - '.scoped.base2', + '.level0.derivedA', + '.level1.derivedA', + '.level1.derivedB', ]; expect(getTextContents(container, atomValueSelectors)).toEqual([ - '0', - '0', - '0', + '0', // level0 derivedA + '0', // level1 derivedA + '0', // level1 derivedB ]); - clickButton(container, increaseUnscopedBase); + clickButton(container, increaseLevel0Base); expect(getTextContents(container, atomValueSelectors)).toEqual([ - '1', - '0', - '0', + '1', // level0 derivedA + '0', // level1 derivedA + '0', // level1 derivedB ]); - clickButton(container, increaseScopedBase); + clickButton(container, increaseLevel1Base); expect(getTextContents(container, atomValueSelectors)).toEqual([ - '1', - '1', - '1', + '2', // level0 derivedA + '0', // level1 derivedA + '0', // level1 derivedB + ]); + + clickButton(container, increaseLevel1DerivedA); + expect(getTextContents(container, atomValueSelectors)).toEqual([ + '2', // level0 derivedA + '1', // level1 derivedA + '1', // level1 derivedB + ]); + + clickButton(container, increaseLevel1DerivedB); + expect(getTextContents(container, atomValueSelectors)).toEqual([ + '2', // level0 derivedA + '2', // level1 derivedA + '2', // level1 derivedB ]); }); + /* + base, derivedA(base), derivedB(base), + S0[base]: base0 + S1[base]: base1 + S2[base]: base2 + S3[base]: base3 + */ test('nested scopes provide isolation for primitive atoms at every level', () => { const baseAtom = atomWithReducer(0, (v) => v + 1); - const Counter = ({ counterClass }: { counterClass: string }) => { + const Counter = ({ level }: { level: string }) => { const [base, increaseBase] = useAtom(baseAtom); return (
- base: {base} + base:{base} @@ -444,153 +539,159 @@ describe('Counter', () => { return (

Unscoped

- +

Scoped Provider

- - - - + + + +
); }; const { container } = render(); - const increaseLevel0Base = '.level0.increaseBase'; - const increaseLevel1Base = '.level1.increaseBase'; - const increaseLevel2Base = '.level2.increaseBase'; + const increaseLevel0BaseA = '.level0.increaseBase'; + const increaseLevel1BaseB = '.level1.increaseBase'; + const increaseLevel2BaseC = '.level2.increaseBase'; const increaseLevel0All = '.level0.increaseAll'; const increaseLevel1All = '.level1.increaseAll'; const increaseLevel2All = '.level2.increaseAll'; - const atomValueSelectors = [ - '.level0.base0', - '.level0.base1', - '.level0.base2', - '.level1.base0', - '.level1.base1', - '.level1.base2', - '.level2.base0', - '.level2.base1', - '.level2.base2', + '.level0.baseA', + '.level0.baseB', + '.level0.baseC', + '.level1.baseA', + '.level1.baseB', + '.level1.baseC', + '.level2.baseA', + '.level2.baseB', + '.level2.baseC', ]; expect(getTextContents(container, atomValueSelectors)).toEqual([ - '0', // level0 base0 - '0', // level0 base1 - '0', // level0 base2 - '0', // level1 base0 - '0', // level1 base1 - '0', // level1 base2 - '0', // level2 base0 - '0', // level2 base1 - '0', // level2 base2 + '0', // level0 baseA + '0', // level0 baseB + '0', // level0 baseC + '0', // level1 baseA + '0', // level1 baseB + '0', // level1 baseC + '0', // level2 baseA + '0', // level2 baseB + '0', // level2 baseC ]); - clickButton(container, increaseLevel0Base); + clickButton(container, increaseLevel0BaseA); expect(getTextContents(container, atomValueSelectors)).toEqual([ - '1', // level0 base0 - '0', // level0 base1 - '0', // level0 base2 - '1', // level1 base0 - '0', // level1 base1 - '0', // level1 base2 - '1', // level2 base0 - '0', // level2 base1 - '0', // level2 base2 + '1', // level0 baseA + '0', // level0 baseB + '0', // level0 baseC + '1', // level1 baseA + '0', // level1 baseB + '0', // level1 baseC + '1', // level2 baseA + '0', // level2 baseB + '0', // level2 baseC ]); - clickButton(container, increaseLevel1Base); + clickButton(container, increaseLevel1BaseB); expect(getTextContents(container, atomValueSelectors)).toEqual([ - '1', // level0 base0 - '0', // level0 base1 - '0', // level0 base2 - '1', // level1 base0 - '1', // level1 base1 - '0', // level1 base2 - '1', // level2 base0 - '1', // level2 base1 - '0', // level2 base2 + '1', // level0 baseA + '0', // level0 baseB + '0', // level0 baseC + '1', // level1 baseA + '1', // level1 baseB + '0', // level1 baseC + '1', // level2 baseA + '1', // level2 baseB + '0', // level2 baseC ]); - clickButton(container, increaseLevel2Base); + clickButton(container, increaseLevel2BaseC); expect(getTextContents(container, atomValueSelectors)).toEqual([ - '1', // level0 base0 - '0', // level0 base1 - '0', // level0 base2 - '1', // level1 base0 - '1', // level1 base1 - '0', // level1 base2 - '1', // level2 base0 - '1', // level2 base1 - '1', // level2 base2 + '1', // level0 baseA + '0', // level0 baseB + '0', // level0 baseC + '1', // level1 baseA + '1', // level1 baseB + '0', // level1 baseC + '1', // level2 baseA + '1', // level2 baseB + '1', // level2 baseC ]); clickButton(container, increaseLevel0All); expect(getTextContents(container, atomValueSelectors)).toEqual([ - '2', // level0 base0 - '1', // level0 base1 - '1', // level0 base2 - '2', // level1 base0 - '1', // level1 base1 - '0', // level1 base2 - '2', // level2 base0 - '1', // level2 base1 - '1', // level2 base2 + '2', // level0 baseA + '1', // level0 baseB + '1', // level0 baseC + '2', // level1 baseA + '1', // level1 baseB + '1', // level1 baseC + '2', // level2 baseA + '1', // level2 baseB + '1', // level2 baseC ]); clickButton(container, increaseLevel1All); expect(getTextContents(container, atomValueSelectors)).toEqual([ - '3', // level0 base0 - '1', // level0 base1 - '1', // level0 base2 - '3', // level1 base0 - '2', // level1 base1 - '1', // level1 base2 - '3', // level2 base0 - '2', // level2 base1 - '1', // level2 base2 + '3', // level0 baseA + '1', // level0 baseB + '2', // level0 baseC + '3', // level1 baseA + '2', // level1 baseB + '2', // level1 baseC + '3', // level2 baseA + '2', // level2 baseB + '1', // level2 baseC ]); clickButton(container, increaseLevel2All); expect(getTextContents(container, atomValueSelectors)).toEqual([ - '4', // level0 base0 - '1', // level0 base1 - '1', // level0 base2 - '4', // level1 base0 - '3', // level1 base1 - '1', // level1 base2 - '4', // level2 base0 - '3', // level2 base1 - '2', // level2 base2 + '4', // level0 baseA + '1', // level0 baseB + '2', // level0 baseC + '4', // level1 baseA + '3', // level1 baseB + '2', // level1 baseC + '4', // level2 baseA + '3', // level2 baseB + '2', // level2 baseC ]); }); - test.skip('inherited scoped derived atoms can read and write to scoped primitive atoms at every nested level', () => { - const base1Atom = atom(0); - base1Atom.debugLabel = 'base1Atom'; - const base2Atom = atom(0); - base2Atom.debugLabel = 'base2Atom'; + /* + baseA, baseB, derived(baseA + baseB) + S1[baseB, derived]: derived1(baseA1 + baseB1) + S2[baseB]: derived1(baseA1 + baseB2) + */ + test('inherited scoped derived atoms can read and write to scoped primitive atoms at every nested level', () => { + const baseAAtom = atomWithReducer(0, (v) => v + 1); + baseAAtom.debugLabel = 'baseA'; + + const baseBAtom = atomWithReducer(0, (v) => v + 1); + baseBAtom.debugLabel = 'baseB'; + const derivedAtom = atom( (get) => ({ - base1: get(base1Atom), - base2: get(base2Atom), + baseA: get(baseAAtom), + baseB: get(baseBAtom), }), - (get, set) => { - set(base1Atom, get(base1Atom) + 1); - set(base2Atom, get(base2Atom) + 1); + (_get, set) => { + set(baseAAtom); + set(baseBAtom); }, ); - derivedAtom.debugLabel = 'derivedAtom'; + derivedAtom.debugLabel = 'derived'; - const Counter = ({ counterClass }: { counterClass: string }) => { - const [{ base1, base2 }, increaseAll] = useAtom(derivedAtom); + const Counter = ({ level }: { level: string }) => { + const [{ baseA, baseB }, increaseAll] = useAtom(derivedAtom); return (
- level1: {base1} - level2: {base2} + baseA:{baseA} + baseB:{baseB} -
-
- derived1: {derived1} - -
-
- derived2: {derived2} - -
- +const atomValueSelectors = [ + '.case1.base', + '.case1.derivedA', + '.case1.derivedB', + '.case2.base', + '.case2.derivedA', + '.case2.derivedB', + '.layer1.base', + '.layer1.derivedA', + '.layer1.derivedB', + '.layer2.base', + '.layer2.derivedA', + '.layer2.derivedB', +]; + +function clickButtonGetResults(buttonSelector: string) { + const baseAtom = atom(0); + const derivedAtomA = atom( + (get) => get(baseAtom), + (get, set) => { + set(baseAtom, get(baseAtom) + 1); + }, ); -}; - -const App = () => { - return ( -
-

Only base is scoped

-

derived1 and derived2 should also be scoped

- - - -

Both derived1 an derived2 are scoped

-

base should be global, derived1 and derived2 are shared

- - - -

Layer1: Only derived1 is scoped

-

base and derived2 should be global

- - -

Layer2: Base and derived2 are scoped

-

- derived1 should use layer2's atom, base and derived2 are layer 2 - scoped -

- - - -
-
+ + const derivedAtomB = atom( + (get) => get(baseAtom), + (get, set) => { + set(baseAtom, get(baseAtom) + 1); + }, ); -}; + + const Counter = ({ counterClass }: { counterClass: string }) => { + const [base, setBase] = useAtom(baseAtom); + const [derivedA, setDerivedA] = useAtom(derivedAtomA); + const [derivedB, setDerivedB] = useAtom(derivedAtomB); + return ( + <> +
+ base:{base} + +
+
+ derivedA: + {derivedA} + +
+
+ derivedB: + {derivedB} + +
+ + ); + }; + + const App = () => { + return ( +
+

Only base is scoped

+

derivedA and derivedB should also be scoped

+ + + +

Both derivedA an derivedB are scoped

+

base should be global, derivedA and derivedB are shared

+ + + +

Layer1: Only derivedA is scoped

+

base and derivedB should be global

+ + +

Layer2: Base and derivedB are scoped

+

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

+ + + +
+
+ ); + }; + + const { container } = render(); + expectAllZeroes(container); + clickButton(container, buttonSelector); + return getTextContents(container, atomValueSelectors); +} + +function expectAllZeroes(container: HTMLElement) { + expect(getTextContents(container, atomValueSelectors)).toEqual([ + // case 1 + '0', // base + '0', // derivedA + '0', // derivedB + + // case 2 + '0', // base + '0', // derivedA + '0', // derivedB + + // layer 1 + '0', // base + '0', // derivedA + '0', // derivedB + + // layer 2 + '0', // base + '0', // derivedA + '0', // derivedB + ]); +} describe('Counter', () => { test("parent scope's derived atom is prior to nested scope's scoped base", () => { const increaseCase1Base = '.case1.setBase'; - const increaseCase1Derived1 = '.case1.setDerived1'; - const increaseCase1Derived2 = '.case1.setDerived2'; + const increaseCase1DerivedA = '.case1.setDerivedA'; + const increaseCase1DerivedB = '.case1.setDerivedB'; const increaseCase2Base = '.case2.setBase'; - const increaseCase2Derived1 = '.case2.setDerived1'; - const increaseCase2Derived2 = '.case2.setDerived2'; + const increaseCase2DerivedA = '.case2.setDerivedA'; + const increaseCase2DerivedB = '.case2.setDerivedB'; const increaseLayer1Base = '.layer1.setBase'; - const increaseLayer1Derived1 = '.layer1.setDerived1'; - const increaseLayer1Derived2 = '.layer1.setDerived2'; + const increaseLayer1DerivedA = '.layer1.setDerivedA'; + const increaseLayer1DerivedB = '.layer1.setDerivedB'; const increaseLayer2Base = '.layer2.setBase'; - const increaseLayer2Derived1 = '.layer2.setDerived1'; - const increaseLayer2Derived2 = '.layer2.setDerived2'; - - const atomValueSelectors = [ - '.case1.base', - '.case1.derived1', - '.case1.derived2', - '.case2.base', - '.case2.derived1', - '.case2.derived2', - '.layer1.base', - '.layer1.derived1', - '.layer1.derived2', - '.layer2.base', - '.layer2.derived1', - '.layer2.derived2', - ]; - - const { container } = render(); - - expect(getTextContents(container, atomValueSelectors)).toEqual([ - // case 1: baseAtom scoped - '0', // .case1.base - '0', // .case1.derived1 - '0', // .case1.derived2 - - // case 2: derivedAtom1 and derivedAtom2 scoped - '0', // .case2.base - '0', // .case2.derived1 - '0', // .case2.derived2 - - // layer1: derivedAtom1 scoped - '0', // .layer1.base - '0', // .layer1.derived1 - '0', // .layer1.derived2 - - // layer2: baseAtom and derivedAtom2 scoped (nested in layer1) - '0', // .layer2.base - '0', // .layer2.derived1 - '0', // .layer2.derived2 - ]); - - clickButton(container, increaseCase1Base); - expect(getTextContents(container, atomValueSelectors)).toEqual([ - // case 1: baseAtom scoped - '1', // .case1.base - '1', // .case1.derived1 - '1', // .case1.derived2 - - // case 2: derivedAtom1 and derivedAtom2 scoped - '0', // .case2.base - '0', // .case2.derived1 - '0', // .case2.derived2 - - // layer1: derivedAtom1 scoped - '0', // .layer1.base - '0', // .layer1.derived1 - '0', // .layer1.derived2 - - // layer2: baseAtom and derivedAtom2 scoped (nested in layer1) - '0', // .layer2.base - '0', // .layer2.derived1 - '0', // .layer2.derived2 + const increaseLayer2DerivedA = '.layer2.setDerivedA'; + const increaseLayer2DerivedB = '.layer2.setDerivedB'; + + /* + base, derivedA(base), derivedB(base) + case1[base]: base1, derivedA0(base1), derivedB0(base1) + case2[derivedA, derivedB]: base0, derivedA1(base1), derivedB1(base1) + layer1[derivedA]: base0, derivedA1(base1), derivedB0(base0) + layer2[base, derivedB]: base2, derivedA1(base2), derivedB2(base2) + */ + expect(clickButtonGetResults(increaseCase1Base)).toEqual([ + // case 1 + '1', // base + '1', // derivedA + '1', // derivedB + + // case 2 + '0', // base + '0', // derivedA + '0', // derivedB + + // layer 1 + '0', // base + '0', // derivedA + '0', // derivedB + + // layer 2 + '0', // base + '0', // derivedA + '0', // derivedB ]); - clickButton(container, increaseCase1Derived1); - expect(getTextContents(container, atomValueSelectors)).toEqual([ - // case 1: baseAtom scoped - '2', // .case1.base - '2', // .case1.derived1 - '2', // .case1.derived2 - - // case 2: derivedAtom1 and derivedAtom2 scoped - '0', // .case2.base - '0', // .case2.derived1 - '0', // .case2.derived2 - - // layer1: derivedAtom1 scoped - '0', // .layer1.base - '0', // .layer1.derived1 - '0', // .layer1.derived2 - - // layer2: baseAtom and derivedAtom2 scoped (nested in layer1) - '0', // .layer2.base - '0', // .layer2.derived1 - '0', // .layer2.derived2 + /* + base, derivedA(base), derivedB(base) + case1[base]: base1, derivedA0(base1), derivedB0(base1) + case2[derivedA, derivedB]: base0, derivedA1(base1), derivedB1(base1) + layer1[derivedA]: base0, derivedA1(base1), derivedB0(base0) + layer2[base, derivedB]: base2, derivedA1(base2), derivedB2(base2) + */ + expect(clickButtonGetResults(increaseCase1DerivedA)).toEqual([ + // case 1 + '1', // base + '1', // derivedA + '1', // derivedB + + // case 2 + '0', // base + '0', // derivedA + '0', // derivedB + + // layer 1 + '0', // base + '0', // derivedA + '0', // derivedB + + // layer 2 + '0', // base + '0', // derivedA + '0', // derivedB ]); - clickButton(container, increaseCase1Derived2); - expect(getTextContents(container, atomValueSelectors)).toEqual([ - // case 1: baseAtom scoped - '3', // .case1.base - '3', // .case1.derived1 - '3', // .case1.derived2 - - // case 2: derivedAtom1 and derivedAtom2 scoped - '0', // .case2.base - '0', // .case2.derived1 - '0', // .case2.derived2 - - // layer1: derivedAtom1 scoped - '0', // .layer1.base - '0', // .layer1.derived1 - '0', // .layer1.derived2 - - // layer2: baseAtom and derivedAtom2 scoped (nested in layer1) - '0', // .layer2.base - '0', // .layer2.derived1 - '0', // .layer2.derived2 + /* + base, derivedA(base), derivedB(base) + case1[base]: base1, derivedA0(base1), derivedB0(base1) + case2[derivedA, derivedB]: base0, derivedA1(base1), derivedB1(base1) + layer1[derivedA]: base0, derivedA1(base1), derivedB0(base0) + layer2[base, derivedB]: base2, derivedA1(base2), derivedB2(base2) + */ + expect(clickButtonGetResults(increaseCase1DerivedB)).toEqual([ + // case 1 + '1', // base + '1', // derivedA + '1', // derivedB + + // case 2 + '0', // base + '0', // derivedA + '0', // derivedB + + // layer 1 + '0', // base + '0', // derivedA + '0', // derivedB + + // layer 2 + '0', // base + '0', // derivedA + '0', // derivedB ]); - clickButton(container, increaseCase2Base); - expect(getTextContents(container, atomValueSelectors)).toEqual([ - // case 1: baseAtom scoped - '3', // .case1.base - '3', // .case1.derived1 - '3', // .case1.derived2 - - // case 2: derivedAtom1 and derivedAtom2 scoped - '1', // .case2.base - '0', // .case2.derived1 - '0', // .case2.derived2 - - // layer1: derivedAtom1 scoped - '1', // .layer1.base - '0', // .layer1.derived1 - '1', // .layer1.derived2 - - // layer2: baseAtom and derivedAtom2 scoped (nested in layer1) - '0', // .layer2.base - '0', // .layer2.derived1 - '0', // .layer2.derived2 + /* + base, derivedA(base), derivedB(base) + case1[base]: base1, derivedA0(base1), derivedB0(base1) + case2[derivedA, derivedB]: base0, derivedA1(base1), derivedB1(base1) + layer1[derivedA]: base0, derivedA1(base1), derivedB0(base0) + layer2[base, derivedB]: base2, derivedA1(base2), derivedB2(base2) + */ + expect(clickButtonGetResults(increaseCase2Base)).toEqual([ + // case 1 + '0', // base + '0', // derivedA + '0', // derivedB + + // case 2 + '1', // base + '0', // derivedA + '0', // derivedB + + // layer 1 + '1', // base + '0', // derivedA + '1', // derivedB + + // layer 2 + '0', // base + '0', // derivedA + '0', // derivedB ]); - clickButton(container, increaseCase2Derived1); - expect(getTextContents(container, atomValueSelectors)).toEqual([ - // case 1: baseAtom scoped - '3', // .case1.base - '3', // .case1.derived1 - '3', // .case1.derived2 - - // case 2: derivedAtom1 and derivedAtom2 scoped - '1', // .case2.base - '1', // .case2.derived1 - '1', // .case2.derived2 - - // layer1: derivedAtom1 scoped - '1', // .layer1.base - '0', // .layer1.derived1 - '1', // .layer1.derived2 - - // layer2: baseAtom and derivedAtom2 scoped (nested in layer1) - '0', // .layer2.base - '0', // .layer2.derived1 - '0', // .layer2.derived2 + /* + base, derivedA(base), derivedB(base) + case1[base]: base1, derivedA0(base1), derivedB0(base1) + case2[derivedA, derivedB]: base0, derivedA1(base1), derivedB1(base1) + layer1[derivedA]: base0, derivedA1(base1), derivedB0(base0) + layer2[base, derivedB]: base2, derivedA1(base2), derivedB2(base2) + */ + expect(clickButtonGetResults(increaseCase2DerivedA)).toEqual([ + // case 1: case 1 + '0', // base actual: 0, + '0', // derivedA actual: 0, + '0', // derivedB actual: 0, + + // case 2 + '0', // base actual: 1, + '1', // derivedA actual: 1, + '1', // derivedB actual: 1, + + // layer 1 + '0', // base actual: 1, + '0', // derivedA actual: 0, + '0', // derivedB actual: 1, + + // layer 2 + '0', // base actual: 0, + '0', // derivedA actual: 0, + '0', // derivedB actual: 0 ]); - clickButton(container, increaseCase2Derived2); - expect(getTextContents(container, atomValueSelectors)).toEqual([ - // case 1: baseAtom scoped - '3', // .case1.base - '3', // .case1.derived1 - '3', // .case1.derived2 - - // case 2: derivedAtom1 and derivedAtom2 scoped - '1', // .case2.base - '2', // .case2.derived1 - '2', // .case2.derived2 - - // layer1: derivedAtom1 scoped - '1', // .layer1.base - '0', // .layer1.derived1 - '1', // .layer1.derived2 - - // layer2: baseAtom and derivedAtom2 scoped (nested in layer1) - '0', // .layer2.base - '0', // .layer2.derived1 - '0', // .layer2.derived2 + /* + base, derivedA(base), derivedB(base) + case1[base]: base1, derivedA0(base1), derivedB0(base1) + case2[derivedA, derivedB]: base0, derivedA1(base1), derivedB1(base1) + layer1[derivedA]: base0, derivedA1(base1), derivedB0(base0) + layer2[base, derivedB]: base2, derivedA1(base2), derivedB2(base2) + */ + expect(clickButtonGetResults(increaseCase2DerivedB)).toEqual([ + // case 1 + '0', // base + '0', // derivedA + '0', // derivedB + + // case 2 + '0', // base + '1', // derivedA + '1', // derivedB + + // layer 1 + '0', // base + '0', // derivedA + '0', // derivedB + + // layer 2 + '0', // base + '0', // derivedA + '0', // derivedB ]); - clickButton(container, increaseLayer1Base); - expect(getTextContents(container, atomValueSelectors)).toEqual([ - // case 1: baseAtom scoped - '3', // .case1.base - '3', // .case1.derived1 - '3', // .case1.derived2 - - // case 2: derivedAtom1 and derivedAtom2 scoped - '2', // .case2.base - '2', // .case2.derived1 - '2', // .case2.derived2 - - // layer1: derivedAtom1 scoped - '2', // .layer1.base - '0', // .layer1.derived1 - '2', // .layer1.derived2 - - // layer2: baseAtom and derivedAtom2 scoped (nested in layer1) - '0', // .layer2.base - '0', // .layer2.derived1 - '0', // .layer2.derived2 + /* + base, derivedA(base), derivedB(base) + case1[base]: base1, derivedA0(base1), derivedB0(base1) + case2[derivedA, derivedB]: base0, derivedA1(base1), derivedB1(base1) + layer1[derivedA]: base0, derivedA1(base1), derivedB0(base0) + layer2[base, derivedB]: base2, derivedA1(base2), derivedB2(base2) + */ + expect(clickButtonGetResults(increaseLayer1Base)).toEqual([ + // case 1 + '0', // base + '0', // derivedA + '0', // derivedB + + // case 2 + '1', // base + '0', // derivedA + '0', // derivedB + + // layer 1 + '1', // base + '0', // derivedA + '1', // derivedB + + // layer 2 + '0', // base + '0', // derivedA + '0', // derivedB ]); - clickButton(container, increaseLayer1Derived1); - expect(getTextContents(container, atomValueSelectors)).toEqual([ - // case 1: baseAtom scoped - '3', // .case1.base - '3', // .case1.derived1 - '3', // .case1.derived2 - - // case 2: derivedAtom1 and derivedAtom2 scoped - '2', // .case2.base - '2', // .case2.derived1 - '2', // .case2.derived2 - - // layer1: derivedAtom1 scoped - '2', // .layer1.base - '1', // .layer1.derived1 - '2', // .layer1.derived2 - - // layer2: baseAtom and derivedAtom2 scoped (nested in layer1) - '0', // .layer2.base - '0', // .layer2.derived1 - '0', // .layer2.derived2 + /* + base, derivedA(base), derivedB(base) + case1[base]: base1, derivedA0(base1), derivedB0(base1) + case2[derivedA, derivedB]: base0, derivedA1(base1), derivedB1(base1) + layer1[derivedA]: base0, derivedA1(base1), derivedB0(base0) + layer2[base, derivedB]: base2, derivedA1(base2), derivedB2(base2) + */ + expect(clickButtonGetResults(increaseLayer1DerivedA)).toEqual([ + // case 1 + '0', // base + '0', // derivedA + '0', // derivedB + + // case 2 + '0', // base + '0', // derivedA + '0', // derivedB + + // layer 1 + '0', // base + '1', // derivedA + '0', // derivedB + + // layer 2 + '0', // base + '0', // derivedA + '0', // derivedB ]); - clickButton(container, increaseLayer1Derived2); - expect(getTextContents(container, atomValueSelectors)).toEqual([ - // case 1: baseAtom scoped - '3', // .case1.base - '3', // .case1.derived1 - '3', // .case1.derived2 - - // case 2: derivedAtom1 and derivedAtom2 scoped - '3', // .case2.base - '2', // .case2.derived1 - '2', // .case2.derived2 - - // layer1: derivedAtom1 scoped - '3', // .layer1.base - '1', // .layer1.derived1 - '3', // .layer1.derived2 - - // layer2: baseAtom and derivedAtom2 scoped (nested in layer1) - '0', // .layer2.base - '0', // .layer2.derived1 - '0', // .layer2.derived2 + /* + base, derivedA(base), derivedB(base) + case1[base]: base1, derivedA0(base1), derivedB0(base1) + case2[derivedA, derivedB]: base0, derivedA1(base1), derivedB1(base1) + layer1[derivedA]: base0, derivedA1(base1), derivedB0(base0) + layer2[base, derivedB]: base2, derivedA1(base2), derivedB2(base2) + */ + expect(clickButtonGetResults(increaseLayer1DerivedB)).toEqual([ + // case 1 + '0', // base + '0', // derivedA + '0', // derivedB + + // case 2 + '1', // base + '0', // derivedA + '0', // derivedB + + // layer 1 + '1', // base + '0', // derivedA + '1', // derivedB + + // layer 2 + '0', // base + '0', // derivedA + '0', // derivedB ]); - clickButton(container, increaseLayer2Base); - expect(getTextContents(container, atomValueSelectors)).toEqual([ - // case 1: baseAtom scoped - '3', // .case1.base - '3', // .case1.derived1 - '3', // .case1.derived2 - - // case 2: derivedAtom1 and derivedAtom2 scoped - '3', // .case2.base - '2', // .case2.derived1 - '2', // .case2.derived2 - - // layer1: derivedAtom1 scoped - '3', // .layer1.base - '1', // .layer1.derived1 - '3', // .layer1.derived2 - - // layer2: baseAtom and derivedAtom2 scoped (nested in layer1) - '1', // .layer2.base - '1', // .layer2.derived1 - '1', // .layer2.derived2 + /* + base, derivedA(base), derivedB(base) + case1[base]: base1, derivedA0(base1), derivedB0(base1) + case2[derivedA, derivedB]: base0, derivedA1(base1), derivedB1(base1) + layer1[derivedA]: base0, derivedA1(base1), derivedB0(base0) + layer2[base, derivedB]: base2, derivedA1(base2), derivedB2(base2) + */ + expect(clickButtonGetResults(increaseLayer2Base)).toEqual([ + // case 1 + '0', // base + '0', // derivedA + '0', // derivedB + + // case 2 + '0', // base + '0', // derivedA + '0', // derivedB + + // layer 1 + '0', // base + '0', // derivedA + '0', // derivedB + + // layer 2 + '1', // base + '1', // derivedA + '1', // derivedB ]); - clickButton(container, increaseLayer2Derived1); - expect(getTextContents(container, atomValueSelectors)).toEqual([ - // case 1: baseAtom scoped - '3', // .case1.base - '3', // .case1.derived1 - '3', // .case1.derived2 - - // case 2: derivedAtom1 and derivedAtom2 scoped - '3', // .case2.base - '2', // .case2.derived1 - '2', // .case2.derived2 - - // layer1: derivedAtom1 scoped - '3', // .layer1.base - '2', // .layer1.derived1 - '3', // .layer1.derived2 - - // layer2: baseAtom and derivedAtom2 scoped (nested in layer1) - '2', // .layer2.base - '2', // .layer2.derived1 - '2', // .layer2.derived2 + /* + base, derivedA(base), derivedB(base) + case1[base]: base1, derivedA0(base1), derivedB0(base1) + case2[derivedA, derivedB]: base0, derivedA1(base1), derivedB1(base1) + layer1[derivedA]: base0, derivedA1(base1), derivedB0(base0) + layer2[base, derivedB]: base2, derivedA1(base2), derivedB2(base2) + */ + expect(clickButtonGetResults(increaseLayer2DerivedA)).toEqual([ + // case 1 + '0', // base + '0', // derivedA + '0', // derivedB + + // case 2 + '0', // base + '0', // derivedA + '0', // derivedB + + // layer 1 + '0', // base + '0', // derivedA + '0', // derivedB + + // layer 2 + '1', // base + '1', // derivedA + '1', // derivedB ]); - clickButton(container, increaseLayer2Derived2); - expect(getTextContents(container, atomValueSelectors)).toEqual([ - // case 1: baseAtom scoped - '3', // .case1.base - '3', // .case1.derived1 - '3', // .case1.derived2 - - // case 2: derivedAtom1 and derivedAtom2 scoped - '3', // .case2.base - '2', // .case2.derived1 - '2', // .case2.derived2 - - // layer1: derivedAtom1 scoped - '3', // .layer1.base - '2', // .layer1.derived1 - '3', // .layer1.derived2 - - // layer2: baseAtom and derivedAtom2 scoped (nested in layer1) - '3', // .layer2.base - '3', // .layer2.derived1 - '3', // .layer2.derived2 + /* + base, derivedA(base), derivedB(base) + case1[base]: base1, derivedA0(base1), derivedB0(base1) + case2[derivedA, derivedB]: base0, derivedA1(base1), derivedB1(base1) + layer1[derivedA]: base0, derivedA1(base1), derivedB0(base0) + layer2[base, derivedB]: base2, derivedA1(base2), derivedB2(base2) + */ + expect(clickButtonGetResults(increaseLayer2DerivedB)).toEqual([ + // case 1 + '0', // base + '0', // derivedA + '0', // derivedB + + // case 2 + '0', // base + '0', // derivedA + '0', // derivedB + + // layer 1 + '0', // base + '0', // derivedA + '0', // derivedB + + // layer 2 + '1', // base + '1', // derivedA + '1', // derivedB ]); }); }); diff --git a/src/ScopeProvider/ScopeProvider.tsx b/src/ScopeProvider/ScopeProvider.tsx index a313017..181265b 100644 --- a/src/ScopeProvider/ScopeProvider.tsx +++ b/src/ScopeProvider/ScopeProvider.tsx @@ -1,29 +1,29 @@ import { Provider, useStore } from 'jotai/react'; -import { type ReactNode, createContext, useContext, useState } from 'react'; +import { + createContext, + useContext, + useState, + type PropsWithChildren, +} from 'react'; import { createScope, type Scope } from './scope'; -import { AnyAtom, Store } from './types'; +import type { AnyAtom, Store } from './types'; export const ScopeContext = createContext<{ scope: Scope | undefined; baseStore: Store | undefined; -}>({ - scope: undefined, - baseStore: undefined, -}); - -const patchedStoreSymbol = Symbol(); +}>({ scope: undefined, baseStore: undefined }); export const ScopeProvider = ({ atoms, children, -}: { - atoms: Iterable; - children: ReactNode; -}) => { - const parentStore = useStore(); + debugName, +}: PropsWithChildren<{ atoms: Iterable; debugName?: string }>) => { + const parentStore: Store = useStore(); let { scope: parentScope, baseStore = parentStore } = useContext(ScopeContext); - if (!(patchedStoreSymbol in parentStore)) { + // if this scope is the first descendant scope under Provider then we don't want to inherit parentScope + // https://github.com/jotaijs/jotai-scope/pull/33#discussion_r1604268003 + if (isTopLevelScope(parentStore)) { parentScope = undefined; baseStore = parentStore; } @@ -34,20 +34,37 @@ export const ScopeProvider = ({ const atomSet = new Set(atoms); function initialize() { - const scope = createScope(atoms, parentScope); - const patchedStore: Store & { [patchedStoreSymbol]: true } = { - // TODO: update this patch to support devtools + const scope = createScope(atoms, parentScope, debugName); + + /** + * When an atom is accessed via useAtomValue/useSetAtom, the access should + * be handled by a router atom copy. + */ + const patchedStore: PatchedStore = { ...baseStore, - get(anAtom) { - return baseStore.get(scope.getAtom(anAtom)); + get(anAtom, ...args) { + const [scopedAtom] = scope.getAtom(anAtom); + return baseStore.get(scopedAtom, ...args); }, set(anAtom, ...args) { - return baseStore.set(scope.getAtom(anAtom), ...args); + const [scopedAtom, implicitScope] = scope.getAtom(anAtom); + const restore = scope.prepareWriteAtom( + scopedAtom, + anAtom, + implicitScope, + ); + try { + return baseStore.set(scopedAtom, ...args); + } finally { + restore?.(); + } }, sub(anAtom, ...args) { - return baseStore.sub(scope.getAtom(anAtom), ...args); + const [scopedAtom] = scope.getAtom(anAtom); + return baseStore.sub(scopedAtom, ...args); }, - [patchedStoreSymbol]: true, + [isPatchedStore]: true, + // TODO: update this patch to support devtools }; return { @@ -82,3 +99,13 @@ export const ScopeProvider = ({ function isEqualSet(a: Set, b: Set) { return a === b || (a.size === b.size && Array.from(a).every((v) => b.has(v))); } + +/** + * @returns true if the current scope is the first descendant scope under Provider + */ +function isTopLevelScope(parentStore: Store) { + return !(isPatchedStore in parentStore); +} + +const isPatchedStore = Symbol(); +type PatchedStore = Store & { [isPatchedStore]: true }; diff --git a/src/ScopeProvider/scope.ts b/src/ScopeProvider/scope.ts index 1b8fbc3..9055494 100644 --- a/src/ScopeProvider/scope.ts +++ b/src/ScopeProvider/scope.ts @@ -2,114 +2,212 @@ import { atom, type Atom } from 'jotai'; import { type AnyAtom, type AnyWritableAtom } from './types'; export type Scope = { - getAtom: (originalAtom: T, fromScoped?: boolean) => T; + /** + * Returns a scoped atom from the original atom. + * @param anAtom + * @param implicitScope the atom is implicitly scoped in the provided scope + * @returns the scoped atom and the scope of the atom + */ + getAtom: (anAtom: T, implicitScope?: Scope) => [T, Scope?]; + /** + * @modifies the atom's write function for atoms that can hold a value + * @returns a function to restore the original write function + */ + prepareWriteAtom: ( + anAtom: T, + originalAtom: T, + implicitScope?: Scope, + ) => (() => void) | undefined; + + /** + * @debug + */ + name?: string; }; export function createScope( atoms: Iterable, parentScope: Scope | undefined, + scopeName?: string | undefined, ): Scope { - const explicit = new WeakMap(); - const implicit = new WeakMap(); - const inherited = new WeakMap(); - const unscoped = new WeakMap(); + const explicit = new WeakMap(); + const implicit = new WeakMap(); + const inherited = new WeakMap(); + + const currentScope: Scope = { + getAtom, + prepareWriteAtom(anAtom, originalAtom, implicitScope) { + if ( + originalAtom.read === defaultRead && + isWritableAtom(originalAtom) && + isWritableAtom(anAtom) && + originalAtom.write !== defaultWrite && + currentScope !== implicitScope + ) { + // atom is writable with init and holds a value + // we need to preserve the value, so we don't want to copy the atom + // instead, we need to override write until the write is finished + const { write } = originalAtom; + anAtom.write = createScopedWrite( + originalAtom.write.bind( + originalAtom, + ) as (typeof originalAtom)['write'], + implicitScope, + ); + return () => { + anAtom.write = write; + }; + } + return undefined; + }, + }; - // populate explicit scope map + if (scopeName && process.env.NODE_ENV !== 'production') { + currentScope.name = scopeName; + } + // populate explicitly scoped atoms for (const anAtom of atoms) { - const scopedCopy = copyAtom(anAtom, true); - explicit.set(anAtom, scopedCopy); + explicit.set(anAtom, [cloneAtom(anAtom, currentScope), currentScope]); } /** - * Creates a scoped atom from the original atom. - * All dependencies of the original atom will be implicitly scoped. - * All scoped atoms will be inherited scoped in nested scopes. + * Returns a scoped atom from the original atom. * @param anAtom - * @param isScoped - the atom is a dependency of an explicitly scoped atom - * @returns + * @param implicitScope the atom is implicitly scoped in the provided scope + * @returns the scoped atom and the scope of the atom + */ + function getAtom( + anAtom: T, + implicitScope?: Scope, + ): [T, Scope?] { + if (explicit.has(anAtom)) { + return explicit.get(anAtom) as [T, Scope]; + } + if (implicitScope === currentScope) { + // dependencies of explicitly scoped atoms are implicitly scoped + // implicitly scoped atoms are only accessed by implicit and explicit scoped atoms + if (!implicit.has(anAtom)) { + implicit.set(anAtom, [cloneAtom(anAtom, implicitScope), implicitScope]); + } + return implicit.get(anAtom) as [T, Scope]; + } + if (parentScope) { + // inherited atoms are copied so they can access scoped atoms + // but they are not explicitly scoped + // dependencies of inherited atoms first check if they are explicitly scoped + // otherwise they use their original scope's atom + if (!inherited.has(anAtom)) { + const [ancestorAtom, ...ancestorScope] = parentScope.getAtom( + anAtom, + implicitScope, + ); + inherited.set(anAtom, [ + inheritAtom(ancestorAtom, anAtom, ...ancestorScope), + ...ancestorScope, + ]); + } + return inherited.get(anAtom) as [T, Scope]; + } + if (!inherited.has(anAtom)) { + // non-primitive atoms may need to access scoped atoms + // so we need to create a copy of the atom + inherited.set(anAtom, [inheritAtom(anAtom, anAtom)]); + } + return inherited.get(anAtom) as [T]; + } + + /** + * @returns a copy of the atom for derived atoms or the original atom for primitive and writable atoms */ - function copyAtom(anAtom: Atom, isScoped = false) { + function inheritAtom( + anAtom: Atom, + originalAtom: Atom, + implicitScope?: Scope, + ) { + if (originalAtom.read !== defaultRead) { + return cloneAtom(originalAtom, implicitScope); + } + return anAtom; + } + + /** + * @returns a scoped copy of the atom + */ + function cloneAtom(originalAtom: Atom, implicitScope?: Scope) { // avoid reading `init` to preserve lazy initialization const scopedAtom: Atom = Object.create( - Object.getPrototypeOf(anAtom), - Object.getOwnPropertyDescriptors(anAtom), + Object.getPrototypeOf(originalAtom), + Object.getOwnPropertyDescriptors(originalAtom), ); - if ('read' in scopedAtom) { - // inherited atoms should preserve their value - if (scopedAtom.read === defaultRead) { - const self = isScoped ? scopedAtom : anAtom; - scopedAtom.read = defaultRead.bind(self) as Atom['read']; - } else { - scopedAtom.read = (get, opts) => { - return anAtom.read((a) => { - return get(getAtom(a, isScoped)); - }, opts); - }; - } + if (scopedAtom.read !== defaultRead) { + scopedAtom.read = createScopedRead( + originalAtom.read.bind(originalAtom), + implicitScope, + ); } - if ('write' in scopedAtom) { - if (scopedAtom.write === defaultWrite) { - const self = isScoped ? scopedAtom : anAtom; - scopedAtom.write = defaultWrite.bind(self); - } else { - (scopedAtom as AnyWritableAtom).write = (get, set, ...args) => { - return (anAtom as AnyWritableAtom).write( - (a) => get(getAtom(a, isScoped)), - (a, ...v) => set(getAtom(a, isScoped), ...v), - ...args, - ); - }; - } + if ( + isWritableAtom(scopedAtom) && + isWritableAtom(originalAtom) && + scopedAtom.write !== defaultWrite + ) { + scopedAtom.write = createScopedWrite( + originalAtom.write.bind(originalAtom), + implicitScope, + ); } // eslint-disable-next-line camelcase scopedAtom.unstable_is = (a: AnyAtom) => { - return a === scopedAtom || (a.unstable_is?.(anAtom) ?? a === anAtom); + return ( + a === scopedAtom || + (a.unstable_is?.(originalAtom) ?? a === originalAtom) + ); }; return scopedAtom; } - function getAtom(anAtom: T, fromScoped = false): T { - if (explicit.has(anAtom)) { - return explicit.get(anAtom) as T; - } - // atom is a dependency of an explicitly scoped atom - if (fromScoped && !implicit.has(anAtom)) { - implicit.set(anAtom, copyAtom(anAtom, true)); - } - if (implicit.has(anAtom)) { - return implicit.get(anAtom) as T; - } - if (inherited.has(anAtom)) { - return inherited.get(anAtom) as T; - } - if (parentScope) { - const inheritedAtom = copyAtom(parentScope.getAtom(anAtom)); - inherited.set(anAtom, inheritedAtom); - return inherited.get(anAtom) as T; - } - // derived atoms may need to access scoped atoms - if (!isPrimitiveAtom(anAtom) && !unscoped.has(anAtom)) { - unscoped.set(anAtom, copyAtom(anAtom)); - } - if (unscoped.has(anAtom)) { - return unscoped.get(anAtom) as T; - } - return anAtom; + function createScopedRead>( + read: T['read'], + implicitScope?: Scope, + ): T['read'] { + return function scopedRead(get, opts) { + return read( + function scopedGet(a) { + const [scopedAtom] = getAtom(a, implicitScope); + return get(scopedAtom); + }, // + opts, + ); + }; + } + + function createScopedWrite( + write: T['write'], + implicitScope?: Scope, + ): T['write'] { + return function scopedWrite(get, set, ...args) { + return write( + function scopedGet(a) { + const [scopedAtom] = getAtom(a, implicitScope); + return get(scopedAtom); + }, + function scopedSet(a, ...v) { + const [scopedAtom] = getAtom(a, implicitScope); + return set(scopedAtom, ...v); + }, + ...args, + ); + }; } - return { getAtom }; + return currentScope; } -function isPrimitiveAtom(anAtom: AnyAtom) { - return ( - anAtom.read === defaultRead && - 'write' in anAtom && - anAtom.write === defaultWrite - ); +function isWritableAtom(anAtom: AnyAtom): anAtom is AnyWritableAtom { + return 'write' in anAtom; } const { read: defaultRead, write: defaultWrite } = atom(null); From 12a5636609b88e09fad8e448942258bdc85dc889 Mon Sep 17 00:00:00 2001 From: David Maskasky Date: Mon, 20 May 2024 22:33:06 -0700 Subject: [PATCH 10/12] support implicit parent edge case --- .../ScopeProvider/06_implicit_parent.tsx | 120 ++++++++++++++++++ src/ScopeProvider/scope.ts | 63 +++++++-- 2 files changed, 172 insertions(+), 11 deletions(-) create mode 100644 __tests__/ScopeProvider/06_implicit_parent.tsx diff --git a/__tests__/ScopeProvider/06_implicit_parent.tsx b/__tests__/ScopeProvider/06_implicit_parent.tsx new file mode 100644 index 0000000..e5a73da --- /dev/null +++ b/__tests__/ScopeProvider/06_implicit_parent.tsx @@ -0,0 +1,120 @@ +import { FC } from 'react'; +import { render } from '@testing-library/react'; +import { atom, useAtom, useAtomValue } from 'jotai'; +import { atomWithReducer } from 'jotai/vanilla/utils'; +import { clickButton, getTextContents } from '../utils'; +import { ScopeProvider } from '../../src/index'; + +function renderWithOrder(level1: 'BD' | 'DB', level2: 'BD' | 'DB') { + const baseAtom = atomWithReducer(0, (v) => v + 1); + baseAtom.debugLabel = 'baseAtom'; + baseAtom.toString = function toString() { + return this.debugLabel ?? 'Unknown Atom'; + }; + + const derivedAtom = atom((get) => get(baseAtom)); + derivedAtom.debugLabel = 'derivedAtom'; + derivedAtom.toString = function toString() { + return this.debugLabel ?? 'Unknown Atom'; + }; + + type Counter = FC<{ level: string }>; + const BaseThenDerived: Counter = ({ level }) => { + const [base, increaseBase] = useAtom(baseAtom); + const derived = useAtomValue(derivedAtom); + return ( + <> +
+ base: {base} + +
+
+ derived:{derived} +
+ + ); + }; + + const DerivedThenBase: Counter = ({ level }) => { + const derived = useAtomValue(derivedAtom); + const [base, increaseBase] = useAtom(baseAtom); + return ( + <> +
+ base:{base} + +
+
+ derived:{derived} +
+ + ); + }; + const App = (props: { Level1Counter: Counter; Level2Counter: Counter }) => { + const { Level1Counter, Level2Counter } = props; + return ( +
+

Layer 1: Scope derived

+

base should be globally shared

+ + +

Layer 2: Scope base

+

base should be globally shared

+ + + +
+
+ ); + }; + function getCounter(order: 'BD' | 'DB') { + return order === 'BD' ? BaseThenDerived : DerivedThenBase; + } + return render( + , + ); +} + +/* + b, D(b) + S1[D]: b0, D1(b1) + S2[ ]: b0, D1(b1) +*/ +describe('Implicit parent does not affect unscoped', () => { + const cases = [ + ['BD', 'BD'], + ['BD', 'DB'], + ['DB', 'BD'], + ['DB', 'DB'], + ] as const; + test.each(cases)('level 1: %p and level 2: %p', (level1, level2) => { + const { container } = renderWithOrder(level1, level2); + const increaseLayer2Base = '.layer2.setBase'; + const selectors = [ + '.layer1.base', + '.layer1.derived', + '.layer2.base', + '.layer2.derived', + ]; + + expect(getTextContents(container, selectors).join('')).toEqual('0000'); + + clickButton(container, increaseLayer2Base); + expect(getTextContents(container, selectors).join('')).toEqual('1010'); + }); +}); diff --git a/src/ScopeProvider/scope.ts b/src/ScopeProvider/scope.ts index 9055494..587d5a0 100644 --- a/src/ScopeProvider/scope.ts +++ b/src/ScopeProvider/scope.ts @@ -23,8 +23,21 @@ export type Scope = { * @debug */ name?: string; + + /** + * @debug + */ + toString?: () => string; }; +const globalScopeKey: { name?: string } = {}; +if (process.env.NODE_ENV !== 'production') { + globalScopeKey.name = 'unscoped'; + globalScopeKey.toString = toString; +} + +type GlobalScopeKey = typeof globalScopeKey; + export function createScope( atoms: Iterable, parentScope: Scope | undefined, @@ -32,7 +45,8 @@ export function createScope( ): Scope { const explicit = new WeakMap(); const implicit = new WeakMap(); - const inherited = new WeakMap(); + type ScopeMap = WeakMap; + const inherited = new WeakMap(); const currentScope: Scope = { getAtom, @@ -64,6 +78,7 @@ export function createScope( if (scopeName && process.env.NODE_ENV !== 'production') { currentScope.name = scopeName; + currentScope.toString = toString; } // populate explicitly scoped atoms for (const anAtom of atoms) { @@ -91,29 +106,51 @@ export function createScope( } return implicit.get(anAtom) as [T, Scope]; } + const scopeKey = implicitScope ?? globalScopeKey; if (parentScope) { // inherited atoms are copied so they can access scoped atoms // but they are not explicitly scoped // dependencies of inherited atoms first check if they are explicitly scoped // otherwise they use their original scope's atom - if (!inherited.has(anAtom)) { - const [ancestorAtom, ...ancestorScope] = parentScope.getAtom( + if (!inherited.get(scopeKey)?.has(anAtom)) { + const [ancestorAtom, explicitScope] = parentScope.getAtom( + anAtom, + implicitScope, + ); + setInheritedAtom( + inheritAtom(ancestorAtom, anAtom, explicitScope), anAtom, implicitScope, + explicitScope, ); - inherited.set(anAtom, [ - inheritAtom(ancestorAtom, anAtom, ...ancestorScope), - ...ancestorScope, - ]); } - return inherited.get(anAtom) as [T, Scope]; + return inherited.get(scopeKey)!.get(anAtom) as [T, Scope]; } - if (!inherited.has(anAtom)) { + if (!inherited.get(scopeKey)?.has(anAtom)) { // non-primitive atoms may need to access scoped atoms // so we need to create a copy of the atom - inherited.set(anAtom, [inheritAtom(anAtom, anAtom)]); + setInheritedAtom(inheritAtom(anAtom, anAtom), anAtom); } - return inherited.get(anAtom) as [T]; + return inherited.get(scopeKey)!.get(anAtom) as [T, Scope?]; + } + + function setInheritedAtom( + scopedAtom: T, + originalAtom: T, + implicitScope?: Scope, + explicitScope?: Scope, + ) { + const scopeKey = implicitScope ?? globalScopeKey; + if (!inherited.has(scopeKey)) { + inherited.set(scopeKey, new Map()); + } + inherited.get(scopeKey)!.set( + originalAtom, + [ + scopedAtom, // + explicitScope, + ].filter(Boolean) as [T, Scope?], + ); } /** @@ -211,3 +248,7 @@ function isWritableAtom(anAtom: AnyAtom): anAtom is AnyWritableAtom { } const { read: defaultRead, write: defaultWrite } = atom(null); + +function toString(this: { name: string }) { + return this.name; +} From 1d767d11458f7b92dc3b5b0e9eda9bc0958ede49 Mon Sep 17 00:00:00 2001 From: David Maskasky Date: Mon, 20 May 2024 23:08:18 -0700 Subject: [PATCH 11/12] remove unnecessary index file --- src/ScopeProvider/ScopeProvider.tsx | 2 +- src/ScopeProvider/index.ts | 1 - src/ScopeProvider/scope.ts | 2 +- src/index.ts | 5 +---- 4 files changed, 3 insertions(+), 7 deletions(-) delete mode 100644 src/ScopeProvider/index.ts diff --git a/src/ScopeProvider/ScopeProvider.tsx b/src/ScopeProvider/ScopeProvider.tsx index 181265b..bf789c7 100644 --- a/src/ScopeProvider/ScopeProvider.tsx +++ b/src/ScopeProvider/ScopeProvider.tsx @@ -8,7 +8,7 @@ import { import { createScope, type Scope } from './scope'; import type { AnyAtom, Store } from './types'; -export const ScopeContext = createContext<{ +const ScopeContext = createContext<{ scope: Scope | undefined; baseStore: Store | undefined; }>({ scope: undefined, baseStore: undefined }); diff --git a/src/ScopeProvider/index.ts b/src/ScopeProvider/index.ts deleted file mode 100644 index 2ec43f8..0000000 --- a/src/ScopeProvider/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { ScopeContext, ScopeProvider } from './ScopeProvider'; diff --git a/src/ScopeProvider/scope.ts b/src/ScopeProvider/scope.ts index 587d5a0..b953888 100644 --- a/src/ScopeProvider/scope.ts +++ b/src/ScopeProvider/scope.ts @@ -142,7 +142,7 @@ export function createScope( ) { const scopeKey = implicitScope ?? globalScopeKey; if (!inherited.has(scopeKey)) { - inherited.set(scopeKey, new Map()); + inherited.set(scopeKey, new WeakMap()); } inherited.get(scopeKey)!.set( originalAtom, diff --git a/src/index.ts b/src/index.ts index d89ec43..0c90ebb 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,2 @@ export { createIsolation } from './createIsolation'; -export { - ScopeContext as INTERNAL_ScopeContext, - ScopeProvider, -} from './ScopeProvider'; +export { ScopeProvider } from './ScopeProvider/ScopeProvider'; From 3562d75bfa784c29cc205d00a321fd35e24fd8d8 Mon Sep 17 00:00:00 2001 From: David Maskasky Date: Wed, 22 May 2024 20:27:58 -0700 Subject: [PATCH 12/12] - use instanceof instead of symbol to identify patched store - add test for scoped writable --- __tests__/ScopeProvider/07_writable.tsx | 157 ++++++++++++++++++++++++ src/ScopeProvider/ScopeProvider.tsx | 51 +------- src/ScopeProvider/patchedStore.ts | 39 ++++++ 3 files changed, 200 insertions(+), 47 deletions(-) create mode 100644 __tests__/ScopeProvider/07_writable.tsx create mode 100644 src/ScopeProvider/patchedStore.ts diff --git a/__tests__/ScopeProvider/07_writable.tsx b/__tests__/ScopeProvider/07_writable.tsx new file mode 100644 index 0000000..9292249 --- /dev/null +++ b/__tests__/ScopeProvider/07_writable.tsx @@ -0,0 +1,157 @@ +import { render } from '@testing-library/react'; +import { type WritableAtom, type PrimitiveAtom, atom, useAtom } from 'jotai'; +import { clickButton, getTextContents } from '../utils'; +import { ScopeProvider } from '../../src/index'; + +let baseAtom: PrimitiveAtom; + +type WritableNumberAtom = WritableAtom; + +const writableAtom: WritableNumberAtom = atom(0, (get, set, value = 0) => { + set(writableAtom, get(writableAtom) + get(baseAtom) + value); +}); + +const thisWritableAtom: WritableNumberAtom = atom( + 0, + function write(this: WritableNumberAtom, get, set, value = 0) { + set(this, get(this) + get(baseAtom) + value); + }, +); + +function renderTest(targetAtom: WritableNumberAtom) { + baseAtom = atom(0); + const Component = ({ level }: { level: string }) => { + const [value, increaseWritable] = useAtom(targetAtom); + const [baseValue, increaseBase] = useAtom(baseAtom); + return ( +
+
{value}
+
{baseValue}
+ + +
+ ); + }; + + const App = () => { + return ( + <> +

unscoped

+ + +

scoped

+

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

+ +
+ + ); + }; + return render(); +} + +/* +writable=w(,w + s), base=b +S0[ ]: b0, w0(,w0 + b0) +S1[b]: b1, w0(,w0 + b1) +*/ +describe('Self', () => { + test.each(['writableAtom', 'thisWritableAtom'])( + '%p updates its value in both scoped and unscoped and read scoped atom', + (atomKey) => { + const target = + atomKey === 'writableAtom' ? writableAtom : thisWritableAtom; + const { container } = renderTest(target); + + const increaseLevel0BaseAtom = '.level0 .writeBase'; + const increaseLevel0Writable = '.level0 .write'; + const increaseLevel1BaseAtom = '.level1 .writeBase'; + const increaseLevel1Writable = '.level1 .write'; + + const selectors = [ + '.level0 .readBase', + '.level0 .read', + '.level1 .readBase', + '.level1 .read', + ]; + + // all initial values are zero + expect(getTextContents(container, selectors)).toEqual([ + '0', // level0 readBase + '0', // level0 read + '0', // level1 readBase + '0', // level1 read + ]); + + // level0 base atom updates its value to 1 + clickButton(container, increaseLevel0BaseAtom); + expect(getTextContents(container, selectors)).toEqual([ + '1', // level0 readBase + '0', // level0 read + '0', // level1 readBase + '0', // level1 read + ]); + + // level0 writable atom increases its value, level1 writable atom shares the same value + clickButton(container, increaseLevel0Writable); + expect(getTextContents(container, selectors)).toEqual([ + '1', // level0 readBase + '1', // level0 read + '0', // level1 readBase + '1', // level1 read + ]); + + // level1 writable atom increases its value, + // but since level1 base atom is zero, + // level0 and level1 writable atoms value should not change + clickButton(container, increaseLevel1Writable); + expect(getTextContents(container, selectors)).toEqual([ + '1', // level0 readBase + '1', // level0 read + '0', // level1 readBase + '1', // level1 read + ]); + + // level1 base atom updates its value to 10 + clickButton(container, increaseLevel1BaseAtom); + expect(getTextContents(container, selectors)).toEqual([ + '1', // level0 readBase + '1', // level0 read + '10', // level1 readBase + '1', // level1 read + ]); + + // level0 writable atom increases its value using level0 base atom + clickButton(container, increaseLevel0Writable); + expect(getTextContents(container, selectors)).toEqual([ + '1', // level0 readBase + '2', // level0 read + '10', // level1 readBase + '2', // level1 read + ]); + + // level1 writable atom increases its value using level1 base atom + clickButton(container, increaseLevel1Writable); + expect(getTextContents(container, selectors)).toEqual([ + '1', // level0 readBase + '12', // level0 read + '10', // level1 readBase + '12', // level1 read + ]); + }, + ); +}); diff --git a/src/ScopeProvider/ScopeProvider.tsx b/src/ScopeProvider/ScopeProvider.tsx index bf789c7..fe85f14 100644 --- a/src/ScopeProvider/ScopeProvider.tsx +++ b/src/ScopeProvider/ScopeProvider.tsx @@ -7,6 +7,7 @@ import { } from 'react'; import { createScope, type Scope } from './scope'; import type { AnyAtom, Store } from './types'; +import { createPatchedStore, isTopLevelScope } from './patchedStore'; const ScopeContext = createContext<{ scope: Scope | undefined; @@ -21,54 +22,20 @@ export const ScopeProvider = ({ const parentStore: Store = useStore(); let { scope: parentScope, baseStore = parentStore } = useContext(ScopeContext); - // if this scope is the first descendant scope under Provider then we don't want to inherit parentScope + // if this ScopeProvider is the first descendant scope under Provider then it is the top level scope // https://github.com/jotaijs/jotai-scope/pull/33#discussion_r1604268003 if (isTopLevelScope(parentStore)) { parentScope = undefined; baseStore = parentStore; } - /** - * atomSet is used to detect if the atoms prop has changed. - */ + // atomSet is used to detect if the atoms prop has changed. const atomSet = new Set(atoms); function initialize() { const scope = createScope(atoms, parentScope, debugName); - - /** - * When an atom is accessed via useAtomValue/useSetAtom, the access should - * be handled by a router atom copy. - */ - const patchedStore: PatchedStore = { - ...baseStore, - get(anAtom, ...args) { - const [scopedAtom] = scope.getAtom(anAtom); - return baseStore.get(scopedAtom, ...args); - }, - set(anAtom, ...args) { - const [scopedAtom, implicitScope] = scope.getAtom(anAtom); - const restore = scope.prepareWriteAtom( - scopedAtom, - anAtom, - implicitScope, - ); - try { - return baseStore.set(scopedAtom, ...args); - } finally { - restore?.(); - } - }, - sub(anAtom, ...args) { - const [scopedAtom] = scope.getAtom(anAtom); - return baseStore.sub(scopedAtom, ...args); - }, - [isPatchedStore]: true, - // TODO: update this patch to support devtools - }; - return { - patchedStore, + patchedStore: createPatchedStore(baseStore, scope), scopeContext: { scope, baseStore }, hasChanged(current: { baseStore: Store; @@ -99,13 +66,3 @@ export const ScopeProvider = ({ function isEqualSet(a: Set, b: Set) { return a === b || (a.size === b.size && Array.from(a).every((v) => b.has(v))); } - -/** - * @returns true if the current scope is the first descendant scope under Provider - */ -function isTopLevelScope(parentStore: Store) { - return !(isPatchedStore in parentStore); -} - -const isPatchedStore = Symbol(); -type PatchedStore = Store & { [isPatchedStore]: true }; diff --git a/src/ScopeProvider/patchedStore.ts b/src/ScopeProvider/patchedStore.ts new file mode 100644 index 0000000..fd00f0c --- /dev/null +++ b/src/ScopeProvider/patchedStore.ts @@ -0,0 +1,39 @@ +import type { Scope } from './scope'; +import type { Store } from './types'; + +function PatchedStore() {} + +/** + * @returns a patched store that intercepts get and set calls to apply the scope + */ +export function createPatchedStore(baseStore: Store, scope: Scope): Store { + const store: Store = { + ...baseStore, + get(anAtom, ...args) { + const [scopedAtom] = scope.getAtom(anAtom); + return baseStore.get(scopedAtom, ...args); + }, + set(anAtom, ...args) { + const [scopedAtom, implicitScope] = scope.getAtom(anAtom); + const restore = scope.prepareWriteAtom(scopedAtom, anAtom, implicitScope); + try { + return baseStore.set(scopedAtom, ...args); + } finally { + restore?.(); + } + }, + sub(anAtom, ...args) { + const [scopedAtom] = scope.getAtom(anAtom); + return baseStore.sub(scopedAtom, ...args); + }, + // TODO: update this patch to support devtools + }; + return Object.assign(Object.create(PatchedStore.prototype), store); +} + +/** + * @returns true if the current scope is the first descendant scope under Provider + */ +export function isTopLevelScope(parentStore: Store) { + return !(parentStore instanceof PatchedStore); +}