@shivaedev/effect-react 0.2.1 → 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/README.md +452 -55
  3. package/dist/client/create.d.ts +17 -0
  4. package/dist/client/create.d.ts.map +1 -0
  5. package/dist/{create.js → client/create.js} +9 -9
  6. package/dist/client/create.js.map +1 -0
  7. package/dist/client/editor.d.ts +20 -0
  8. package/dist/client/editor.d.ts.map +1 -0
  9. package/dist/{editor.js → client/editor.js} +5 -5
  10. package/dist/client/editor.js.map +1 -0
  11. package/dist/client/resultState.d.ts +23 -0
  12. package/dist/client/resultState.d.ts.map +1 -0
  13. package/dist/client/resultState.js +48 -0
  14. package/dist/client/resultState.js.map +1 -0
  15. package/dist/{save-state.d.ts → client/saveState.d.ts} +2 -2
  16. package/dist/client/saveState.d.ts.map +1 -0
  17. package/dist/{save-state.js → client/saveState.js} +5 -5
  18. package/dist/client/saveState.js.map +1 -0
  19. package/dist/client/sessionBoundary.d.ts +12 -0
  20. package/dist/client/sessionBoundary.d.ts.map +1 -0
  21. package/dist/{session-boundary.js → client/sessionBoundary.js} +18 -12
  22. package/dist/client/sessionBoundary.js.map +1 -0
  23. package/dist/client/testing.d.ts +12 -0
  24. package/dist/client/testing.d.ts.map +1 -0
  25. package/dist/client/testing.js +66 -0
  26. package/dist/client/testing.js.map +1 -0
  27. package/dist/field-rejection.d.ts.map +1 -1
  28. package/dist/field-rejection.js +3 -1
  29. package/dist/field-rejection.js.map +1 -1
  30. package/dist/loadQuery.d.ts +6 -0
  31. package/dist/loadQuery.d.ts.map +1 -0
  32. package/dist/loadQuery.js +21 -0
  33. package/dist/loadQuery.js.map +1 -0
  34. package/dist/submission.d.ts.map +1 -1
  35. package/docs/north-star.md +45 -0
  36. package/docs/roadmap.md +28 -0
  37. package/package.json +122 -16
  38. package/src/client/create.ts +41 -0
  39. package/src/client/editor.ts +56 -0
  40. package/src/client/resultState.ts +73 -0
  41. package/src/{save-state.ts → client/saveState.ts} +11 -10
  42. package/src/{session-boundary.ts → client/sessionBoundary.ts} +28 -32
  43. package/src/client/testing.ts +68 -0
  44. package/src/field-rejection.ts +3 -1
  45. package/src/loadQuery.ts +27 -0
  46. package/dist/create.d.ts +0 -17
  47. package/dist/create.d.ts.map +0 -1
  48. package/dist/create.js.map +0 -1
  49. package/dist/editor.d.ts +0 -20
  50. package/dist/editor.d.ts.map +0 -1
  51. package/dist/editor.js.map +0 -1
  52. package/dist/result-state.d.ts +0 -20
  53. package/dist/result-state.d.ts.map +0 -1
  54. package/dist/result-state.js +0 -36
  55. package/dist/result-state.js.map +0 -1
  56. package/dist/save-state.d.ts.map +0 -1
  57. package/dist/save-state.js.map +0 -1
  58. package/dist/session-boundary.d.ts +0 -12
  59. package/dist/session-boundary.d.ts.map +0 -1
  60. package/dist/session-boundary.js.map +0 -1
  61. package/src/create.ts +0 -43
  62. package/src/editor.ts +0 -58
  63. package/src/result-state.ts +0 -58
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shivaedev/effect-react",
3
- "version": "0.2.1",
3
+ "version": "0.2.2",
4
4
  "description": "React query, action, editor and session hooks for native Effect atoms",
5
5
  "homepage": "https://github.com/ShivaeDev/platform/tree/main/packages/effect-react#readme",
6
6
  "bugs": {
@@ -20,19 +20,105 @@
20
20
  "source": "./src/*.ts",
21
21
  "types": "./dist/*.d.ts",
22
22
  "default": "./dist/*.js"
23
+ },
24
+ "#create.ts": {
25
+ "source": "./src/client/create.ts",
26
+ "types": "./dist/client/create.d.ts",
27
+ "default": "./dist/client/create.js"
28
+ },
29
+ "#editor.ts": {
30
+ "source": "./src/client/editor.ts",
31
+ "types": "./dist/client/editor.d.ts",
32
+ "default": "./dist/client/editor.js"
33
+ },
34
+ "#result-state.ts": {
35
+ "source": "./src/client/resultState.ts",
36
+ "types": "./dist/client/resultState.d.ts",
37
+ "default": "./dist/client/resultState.js"
38
+ },
39
+ "#save-state.ts": {
40
+ "source": "./src/client/saveState.ts",
41
+ "types": "./dist/client/saveState.d.ts",
42
+ "default": "./dist/client/saveState.js"
43
+ },
44
+ "#session-boundary.ts": {
45
+ "source": "./src/client/sessionBoundary.ts",
46
+ "types": "./dist/client/sessionBoundary.d.ts",
47
+ "default": "./dist/client/sessionBoundary.js"
48
+ },
49
+ "#testing.ts": {
50
+ "source": "./src/client/testing.ts",
51
+ "types": "./dist/client/testing.d.ts",
52
+ "default": "./dist/client/testing.js"
23
53
  }
24
54
  },
