@shivaedev/effect-react 0.2.0 → 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 (68) hide show
  1. package/CHANGELOG.md +24 -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/resume-signal.d.ts +5 -16
  35. package/dist/resume-signal.d.ts.map +1 -1
  36. package/dist/resume-signal.js +4 -22
  37. package/dist/resume-signal.js.map +1 -1
  38. package/dist/submission.d.ts.map +1 -1
  39. package/docs/north-star.md +45 -0
  40. package/docs/roadmap.md +28 -0
  41. package/package.json +131 -18
  42. package/src/client/create.ts +41 -0
  43. package/src/client/editor.ts +56 -0
  44. package/src/client/resultState.ts +73 -0
  45. package/src/{save-state.ts → client/saveState.ts} +11 -10
  46. package/src/{session-boundary.ts → client/sessionBoundary.ts} +28 -32
  47. package/src/client/testing.ts +68 -0
  48. package/src/field-rejection.ts +3 -1
  49. package/src/loadQuery.ts +27 -0
  50. package/src/resume-signal.ts +12 -40
  51. package/dist/create.d.ts +0 -17
  52. package/dist/create.d.ts.map +0 -1
  53. package/dist/create.js.map +0 -1
  54. package/dist/editor.d.ts +0 -20
  55. package/dist/editor.d.ts.map +0 -1
  56. package/dist/editor.js.map +0 -1
  57. package/dist/result-state.d.ts +0 -20
  58. package/dist/result-state.d.ts.map +0 -1
  59. package/dist/result-state.js +0 -36
  60. package/dist/result-state.js.map +0 -1
  61. package/dist/save-state.d.ts.map +0 -1
  62. package/dist/save-state.js.map +0 -1
  63. package/dist/session-boundary.d.ts +0 -12
  64. package/dist/session-boundary.d.ts.map +0 -1
  65. package/dist/session-boundary.js.map +0 -1
  66. package/src/create.ts +0 -43
  67. package/src/editor.ts +0 -58
  68. package/src/result-state.ts +0 -58
