@shivaedev/react 0.0.0 → 0.19.0

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 (154) hide show
  1. package/CHANGELOG.md +300 -0
  2. package/LICENSE +21 -0
  3. package/README.md +844 -1
  4. package/dist/actionTrace.d.ts +3 -0
  5. package/dist/actionTrace.d.ts.map +1 -0
  6. package/dist/actionTrace.js +5 -0
  7. package/dist/actionTrace.js.map +1 -0
  8. package/dist/client/QueryView.d.ts +10 -0
  9. package/dist/client/QueryView.d.ts.map +1 -0
  10. package/dist/client/QueryView.js +13 -0
  11. package/dist/client/QueryView.js.map +1 -0
  12. package/dist/client/actionCompletion.d.ts +12 -0
  13. package/dist/client/actionCompletion.d.ts.map +1 -0
  14. package/dist/client/actionCompletion.js +68 -0
  15. package/dist/client/actionCompletion.js.map +1 -0
  16. package/dist/client/authAction.d.ts +15 -0
  17. package/dist/client/authAction.d.ts.map +1 -0
  18. package/dist/client/authAction.js +11 -0
  19. package/dist/client/authAction.js.map +1 -0
  20. package/dist/client/create.d.ts +21 -0
  21. package/dist/client/create.d.ts.map +1 -0
  22. package/dist/client/create.js +35 -0
  23. package/dist/client/create.js.map +1 -0
  24. package/dist/client/editor.d.ts +33 -0
  25. package/dist/client/editor.d.ts.map +1 -0
  26. package/dist/client/editor.js +50 -0
  27. package/dist/client/editor.js.map +1 -0
  28. package/dist/client/mountLive.d.ts +4 -0
  29. package/dist/client/mountLive.d.ts.map +1 -0
  30. package/dist/client/mountLive.js +46 -0
  31. package/dist/client/mountLive.js.map +1 -0
  32. package/dist/client/pendingCommand.d.ts +13 -0
  33. package/dist/client/pendingCommand.d.ts.map +1 -0
  34. package/dist/client/pendingCommand.js +30 -0
  35. package/dist/client/pendingCommand.js.map +1 -0
  36. package/dist/client/resultState.d.ts +32 -0
  37. package/dist/client/resultState.d.ts.map +1 -0
  38. package/dist/client/resultState.js +62 -0
  39. package/dist/client/resultState.js.map +1 -0
  40. package/dist/client/saveState.d.ts +11 -0
  41. package/dist/client/saveState.d.ts.map +1 -0
  42. package/dist/client/saveState.js +37 -0
  43. package/dist/client/saveState.js.map +1 -0
  44. package/dist/client/sessionBoundary.d.ts +15 -0
  45. package/dist/client/sessionBoundary.d.ts.map +1 -0
  46. package/dist/client/sessionBoundary.js +70 -0
  47. package/dist/client/sessionBoundary.js.map +1 -0
  48. package/dist/client/testing.d.ts +28 -0
  49. package/dist/client/testing.d.ts.map +1 -0
  50. package/dist/client/testing.js +108 -0
  51. package/dist/client/testing.js.map +1 -0
  52. package/dist/client/useDraftOwner.d.ts +3 -0
  53. package/dist/client/useDraftOwner.d.ts.map +1 -0
  54. package/dist/client/useDraftOwner.js +36 -0
  55. package/dist/client/useDraftOwner.js.map +1 -0
  56. package/dist/client/useLoaderQuery.d.ts +8 -0
  57. package/dist/client/useLoaderQuery.d.ts.map +1 -0
  58. package/dist/client/useLoaderQuery.js +10 -0
  59. package/dist/client/useLoaderQuery.js.map +1 -0
  60. package/dist/fieldRejection.d.ts +25 -0
  61. package/dist/fieldRejection.d.ts.map +1 -0
  62. package/dist/fieldRejection.js +21 -0
  63. package/dist/fieldRejection.js.map +1 -0
  64. package/dist/internal/NoticeContext.d.ts +7 -0
  65. package/dist/internal/NoticeContext.d.ts.map +1 -0
  66. package/dist/internal/NoticeContext.js +3 -0
  67. package/dist/internal/NoticeContext.js.map +1 -0
  68. package/dist/internal/loadedQuery.d.ts +16 -0
  69. package/dist/internal/loadedQuery.d.ts.map +1 -0
  70. package/dist/internal/loadedQuery.js +11 -0
  71. package/dist/internal/loadedQuery.js.map +1 -0
  72. package/dist/internal/noticeEntry.d.ts +21 -0
  73. package/dist/internal/noticeEntry.d.ts.map +1 -0
  74. package/dist/internal/noticeEntry.js +45 -0
  75. package/dist/internal/noticeEntry.js.map +1 -0
  76. package/dist/internal/noticeStore.d.ts +12 -0
  77. package/dist/internal/noticeStore.d.ts.map +1 -0
  78. package/dist/internal/noticeStore.js +37 -0
  79. package/dist/internal/noticeStore.js.map +1 -0
  80. package/dist/internal/queryHandoff.d.ts +9 -0
  81. package/dist/internal/queryHandoff.d.ts.map +1 -0
  82. package/dist/internal/queryHandoff.js +50 -0
  83. package/dist/internal/queryHandoff.js.map +1 -0
  84. package/dist/internal/sessionRecheck.d.ts +4 -0
  85. package/dist/internal/sessionRecheck.d.ts.map +1 -0
  86. package/dist/internal/sessionRecheck.js +14 -0
  87. package/dist/internal/sessionRecheck.js.map +1 -0
  88. package/dist/internal/useLoaderHandoff.d.ts +4 -0
  89. package/dist/internal/useLoaderHandoff.d.ts.map +1 -0
  90. package/dist/internal/useLoaderHandoff.js +18 -0
  91. package/dist/internal/useLoaderHandoff.js.map +1 -0
  92. package/dist/internal/useNoticePublisher.d.ts +4 -0
  93. package/dist/internal/useNoticePublisher.d.ts.map +1 -0
  94. package/dist/internal/useNoticePublisher.js +8 -0
  95. package/dist/internal/useNoticePublisher.js.map +1 -0
  96. package/dist/internal/useQueryNotice.d.ts +8 -0
  97. package/dist/internal/useQueryNotice.d.ts.map +1 -0
  98. package/dist/internal/useQueryNotice.js +26 -0
  99. package/dist/internal/useQueryNotice.js.map +1 -0
  100. package/dist/internal/useSubmissionCallbacks.d.ts +11 -0
  101. package/dist/internal/useSubmissionCallbacks.d.ts.map +1 -0
  102. package/dist/internal/useSubmissionCallbacks.js +45 -0
  103. package/dist/internal/useSubmissionCallbacks.js.map +1 -0
  104. package/dist/loadQuery.d.ts +6 -0
  105. package/dist/loadQuery.d.ts.map +1 -0
  106. package/dist/loadQuery.js +29 -0
  107. package/dist/loadQuery.js.map +1 -0
  108. package/dist/notices.d.ts +32 -0
  109. package/dist/notices.d.ts.map +1 -0
  110. package/dist/notices.js +34 -0
  111. package/dist/notices.js.map +1 -0
  112. package/dist/queryRoute.d.ts +20 -0
  113. package/dist/queryRoute.d.ts.map +1 -0
  114. package/dist/queryRoute.js +20 -0
  115. package/dist/queryRoute.js.map +1 -0
  116. package/dist/resumeSignal.d.ts +6 -0
  117. package/dist/resumeSignal.d.ts.map +1 -0
  118. package/dist/resumeSignal.js +5 -0
  119. package/dist/resumeSignal.js.map +1 -0
  120. package/dist/submission.d.ts +19 -0
  121. package/dist/submission.d.ts.map +1 -0
  122. package/dist/submission.js +19 -0
  123. package/dist/submission.js.map +1 -0
  124. package/package.json +104 -4
  125. package/src/actionTrace.ts +5 -0
  126. package/src/client/QueryView.ts +27 -0
  127. package/src/client/actionCompletion.ts +82 -0
  128. package/src/client/authAction.ts +22 -0
  129. package/src/client/create.ts +55 -0
  130. package/src/client/editor.ts +89 -0
  131. package/src/client/mountLive.ts +57 -0
  132. package/src/client/pendingCommand.ts +42 -0
  133. package/src/client/resultState.ts +102 -0
  134. package/src/client/saveState.ts +54 -0
  135. package/src/client/sessionBoundary.ts +107 -0
  136. package/src/client/testing.ts +142 -0
  137. package/src/client/useDraftOwner.ts +42 -0
  138. package/src/client/useLoaderQuery.ts +16 -0
  139. package/src/fieldRejection.ts +51 -0
  140. package/src/internal/NoticeContext.ts +4 -0
  141. package/src/internal/loadedQuery.ts +18 -0
  142. package/src/internal/noticeEntry.ts +65 -0
  143. package/src/internal/noticeStore.ts +46 -0
  144. package/src/internal/queryHandoff.ts +60 -0
  145. package/src/internal/sessionRecheck.ts +17 -0
  146. package/src/internal/useLoaderHandoff.ts +20 -0
  147. package/src/internal/useNoticePublisher.ts +10 -0
  148. package/src/internal/useQueryNotice.ts +32 -0
  149. package/src/internal/useSubmissionCallbacks.ts +57 -0
  150. package/src/loadQuery.ts +34 -0
  151. package/src/notices.ts +65 -0
  152. package/src/queryRoute.ts +30 -0
  153. package/src/resumeSignal.ts +13 -0
  154. package/src/submission.ts +39 -0