25
55
  "exports": {
26
- "./*.ts": {
27
- "types": "./dist/*.d.ts",
28
- "source": "./src/*.ts",
29
- "import": "./dist/*.js",
30
- "default": "./dist/*.js"
56
+ "./package.json": "./package.json",
57
+ "./router.ts": {
58
+ "types": "./dist/loadQuery.d.ts",
59
+ "source": "./src/loadQuery.ts",
60
+ "import": "./dist/loadQuery.js",
61
+ "default": "./dist/loadQuery.js"
62
+ },
63
+ "./loadQuery.ts": null,
64
+ "./create.ts": {
65
+ "types": "./dist/client/create.d.ts",
66
+ "source": "./src/client/create.ts",
67
+ "import": "./dist/client/create.js",
68
+ "default": "./dist/client/create.js"
69
+ },
70
+ "./editor.ts": {
71
+ "types": "./dist/client/editor.d.ts",
72
+ "source": "./src/client/editor.ts",
73
+ "import": "./dist/client/editor.js",
74
+ "default": "./dist/client/editor.js"
75
+ },
76
+ "./field-rejection.ts": {
77
+ "types": "./dist/field-rejection.d.ts",
78
+ "source": "./src/field-rejection.ts",
79
+ "import": "./dist/field-rejection.js",
80
+ "default": "./dist/field-rejection.js"
81
+ },
82
+ "./result-state.ts": {
83
+ "types": "./dist/client/resultState.d.ts",
84
+ "source": "./src/client/resultState.ts",
85
+ "import": "./dist/client/resultState.js",
86
+ "default": "./dist/client/resultState.js"
87
+ },
88
+ "./resume-signal.ts": {
89
+ "types": "./dist/resume-signal.d.ts",
90
+ "source": "./src/resume-signal.ts",
91
+ "import": "./dist/resume-signal.js",
92
+ "default": "./dist/resume-signal.js"
31
93
  },
32
- "./package.json": "./package.json"
94
+ "./save-state.ts": {
95
+ "types": "./dist/client/saveState.d.ts",
96
+ "source": "./src/client/saveState.ts",
97
+ "import": "./dist/client/saveState.js",
98
+ "default": "./dist/client/saveState.js"
99
+ },
100
+ "./session-boundary.ts": {
101
+ "types": "./dist/client/sessionBoundary.d.ts",
102
+ "source": "./src/client/sessionBoundary.ts",
103
+ "import": "./dist/client/sessionBoundary.js",
104
+ "default": "./dist/client/sessionBoundary.js"
105
+ },
106
+ "./submission.ts": {
107
+ "types": "./dist/submission.d.ts",
108
+ "source": "./src/submission.ts",
109
+ "import": "./dist/submission.js",
110
+ "default": "./dist/submission.js"
111
+ },
112
+ "./testing.ts": {
113
+ "types": "./dist/client/testing.d.ts",
114
+ "source": "./src/client/testing.ts",
115
+ "import": "./dist/client/testing.js",
116
+ "default": "./dist/client/testing.js"
117
+ }
33
118
  },
34
119
  "files": [
35
120
  "dist",
121
+ "docs",
36
122
  "src",
37
123
  "!src/**/*.test.ts",
38
124
  "!src/**/*.test.tsx",
@@ -43,21 +129,29 @@
43
129
  "README.md"
44
130
  ],
45
131
  "dependencies": {
46
- "@shivaedev/effect-contract": "0.4.0"
132
+ "@shivaedev/effect-contract": "0.4.1"
47
133
  },
48
134
  "devDependencies": {
49
135
  "@effect/atom-react": "4.0.0-rc.112",
136
+ "@effect/platform-node": "4.0.0-rc.112",
137
+ "@effect/sql-pg": "4.0.0-rc.112",
50
138
  "@effect/sql-sqlite-node": "4.0.0-rc.112",
51
- "@shivaedev/effect-form": "0.2.0",
139
+ "@shivaedev/effect-form": "0.2.1",
140
+ "@shivaedev/effect-pg-boss": "0.4.0",
52
141
  "@shivaedev/effect-service": "0.2.0",
53
- "@shivaedev/effect-sql": "0.2.0",
54
- "@shivaedev/quality": "0.15.0",
55
- "@types/node": "24.10.1",
142
+ "@shivaedev/effect-sql": "0.2.1",
143
+ "@shivaedev/effect-test": "0.3.2",
144
+ "@shivaedev/local-postgres": "0.2.0",
145
+ "@shivaedev/platform": "0.6.1",
146
+ "@shivaedev/quality": "0.19.0",
147
+ "@shivaedev/test-story": "0.1.1",
148
+ "@tanstack/react-router": "1.170.41",
149
+ "@types/node": "24.19.1",
56
150
  "@types/react": "19.2.18",
57
151
  "@types/react-dom": "19.2.4",
58
152
  "@typescript/native": "npm:typescript@7.0.2",
59
153
  "effect": "4.0.0-rc.112",
60
- "happy-dom": "20.11.2",
154
+ "happy-dom": "20.14.5",
61
155
  "react": "19.2.8",
62
156
  "react-dom": "19.2.8",
63
157
  "typescript": "npm:@typescript/typescript6@6.0.2",
@@ -65,13 +159,25 @@
65
159
  },
66
160
  "peerDependencies": {
67
161
  "@effect/atom-react": "4.0.0-rc.112",
68
- "@shivaedev/effect-form": "0.2.0",
162
+ "@shivaedev/effect-form": "0.2.1",
163
+ "@tanstack/react-router": "1.170.41",
69
164
  "effect": "4.0.0-rc.112",
70
- "react": "19.2.8"
165
+ "react": "19.2.8",
166
+ "react-dom": "19.2.8",
167
+ "vitest": "4.1.11"
71
168
  },
72
169
  "peerDependenciesMeta": {
73
170
  "@shivaedev/effect-form": {
74
171
  "optional": true
172
+ },
173
+ "@tanstack/react-router": {
174
+ "optional": true
175
+ },
176
+ "react-dom": {
177
+ "optional": true
178
+ },
179
+ "vitest": {
180
+ "optional": true
75
181
  }
76
182
  },
77
183
  "engines": {
@@ -82,7 +188,7 @@
82
188
  "provenance": true
83
189
  },
84
190
  "scripts": {
85
- "build": "node --eval \"import('node:fs').then(({ rmSync }) => rmSync('dist', { force: true, recursive: true }))\" && tsc6 --project tsconfig.build.json",
191
+ "build": "node --eval \"import('node:fs').then(({ rmSync }) => rmSync('dist', { force: true, recursive: true }))\" && tsc --project tsconfig.build.json",
86
192
  "ready": "pnpm typecheck && pnpm test && pnpm build",
87
193
  "test": "vitest run",
88
194
  "typecheck": "tsc --noEmit"
@@ -0,0 +1,41 @@
1
+ import { type Effect, Equal, Option, type Schema } from "effect";
2
+ import type * as Atom from "effect/unstable/reactivity/Atom";
3
+ import { useCallback, useState } from "react";
4
+ import type { Decoded, Encoded, FieldFailure, Fields, Form, Services } from "@shivaedev/effect-form/shape.ts";
5
+ import type { AtomServices, FieldRejectionMapping } from "#field-rejection.ts";
6
+ import { submission } from "#submission.ts";
7
+ import { type SaveState, useSaveState } from "./saveState.ts";
8
+ export type CreateConfig<TFields extends Fields, TCreated, TSaveError, R, TRuntimeError> = {
9
+ readonly fields: Schema.Struct<TFields>;
10
+ readonly initialValues: Encoded<TFields>;
11
+ readonly create: (values: Decoded<TFields>) => Effect.Effect<TCreated, TSaveError, AtomServices<R>>;
12
+ readonly runtime: Atom.AtomRuntime<Services<TFields, R>, TRuntimeError>;
13
+ } & FieldRejectionMapping<TFields, TSaveError>;
14
+ export interface Create<TFields extends Fields, TCreated, TSaveError, TRuntimeError> extends SaveState<TSaveError | TRuntimeError> {
15
+ readonly created: Option.Option<TCreated>;
16
+ readonly form: Form<TFields, TCreated, TSaveError | FieldFailure, TRuntimeError>;
17
+ }
18
+ function editedSince<TV extends object>(current: TV, submitted: TV | undefined): Readonly<Record<string, unknown>> {
19
+ const before = new Map(Object.entries(submitted ?? current));
20
+ return Object.fromEntries(Object.entries(current).filter(([name, value]) => !Equal.equals(value, before.get(name))));
21
+ }
22
+ export function useCreate<TFields extends Fields, TCreated, TSaveError, R, TRuntimeError>(
23
+ config: CreateConfig<TFields, TCreated, TSaveError, R, TRuntimeError>,
24
+ ): Create<TFields, TCreated, TSaveError, TRuntimeError> {
25
+ const [{ initialValues: baseline, fresh }] = useState(() => {
26
+ const { fields, initialValues, runtime, create, rejectField } = config;
27
+ return { fresh: () => submission({ fields, initialValues, rejectField, runtime, save: create }), initialValues };
28
+ });
29
+ const [current, replace] = useState(fresh);
30
+ const [created, record] = useState<Option.Option<TCreated>>(Option.none());
31
+ const settled = useCallback(
32
+ (value: TCreated) => {
33
+ const next = fresh();
34
+ next.form.values.set({ ...baseline, ...editedSince(current.form.values.value, current.submitted()) });
35
+ record(Option.some(value));
36
+ replace(next);
37
+ },
38
+ [current, fresh, baseline],
39
+ );
40
+ return { ...useSaveState(current.form, settled), created, form: current.form };
41
+ }
@@ -0,0 +1,56 @@
1
+ import { type Effect, Option, type Schema } from "effect";
2
+ import type * as AsyncResult from "effect/unstable/reactivity/AsyncResult";
3
+ import type * as Atom from "effect/unstable/reactivity/Atom";
4
+ import { useCallback, useEffect, useState } from "react";
5
+ import type { Decoded, Encoded, FieldFailure, Fields, Form, Services } from "@shivaedev/effect-form/shape.ts";
6
+ import type { AtomServices, FieldRejectionMapping } from "#field-rejection.ts";
7
+ import { submission } from "#submission.ts";
8
+ import { type QueryState, useQuery } from "./resultState.ts";
9
+ import { type SaveState, useSaveState } from "./saveState.ts";
10
+ export type EditorConfig<TFields extends Fields, TRow, TQueryError, TSaveError, R, TRuntimeError> = {
11
+ readonly query: Atom.Atom<AsyncResult.AsyncResult<TRow, TQueryError>>;
12
+ readonly fields: Schema.Struct<TFields>;
13
+ readonly values: (row: TRow) => Encoded<TFields>;
14
+ readonly save: (values: Decoded<TFields>) => Effect.Effect<TRow, TSaveError, AtomServices<R>>;
15
+ readonly runtime: Atom.AtomRuntime<Services<TFields, R>, TRuntimeError>;
16
+ } & FieldRejectionMapping<TFields, TSaveError>;
17
+ export interface Editor<TFields extends Fields, TRow, TQueryError, TSaveError, TRuntimeError> extends SaveState<TSaveError | TRuntimeError> {
18
+ readonly form: Form<TFields, TRow, TSaveError | FieldFailure, TRuntimeError> | undefined;
19
+ readonly query: QueryState<TRow, TQueryError>;
20
+ }
21
+ interface Held<TFields extends Fields, TRow, TSaveError, TRuntimeError> {
22
+ readonly form: Form<TFields, TRow, TSaveError | FieldFailure, TRuntimeError>;
23
+ readonly query: Atom.Atom<unknown>;
24
+ readonly values: (row: TRow) => Encoded<TFields>;
25
+ }
26
+ export function useEditor<TFields extends Fields, TRow, TQueryError, TSaveError, R, TRuntimeError>(
27
+ config: EditorConfig<TFields, TRow, TQueryError, TSaveError, R, TRuntimeError>,
28
+ ): Editor<TFields, TRow, TQueryError, TSaveError, TRuntimeError> {
29
+ const query = useQuery(config.query);
30
+ const row = Option.getOrUndefined(query.data);
31
+ const [held, hold] = useState<Held<TFields, TRow, TSaveError, TRuntimeError> | undefined>(undefined);
32
+ let current = held?.query === config.query ? held : undefined;
33
+ if (current === undefined && row !== undefined) {
34
+ current = {
35
+ form: submission({
36
+ fields: config.fields,
37
+ initialValues: config.values(row),
38
+ rejectField: config.rejectField,
39
+ runtime: config.runtime,
40
+ save: config.save,
41
+ }).form,
42
+ query: config.query,
43
+ values: config.values,
44
+ };
45
+ hold(current);
46
+ }
47
+ const form = current?.form;
48
+ const values = current?.values;
49
+ useEffect(() => {
50
+ if (form && values && row !== undefined) {
51
+ form.receive(values(row));
52
+ }
53
+ }, [form, values, row]);
54
+ const settled = useCallback((saved: TRow) => (form && values ? form.receive(values(saved)) : undefined), [form, values]);
55
+ return { ...useSaveState(form, settled), form, query };
56
+ }
@@ -0,0 +1,73 @@
1
+ import { RegistryContext, useAtomRefresh, useAtomSet, useAtomValue } from "@effect/atom-react";
2
+ import { Cause, Option, Predicate } from "effect";
3
+ import * as AsyncResult from "effect/unstable/reactivity/AsyncResult";
4
+ import type * as Atom from "effect/unstable/reactivity/Atom";
5
+ import type * as AtomRegistry from "effect/unstable/reactivity/AtomRegistry";
6
+ import { useContext, useEffect, useState } from "react";
7
+ import { useSessionRecheck } from "./sessionBoundary.ts";
8
+ export interface ResultState<A, E> {
9
+ readonly cause: Option.Option<Cause.Cause<E>>;
10
+ readonly data: Option.Option<A>;
11
+ readonly pending: boolean;
12
+ readonly refreshing: boolean;
13
+ readonly result: AsyncResult.AsyncResult<A, E>;
14
+ }
15
+ function state<A, E>(result: AsyncResult.AsyncResult<A, E>, data = AsyncResult.value(result)): ResultState<A, E> {
16
+ return {
17
+ cause: AsyncResult.cause(result),
18
+ data,
19
+ pending: result.waiting,
20
+ refreshing: result.waiting && Option.isSome(data),
21
+ result,
22
+ };
23
+ }
24
+ export function isUnauthorized<E>(cause: Cause.Cause<E>): boolean {
25
+ return Option.match(Cause.findErrorOption(cause), { onNone: () => false, onSome: (error) => Predicate.isTagged(error, "Unauthorized") });
26
+ }
27
+ function useRecheckOnUnauthorized<A, E>(result: AsyncResult.AsyncResult<A, E>): void {
28
+ const recheck = useSessionRecheck();
29
+ useEffect(() => {
30
+ if (AsyncResult.isFailure(result) && isUnauthorized(result.cause)) {
31
+ recheck();
32
+ }
33
+ }, [recheck, result]);
34
+ }
35
+ export interface QueryState<A, E> extends ResultState<A, E> {
36
+ readonly refresh: () => void;
37
+ }
38
+ export interface QueryOptions {
39
+ readonly keepPreviousData?: boolean;
40
+ }
41
+ interface HeldQuery<A, E> {
42
+ readonly atom: Atom.Atom<AsyncResult.AsyncResult<A, E>>;
43
+ readonly data: Option.Option<A>;
44
+ readonly dataAtom: Atom.Atom<AsyncResult.AsyncResult<A, E>>;
45
+ readonly keepPreviousData: boolean;
46
+ readonly registry: AtomRegistry.AtomRegistry;
47
+ readonly result: AsyncResult.AsyncResult<A, E>;
48
+ }
49
+ export function useQuery<A, E>(atom: Atom.Atom<AsyncResult.AsyncResult<A, E>>, options: QueryOptions = {}): QueryState<A, E> {
50
+ const result = useAtomValue(atom);
51
+ const registry = useContext(RegistryContext);
52
+ const keepPreviousData = options.keepPreviousData === true;
53
+ const nativeData = AsyncResult.value(result);
54
+ const [held, hold] = useState<HeldQuery<A, E>>(() => ({ atom, data: nativeData, dataAtom: atom, keepPreviousData, registry, result }));
55
+ const previous = held.registry === registry && (held.dataAtom === atom || keepPreviousData) ? held.data : Option.none<A>();
56
+ const data = Option.orElse(nativeData, () => previous);
57
+ const dataAtom = Option.isSome(nativeData) || Option.isNone(previous) ? atom : held.dataAtom;
58
+ // Render state keeps abandoned React renders from changing the committed screen's retained value.
59
+ if (held.atom !== atom || held.registry !== registry || held.result !== result || held.keepPreviousData !== keepPreviousData) {
60
+ hold({ atom, data, dataAtom, keepPreviousData, registry, result });
61
+ }
62
+ useRecheckOnUnauthorized(result);
63
+ return { ...state(result, data), refresh: useAtomRefresh(atom) };
64
+ }
65
+ export interface ActionState<TInput, A, E> extends ResultState<A, E> {
66
+ readonly dispatch: (input: TInput) => void;
67
+ }
68
+ // The atom's state is shared by every dispatch, so it never claims to be one invocation's completion result.
69
+ export function useAction<TInput, A, E>(atom: Atom.AtomResultFn<TInput, A, E>): ActionState<TInput, A, E> {
70
+ const result = useAtomValue(atom);
71
+ useRecheckOnUnauthorized(result);
72
+ return { ...state(result), dispatch: useAtomSet(atom) };
73
+ }
@@ -4,12 +4,11 @@ import * as AsyncResult from "effect/unstable/reactivity/AsyncResult";
4
4
  import * as Atom from "effect/unstable/reactivity/Atom";
5
5
  import { useCallback, useContext, useEffect } from "react";
6
6
  import { FieldFailure, type Fields, type Form, Invalid } from "@shivaedev/effect-form/shape.ts";
7
- import { isUnauthorized } from "./result-state.ts";
8
- import { useSessionRecheck } from "./session-boundary.ts";
7
+ import { isUnauthorized } from "./resultState.ts";
8
+ import { useSessionRecheck } from "./sessionBoundary.ts";
9
9
 
10
10
  const idle = Atom.make(AsyncResult.initial());
11
11
  const clean = Atom.make(false);
12
-
13
12
  export interface SaveState<E> {
14
13
  readonly dirty: boolean;
15
14
  readonly failure: Option.Option<E>;
@@ -17,14 +16,13 @@ export interface SaveState<E> {
17
16
  readonly save: () => void;
18
17
  readonly saving: boolean;
19
18
  }
20
-
21
- export const useSaveState = <F extends Fields, A, E, ER>(
22
- form: Form<F, A, E | FieldFailure, ER> | undefined,
19
+ export function useSaveState<TFields extends Fields, A, E, TRuntimeError>(
20
+ form: Form<TFields, A, E | FieldFailure, TRuntimeError> | undefined,
23
21
  settled: (result: A) => void,
24
- ): SaveState<E | ER> => {
22
+ ): SaveState<E | TRuntimeError> {
25
23
  const registry = useContext(RegistryContext);
26
24
  const recheck = useSessionRecheck();
27
- const result: AsyncResult.AsyncResult<A, E | ER | FieldFailure | Invalid> = useAtomValue(form?.submit ?? idle);
25
+ const result: AsyncResult.AsyncResult<A, E | TRuntimeError | FieldFailure | Invalid> = useAtomValue(form?.submit ?? idle);
28
26
  const dirty = useAtomValue(form?.dirty ?? clean);
29
27
  useEffect(() => {
30
28
  if (AsyncResult.isFailure(result) && isUnauthorized(result.cause)) {
@@ -48,6 +46,9 @@ export const useSaveState = <F extends Fields, A, E, ER>(
48
46
  registry.set(form.submit, undefined);
49
47
  }, [form, registry]);
50
48
  const revert = useCallback(() => form?.revert(), [form]);
51
- const failure = Option.filter(AsyncResult.error(result), (error): error is E | ER => !(error instanceof FieldFailure || error instanceof Invalid));
49
+ const failure = Option.filter(
50
+ AsyncResult.error(result),
51
+ (error): error is E | TRuntimeError => !(error instanceof FieldFailure || error instanceof Invalid),
52
+ );
52
53
  return { dirty, failure, revert, save, saving: result.waiting };
53
- };
54
+ }
@@ -1,30 +1,28 @@
1
1
  import { RegistryContext } from "@effect/atom-react";
2
2
  import * as AtomRegistry from "effect/unstable/reactivity/AtomRegistry";
3
3
  import { createContext, createElement, Fragment, type ReactNode, useContext, useEffect, useState } from "react";
4
-
5
- export interface SessionBoundaryProps<S, Client> {
6
- readonly children: (client: Client) => ReactNode;
7
- readonly connect: (session: S) => Client;
8
- readonly identify: (session: S) => string;
4
+ export interface SessionBoundaryProps<TSession, TClient> {
5
+ readonly children: (client: TClient) => ReactNode;
6
+ readonly connect: (session: TSession) => TClient;
7
+ readonly identify: (session: TSession) => string;
9
8
  readonly recheck: () => void;
10
- readonly session: S | undefined;
9
+ readonly session: TSession | undefined;
11
10
  readonly signedOut?: ReactNode;
12
11
  }
13
-
14
- const Recheck = createContext<() => void>(() => {});
15
-
16
- export const useSessionRecheck = (): (() => void) => useContext(Recheck);
17
-
18
- interface Owned<Client> {
19
- readonly client: Client;
12
+ const RecheckContext = createContext<() => void>((): void => undefined);
13
+ export function useSessionRecheck(): () => void {
14
+ return useContext(RecheckContext);
15
+ }
16
+ interface Owned<TClient> {
17
+ readonly client: TClient;
20
18
  mounted: number;
21
19
  readonly registry: AtomRegistry.AtomRegistry;
22
20
  }
23
-
24
- const owning = <Client>(client: Client): Owned<Client> => ({ client, mounted: 0, registry: AtomRegistry.make() });
25
-
21
+ function owning<TClient>(client: TClient): Owned<TClient> {
22
+ return { client, mounted: 0, registry: AtomRegistry.make() };
23
+ }
26
24
  // No cleanup runs when a hidden <Activity> subtree is unmounted, so nodes a hidden render creates must not outlive it.
27
- const hidden = <Client>(client: Client): Owned<Client> => {
25
+ function hidden<TClient>(client: TClient): Owned<TClient> {
28
26
  const owned = owning(client);
29
27
  let reaping = false;
30
28
  owned.registry.onNodeAdded = () => {
@@ -40,16 +38,14 @@ const hidden = <Client>(client: Client): Owned<Client> => {
40
38
  }, 0);
41
39
  };
42
40
  return owned;
43
- };
44
-
45
- interface GenerationProps<S, Client> {
46
- readonly children: (client: Client) => ReactNode;
47
- readonly connect: (session: S) => Client;
41
+ }
42
+ interface GenerationProps<TSession, TClient> {
43
+ readonly children: (client: TClient) => ReactNode;
44
+ readonly connect: (session: TSession) => TClient;
48
45
  readonly recheck: () => void;
49
- readonly session: S;
46
+ readonly session: TSession;
50
47
  }
51
-
52
- const Generation = <S, Client>({ session, connect, recheck, children }: GenerationProps<S, Client>): ReactNode => {
48
+ function Generation<TSession, TClient>({ session, connect, recheck, children }: GenerationProps<TSession, TClient>): ReactNode {
53
49
  const [owned, own] = useState(() => owning(connect(session)));
54
50
  useEffect(() => {
55
51
  owned.mounted += 1;
@@ -65,20 +61,20 @@ const Generation = <S, Client>({ session, connect, recheck, children }: Generati
65
61
  };
66
62
  }, [owned]);
67
63
  return createElement(
68
- Recheck.Provider,
64
+ RecheckContext.Provider,
69
65
  { value: recheck },
70
66
  createElement(RegistryContext.Provider, { value: owned.registry }, children(owned.client)),
71
67
  );
72
- };
73
-
74
- export const SessionBoundary = <S, Client>({
68
+ }
69
+ export function SessionBoundary<TSession, TClient>({
75
70
  session,
76
71
  identify,
77
72
  connect,
78
73
  recheck,
79
74
  signedOut,
80
75
  children,
81
- }: SessionBoundaryProps<S, Client>): ReactNode =>
82
- session === undefined
76
+ }: SessionBoundaryProps<TSession, TClient>): ReactNode {
77
+ return session === undefined
83
78
  ? createElement(Fragment, null, signedOut)
84
- : createElement(Generation<S, Client>, { children, connect, key: identify(session), recheck, session });
79
+ : createElement(Generation<TSession, TClient>, { children, connect, key: identify(session), recheck, session });
80
+ }
@@ -0,0 +1,68 @@
1
+ import { RegistryContext } from "@effect/atom-react";
2
+ import { Clock, Effect } from "effect";
3
+ import * as AtomRegistry from "effect/unstable/reactivity/AtomRegistry";
4
+ import { act, createElement, type ReactNode } from "react";
5
+ import { createRoot } from "react-dom/client";
6
+ import { beforeEach, expect, vi } from "vitest";
7
+ export function expectNoReactWarnings() {
8
+ beforeEach(({ onTestFinished }) => {
9
+ const errors = vi.spyOn(console, "error");
10
+ onTestFinished(() => {
11
+ const calls = errors.mock.calls;
12
+ errors.mockRestore();
13
+ expect(calls).toEqual([]);
14
+ });
15
+ });
16
+ }
17
+ export async function eventually(assert: () => void) {
18
+ const deadline = Effect.runSync(Clock.currentTimeMillis) + 3000;
19
+ while (true) {
20
+ // Network responses must settle inside act, including the time between assertions.
21
+ await act(async () => {
22
+ await new Promise<void>((resolve) => setTimeout(resolve, 10));
23
+ });
24
+ try {
25
+ assert();
26
+ return;
27
+ } catch (error) {
28
+ if (Effect.runSync(Clock.currentTimeMillis) >= deadline) {
29
+ throw error;
30
+ }
31
+ }
32
+ }
33
+ }
34
+ export interface Rendered {
35
+ readonly container: HTMLDivElement;
36
+ readonly registry: AtomRegistry.AtomRegistry;
37
+ readonly render: (node: ReactNode) => Promise<void>;
38
+ readonly unmount: () => Promise<void>;
39
+ }
40
+ export async function render(node: ReactNode): Promise<Rendered> {
41
+ const registry = AtomRegistry.make();
42
+ const container = document.createElement("div");
43
+ document.body.append(container);
44
+ const root = createRoot(container);
45
+ let mounted = true;
46
+ async function unmount() {
47
+ if (!mounted) {
48
+ return;
49
+ }
50
+ mounted = false;
51
+ try {
52
+ await act(async () => root.unmount());
53
+ } finally {
54
+ registry.dispose();
55
+ container.remove();
56
+ }
57
+ }
58
+ async function update(next: ReactNode) {
59
+ await act(async () => root.render(createElement(RegistryContext.Provider, { value: registry }, next)));
60
+ }
61
+ try {
62
+ await update(node);
63
+ } catch (error) {
64
+ await unmount();
65
+ throw error;
66
+ }
67
+ return { container, registry, render: update, unmount };
68
+ }
@@ -25,7 +25,9 @@ export type FieldRejectionMapping<F extends Fields, E> = { readonly rejectField?
25
25
  export const fieldRejectionOf =
26
26
  <F extends Fields>(schema: Schema.Struct<F>) =>
27
27
  (error: unknown): FieldRejection<Name<F>> | undefined => {
28
- const isName = (name: unknown): name is Name<F> => typeof name === "string" && Object.hasOwn(schema.fields, name);
28
+ function isName(name: unknown): name is Name<F> {
29
+ return typeof name === "string" && Object.hasOwn(schema.fields, name);
30
+ }
29
31
  if (!(Predicate.hasProperty(error, "_tag") && Predicate.hasProperty(error, "field") && Predicate.hasProperty(error, "message"))) {
30
32
  return undefined;
31
33
  }
@@ -0,0 +1,27 @@
1
+ import type { LoaderFnContext } from "@tanstack/react-router";
2
+ import { Effect } from "effect";
3
+ import type * as AsyncResult from "effect/unstable/reactivity/AsyncResult";
4
+ import type * as Atom from "effect/unstable/reactivity/Atom";
5
+ import * as AtomRegistry from "effect/unstable/reactivity/AtomRegistry";
6
+
7
+ export async function loadQuery<A, E>(
8
+ registry: AtomRegistry.AtomRegistry,
9
+ atom: Atom.Atom<AsyncResult.AsyncResult<A, E>>,
10
+ { abortController }: Pick<LoaderFnContext, "abortController">,
11
+ ): Promise<A> {
12
+ const { signal } = abortController;
13
+ try {
14
+ const result = await Effect.runPromise(Effect.result(AtomRegistry.getResult(registry, atom, { suspendOnWaiting: true })), { signal });
15
+ if (result._tag === "Failure") {
16
+ throw result.failure;
17
+ }
18
+ return result.success;
19
+ } catch (error) {
20
+ if (signal.aborted) {
21
+ const canceled = new Error("Query loading canceled", { cause: error });
22
+ canceled.name = "AbortError";
23
+ throw canceled;
24
+ }
25
+ throw error;
26
+ }
27
+ }
package/dist/create.d.ts DELETED
@@ -1,17 +0,0 @@
1
- import { type Effect, Option, type Schema } from "effect";
2
- import type * as Atom from "effect/unstable/reactivity/Atom";
3
- import type { Decoded, Encoded, FieldFailure, Fields, Form, Services } from "@shivaedev/effect-form/shape.ts";
4
- import type { AtomServices, FieldRejectionMapping } from "./field-rejection.ts";
5
- import { type SaveState } from "./save-state.ts";
6
- export type CreateConfig<F extends Fields, Created, SE, R, ER> = {
7
- readonly fields: Schema.Struct<F>;
8
- readonly initialValues: Encoded<F>;
9
- readonly create: (values: Decoded<F>) => Effect.Effect<Created, SE, AtomServices<R>>;
10
- readonly runtime: Atom.AtomRuntime<Services<F, R>, ER>;
11
- } & FieldRejectionMapping<F, SE>;
12
- export interface Create<F extends Fields, Created, SE, ER> extends SaveState<SE | ER> {
13
- readonly created: Option.Option<Created>;
14
- readonly form: Form<F, Created, SE | FieldFailure, ER>;
15
- }
16
- export declare const useCreate: <F extends Fields, Created, SE, R, ER>(config: CreateConfig<F, Created, SE, R, ER>) => Create<F, Created, SE, ER>;
17
- //# sourceMappingURL=create.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"create.d.ts","sourceRoot":"","sources":["../src/create.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,MAAM,EAAS,MAAM,EAAE,KAAK,MAAM,EAAE,MAAM,QAAQ,CAAC;AACjE,OAAO,KAAK,KAAK,IAAI,MAAM,iCAAiC,CAAC;AAE7D,OAAO,KAAK,EAAE,OAAO,EAAE,OAAO,EAAE,YAAY,EAAE,MAAM,EAAE,IAAI,EAAE,QAAQ,EAAE,MAAM,iCAAiC,CAAC;AAC9G,OAAO,KAAK,EAAE,YAAY,EAAE,qBAAqB,EAAE,MAAM,sBAAsB,CAAC;AAChF,OAAO,EAAE,KAAK,SAAS,EAAgB,MAAM,iBAAiB,CAAC;AAG/D,MAAM,MAAM,YAAY,CAAC,CAAC,SAAS,MAAM,EAAE,OAAO,EAAE,EAAE,EAAE,CAAC,EAAE,EAAE,IAAI;IAChE,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC;IAClC,QAAQ,CAAC,aAAa,EAAE,OAAO,CAAC,CAAC,CAAC,CAAC;IACnC,QAAQ,CAAC,MAAM,EAAE,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,CAAC,KAAK,MAAM,CAAC,MAAM,CAAC,OAAO,EAAE,EAAE,EAAE,YAAY,CAAC,CAAC,CAAC,CAAC,CAAC;IACrF,QAAQ,CAAC,OAAO,EAAE,IAAI,CAAC,WAAW,CAAC,QAAQ,CAAC,CAAC,EAAE,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC;CACvD,GAAG,qBAAqB,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC;AAEjC,MAAM,WAAW,MAAM,CAAC,CAAC,SAAS,MAAM,EAAE,OAAO,EAAE,EAAE,EAAE,EAAE,CAAE,SAAQ,SAAS,CAAC,EAAE,GAAG,EAAE,CAAC;IACpF,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC,MAAM,CAAC,OAAO,CAAC,CAAC;IACzC,QAAQ,CAAC,IAAI,EAAE,IAAI,CAAC,CAAC,EAAE,OAAO,EAAE,EAAE,GAAG,YAAY,EAAE,EAAE,CAAC,CAAC;CACvD;AAOD,eAAO,MAAM,SAAS,GAAI,CAAC,SAAS,MAAM,EAAE,OAAO,EAAE,EAAE,EAAE,CAAC,EAAE,EAAE,EAAE,QAAQ,YAAY,CAAC,CAAC,EAAE,OAAO,EAAE,EAAE,EAAE,CAAC,EAAE,EAAE,CAAC,KAAG,MAAM,CAAC,CAAC,EAAE,OAAO,EAAE,EAAE,EAAE,EAAE,CAiBtI,CAAC"}
@@ -1 +0,0 @@
1
- {"version":3,"file":"create.js","sourceRoot":"","sources":["../src/create.ts"],"names":[],"mappings":"AAAA,OAAO,EAAe,KAAK,EAAE,MAAM,EAAe,MAAM,QAAQ,CAAC;AAEjE,OAAO,EAAE,WAAW,EAAE,QAAQ,EAAE,MAAM,OAAO,CAAC;AAG9C,OAAO,EAAkB,YAAY,EAAE,MAAM,iBAAiB,CAAC;AAC/D,OAAO,EAAE,UAAU,EAAE,MAAM,iBAAiB,CAAC;AAc7C,MAAM,WAAW,GAAG,CAAmB,OAAU,EAAE,SAAwB,EAAqC,EAAE;IACjH,MAAM,MAAM,GAAG,IAAI,GAAG,CAAC,MAAM,CAAC,OAAO,CAAC,SAAS,IAAI,OAAO,CAAC,CAAC,CAAC;IAC7D,OAAO,MAAM,CAAC,WAAW,CAAC,MAAM,CAAC,OAAO,CAAC,OAAO,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,IAAI,EAAE,KAAK,CAAC,EAAE,EAAE,CAAC,CAAC,KAAK,CAAC,MAAM,CAAC,KAAK,EAAE,MAAM,CAAC,GAAG,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,CAAC;AACtH,CAAC,CAAC;AAEF,MAAM,CAAC,MAAM,SAAS,GAAG,CAAuC,MAA2C,EAA8B,EAAE;IAC1I,MAAM,CAAC,EAAE,aAAa,EAAE,KAAK,EAAE,CAAC,GAAG,QAAQ,CAAC,GAAG,EAAE;QAChD,MAAM,EAAE,MAAM,EAAE,aAAa,EAAE,OAAO,EAAE,MAAM,EAAE,WAAW,EAAE,GAAG,MAAM,CAAC;QACvE,OAAO,EAAE,KAAK,EAAE,GAAG,EAAE,CAAC,UAAU,CAAC,EAAE,MAAM,EAAE,aAAa,EAAE,WAAW,EAAE,OAAO,EAAE,IAAI,EAAE,MAAM,EAAE,CAAC,EAAE,aAAa,EAAE,CAAC;IAClH,CAAC,CAAC,CAAC;IACH,MAAM,CAAC,OAAO,EAAE,OAAO,CAAC,GAAG,QAAQ,CAAC,KAAK,CAAC,CAAC;IAC3C,MAAM,CAAC,OAAO,EAAE,MAAM,CAAC,GAAG,QAAQ,CAAyB,MAAM,CAAC,IAAI,EAAE,CAAC,CAAC;IAC1E,MAAM,OAAO,GAAG,WAAW,CAC1B,CAAC,KAAc,EAAE,EAAE;QAClB,MAAM,IAAI,GAAG,KAAK,EAAE,CAAC;QACrB,IAAI,CAAC,IAAI,CAAC,MAAM,CAAC,GAAG,CAAC,EAAE,GAAG,aAAa,EAAE,GAAG,WAAW,CAAC,OAAO,CAAC,IAAI,CAAC,MAAM,CAAC,KAAK,EAAE,OAAO,CAAC,SAAS,EAAE,CAAC,EAAE,CAAC,CAAC;QAC3G,MAAM,CAAC,MAAM,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC,CAAC;QAC3B,OAAO,CAAC,IAAI,CAAC,CAAC;IACf,CAAC,EACD,CAAC,OAAO,EAAE,KAAK,EAAE,aAAa,CAAC,CAC/B,CAAC;IACF,OAAO,EAAE,GAAG,YAAY,CAAC,OAAO,CAAC,IAAI,EAAE,OAAO,CAAC,EAAE,OAAO,EAAE,IAAI,EAAE,OAAO,CAAC,IAAI,EAAE,CAAC;AAChF,CAAC,CAAC"}