],
void
>
- >()
-})
+ >(derivedIso);
+});
diff --git a/tests/focus-lens.spec.tsx b/tests/focus-lens.spec.tsx
new file mode 100644
index 0000000..3db729c
--- /dev/null
+++ b/tests/focus-lens.spec.tsx
@@ -0,0 +1,247 @@
+import { afterEach, test } from 'vitest';
+import { StrictMode, Suspense } from 'react';
+import { cleanup, fireEvent, render } from '@testing-library/react';
+import { expectType } from 'ts-expect';
+import { useAtom } from 'jotai/react';
+import { atom } from 'jotai/vanilla';
+import type { SetStateAction, WritableAtom } from 'jotai/vanilla';
+import * as O from 'optics-ts';
+import { focusAtom } from 'jotai-optics';
+
+const succ = (input: number) => input + 1;
+
+afterEach(cleanup);
+
+test('basic derivation using focus works', async () => {
+ const bigAtom = atom({ a: 0 });
+ const focusFunction = (optic: O.OpticFor_<{ a: number }>) => optic.prop('a');
+
+ const Counter = () => {
+ const [count, setCount] = useAtom(focusAtom(bigAtom, focusFunction));
+ const [bigAtomValue] = useAtom(bigAtom);
+ return (
+ <>
+ bigAtom: {JSON.stringify(bigAtomValue)}
+ count: {count}
+
+
+ >
+ );
+ };
+
+ const { getByText, findByText } = render(
+
+
+ ,
+ );
+
+ await findByText('count: 0');
+ await findByText('bigAtom: {"a":0}');
+
+ fireEvent.click(getByText('incr'));
+ await findByText('count: 1');
+ await findByText('bigAtom: {"a":1}');
+
+ fireEvent.click(getByText('incr'));
+ await findByText('count: 2');
+ await findByText('bigAtom: {"a":2}');
+
+ fireEvent.click(getByText('set zero'));
+ await findByText('count: 0');
+ await findByText('bigAtom: {"a":0}');
+});
+
+test('focus on an atom works', async () => {
+ const bigAtom = atom({ a: 0 });
+ const focusFunction = (optic: O.OpticFor_<{ a: number }>) => optic.prop('a');
+
+ const Counter = () => {
+ const [count, setCount] = useAtom(focusAtom(bigAtom, focusFunction));
+ const [bigAtomValue] = useAtom(bigAtom);
+ return (
+ <>
+ bigAtom: {JSON.stringify(bigAtomValue)}
+ count: {count}
+
+ >
+ );
+ };
+
+ const { getByText, findByText } = render(
+
+
+ ,
+ );
+
+ await findByText('count: 0');
+ await findByText('bigAtom: {"a":0}');
+
+ fireEvent.click(getByText('button'));
+ await findByText('count: 1');
+ await findByText('bigAtom: {"a":1}');
+});
+
+test('double-focus on an atom works', async () => {
+ const bigAtom = atom({ a: { b: 0 } });
+ const atomA = focusAtom(bigAtom, (optic) => optic.prop('a'));
+ const atomB = focusAtom(atomA, (optic) => optic.prop('b'));
+
+ const Counter = () => {
+ const [bigAtomValue, setBigAtom] = useAtom(bigAtom);
+ const [atomAValue, setAtomA] = useAtom(atomA);
+ const [atomBValue, setAtomB] = useAtom(atomB);
+ return (
+ <>
+ bigAtom: {JSON.stringify(bigAtomValue)}
+ atomA: {JSON.stringify(atomAValue)}
+ atomB: {JSON.stringify(atomBValue)}
+
+
+
+ >
+ );
+ };
+
+ const { getByText, findByText } = render(
+
+
+ ,
+ );
+
+ await findByText('bigAtom: {"a":{"b":0}}');
+ await findByText('atomA: {"b":0}');
+ await findByText('atomB: 0');
+
+ fireEvent.click(getByText('inc bigAtom'));
+ await findByText('bigAtom: {"a":{"b":1}}');
+ await findByText('atomA: {"b":1}');
+ await findByText('atomB: 1');
+
+ fireEvent.click(getByText('inc atomA'));
+ await findByText('bigAtom: {"a":{"b":3}}');
+ await findByText('atomA: {"b":3}');
+ await findByText('atomB: 3');
+
+ fireEvent.click(getByText('inc atomB'));
+ await findByText('bigAtom: {"a":{"b":6}}');
+ await findByText('atomA: {"b":6}');
+ await findByText('atomB: 6');
+});
+
+test('focus on async atom works', async () => {
+ const baseAtom = atom({ count: 0 });
+ const asyncAtom = atom(
+ (get) => Promise.resolve(get(baseAtom)),
+ async (get, set, param: SetStateAction>) => {
+ const prev = Promise.resolve(get(baseAtom));
+ const next = await (typeof param === 'function' ? param(prev) : param);
+ set(baseAtom, next);
+ },
+ );
+ const focusFunction = (optic: O.OpticFor_<{ count: number }>) =>
+ optic.prop('count');
+
+ const Counter = () => {
+ const [count, setCount] = useAtom(focusAtom(asyncAtom, focusFunction));
+ const [asyncValue, setAsync] = useAtom(asyncAtom);
+ const [baseValue, setBase] = useAtom(baseAtom);
+ return (
+ <>
+ baseAtom: {baseValue.count}
+ asyncAtom: {asyncValue.count}
+ count: {count}
+
+
+
+ >
+ );
+ };
+
+ const { getByText, findByText } = render(
+
+ Loading...}>
+
+
+ ,
+ );
+
+ await findByText('baseAtom: 0');
+ await findByText('asyncAtom: 0');
+ await findByText('count: 0');
+
+ fireEvent.click(getByText('incr count'));
+ await findByText('baseAtom: 1');
+ await findByText('asyncAtom: 1');
+ await findByText('count: 1');
+
+ fireEvent.click(getByText('incr async'));
+ await findByText('baseAtom: 2');
+ await findByText('asyncAtom: 2');
+ await findByText('count: 2');
+
+ fireEvent.click(getByText('incr base'));
+ await findByText('baseAtom: 3');
+ await findByText('asyncAtom: 3');
+ await findByText('count: 3');
+});
+
+type BillingData = {
+ id: string;
+};
+
+type CustomerData = {
+ id: string;
+ billing: BillingData[];
+ someOtherData: string;
+};
+
+test('typescript should accept "undefined" as valid value for lens', async () => {
+ const customerListAtom = atom([]);
+
+ const foundCustomerAtom = focusAtom(customerListAtom, (optic) =>
+ optic.find((el) => el.id === 'some-invalid-id'),
+ );
+
+ const derivedLens = focusAtom(foundCustomerAtom, (optic) => {
+ const result = optic
+ .valueOr({ someOtherData: '' } as unknown as CustomerData)
+ .prop('someOtherData');
+ return result;
+ });
+
+ expectType], void>>(derivedLens);
+});
+
+test('should work with promise based atoms with "undefined" value', async () => {
+ const customerBaseAtom = atom(undefined);
+
+ const asyncCustomerDataAtom = atom(
+ async (get) => get(customerBaseAtom),
+ async (_, set, nextValue: Promise) => {
+ set(customerBaseAtom, await nextValue);
+ },
+ );
+
+ const focusedPromiseAtom = focusAtom(asyncCustomerDataAtom, (optic) => {
+ const result = optic
+ .valueOr({ someOtherData: '' } as unknown as CustomerData)
+ .prop('someOtherData');
+ return result;
+ });
+
+ expectType<
+ WritableAtom, [SetStateAction], Promise>
+ >(focusedPromiseAtom);
+});
diff --git a/tests/focus-prism.spec.tsx b/tests/focus-prism.spec.tsx
new file mode 100644
index 0000000..556d154
--- /dev/null
+++ b/tests/focus-prism.spec.tsx
@@ -0,0 +1,127 @@
+import { afterEach, test } from 'vitest';
+import { StrictMode } from 'react';
+import { cleanup, fireEvent, render } from '@testing-library/react';
+import { expectType } from 'ts-expect';
+import { useAtom } from 'jotai/react';
+import { atom } from 'jotai/vanilla';
+import type { SetStateAction, WritableAtom } from 'jotai/vanilla';
+import * as O from 'optics-ts';
+import { focusAtom } from 'jotai-optics';
+
+afterEach(cleanup);
+
+test('updates prisms', async () => {
+ const bigAtom = atom<{ a?: number }>({ a: 5 });
+ const focusFunction = (optic: O.OpticFor_<{ a?: number }>) =>
+ optic.prop('a').optional();
+
+ const Counter = () => {
+ const [count, setCount] = useAtom(focusAtom(bigAtom, focusFunction));
+ const [bigAtomValue] = useAtom(bigAtom);
+ return (
+ <>
+ bigAtom: {JSON.stringify(bigAtomValue)}
+ count: {count}
+
+ >
+ );
+ };
+
+ const { getByText, findByText } = render(
+
+
+ ,
+ );
+
+ await findByText('count: 5');
+ await findByText('bigAtom: {"a":5}');
+
+ fireEvent.click(getByText('button'));
+ await findByText('count: 6');
+ await findByText('bigAtom: {"a":6}');
+});
+
+test('atoms that focus on no values are not updated', async () => {
+ const bigAtom = atom<{ a?: number }>({});
+ const focusFunction = (optic: O.OpticFor_<{ a?: number }>) =>
+ optic.prop('a').optional();
+
+ const Counter = () => {
+ const [count, setCount] = useAtom(focusAtom(bigAtom, focusFunction));
+ const [bigAtomValue] = useAtom(bigAtom);
+ return (
+ <>
+ bigAtom: {JSON.stringify(bigAtomValue)}
+ count: {JSON.stringify(count)}
+
+ >
+ );
+ };
+
+ const { getByText, findByText } = render(
+
+
+ ,
+ );
+
+ await findByText('count:');
+ await findByText('bigAtom: {}');
+
+ fireEvent.click(getByText('button'));
+ await findByText('count:');
+ await findByText('bigAtom: {}');
+});
+
+type BillingData = {
+ id: string;
+};
+
+type CustomerData = {
+ id: string;
+ billing: BillingData[];
+ someDynamicData?: string;
+};
+
+test('typescript should work well with nested arrays containing optional values', async () => {
+ const customerListAtom = atom([]);
+
+ const foundCustomerAtom = focusAtom(customerListAtom, (optic) =>
+ optic.find((el) => el.id === 'some-invalid-id'),
+ );
+
+ const derivedAtom = focusAtom(foundCustomerAtom, (optic) => {
+ const result = optic
+ .valueOr({ billing: [] } as unknown as CustomerData)
+ .prop('billing')
+ .find((el) => el.id === 'some-invalid-id');
+
+ return result;
+ });
+
+ expectType<
+ WritableAtom], void>
+ >(derivedAtom);
+});
+
+test('should work with promise based atoms with "undefined" value', async () => {
+ const customerBaseAtom = atom(undefined);
+
+ const asyncCustomerDataAtom = atom(
+ async (get) => get(customerBaseAtom),
+ async (_, set, nextValue: Promise) => {
+ set(customerBaseAtom, await nextValue);
+ },
+ );
+
+ const focusedPromiseAtom = focusAtom(asyncCustomerDataAtom, (optic) =>
+ optic.optional(),
+ );
+
+ expectType<
+ WritableAtom<
+ Promise,
+ [SetStateAction],
+ Promise
+ >
+ >(focusedPromiseAtom);
+});
diff --git a/tests/focus-traversal.spec.tsx b/tests/focus-traversal.spec.tsx
new file mode 100644
index 0000000..11d3efc
--- /dev/null
+++ b/tests/focus-traversal.spec.tsx
@@ -0,0 +1,93 @@
+import { afterEach, test } from 'vitest';
+import { StrictMode } from 'react';
+import { cleanup, fireEvent, render } from '@testing-library/react';
+import { expectType } from 'ts-expect';
+import { useAtom } from 'jotai/react';
+import { atom } from 'jotai/vanilla';
+import type { SetStateAction, WritableAtom } from 'jotai/vanilla';
+import * as O from 'optics-ts';
+import { focusAtom } from 'jotai-optics';
+
+afterEach(cleanup);
+
+test('updates traversals', async () => {
+ const bigAtom = atom<{ a?: number }[]>([{ a: 5 }, {}, { a: 6 }]);
+ const focusFunction = (optic: O.OpticFor_<{ a?: number }[]>) =>
+ optic.elems().prop('a').optional();
+
+ const Counter = () => {
+ const [count, setCount] = useAtom(focusAtom(bigAtom, focusFunction));
+ const [bigAtomValue] = useAtom(bigAtom);
+ return (
+ <>
+ bigAtom: {JSON.stringify(bigAtomValue)}
+ count: {count.join(',')}
+
+ >
+ );
+ };
+
+ const { getByText, findByText } = render(
+
+
+ ,
+ );
+
+ await findByText('count: 5,6');
+ await findByText('bigAtom: [{"a":5},{},{"a":6}]');
+
+ fireEvent.click(getByText('button'));
+ await findByText('count: 6,7');
+ await findByText('bigAtom: [{"a":6},{},{"a":7}]');
+});
+
+type BillingData = {
+ id: string;
+};
+
+type CustomerData = {
+ id: string;
+ billing: BillingData[];
+ someOtherData: string;
+};
+
+test('typescript should accept "undefined" as valid value for traversals', async () => {
+ const customerListListAtom = atom([]);
+
+ const nonEmptyCustomerListAtom = focusAtom(customerListListAtom, (optic) =>
+ optic.find((el) => el.length > 0),
+ );
+
+ const focusedPromiseAtom = focusAtom(nonEmptyCustomerListAtom, (optic) => {
+ const result = optic.valueOr([]).elems();
+ return result;
+ });
+
+ expectType<
+ WritableAtom], void>
+ >(focusedPromiseAtom);
+});
+
+test('should work with promise based atoms with "undefined" value', async () => {
+ const customerBaseAtom = atom(undefined);
+
+ const asyncCustomerDataAtom = atom(
+ async (get) => get(customerBaseAtom),
+ async (_, set, nextValue: Promise) => {
+ set(customerBaseAtom, await nextValue);
+ },
+ );
+
+ const focusedPromiseAtom = focusAtom(asyncCustomerDataAtom, (optic) => {
+ const result = optic.valueOr([]).elems();
+ return result;
+ });
+
+ expectType<
+ WritableAtom<
+ Promise,
+ [SetStateAction],
+ Promise
+ >
+ >(focusedPromiseAtom);
+});
diff --git a/tests/vitest-setup.js b/tests/vitest-setup.js
new file mode 100644
index 0000000..bb02c60
--- /dev/null
+++ b/tests/vitest-setup.js
@@ -0,0 +1 @@
+import '@testing-library/jest-dom/vitest';
diff --git a/tsconfig.cjs.json b/tsconfig.cjs.json
new file mode 100644
index 0000000..cfb5bb0
--- /dev/null
+++ b/tsconfig.cjs.json
@@ -0,0 +1,10 @@
+{
+ "extends": "./tsconfig.json",
+ "compilerOptions": {
+ "module": "commonjs",
+ "verbatimModuleSyntax": false,
+ "declaration": true,
+ "outDir": "./dist/cjs"
+ },
+ "include": ["src"]
+}
diff --git a/tsconfig.esm.json b/tsconfig.esm.json
new file mode 100644
index 0000000..f9ef3ba
--- /dev/null
+++ b/tsconfig.esm.json
@@ -0,0 +1,8 @@
+{
+ "extends": "./tsconfig.json",
+ "compilerOptions": {
+ "declaration": true,
+ "outDir": "./dist"
+ },
+ "include": ["src"]
+}
diff --git a/tsconfig.json b/tsconfig.json
index 374339c..0e3ad7b 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -1,24 +1,19 @@
{
"compilerOptions": {
"strict": true,
- "target": "es2015",
- "skipLibCheck": true,
+ "target": "es2018",
"esModuleInterop": true,
- "downlevelIteration": true,
- "module": "es2015",
- "moduleResolution": "node",
- "jsx": "react",
+ "module": "nodenext",
+ "skipLibCheck": true,
"allowJs": true,
- "noUnusedLocals": true,
- "noUnusedParameters": true,
+ "verbatimModuleSyntax": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
- "sourceMap": true,
+ "jsx": "react-jsx",
"baseUrl": ".",
"paths": {
- "jotai-optics": ["./src"]
- },
- "outDir": "./dist",
- "typeRoots": ["./node_modules/@types"]
- }
+ "jotai-optics": ["./src/index.js"]
+ }
+ },
+ "exclude": ["dist", "examples"]
}
diff --git a/vite.config.ts b/vite.config.ts
new file mode 100644
index 0000000..63539bd
--- /dev/null
+++ b/vite.config.ts
@@ -0,0 +1,26 @@
+///
+
+import { resolve } from 'node:path';
+import { defineConfig } from 'vite';
+
+const { DIR, PORT = '8080' } = process.env;
+
+export default defineConfig(({ mode }) => {
+ if (mode === 'test') {
+ return {
+ resolve: { alias: { 'jotai-optics': resolve('src') } },
+ test: {
+ environment: 'happy-dom',
+ setupFiles: ['./tests/vitest-setup.js'],
+ },
+ };
+ }
+ if (!DIR) {
+ throw new Error('DIR environment variable is required');
+ }
+ return {
+ root: resolve('examples', DIR),
+ server: { port: Number(PORT) },
+ resolve: { alias: { 'jotai-optics': resolve('src') } },
+ };
+});
diff --git a/webpack.config.js b/webpack.config.js
deleted file mode 100644
index f601745..0000000
--- a/webpack.config.js
+++ /dev/null
@@ -1,43 +0,0 @@
-// eslint-disable-next-line @typescript-eslint/no-var-requires
-const HtmlWebpackPlugin = require('html-webpack-plugin')
-
-const { DIR, EXT = 'ts' } = process.env
-
-module.exports = {
- mode: 'development',
- devtool: 'cheap-module-source-map',
- entry: `./examples/${DIR}/src/index.${EXT}`,
- output: {
- publicPath: '/',
- },
- plugins: [
- new HtmlWebpackPlugin({
- template: `./examples/${DIR}/public/index.html`,
- }),
- ],
- module: {
- rules: [
- {
- test: /\.[jt]sx?$/,
- exclude: /node_modules/,
- loader: 'ts-loader',
- options: {
- transpileOnly: true,
- },
- },
- ],
- },
- resolve: {
- extensions: ['.js', '.jsx', '.ts', '.tsx'],
- alias: {
- 'jotai-optics': `${__dirname}/src`,
- },
- },
- devServer: {
- port: process.env.PORT || '8080',
- static: {
- directory: `./examples/${DIR}/public`,
- },
- historyApiFallback: true,
- },
-}