package/package.json CHANGED
@@ -1,10 +1,110 @@
1
1
  {
2
2
  "name": "@shivaedev/react",
3
- "version": "0.0.0",
4
- "description": "Placeholder for the ShivaeDev react package.",
5
- "license": "MIT",
3
+ "version": "0.19.0",
4
+ "description": "React query, action, editor and session hooks for native Effect atoms",
5
+ "keywords": [
6
+ "effect"
7
+ ],
8
+ "homepage": "https://github.com/ShivaeDev/platform/tree/main/packages/react#readme",
9
+ "bugs": {
10
+ "url": "https://github.com/ShivaeDev/platform/issues"
11
+ },
6
12
  "repository": {
7
13
  "type": "git",
8
- "url": "git+https://github.com/ShivaeDev/platform.git"
14
+ "url": "git+https://github.com/ShivaeDev/platform.git",
15
+ "directory": "packages/react"
16
+ },
17
+ "license": "MIT",
18
+ "sideEffects": false,
19
+ "type": "module",
20
+ "imports": {
21
+ "#test/*": "./src/test-support/*",
22
+ "#*.ts": {
23
+ "source": "./src/*.ts",
24
+ "types": "./dist/*.d.ts",
25
+ "default": "./dist/*.js"
26
+ }
27
+ },
28
+ "exports": {
29
+ "./*.ts": {
30
+ "source": "./src/*.ts",
31
+ "types": "./dist/*.d.ts",
32
+ "default": "./dist/*.js"
33
+ },
34
+ "./internal/*": null,
35
+ "./package.json": "./package.json"
36
+ },
37
+ "files": [
38
+ "dist",
39
+ "src",
40
+ "!src/**/*.test.ts",
41
+ "!src/**/*.test.tsx",
42
+ "!src/**/*.spec.ts",
43
+ "!src/**/*.spec.tsx",
44
+ "!src/**/test-support",
45
+ "CHANGELOG.md",
46
+ "README.md"
47
+ ],
48
+ "dependencies": {
49
+ "@shivaedev/contract": "0.17.0",
50
+ "@shivaedev/form": "0.7.0",
51
+ "@shivaedev/test": "0.10.0"
52
+ },
53
+ "devDependencies": {
54
+ "@effect/atom-react": "4.0.5",
55
+ "@effect/platform-node": "4.0.5",
56
+ "@effect/sql-pg": "4.0.5",
57
+ "@effect/sql-sqlite-node": "4.0.5",
58
+ "@shivaedev/identity": "0.1.0",
59
+ "@shivaedev/jobs": "0.20.0",
60
+ "@shivaedev/local-postgres": "0.7.0",
61
+ "@shivaedev/quality": "0.42.0",
62
+ "@shivaedev/server": "0.1.0",
63
+ "@shivaedev/service": "0.5.0",
64
+ "@shivaedev/sql": "0.22.0",
65
+ "@shivaedev/test-sql": "0.10.0",
66
+ "@shivaedev/test-story": "0.21.0",
67
+ "@tanstack/react-router": "1.170.45",
68
+ "@types/node": "24.19.2",
69
+ "@types/react": "19.3.0",
70
+ "@types/react-dom": "19.3.0",
71
+ "effect": "4.0.5",
72
+ "happy-dom": "21.0.3",
73
+ "react": "19.3.1",
74
+ "react-dom": "19.3.1",
75
+ "typescript": "7.0.2",
76
+ "vitest": "5.0.5"
77
+ },
78
+ "peerDependencies": {
79
+ "@effect/atom-react": "4.0.5",
80
+ "@tanstack/react-router": "1.170.45",
81
+ "effect": "4.0.5",
82
+ "react": "19.3.1",
83
+ "react-dom": "19.3.1",
84
+ "vitest": "5.0.5"
85
+ },
86
+ "peerDependenciesMeta": {
87
+ "@tanstack/react-router": {
88
+ "optional": true
89
+ },
90
+ "react-dom": {
91
+ "optional": true
92
+ },
93
+ "vitest": {
94
+ "optional": true
95
+ }
96
+ },
97
+ "engines": {
98
+ "node": ">=24"
99
+ },
100
+ "publishConfig": {
101
+ "access": "public",
102
+ "provenance": true
103
+ },
104
+ "scripts": {
105
+ "build": "node --eval \"import('node:fs').then(({ rmSync }) => rmSync('dist', { force: true, recursive: true }))\" && tsc --project tsconfig.build.json",
106
+ "ready": "pnpm typecheck && pnpm test && pnpm build",
107
+ "test": "vitest run",
108
+ "typecheck": "tsc --noEmit"
9
109
  }
10
110
  }