@@ -0,0 +1,28 @@
1
+ # Roadmap
2
+
3
+ This roadmap owns the package's hooks, form coordination and session-registry implementation. The [framework roadmap](https://github.com/ShivaeDev/platform/blob/main/docs/framework/roadmap.md) owns composed feature fixtures, adoption and host/deployment validation. A local hook test is evidence for its own runtime and scenario.
4
+
5
+ ## Built
6
+
7
+ - [x] Opt-in `keepPreviousData` retains the last successful query value across payload changes, pending requests and failures; the default resets it. Registry replacement clears retained data, superseded responses do not replace the latest result, and disabling retention drops an inherited value. [Rendered query evidence](https://github.com/ShivaeDev/platform/blob/main/packages/effect-react/src/queryPayload.dom.spec.ts).
8
+ - [x] Public `testing.ts` render helpers supply an isolated native registry per mount, retain it on rerender, and dispose native resources and remove the container on idempotent unmount. [DOM evidence](https://github.com/ShivaeDev/platform/blob/main/packages/effect-react/src/client/testing.dom.test.tsx) also exercises query refetch after a command through a test RPC Layer; it does not establish browser networking.
9
+ - [x] Native query and action hooks with inferred input/success/failure types, retained data, pending/refresh state and explicit refresh/dispatch methods. DOM tests cover query identity changes, shared query execution and latest-dispatch interruption; compiler fixtures cover types.
10
+ - [x] A session generation owning its client, registry and subtree, with replacement/sign-out cleanup, query Unauthorized rechecking and retained screens until the auth owner changes the session.
11
+ - [x] Session cleanup across StrictMode effect replay and Activity hiding, reveal and hidden unmount, with credential rotation tested against the chosen identity key.
12
+ - [x] `useEditor` composing query, fields and save Effect, with initial loading, per-query forms, per-field refresh/saved-result merges, normalized results and in-flight edits covered by rendered tests.
13
+ - [x] `useCreate` starting a fresh form after success while retaining fields edited during schema decoding or the request; submissions through the form API settle the hooks too.
14
+ - [x] Tagged field-rejection mapping, a typed custom mapper and compile-time requirements for rejection field types that exceed the form's names; other save failures remain separately available.
15
+ - [x] A resume module using effect-contract's shared implementation, with visible browser events, an injected native source, listener disposal and native stale-while-revalidate policy covered by tests.
16
+ - [x] TanStack Router query loaders sharing the native registry, with tagged RPC failures, refresh waiting and abort handling. Render tests cover shared query execution, the route error component and previous data during navigation.
17
+
18
+ ## Next
19
+
20
+ - [ ] Add direct rendered regressions for Unauthorized action and save failures invoking the session callback. The implementation calls it, but existing boundary assertions exercise query failures.
21
+ - [ ] Add explicit result-state regressions for a successful `undefined` value and causes containing defects or interruption before making stronger README guarantees about those cases.
22
+ - [ ] Verify changing hook configuration during one form lifetime before documenting any supported reconfiguration policy. Creation currently captures its initial configuration, and an editor holds submission functions per query identity.
23
+
24
+ ## Open questions
25
+
26
+ - Do consumers need an independently scoped action helper, or are distinct native atoms and actual Effects enough for imperative per-invocation outcomes? Keep native shared-action semantics unless a demonstrated use case requires more.
27
+ - What per-request registry and hydration wiring does the TanStack Router host need for SSR? Client render fixtures do not establish SSR isolation.
28
+ - Which hook configuration values should be allowed to change without replacing the form? A rule for changing schemas, runtimes, save functions or initial values needs consumer evidence and explicit lifetime tests.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shivaedev/effect-react",
3
- "version": "0.2.0",
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": {
@@ -15,56 +15,169 @@
15
15
  "sideEffects": false,
16
16
  "type": "module",
17
17
  "imports": {
18
- "#test/*": "./test/*",
18
+ "#test/*": "./src/test-support/*",
19
19
  "#*.ts": {
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",
123
+ "!src/**/*.test.ts",
124
+ "!src/**/*.test.tsx",
125
+ "!src/**/*.spec.ts",
126
+ "!src/**/*.spec.tsx",
127
+ "!src/**/test-support",
37
128
  "CHANGELOG.md",
38
129
  "README.md"
39
130
  ],
131
+ "dependencies": {
132
+ "@shivaedev/effect-contract": "0.4.1"
133
+ },
40
134
  "devDependencies": {
41
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",
42
138
  "@effect/sql-sqlite-node": "4.0.0-rc.112",
43
- "@shivaedev/effect-contract": "0.3.0",
44
- "@shivaedev/effect-form": "0.2.0",
139
+ "@shivaedev/effect-form": "0.2.1",
140
+ "@shivaedev/effect-pg-boss": "0.4.0",
45
141
  "@shivaedev/effect-service": "0.2.0",
46
- "@shivaedev/effect-sql": "0.2.0",
47
- "@shivaedev/quality": "0.9.0",
48
- "@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",
49
150
  "@types/react": "19.2.18",
50
151
  "@types/react-dom": "19.2.4",
51
152
  "@typescript/native": "npm:typescript@7.0.2",
52
153
  "effect": "4.0.0-rc.112",
53
- "happy-dom": "20.11.2",
154
+ "happy-dom": "20.14.5",
54
155
  "react": "19.2.8",
55
156
  "react-dom": "19.2.8",
56
157
  "typescript": "npm:@typescript/typescript6@6.0.2",
57
- "vitest": "4.1.9"
158
+ "vitest": "4.1.11"
58
159
  },
59
160
  "peerDependencies": {
60
161
  "@effect/atom-react": "4.0.0-rc.112",
61
- "@shivaedev/effect-form": "0.2.0",
162
+ "@shivaedev/effect-form": "0.2.1",
163
+ "@tanstack/react-router": "1.170.41",
62
164
  "effect": "4.0.0-rc.112",
63
- "react": "19.2.8"
165
+ "react": "19.2.8",
166
+ "react-dom": "19.2.8",
167
+ "vitest": "4.1.11"
64
168
  },
65
169
  "peerDependenciesMeta": {
66
170
  "@shivaedev/effect-form": {
67
171
  "optional": true
172
+ },
173
+ "@tanstack/react-router": {
174
+ "optional": true
175
+ },
176
+ "react-dom": {
177
+ "optional": true
178
+ },
179
+ "vitest": {
180
+ "optional": true
68
181
  }
69
182
  },
70
183
  "engines": {
@@ -75,7 +188,7 @@
75
188
  "provenance": true
76
189
  },
77
190
  "scripts": {
78
- "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",
79
192
  "ready": "pnpm typecheck && pnpm test && pnpm build",
80
193
  "test": "vitest run",
81
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
+ }