Skip to content

Commit

Permalink
feat: support scoping atomFamilies (#51)
Browse files Browse the repository at this point in the history
  • Loading branch information
dmaskasky authored Aug 10, 2024
1 parent 0d152e9 commit a1d7c90
Show file tree
Hide file tree
Showing 8 changed files with 386 additions and 8,319 deletions.
257 changes: 257 additions & 0 deletions __tests__/ScopeProvider/08_family.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,257 @@
import { render, act } from '@testing-library/react';
import { useAtom, atom, useSetAtom } from 'jotai';
import { atomFamily, atomWithReducer } from 'jotai/utils';
import { ScopeProvider } from '../../src/index';
import { clickButton, getTextContents } from '../utils';

describe('AtomFamily with ScopeProvider', () => {
/*
a = aFamily('a'), b = aFamily('b')
S0[]: a0 b0
S1[aFamily]: a1 b1
*/
test('01. Scoped atom families provide isolated state', () => {
const aFamily = atomFamily(() => atom(0));
const aAtom = aFamily('a');
aAtom.debugLabel = 'aAtom';
const bAtom = aFamily('b');
bAtom.debugLabel = 'bAtom';
function Counter({ level, param }: { level: string; param: string }) {
const [value, setValue] = useAtom(aFamily(param));
return (
<div>
{param}:<span className={`${level} ${param}`}>{value}</span>
<button
className={`${level} set-${param}`}
type="button"
onClick={() => setValue((c) => c + 1)}
>
increase
</button>
</div>
);
}

function App() {
return (
<div>
<h1>Unscoped</h1>
<Counter level="level0" param="a" />
<Counter level="level0" param="b" />
<h1>Scoped Provider</h1>
<ScopeProvider atomFamilies={[aFamily]} debugName="level1">
<Counter level="level1" param="a" />
<Counter level="level1" param="b" />
</ScopeProvider>
</div>
);
}

const { container } = render(<App />);
const selectors = ['.level0.a', '.level0.b', '.level1.a', '.level1.b'];

expect(getTextContents(container, selectors)).toEqual([
'0', // level0 a
'0', // level0 b
'0', // level1 a
'0', // level1 b
]);

clickButton(container, '.level0.set-a');
expect(getTextContents(container, selectors)).toEqual([
'1', // level0 a
'0', // level0 b
'0', // level1 a
'0', // level1 b
]);

clickButton(container, '.level1.set-a');
expect(getTextContents(container, selectors)).toEqual([
'1', // level0 a
'0', // level0 b
'1', // level1 a
'0', // level1 b
]);

clickButton(container, '.level1.set-b');
expect(getTextContents(container, selectors)).toEqual([
'1', // level0 a
'0', // level0 b
'1', // level1 a
'1', // level1 b
]);
});

/*
aFamily('a'), aFamily.remove('a')
S0[aFamily('a')]: a0 -> removed
S1[aFamily('a')]: a1
*/
// TODO: refactor atomFamily to support descoping removing atoms
test.skip('02. Removing atom from atomFamily does not affect scoped state', () => {
const aFamily = atomFamily(() => atom(0));
const atomA = aFamily('a');
atomA.debugLabel = 'atomA';
const rerenderAtom = atomWithReducer(0, (s) => s + 1);
rerenderAtom.debugLabel = 'rerenderAtom';
function Counter({ level, param }: { level: string; param: string }) {
const [value, setValue] = useAtom(atomA);
useAtom(rerenderAtom);
return (
<div>
{param}:<span className={`${level} ${param}`}>{value}</span>
<button
className={`${level} set-${param}`}
type="button"
onClick={() => setValue((c) => c + 1)}
>
increase
</button>
</div>
);
}

function App() {
const rerender = useSetAtom(rerenderAtom);
return (
<div>
<h1>Unscoped</h1>
<Counter level="level0" param="a" />
<button
className="remove-atom"
type="button"
onClick={() => {
aFamily.remove('a');
rerender();
}}
>
remove a from atomFamily
</button>
<h1>Scoped Provider</h1>
<ScopeProvider atomFamilies={[aFamily]} debugName="level1">
<Counter level="level1" param="a" />
</ScopeProvider>
</div>
);
}

const { container } = render(<App />);
const selectors = ['.level0.a', '.level1.a'];

expect(getTextContents(container, selectors)).toEqual([
'0', // level0 a
'0', // level1 a
]);

clickButton(container, '.level0.set-a');
expect(getTextContents(container, selectors)).toEqual([
'1', // level0 a
'0', // level1 a
]);

act(() => {
clickButton(container, '.remove-atom');
});

expect(getTextContents(container, ['.level0.a', '.level1.a'])).toEqual([
'1', // level0 a
'1', // level1 a // atomA is now unscoped
]);

clickButton(container, '.level1.set-a');
expect(getTextContents(container, ['.level0.a', '.level1.a'])).toEqual([
'2', // level0 a
'2', // level1 a
]);
});

/*
aFamily.setShouldRemove((createdAt, param) => param === 'b')
S0[aFamily('a'), aFamily('b')]: a0 removed
S1[aFamily('a'), aFamily('b')]: a1 b1
*/
// TODO: refactor atomFamily to support descoping removing atoms
test.skip('03. Scoped atom families respect custom removal conditions', () => {
const aFamily = atomFamily(() => atom(0));
const atomA = aFamily('a');
atomA.debugLabel = 'atomA';
const atomB = aFamily('b');
atomB.debugLabel = 'atomB';
const rerenderAtom = atomWithReducer(0, (s) => s + 1);
rerenderAtom.debugLabel = 'rerenderAtom';

function Counter({ level, param }: { level: string; param: string }) {
const [value, setValue] = useAtom(aFamily(param));
useAtom(rerenderAtom);
return (
<div>
{param}:<span className={`${level} ${param}`}>{value}</span>
<button
className={`${level} set-${param}`}
type="button"
onClick={() => setValue((c) => c + 1)}
>
increase
</button>
</div>
);
}

function App() {
const rerender = useSetAtom(rerenderAtom);
return (
<div>
<button
className="remove-b"
type="button"
onClick={() => {
aFamily.setShouldRemove((_, param) => param === 'b');
rerender();
}}
>
remove b from atomFamily
</button>
<h1>Unscoped</h1>
<Counter level="level0" param="a" />
<Counter level="level0" param="b" />
<h1>Scoped Provider</h1>
<ScopeProvider atomFamilies={[aFamily]} debugName="level1">
<Counter level="level1" param="a" />
<Counter level="level1" param="b" />
</ScopeProvider>
</div>
);
}

const { container } = render(<App />);
const removeBButton = '.remove-b';
const selectors = ['.level0.a', '.level0.b', '.level1.a', '.level1.b'];

expect(getTextContents(container, selectors)).toEqual([
'0', // level0 a
'0', // level0 b
'0', // level1 a
'0', // level1 b
]);

clickButton(container, '.level0.set-a');
clickButton(container, '.level0.set-b');
expect(getTextContents(container, selectors)).toEqual([
'1', // level0 a
'1', // level0 b
'0', // level1 a // a is scoped
'0', // level1 b // b is scoped
]);

act(() => {
clickButton(container, removeBButton);
});

expect(getTextContents(container, selectors)).toEqual([
'1', // level0 a
'1', // level0 b
'0', // level1 a // a is still scoped
'1', // level1 b // b is no longer scoped
]);
});
});
6 changes: 3 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "jotai-scope",
"description": "👻🔭",
"version": "0.7.0",
"version": "0.7.1",
"author": "Daishi Kato",
"contributors": [
"yf-yang (https://github.com/yf-yang)",
Expand Down Expand Up @@ -75,7 +75,7 @@
"html-webpack-plugin": "^5.5.3",
"jest": "^29.7.0",
"jest-environment-jsdom": "^29.7.0",
"jotai": "2.9.0",
"jotai": "2.9.2",
"microbundle": "^0.15.1",
"npm-run-all": "^4.1.5",
"prettier": "^3.0.3",
Expand All @@ -90,7 +90,7 @@
"webpack-dev-server": "^4.15.1"
},
"peerDependencies": {
"jotai": ">=2.9.0",
"jotai": ">=2.9.2",
"react": ">=17.0.0"
}
}
8 changes: 4 additions & 4 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading

0 comments on commit a1d7c90

Please sign in to comment.