@@ -0,0 +1,5 @@
1
+ import { Effect } from "effect";
2
+
3
+ export function actionTrace<A, E, R>(effect: Effect.Effect<A, E, R>, name = "ui.action"): Effect.Effect<A, E, R> {
4
+ return Effect.withSpan(effect, name, { root: true });
5
+ }
@@ -0,0 +1,27 @@
1
+ import { Cause, Option, Result } from "effect";
2
+ import { createElement, Fragment, type ReactNode } from "react";
3
+ import type { QueryState } from "./resultState.ts";
4
+
5
+ export interface QueryViewProps<A, E> {
6
+ readonly content: (data: A, query: QueryState<A, E>) => ReactNode;
7
+ readonly failure: (error: E, query: QueryState<A, E>) => ReactNode;
8
+ readonly loading: (query: QueryState<A, E>) => ReactNode;
9
+ readonly query: QueryState<A, E>;
10
+ }
11
+
12
+ export function QueryView<A, E>({ query, loading, content, failure }: QueryViewProps<A, E>): ReactNode {
13
+ const error = Option.flatMap(query.cause, (cause) => {
14
+ const defect = Cause.findDefect(cause);
15
+ if (Result.isSuccess(defect)) {
16
+ throw defect.success;
17
+ }
18
+ return Cause.findErrorOption(cause);
19
+ });
20
+ return createElement(
21
+ Fragment,
22
+ null,
23
+ Option.isSome(query.data) ? content(query.data.value, query) : null,
24
+ query.pending || (Option.isNone(query.data) && Option.isNone(error)) ? loading(query) : null,
25
+ Option.isSome(error) ? failure(error.value, query) : null,
26
+ );
27
+ }
@@ -0,0 +1,82 @@
1
+ import { RegistryContext } from "@effect/atom-react";
2
+ import { Effect, Predicate } from "effect";
3
+ import type * as Atom from "effect/reactivity/Atom";
4
+ import { useContext, useInsertionEffect, useMemo, useRef } from "react";
5
+ import type { AtomServices } from "#fieldRejection.ts";
6
+ import { NoticeContext } from "#internal/NoticeContext.ts";
7
+ import { publishNotice } from "#internal/noticeStore.ts";
8
+ import type { NoticeFeedback } from "#notices.ts";
9
+ import { useSessionRecheck } from "./sessionBoundary.ts";
10
+
11
+ export interface ActionOptions<TInput, A, E, R = never> {
12
+ readonly concurrent?: boolean;
13
+ readonly notices?: NoticeFeedback<TInput, A, E, R>;
14
+ readonly onFailure?: (error: E, input: TInput) => void;
15
+ readonly onSuccess?: (value: A, input: TInput) => void;
16
+ }
17
+
18
+ export function useActionSource<TInput, A, E, R, TRuntimeError>(
19
+ source: Atom.AtomResultFn<TInput, A, E> | Atom.AtomRuntime<R, TRuntimeError>,
20
+ run: ((input: TInput) => Effect.Effect<A, E, AtomServices<R>>) | undefined,
21
+ options: ActionOptions<TInput, A, E, R>,
22
+ ): Atom.AtomResultFn<TInput, A, E | TRuntimeError> {
23
+ const registry = useContext(RegistryContext);
24
+ const recheck = useSessionRecheck();
25
+ const notices = useContext(NoticeContext);
26
+ const latestRef = useRef({ options, recheck, run });
27
+ useInsertionEffect(() => {
28
+ latestRef.current = { options, recheck, run };
29
+ }, [options, recheck, run]);
30
+ const concurrent = options.concurrent;
31
+ const owner = useMemo(() => {
32
+ const lifetime = { active: false, registry };
33
+ function rejected(error: E, input: TInput) {
34
+ return Effect.gen(function* () {
35
+ if (lifetime.active && Predicate.isTagged(error, "Unauthorized")) {
36
+ yield* Effect.sync(() => latestRef.current.recheck());
37
+ }
38
+ yield* Effect.sync(() => {
39
+ if (lifetime.active) {
40
+ latestRef.current.options.onFailure?.(error, input);
41
+ if ("fn" in source) {
42
+ publishNotice(notices, source, latestRef.current.options.notices?.onFailure?.(error, input));
43
+ }
44
+ }
45
+ });
46
+ return yield* Effect.fail(error);
47
+ });
48
+ }
49
+ function completed(value: A, input: TInput) {
50
+ return Effect.sync(() => {
51
+ if (lifetime.active) {
52
+ latestRef.current.options.onSuccess?.(value, input);
53
+ if ("fn" in source) {
54
+ publishNotice(notices, source, latestRef.current.options.notices?.onSuccess?.(value, input));
55
+ }
56
+ }
57
+ return value;
58
+ });
59
+ }
60
+ function execute(input: TInput) {
61
+ return Effect.suspend(() => {
62
+ const command = latestRef.current.run;
63
+ if (!command) {
64
+ return Effect.die(new Error("Missing command Effect"));
65
+ }
66
+ return Effect.matchEffect(command(input), {
67
+ onFailure: (error) => rejected(error, input),
68
+ onSuccess: (value) => completed(value, input),
69
+ });
70
+ });
71
+ }
72
+ const atom = "fn" in source ? source.fn(execute, { concurrent }) : source;
73
+ return { atom, lifetime };
74
+ }, [source, registry, concurrent, notices]);
75
+ useInsertionEffect(() => {
76
+ owner.lifetime.active = true;
77
+ return () => {
78
+ owner.lifetime.active = false;
79
+ };
80
+ }, [owner]);
81
+ return owner.atom;
82
+ }
@@ -0,0 +1,22 @@
1
+ import { Data, Effect } from "effect";
2
+ import * as Atom from "effect/reactivity/Atom";
3
+
4
+ export class AuthActionUnavailable extends Data.TaggedError("AuthActionUnavailable")<{
5
+ readonly cause: unknown;
6
+ }> {}
7
+
8
+ export interface AuthActionResult<A, E> {
9
+ readonly data: A;
10
+ readonly error: E | null;
11
+ }
12
+
13
+ export function authAction<TInput, A, E extends object = never>(
14
+ run: (input: TInput, signal: AbortSignal) => Promise<AuthActionResult<A, E>>,
15
+ ): Atom.AtomResultFn<TInput, A, E | AuthActionUnavailable> {
16
+ return Atom.fn<TInput>()((input) =>
17
+ Effect.tryPromise({
18
+ catch: (cause) => new AuthActionUnavailable({ cause }),
19
+ try: (signal) => run(input, signal),
20
+ }).pipe(Effect.flatMap((result) => (result.error === null ? Effect.succeed(result.data) : Effect.fail(result.error)))),
21
+ );
22
+ }
@@ -0,0 +1,55 @@
1
+ import { type Effect, Equal, Option, type Schema } from "effect";
2
+ import type * as Atom from "effect/reactivity/Atom";
3
+ import { useCallback, useState } from "react";
4
+ import { type BrowserDraftConfig, usePersistedDraft } from "@shivaedev/form/client/react.ts";
5
+ import type { Decoded, Encoded, FieldFailure, Fields, Form, Services } from "@shivaedev/form/shape.ts";
6
+ import type { AtomServices, FieldRejectionMapping } from "#fieldRejection.ts";
7
+ import { useSubmissionCallbacks } from "#internal/useSubmissionCallbacks.ts";
8
+ import type { NoticeFeedback } from "#notices.ts";
9
+ import { submission } from "#submission.ts";
10
+ import { type SaveState, useSaveState } from "./saveState.ts";
11
+ export type CreateConfig<TFields extends Fields, TCreated, TSaveError, R, TRuntimeError> = {
12
+ readonly persistedDraft?: BrowserDraftConfig | undefined;
13
+ readonly fields: Schema.Struct<TFields>;
14
+ readonly initialValues: Encoded<TFields>;
15
+ readonly create: (values: Decoded<TFields>) => Effect.Effect<TCreated, TSaveError, AtomServices<R>>;
16
+ readonly runtime: Atom.AtomRuntime<Services<TFields, R>, TRuntimeError>;
17
+ readonly notices?: NoticeFeedback<Decoded<TFields>, TCreated, TSaveError, Services<TFields, R>>;
18
+ } & FieldRejectionMapping<TFields, TSaveError>;
19
+ export interface Create<TFields extends Fields, TCreated, TSaveError, TRuntimeError> extends SaveState<TSaveError | TRuntimeError> {
20
+ readonly created: Option.Option<TCreated>;
21
+ readonly form: Form<TFields, TCreated, TSaveError | FieldFailure, TRuntimeError>;
22
+ }
23
+ function editedSince<TV extends object>(current: TV, submitted: TV | undefined): Readonly<Record<string, unknown>> {
24
+ const before = new Map(Object.entries(submitted ?? current));
25
+ return Object.fromEntries(Object.entries(current).filter(([name, value]) => !Equal.equals(value, before.get(name))));
26
+ }
27
+ export function useCreate<TFields extends Fields, TCreated, TSaveError, R, TRuntimeError>(
28
+ config: CreateConfig<TFields, TCreated, TSaveError, R, TRuntimeError>,
29
+ ): Create<TFields, TCreated, TSaveError, TRuntimeError> {
30
+ const persistedDraft = usePersistedDraft(config.persistedDraft);
31
+ const callbacks = useSubmissionCallbacks(config.fields, config.create, config.rejectField, config.runtime, config.notices);
32
+ function initialize() {
33
+ const { fields, initialValues, runtime } = config;
34
+ function makeSubmission() {
35
+ return submission({ ...callbacks, fields, initialValues, persistedDraft, runtime });
36
+ }
37
+ return { baseline: initialValues, created: Option.none<TCreated>(), current: makeSubmission(), fields, fresh: makeSubmission, runtime };
38
+ }
39
+ const [held, hold] = useState(initialize);
40
+ let entry = held;
41
+ if (entry.fields !== config.fields || entry.runtime !== config.runtime) {
42
+ entry = initialize();
43
+ hold(entry);
44
+ }
45
+ const { baseline, created, current, fresh } = entry;
46
+ const settled = useCallback(
47
+ (value: TCreated) => {
48
+ const next = fresh();
49
+ next.form.values.set({ ...baseline, ...editedSince(current.form.values.value, current.submitted()) });
50
+ hold({ ...entry, created: Option.some(value), current: next });
51
+ },
52
+ [entry, current, fresh, baseline],
53
+ );
54
+ return { ...useSaveState(current.form, settled), created, form: current.form };
55
+ }
@@ -0,0 +1,89 @@
1
+ import { type Effect, Option, type Schema } from "effect";
2
+ import type * as AsyncResult from "effect/reactivity/AsyncResult";
3
+ import type * as Atom from "effect/reactivity/Atom";
4
+ import { useCallback, useEffect, useState } from "react";
5
+ import { type BrowserDraftConfig, usePersistedDraft } from "@shivaedev/form/client/react.ts";
6
+ import type { Decoded, Encoded, FieldFailure, Fields, Form, Services } from "@shivaedev/form/shape.ts";
7
+ import type { AtomServices, FieldRejectionMapping } from "#fieldRejection.ts";
8
+ import { loadedQuery } from "#internal/loadedQuery.ts";
9
+ import { useSubmissionCallbacks } from "#internal/useSubmissionCallbacks.ts";
10
+ import type { NoticeFeedback } from "#notices.ts";
11
+ import { submission } from "#submission.ts";
12
+ import { type QueryOptions, type QueryState, useQuery } from "./resultState.ts";
13
+ import { type SaveState, useSaveState } from "./saveState.ts";
14
+ import type { LoadedQueryState } from "./useLoaderQuery.ts";
15
+ export type EditorConfig<TFields extends Fields, TRow, TQueryError, TSaveError, R, TRuntimeError> = {
16
+ readonly persistedDraft?: BrowserDraftConfig | undefined;
17
+ readonly query: Atom.Atom<AsyncResult.AsyncResult<TRow, TQueryError>>;
18
+ readonly fields: Schema.Struct<TFields>;
19
+ readonly values: (row: TRow) => Encoded<TFields>;
20
+ readonly save: (values: Decoded<TFields>) => Effect.Effect<TRow, TSaveError, AtomServices<R>>;
21
+ readonly runtime: Atom.AtomRuntime<Services<TFields, R>, TRuntimeError>;
22
+ readonly notices?: NoticeFeedback<Decoded<TFields>, TRow, TSaveError, Services<TFields, R>>;
23
+ readonly queryNotice?: QueryOptions<TQueryError>["notice"];
24
+ } & FieldRejectionMapping<TFields, TSaveError>;
25
+ export interface Editor<TFields extends Fields, TRow, TQueryError, TSaveError, TRuntimeError> extends SaveState<TSaveError | TRuntimeError> {
26
+ readonly form: Form<TFields, TRow, TSaveError | FieldFailure, TRuntimeError> | undefined;
27
+ readonly query: QueryState<TRow, TQueryError>;
28
+ }
29
+ export interface LoadedEditor<TFields extends Fields, TRow, TQueryError, TSaveError, TRuntimeError>
30
+ extends Omit<Editor<TFields, TRow, TQueryError, TSaveError, TRuntimeError>, "form" | "query"> {
31
+ readonly form: Form<TFields, TRow, TSaveError | FieldFailure, TRuntimeError>;
32
+ readonly query: LoadedQueryState<TRow, TQueryError>;
33
+ }
34
+ interface Held<TFields extends Fields, TRow, TSaveError, R, TRuntimeError> {
35
+ readonly fields: Schema.Struct<TFields>;
36
+ readonly form: Form<TFields, TRow, TSaveError | FieldFailure, TRuntimeError>;
37
+ readonly query: Atom.Atom<unknown>;
38
+ readonly runtime: Atom.AtomRuntime<Services<TFields, R>, TRuntimeError>;
39
+ readonly values: (row: TRow) => Encoded<TFields>;
40
+ }
41
+ export function useEditor<TFields extends Fields, TRow, TQueryError, TSaveError, R, TRuntimeError>(
42
+ config: EditorConfig<TFields, TRow, TQueryError, TSaveError, R, TRuntimeError> & { readonly loaded: TRow },
43
+ ): LoadedEditor<TFields, TRow, TQueryError, TSaveError, TRuntimeError>;
44
+ export function useEditor<TFields extends Fields, TRow, TQueryError, TSaveError, R, TRuntimeError>(
45
+ config: EditorConfig<TFields, TRow, TQueryError, TSaveError, R, TRuntimeError>,
46
+ ): Editor<TFields, TRow, TQueryError, TSaveError, TRuntimeError>;
47
+ export function useEditor<TFields extends Fields, TRow, TQueryError, TSaveError, R, TRuntimeError>(
48
+ config: EditorConfig<TFields, TRow, TQueryError, TSaveError, R, TRuntimeError> & { readonly loaded?: TRow },
49
+ ): Editor<TFields, TRow, TQueryError, TSaveError, TRuntimeError> | LoadedEditor<TFields, TRow, TQueryError, TSaveError, TRuntimeError> {
50
+ const persistedDraft = usePersistedDraft(config.persistedDraft);
51
+ const callbacks = useSubmissionCallbacks(config.fields, config.save, config.rejectField, config.runtime, config.notices);
52
+ const query = useQuery(config.query, config.queryNotice === undefined ? {} : { notice: config.queryNotice });
53
+ const row = Option.orElse(query.data, () => ("loaded" in config ? Option.some(config.loaded) : Option.none()));
54
+ const [held, hold] = useState<Held<TFields, TRow, TSaveError, R, TRuntimeError> | undefined>(undefined);
55
+ let current = held?.query === config.query && held.fields === config.fields && held.runtime === config.runtime ? held : undefined;
56
+ if (current === undefined && Option.isSome(row)) {
57
+ current = {
58
+ fields: config.fields,
59
+ form: submission({
60
+ ...callbacks,
61
+ fields: config.fields,
62
+ initialValues: config.values(row.value),
63
+ persistedDraft,
64
+ runtime: config.runtime,
65
+ }).form,
66
+ query: config.query,
67
+ runtime: config.runtime,
68
+ values: config.values,
69
+ };
70
+ hold(current);
71
+ }
72
+ const form = current?.form;
73
+ const values = current?.values;
74
+ const received = Option.getOrUndefined(row);
75
+ useEffect(() => {
76
+ if (form && values && received !== undefined) {
77
+ form.receive(values(received));
78
+ }
79
+ }, [form, values, received]);
80
+ const settled = useCallback((saved: TRow) => (form && values ? form.receive(values(saved)) : undefined), [form, values]);
81
+ const saveState = useSaveState(form, settled);
82
+ if ("loaded" in config) {
83
+ if (!form) {
84
+ throw new Error("A loaded editor requires a row");
85
+ }
86
+ return { ...saveState, form, query: loadedQuery(query, config.loaded) };
87
+ }
88
+ return { ...saveState, form, query };
89
+ }
@@ -0,0 +1,57 @@
1
+ import { Cause, type Duration, Option, Predicate } from "effect";
2
+ import * as Atom from "effect/reactivity/Atom";
3
+ import type * as AtomRegistry from "effect/reactivity/AtomRegistry";
4
+ import { type LiveBound, LiveConnection } from "@shivaedev/contract/automaticLive.ts";
5
+ import { recheckUnauthorized } from "#internal/sessionRecheck.ts";
6
+
7
+ function isLiveBound(value: unknown): value is LiveBound {
8
+ return Predicate.hasProperty(value, LiveConnection);
9
+ }
10
+
11
+ function recheckFailure(registry: AtomRegistry.AtomRegistry, failure: Option.Option<Cause.Cause<unknown>>) {
12
+ const error = Option.flatMap(failure, Cause.findErrorOption);
13
+ if (Option.isSome(error)) {
14
+ void recheckUnauthorized(registry, error.value);
15
+ }
16
+ }
17
+
18
+ export function mountLive(registry: AtomRegistry.AtomRegistry, client: unknown, coalesceWindow?: Duration.Input) {
19
+ const releases: Array<() => void> = [];
20
+ const found = new Set<unknown>();
21
+ function mount(value: unknown) {
22
+ if (!isLiveBound(value)) {
23
+ return;
24
+ }
25
+ const live = value[LiveConnection];
26
+ if (Predicate.hasProperty(live, "connection") && Atom.isAtom(live.connection) && !found.has(live.connection)) {
27
+ if (
28
+ coalesceWindow !== undefined
29
+ && Predicate.hasProperty(live, "coalesceWindow")
30
+ && Atom.isAtom(live.coalesceWindow)
31
+ && Atom.isWritable(live.coalesceWindow)
32
+ ) {
33
+ registry.set(live.coalesceWindow, coalesceWindow);
34
+ }
35
+ found.add(live.connection);
36
+ releases.push(
37
+ registry.subscribe(
38
+ Atom.map(live.status, (status) => status.failure),
39
+ (failure) => recheckFailure(registry, failure),
40
+ { immediate: true },
41
+ ),
42
+ );
43
+ releases.push(registry.mount(live.connection));
44
+ }
45
+ }
46
+ mount(client);
47
+ if (typeof client === "object" && client !== null) {
48
+ for (const value of Object.values(client)) {
49
+ mount(value);
50
+ }
51
+ }
52
+ return () => {
53
+ for (const release of releases) {
54
+ release();
55
+ }
56
+ };
57
+ }
@@ -0,0 +1,42 @@
1
+ import { RegistryContext, useAtomValue } from "@effect/atom-react";
2
+ import { Option } from "effect";
3
+ import * as AsyncResult from "effect/reactivity/AsyncResult";
4
+ import * as Atom from "effect/reactivity/Atom";
5
+ import { useContext, useMemo, useState } from "react";
6
+ import { type ActionState, useAction } from "./resultState.ts";
7
+
8
+ export interface PendingCommandOptions<TInput, TValue> {
9
+ readonly queries: readonly Atom.Atom<AsyncResult.AsyncResult<unknown, unknown>>[];
10
+ readonly value: TValue;
11
+ readonly wanted: (input: TInput) => TValue;
12
+ }
13
+ export interface PendingCommandState<TInput, A, E, TValue> extends ActionState<TInput, A, E> {
14
+ readonly value: TValue;
15
+ }
16
+
17
+ export function usePendingCommand<TInput, A, E, TValue>(
18
+ atom: Atom.AtomResultFn<TInput, A, E>,
19
+ options: PendingCommandOptions<TInput, TValue>,
20
+ ): PendingCommandState<TInput, A, E, TValue> {
21
+ const action = useAction(atom);
22
+ const registry = useContext(RegistryContext);
23
+ const { queries, value, wanted } = options;
24
+ const waiting = useMemo(() => Atom.make((get) => queries.map((query) => get(query).waiting).some(Boolean)), [queries]);
25
+ const refreshing = useAtomValue(waiting);
26
+ const [held, hold] = useState({ atom, registry, value: Option.none<TValue>() });
27
+ const sameOwner = held.atom === atom && held.registry === registry;
28
+ const failed = AsyncResult.isFailure(action.result) && !action.pending;
29
+ const pending = sameOwner && Option.isSome(held.value) && !failed && (action.pending || refreshing);
30
+ if (!sameOwner || (Option.isSome(held.value) && !pending)) {
31
+ hold({ atom, registry, value: Option.none() });
32
+ }
33
+ return {
34
+ ...action,
35
+ dispatch: (input) => {
36
+ hold({ atom, registry, value: Option.some(wanted(input)) });
37
+ action.dispatch(input);
38
+ },
39
+ pending: action.pending || pending,
40
+ value: pending ? Option.getOrElse(held.value, () => value) : value,
41
+ };
42
+ }
@@ -0,0 +1,102 @@
1
+ import { RegistryContext, useAtomRefresh, useAtomSet, useAtomValue } from "@effect/atom-react";
2
+ import { Cause, type Duration, type Effect, Option, Predicate } from "effect";
3
+ import * as AsyncResult from "effect/reactivity/AsyncResult";
4
+ import type * as Atom from "effect/reactivity/Atom";
5
+ import type * as AtomRegistry from "effect/reactivity/AtomRegistry";
6
+ import { useContext, useEffect, useState } from "react";
7
+ import { queryCoalesceWindow } from "@shivaedev/contract/queryCoalesceWindow.ts";
8
+ import type { AtomServices } from "#fieldRejection.ts";
9
+ import { acceptLoaderQuery } from "#internal/queryHandoff.ts";
10
+ import { useQueryNotice } from "#internal/useQueryNotice.ts";
11
+ import type { NoticeDescriptor } from "#notices.ts";
12
+ import { type ActionOptions, useActionSource } from "./actionCompletion.ts";
13
+ import { useSessionRecheck } from "./sessionBoundary.ts";
14
+
15
+ export interface ResultState<A, E> {
16
+ readonly cause: Option.Option<Cause.Cause<E>>;
17
+ readonly data: Option.Option<A>;
18
+ readonly pending: boolean;
19
+ readonly refreshing: boolean;
20
+ readonly result: AsyncResult.AsyncResult<A, E>;
21
+ }
22
+ function state<A, E>(result: AsyncResult.AsyncResult<A, E>, data = AsyncResult.value(result)): ResultState<A, E> {
23
+ return {
24
+ cause: AsyncResult.cause(result),
25
+ data,
26
+ pending: result.waiting,
27
+ refreshing: result.waiting && Option.isSome(data),
28
+ result,
29
+ };
30
+ }
31
+ export function isUnauthorized<E>(cause: Cause.Cause<E>): boolean {
32
+ return Option.match(Cause.findErrorOption(cause), { onNone: () => false, onSome: (error) => Predicate.isTagged(error, "Unauthorized") });
33
+ }
34
+ function useRecheckOnUnauthorized<A, E>(result: AsyncResult.AsyncResult<A, E>, enabled = true): void {
35
+ const recheck = useSessionRecheck();
36
+ useEffect(() => {
37
+ if (enabled && AsyncResult.isFailure(result) && isUnauthorized(result.cause)) {
38
+ recheck();
39
+ }
40
+ }, [enabled, recheck, result]);
41
+ }
42
+ export interface QueryState<A, E> extends ResultState<A, E> {
43
+ readonly refresh: () => void;
44
+ }
45
+ export interface QueryOptions<E = unknown> {
46
+ readonly coalesceWindow?: Duration.Input | undefined;
47
+ readonly keepPreviousData?: boolean;
48
+ readonly notice?: (failure: { readonly cause: Cause.Cause<E>; readonly refreshing: boolean }) => NoticeDescriptor | undefined;
49
+ }
50
+ interface HeldQuery<A, E> {
51
+ readonly atom: Atom.Atom<AsyncResult.AsyncResult<A, E>>;
52
+ readonly data: Option.Option<A>;
53
+ readonly dataAtom: Atom.Atom<AsyncResult.AsyncResult<A, E>>;
54
+ readonly keepPreviousData: boolean;
55
+ readonly registry: AtomRegistry.AtomRegistry;
56
+ readonly result: AsyncResult.AsyncResult<A, E>;
57
+ }
58
+ export function useQuery<A, E>(atom: Atom.Atom<AsyncResult.AsyncResult<A, E>>, options: QueryOptions<E> = {}): QueryState<A, E> {
59
+ const result = useAtomValue(atom);
60
+ const registry = useContext(RegistryContext);
61
+ useEffect(() => acceptLoaderQuery(registry, atom), [registry, atom]);
62
+ useEffect(() => {
63
+ if (options.coalesceWindow === undefined) {
64
+ return;
65
+ }
66
+ const window = queryCoalesceWindow(atom);
67
+ registry.set(window, options.coalesceWindow);
68
+ return () => registry.set(window, undefined);
69
+ }, [registry, atom, options.coalesceWindow]);
70
+ const keepPreviousData = options.keepPreviousData === true;
71
+ const nativeData = AsyncResult.value(result);
72
+ const [held, hold] = useState<HeldQuery<A, E>>(() => ({ atom, data: nativeData, dataAtom: atom, keepPreviousData, registry, result }));
73
+ const previous = held.registry === registry && (held.dataAtom === atom || keepPreviousData) ? held.data : Option.none<A>();
74
+ const data = Option.orElse(nativeData, () => previous);
75
+ const dataAtom = Option.isSome(nativeData) || Option.isNone(previous) ? atom : held.dataAtom;
76
+ // Render state keeps abandoned React renders from changing the committed screen's retained value.
77
+ if (held.atom !== atom || held.registry !== registry || held.result !== result || held.keepPreviousData !== keepPreviousData) {
78
+ hold({ atom, data, dataAtom, keepPreviousData, registry, result });
79
+ }
80
+ useRecheckOnUnauthorized(result);
81
+ useQueryNotice(result, Option.isSome(data), options.notice);
82
+ return { ...state(result, data), refresh: useAtomRefresh(atom) };
83
+ }
84
+ export interface ActionState<TInput, A, E> extends ResultState<A, E> {
85
+ readonly dispatch: (input: TInput) => void;
86
+ }
87
+ export function useAction<TInput, A, E>(atom: Atom.AtomResultFn<TInput, A, E>): ActionState<TInput, A, E>;
88
+ export function useAction<TInput, A, E, R, TRuntimeError>(
89
+ runtime: Atom.AtomRuntime<R, TRuntimeError>,
90
+ run: (input: TInput) => Effect.Effect<A, E, AtomServices<R>>,
91
+ options?: ActionOptions<NoInfer<TInput>, NoInfer<A>, NoInfer<E>, R>,
92
+ ): ActionState<TInput, A, E | TRuntimeError>;
93
+ export function useAction<TInput, A, E, R, TRuntimeError>(
94
+ source: Atom.AtomResultFn<TInput, A, E> | Atom.AtomRuntime<R, TRuntimeError>,
95
+ run?: (input: TInput) => Effect.Effect<A, E, AtomServices<R>>,
96
+ options: ActionOptions<TInput, A, E, R> = {},
97
+ ): ActionState<TInput, A, E | TRuntimeError> {
98
+ const atom = useActionSource(source, run, options);
99
+ const result = useAtomValue(atom);
100
+ useRecheckOnUnauthorized(result, run === undefined);
101
+ return { ...state(result), dispatch: useAtomSet(atom) };
102
+ }
@@ -0,0 +1,54 @@
1
+ import { RegistryContext, useAtomValue } from "@effect/atom-react";
2
+ import { Option } from "effect";
3
+ import * as AsyncResult from "effect/reactivity/AsyncResult";
4
+ import * as Atom from "effect/reactivity/Atom";
5
+ import { useCallback, useContext, useEffect } from "react";
6
+ import { FieldFailure, type Fields, type Form, Invalid } from "@shivaedev/form/shape.ts";
7
+ import { isUnauthorized } from "./resultState.ts";
8
+ import { useSessionRecheck } from "./sessionBoundary.ts";
9
+
10
+ const idle = Atom.make(AsyncResult.initial());
11
+ const clean = Atom.make(false);
12
+ export interface SaveState<E> {
13
+ readonly dirty: boolean;
14
+ readonly failure: Option.Option<E>;
15
+ readonly revert: () => void;
16
+ readonly save: () => void;
17
+ readonly saving: boolean;
18
+ }
19
+ export function useSaveState<TFields extends Fields, A, E, TRuntimeError>(
20
+ form: Form<TFields, A, E | FieldFailure, TRuntimeError> | undefined,
21
+ settled: (result: A) => void,
22
+ ): SaveState<E | TRuntimeError> {
23
+ const registry = useContext(RegistryContext);
24
+ const recheck = useSessionRecheck();
25
+ const result: AsyncResult.AsyncResult<A, E | TRuntimeError | FieldFailure | Invalid> = useAtomValue(form?.submit ?? idle);
26
+ const dirty = useAtomValue(form?.dirty ?? clean);
27
+ useEffect(() => {
28
+ if (AsyncResult.isFailure(result) && isUnauthorized(result.cause)) {
29
+ recheck();
30
+ }
31
+ }, [recheck, result]);
32
+ useEffect(
33
+ () =>
34
+ form
35
+ && registry.subscribe(form.submit, (next) => {
36
+ if (AsyncResult.isSuccess(next) && !next.waiting) {
37
+ settled(next.value);
38
+ }
39
+ }),
40
+ [form, registry, settled],
41
+ );
42
+ const save = useCallback(() => {
43
+ if (form === undefined || registry.get(form.submit).waiting) {
44
+ return;
45
+ }
46
+ registry.set(form.submit, undefined);
47
+ }, [form, registry]);
48
+ const revert = useCallback(() => form?.revert(), [form]);
49
+ const failure = Option.filter(
50
+ AsyncResult.error(result),
51
+ (error): error is E | TRuntimeError => !(error instanceof FieldFailure || error instanceof Invalid),
52
+ );
53
+ return { dirty, failure, revert, save, saving: result.waiting };
54
+ }