@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
@@ -0,0 +1,107 @@
1
+ import { RegistryContext } from "@effect/atom-react";
2
+ import type { Duration } from "effect";
3
+ import * as AtomRegistry from "effect/reactivity/AtomRegistry";
4
+ import { createContext, createElement, Fragment, type ReactNode, useContext, useEffect, useState } from "react";
5
+ import { DraftSessionContext } from "@shivaedev/form/client/react.ts";
6
+ import { registerSessionRecheck } from "#internal/sessionRecheck.ts";
7
+ import { mountLive } from "./mountLive.ts";
8
+ import { useDraftOwner } from "./useDraftOwner.ts";
9
+ export interface SessionBoundaryProps<TSession, TClient> {
10
+ readonly children: (client: TClient) => ReactNode;
11
+ readonly connect: (session: TSession, registry: AtomRegistry.AtomRegistry) => TClient;
12
+ readonly identify: (session: TSession) => string;
13
+ readonly liveCoalesceWindow?: Duration.Input | undefined;
14
+ readonly recheck: () => void;
15
+ readonly session: TSession | undefined;
16
+ readonly signedOut?: ReactNode;
17
+ }
18
+ const RecheckContext = createContext<() => void>((): void => undefined);
19
+ export function useSessionRecheck(): () => void {
20
+ return useContext(RecheckContext);
21
+ }
22
+ interface Owned<TClient> {
23
+ readonly client: TClient;
24
+ mounted: number;
25
+ readonly registry: AtomRegistry.AtomRegistry;
26
+ }
27
+ function owning<TClient>(connect: (registry: AtomRegistry.AtomRegistry) => TClient): Owned<TClient> {
28
+ const registry = AtomRegistry.make({ "defaultIdleTTL": 400 });
29
+ let client: { readonly value: TClient } | undefined;
30
+ return {
31
+ get client() {
32
+ client ??= { value: connect(registry) };
33
+ return client.value;
34
+ },
35
+ mounted: 0,
36
+ registry,
37
+ };
38
+ }
39
+ // No cleanup runs when a hidden <Activity> subtree is unmounted, so nodes a hidden render creates must not outlive it.
40
+ function hidden<TClient>(connect: (registry: AtomRegistry.AtomRegistry) => TClient): Owned<TClient> {
41
+ const owned = owning(connect);
42
+ let reaping = false;
43
+ owned.registry.onNodeAdded = () => {
44
+ if (reaping || owned.mounted > 0) {
45
+ return;
46
+ }
47
+ reaping = true;
48
+ setTimeout(() => {
49
+ reaping = false;
50
+ if (owned.mounted === 0) {
51
+ owned.registry.reset();
52
+ }
53
+ }, 0);
54
+ };
55
+ return owned;
56
+ }
57
+ interface GenerationProps<TSession, TClient> {
58
+ readonly children: (client: TClient) => ReactNode;
59
+ readonly connect: (session: TSession, registry: AtomRegistry.AtomRegistry) => TClient;
60
+ readonly liveCoalesceWindow?: Duration.Input | undefined;
61
+ readonly recheck: () => void;
62
+ readonly session: TSession;
63
+ }
64
+ function Generation<TSession, TClient>({ session, connect, recheck, children, liveCoalesceWindow }: GenerationProps<TSession, TClient>): ReactNode {
65
+ const [open] = useState(() => (registry: AtomRegistry.AtomRegistry) => connect(session, registry));
66
+ const [owned, own] = useState(() => owning(open));
67
+ useEffect(() => registerSessionRecheck(owned.registry, recheck), [owned, recheck]);
68
+ useEffect(() => {
69
+ owned.mounted += 1;
70
+ const stopLive = mountLive(owned.registry, owned.client, liveCoalesceWindow);
71
+ return () => {
72
+ stopLive();
73
+ owned.mounted -= 1;
74
+ queueMicrotask(() => {
75
+ if (owned.mounted > 0) {
76
+ return;
77
+ }
78
+ owned.registry.dispose();
79
+ own((current) => (current === owned ? hidden(open) : current));
80
+ });
81
+ };
82
+ }, [owned, liveCoalesceWindow, open]);
83
+ return createElement(
84
+ RecheckContext.Provider,
85
+ { value: recheck },
86
+ createElement(RegistryContext.Provider, { value: owned.registry }, children(owned.client)),
87
+ );
88
+ }
89
+ export function SessionBoundary<TSession, TClient>({
90
+ session,
91
+ liveCoalesceWindow,
92
+ identify,
93
+ connect,
94
+ recheck,
95
+ signedOut,
96
+ children,
97
+ }: SessionBoundaryProps<TSession, TClient>): ReactNode {
98
+ const id = session === undefined ? undefined : identify(session);
99
+ const current = useDraftOwner(id);
100
+ return session === undefined
101
+ ? createElement(Fragment, null, signedOut)
102
+ : createElement(
103
+ DraftSessionContext.Provider,
104
+ { value: current },
105
+ createElement(Generation<TSession, TClient>, { children, connect, key: id, liveCoalesceWindow, recheck, session }),
106
+ );
107
+ }
@@ -0,0 +1,142 @@
1
+ import { RegistryContext } from "@effect/atom-react";
2
+ import { Clock, Effect, Exit, Scope } from "effect";
3
+ import * as AtomRegistry from "effect/reactivity/AtomRegistry";
4
+ import { act, createElement, type ReactNode } from "react";
5
+ import { createRoot, type RootOptions } from "react-dom/client";
6
+ import { beforeEach, expect, vi } from "vitest";
7
+ import { eventually as retry } from "@shivaedev/test/eventually.ts";
8
+ import { withLive } from "@shivaedev/test/withLive.ts";
9
+
10
+ type CaughtErrorHandler = NonNullable<RootOptions["onCaughtError"]>;
11
+
12
+ export interface ReactWarningsReadout {
13
+ readonly caughtErrors: readonly unknown[];
14
+ readonly consoleErrors: readonly (readonly unknown[])[];
15
+ }
16
+
17
+ export interface ReactWarnings {
18
+ readonly expectCaughtError: (matches: (error: unknown) => boolean) => CaughtErrorHandler;
19
+ readonly onCaughtError: CaughtErrorHandler;
20
+ readonly read: () => ReactWarningsReadout;
21
+ }
22
+
23
+ const acquireReactWarnings = Effect.acquireRelease(
24
+ Effect.sync(() => {
25
+ const errors = vi.spyOn(console, "error").mockImplementation(() => undefined);
26
+ let consoleErrors: readonly (readonly unknown[])[] | undefined;
27
+ const caughtErrors: unknown[] = [];
28
+ const expectations: { caught: boolean }[] = [];
29
+ const capture: ReactWarnings = {
30
+ expectCaughtError: (matches) => {
31
+ const expectation = { caught: false };
32
+ expectations.push(expectation);
33
+ return (error) => {
34
+ if (matches(error)) {
35
+ expectation.caught = true;
36
+ } else {
37
+ caughtErrors.push(error);
38
+ }
39
+ };
40
+ },
41
+ onCaughtError: (error) => {
42
+ caughtErrors.push(error);
43
+ },
44
+ read: () => ({ caughtErrors: [...caughtErrors], consoleErrors: consoleErrors ?? errors.mock.calls.map((args) => [...args]) }),
45
+ };
46
+ return {
47
+ capture,
48
+ expectations,
49
+ restore: () => {
50
+ consoleErrors = capture.read().consoleErrors;
51
+ errors.mockRestore();
52
+ },
53
+ };
54
+ }),
55
+ ({ restore }) => Effect.sync(restore),
56
+ );
57
+
58
+ export const captureReactWarnings = Effect.map(acquireReactWarnings, ({ capture }) => capture);
59
+
60
+ export function expectNoReactWarnings() {
61
+ let current: Effect.Success<typeof acquireReactWarnings>;
62
+ beforeEach(({ onTestFinished }) => {
63
+ const scope = Effect.runSync(Scope.make());
64
+ current = Effect.runSync(Effect.provideService(acquireReactWarnings, Scope.Scope, scope));
65
+ const { capture, expectations } = current;
66
+ onTestFinished(() => {
67
+ Effect.runSync(Scope.close(scope, Exit.void));
68
+ const { consoleErrors, caughtErrors } = capture.read();
69
+ expect(consoleErrors).toEqual([]);
70
+ expect(caughtErrors, "Unexpected caught React errors").toEqual([]);
71
+ expect(
72
+ expectations.every(({ caught }) => caught),
73
+ "Expected caught React error did not occur",
74
+ ).toBe(true);
75
+ });
76
+ });
77
+ return {
78
+ expectCaughtError(matches: (error: unknown) => boolean): NonNullable<RootOptions["onCaughtError"]> {
79
+ return current.capture.expectCaughtError(matches);
80
+ },
81
+ };
82
+ }
83
+ export function eventually(assert: () => void) {
84
+ return withLive(
85
+ Effect.gen(function* () {
86
+ const started = yield* Clock.currentTimeMillis;
87
+ return yield* retry(Effect.try({ catch: (error) => error, try: assert }), {
88
+ timeout: "3 seconds",
89
+ // Responses and React updates must settle together inside act.
90
+ wait: Effect.promise(() =>
91
+ act(async () => {
92
+ await new Promise<void>((resolve) => setTimeout(resolve, 10));
93
+ }),
94
+ ),
95
+ }).pipe(
96
+ Effect.catch((error) =>
97
+ Effect.gen(function* () {
98
+ const elapsed = (yield* Clock.currentTimeMillis) - started;
99
+ return yield* Effect.fail(
100
+ new Error(`Assertion did not pass after ${elapsed}ms: ${error instanceof Error ? error.message : String(error)}`, { cause: error }),
101
+ );
102
+ }),
103
+ ),
104
+ );
105
+ }),
106
+ );
107
+ }
108
+ export interface Rendered {
109
+ readonly container: HTMLDivElement;
110
+ readonly registry: AtomRegistry.AtomRegistry;
111
+ readonly render: (node: ReactNode) => Promise<void>;
112
+ readonly unmount: () => Promise<void>;
113
+ }
114
+ export async function render(node: ReactNode, options?: RootOptions): Promise<Rendered> {
115
+ const registry = AtomRegistry.make();
116
+ const container = document.createElement("div");
117
+ document.body.append(container);
118
+ const root = createRoot(container, options);
119
+ let mounted = true;
120
+ async function unmount() {
121
+ if (!mounted) {
122
+ return;
123
+ }
124
+ mounted = false;
125
+ try {
126
+ await act(async () => root.unmount());
127
+ } finally {
128
+ registry.dispose();
129
+ container.remove();
130
+ }
131
+ }
132
+ async function update(next: ReactNode) {
133
+ await act(async () => root.render(createElement(RegistryContext.Provider, { value: registry }, next)));
134
+ }
135
+ try {
136
+ await update(node);
137
+ } catch (error) {
138
+ await unmount();
139
+ throw error;
140
+ }
141
+ return { container, registry, render: update, unmount };
142
+ }
@@ -0,0 +1,42 @@
1
+ import { useInsertionEffect, useRef, useState } from "react";
2
+ import { type DraftSession, draftSession } from "@shivaedev/form/draftSession.ts";
3
+
4
+ interface DraftOwner {
5
+ readonly activate: () => void;
6
+ readonly session: DraftSession;
7
+ }
8
+
9
+ function owner(id: string, active: boolean): DraftOwner {
10
+ const session = draftSession(id);
11
+ let committed = active;
12
+ return {
13
+ activate: () => {
14
+ committed = true;
15
+ },
16
+ session: {
17
+ ...session,
18
+ get active() {
19
+ return committed && session.active;
20
+ },
21
+ },
22
+ };
23
+ }
24
+
25
+ export function useDraftOwner(id: string | undefined): DraftSession | undefined {
26
+ const [held, hold] = useState(() => (id === undefined ? undefined : owner(id, true)));
27
+ const committedRef = useRef(held);
28
+ let current = held;
29
+ if (current?.session.id !== id) {
30
+ current = id === undefined ? undefined : owner(id, committedRef.current === undefined);
31
+ hold(current);
32
+ }
33
+ // Insertion effects also run for Activity commits while layout and passive effects are disconnected.
34
+ useInsertionEffect(() => {
35
+ if (committedRef.current !== current) {
36
+ committedRef.current?.session.drop();
37
+ current?.activate();
38
+ committedRef.current = current;
39
+ }
40
+ }, [current]);
41
+ return current?.session;
42
+ }
@@ -0,0 +1,16 @@
1
+ import { RegistryContext } from "@effect/atom-react";
2
+ import type * as AsyncResult from "effect/reactivity/AsyncResult";
3
+ import type * as Atom from "effect/reactivity/Atom";
4
+ import { useContext } from "react";
5
+ import { loadedQuery } from "#internal/loadedQuery.ts";
6
+ import { useLoaderHandoff } from "#internal/useLoaderHandoff.ts";
7
+ import { type QueryState, useQuery } from "./resultState.ts";
8
+
9
+ export interface LoadedQueryState<A, E> extends Omit<QueryState<A, E>, "data" | "result"> {
10
+ readonly data: A;
11
+ }
12
+
13
+ export function useLoaderQuery<A, E>(atom: Atom.Atom<AsyncResult.AsyncResult<A, E>>, loaded: NoInfer<A>): LoadedQueryState<A, E> {
14
+ useLoaderHandoff(useContext(RegistryContext), atom);
15
+ return loadedQuery(useQuery(atom), loaded);
16
+ }
@@ -0,0 +1,51 @@
1
+ import { Effect, Predicate, type Schema, type Scope } from "effect";
2
+ import type { AtomRegistry } from "effect/reactivity/AtomRegistry";
3
+ import type { Reactivity } from "effect/reactivity/Reactivity";
4
+ import type { FieldFailure, Fields, Name, Submitter } from "@shivaedev/form/shape.ts";
5
+
6
+ export interface FieldRejection<TName extends string> {
7
+ readonly field: TName;
8
+ readonly message: string;
9
+ }
10
+
11
+ export type AtomServices<TRequirements> = TRequirements | Scope.Scope | AtomRegistry | Reactivity;
12
+
13
+ export type RejectField<TFields extends Fields, TError> = (error: TError) => FieldRejection<Name<TFields>> | undefined;
14
+
15
+ type Unmatched<TFields extends Fields, TError> = TError extends { readonly _tag: string; readonly message: string }
16
+ ? "field" extends keyof TError
17
+ ? Exclude<TError["field"], Name<TFields> | undefined>
18
+ : never
19
+ : never;
20
+
21
+ export type FieldRejectionMapping<TFields extends Fields, TError> = { readonly rejectField?: RejectField<TFields, TError> } & ([
22
+ Unmatched<TFields, TError>,
23
+ ] extends [never]
24
+ ? unknown
25
+ : { readonly rejectField: RejectField<TFields, TError> });
26
+
27
+ export function fieldRejectionOf<TFields extends Fields>(schema: Schema.Struct<TFields>) {
28
+ return (error: unknown): FieldRejection<Name<TFields>> | undefined => {
29
+ function isName(name: unknown): name is Name<TFields> {
30
+ return typeof name === "string" && Object.hasOwn(schema.fields, name);
31
+ }
32
+ if (!(Predicate.hasProperty(error, "_tag") && Predicate.hasProperty(error, "field") && Predicate.hasProperty(error, "message"))) {
33
+ return undefined;
34
+ }
35
+ const { _tag, field, message } = error;
36
+ return typeof _tag === "string" && isName(field) && typeof message === "string" ? { field, message } : undefined;
37
+ };
38
+ }
39
+
40
+ export function rejecting<TFields extends Fields, TValues, TResult, TError, TRequirements>(
41
+ schema: Schema.Struct<TFields>,
42
+ save: (values: TValues) => Effect.Effect<TResult, TError, TRequirements>,
43
+ reject: RejectField<TFields, TError> | undefined,
44
+ ) {
45
+ const rejection = reject ?? fieldRejectionOf(schema);
46
+ return (values: TValues, submitter: Submitter<Name<TFields>>): Effect.Effect<TResult, TError | FieldFailure, TRequirements> =>
47
+ Effect.catch(save(values), (error): Effect.Effect<never, TError | FieldFailure> => {
48
+ const found = rejection(error);
49
+ return found === undefined ? Effect.fail(error) : submitter.fail(found.field, found.message);
50
+ });
51
+ }
@@ -0,0 +1,4 @@
1
+ import { createContext } from "react";
2
+ import type { noticeStore } from "./noticeStore.ts";
3
+
4
+ export const NoticeContext = createContext<ReturnType<typeof noticeStore> | undefined>(undefined);
@@ -0,0 +1,18 @@
1
+ import { type Cause, Option } from "effect";
2
+
3
+ interface QuerySnapshot<A, E> {
4
+ readonly cause: Option.Option<Cause.Cause<E>>;
5
+ readonly data: Option.Option<A>;
6
+ readonly pending: boolean;
7
+ readonly refresh: () => void;
8
+ }
9
+
10
+ export function loadedQuery<A, E>(query: QuerySnapshot<A, E>, loaded: A) {
11
+ return {
12
+ cause: query.cause,
13
+ data: Option.getOrElse(query.data, () => loaded),
14
+ pending: query.pending,
15
+ refresh: query.refresh,
16
+ refreshing: query.pending,
17
+ };
18
+ }
@@ -0,0 +1,65 @@
1
+ import { Duration } from "effect";
2
+ import * as AsyncResult from "effect/reactivity/AsyncResult";
3
+ import * as Atom from "effect/reactivity/Atom";
4
+ import type * as AtomRegistry from "effect/reactivity/AtomRegistry";
5
+ import type { NoticeDescriptor } from "#notices.ts";
6
+
7
+ export interface NoticeAction {
8
+ readonly atom: Atom.AtomResultFn<void, unknown, unknown>;
9
+ readonly label: NoticeDescriptor["message"];
10
+ }
11
+ export interface NoticeEntry {
12
+ readonly action?: { readonly label: NoticeDescriptor["message"]; readonly run: () => void } | undefined;
13
+ readonly dismiss: () => void;
14
+ readonly id: number;
15
+ readonly message: NoticeDescriptor["message"];
16
+ readonly release: () => void;
17
+ readonly status: Atom.Atom<AsyncResult.AsyncResult<unknown, unknown>>;
18
+ }
19
+ const idle = Atom.make(AsyncResult.initial<unknown, unknown>());
20
+ export function noticeEntry(
21
+ registry: AtomRegistry.AtomRegistry,
22
+ id: number,
23
+ descriptor: Pick<NoticeDescriptor, "message" | "expiresIn">,
24
+ remove: (id: number) => void,
25
+ action: NoticeAction | undefined,
26
+ ): NoticeEntry {
27
+ let active = true;
28
+ function dismiss() {
29
+ if (active) {
30
+ remove(id);
31
+ release();
32
+ }
33
+ }
34
+ function run() {
35
+ if (active && action && !registry.get(action.atom).waiting) {
36
+ registry.set(action.atom, undefined);
37
+ }
38
+ }
39
+ const resource = Atom.make((get) => {
40
+ if (action) {
41
+ get.mount(action.atom);
42
+ }
43
+ if (descriptor.expiresIn !== undefined) {
44
+ const timer = setTimeout(dismiss, Duration.toMillis(descriptor.expiresIn));
45
+ get.addFinalizer(() => clearTimeout(timer));
46
+ }
47
+ return true;
48
+ }).pipe(Atom.setIdleTTL(0));
49
+ const unmount = registry.mount(resource);
50
+ function release() {
51
+ active = false;
52
+ if (action) {
53
+ registry.set(action.atom, Atom.Interrupt);
54
+ }
55
+ unmount();
56
+ }
57
+ return {
58
+ action: action === undefined ? undefined : { label: action.label, run },
59
+ dismiss,
60
+ id,
61
+ message: descriptor.message,
62
+ release,
63
+ status: action?.atom ?? idle,
64
+ };
65
+ }
@@ -0,0 +1,46 @@
1
+ import * as Atom from "effect/reactivity/Atom";
2
+ import type * as AtomRegistry from "effect/reactivity/AtomRegistry";
3
+ import type { NoticeDescriptor } from "#notices.ts";
4
+ import { type NoticeAction, type NoticeEntry, noticeEntry } from "./noticeEntry.ts";
5
+
6
+ export function noticeStore(registry: AtomRegistry.AtomRegistry) {
7
+ const entries = Atom.make<readonly NoticeEntry[]>([]);
8
+ let nextId = 0;
9
+ function remove(id: number) {
10
+ registry.set(
11
+ entries,
12
+ registry.get(entries).filter((entry) => entry.id !== id),
13
+ );
14
+ }
15
+ const store = {
16
+ active: false,
17
+ close: () => {
18
+ store.active = false;
19
+ for (const entry of registry.get(entries)) {
20
+ entry.release();
21
+ }
22
+ registry.set(entries, []);
23
+ },
24
+ entries,
25
+ publish: (descriptor: Pick<NoticeDescriptor, "message" | "expiresIn">, action?: NoticeAction) => {
26
+ if (!store.active) {
27
+ return;
28
+ }
29
+ const entry = noticeEntry(registry, nextId, descriptor, remove, action);
30
+ nextId += 1;
31
+ registry.set(entries, [...registry.get(entries), entry]);
32
+ },
33
+ };
34
+ return store;
35
+ }
36
+ export function publishNotice<R, E>(
37
+ store: ReturnType<typeof noticeStore> | undefined,
38
+ runtime: Atom.AtomRuntime<R, E>,
39
+ descriptor: NoticeDescriptor<R> | undefined,
40
+ ) {
41
+ if (!(store?.active && descriptor)) {
42
+ return;
43
+ }
44
+ const action = descriptor.action;
45
+ store.publish(descriptor, action === undefined ? undefined : { atom: runtime.fn(() => action.run).pipe(Atom.setIdleTTL(0)), label: action.label });
46
+ }
@@ -0,0 +1,60 @@
1
+ import * as Atom from "effect/reactivity/Atom";
2
+ import type * as AtomRegistry from "effect/reactivity/AtomRegistry";
3
+
4
+ interface Handoff {
5
+ pending: boolean;
6
+ readonly ready: Promise<void>;
7
+ readonly release: () => void;
8
+ }
9
+
10
+ const handoffs = new WeakMap<AtomRegistry.AtomRegistry, WeakMap<Atom.Atom<unknown>, Set<Handoff>>>();
11
+
12
+ export function keepLoaderQuery(registry: AtomRegistry.AtomRegistry, atom: Atom.Atom<unknown>, signal: AbortSignal) {
13
+ let atoms = handoffs.get(registry);
14
+ if (!atoms) {
15
+ atoms = new WeakMap();
16
+ handoffs.set(registry, atoms);
17
+ }
18
+ const readers = atoms.get(atom) ?? new Set<Handoff>();
19
+ atoms.set(atom, readers);
20
+ const key = Atom.isSerializable(atom) ? atom[Atom.SerializableTypeId].key : atom;
21
+ if (atom.initialValueTarget && !registry.getNodes().has(key)) {
22
+ // A removed reactive wrapper may have missed invalidations while its source stayed idle.
23
+ registry.refresh(atom);
24
+ }
25
+ const unmount = registry.mount(atom);
26
+ const ready = Promise.withResolvers<void>();
27
+ const handoff: Handoff = { pending: true, ready: ready.promise, release };
28
+ function complete() {
29
+ handoff.pending = false;
30
+ ready.resolve();
31
+ }
32
+ function release() {
33
+ if (readers.delete(handoff)) {
34
+ complete();
35
+ signal.removeEventListener("abort", release);
36
+ unmount();
37
+ }
38
+ }
39
+ readers.add(handoff);
40
+ signal.addEventListener("abort", release, { once: true });
41
+ if (signal.aborted) {
42
+ release();
43
+ }
44
+ return { complete, release };
45
+ }
46
+
47
+ export function acceptLoaderQuery(registry: AtomRegistry.AtomRegistry, atom: Atom.Atom<unknown>): void {
48
+ for (const handoff of handoffs.get(registry)?.get(atom) ?? []) {
49
+ handoff.release();
50
+ }
51
+ }
52
+
53
+ export function pendingLoaderQuery(registry: AtomRegistry.AtomRegistry, atom: Atom.Atom<unknown>): Promise<void> | undefined {
54
+ for (const handoff of handoffs.get(registry)?.get(atom) ?? []) {
55
+ if (handoff.pending) {
56
+ return handoff.ready;
57
+ }
58
+ }
59
+ return undefined;
60
+ }
@@ -0,0 +1,17 @@
1
+ import { Predicate } from "effect";
2
+ import type * as AtomRegistry from "effect/reactivity/AtomRegistry";
3
+
4
+ const rechecks = new WeakMap<AtomRegistry.AtomRegistry, () => void>();
5
+
6
+ export function registerSessionRecheck(registry: AtomRegistry.AtomRegistry, recheck: () => void): () => void {
7
+ rechecks.set(registry, recheck);
8
+ return () => {
9
+ rechecks.delete(registry);
10
+ };
11
+ }
12
+
13
+ export async function recheckUnauthorized(registry: AtomRegistry.AtomRegistry, error: unknown): Promise<void> {
14
+ if (Predicate.isTagged(error, "Unauthorized")) {
15
+ await rechecks.get(registry)?.();
16
+ }
17
+ }
@@ -0,0 +1,20 @@
1
+ import type * as Atom from "effect/reactivity/Atom";
2
+ import type * as AtomRegistry from "effect/reactivity/AtomRegistry";
3
+ import { useEffect, useRef } from "react";
4
+ import { pendingLoaderQuery } from "./queryHandoff.ts";
5
+
6
+ export function useLoaderHandoff(registry: AtomRegistry.AtomRegistry, atom: Atom.Atom<unknown>): void {
7
+ const committedRef = useRef<{ readonly registry: AtomRegistry.AtomRegistry; readonly atom: Atom.Atom<unknown> } | undefined>(undefined);
8
+ useEffect(() => {
9
+ committedRef.current = { atom, registry };
10
+ return () => {
11
+ committedRef.current = undefined;
12
+ };
13
+ }, [registry, atom]);
14
+ if (committedRef.current?.registry !== registry || committedRef.current.atom !== atom) {
15
+ const pending = pendingLoaderQuery(registry, atom);
16
+ if (pending !== undefined) {
17
+ throw pending;
18
+ }
19
+ }
20
+ }
@@ -0,0 +1,10 @@
1
+ import type * as Atom from "effect/reactivity/Atom";
2
+ import { useContext, useMemo } from "react";
3
+ import type { NoticeDescriptor } from "#notices.ts";
4
+ import { NoticeContext } from "./NoticeContext.ts";
5
+ import { publishNotice } from "./noticeStore.ts";
6
+
7
+ export function useNoticePublisher<R, E>(runtime: Atom.AtomRuntime<R, E>): (descriptor: NoticeDescriptor<R> | undefined) => void {
8
+ const store = useContext(NoticeContext);
9
+ return useMemo(() => (descriptor: NoticeDescriptor<R> | undefined) => publishNotice(store, runtime, descriptor), [store, runtime]);
10
+ }
@@ -0,0 +1,32 @@
1
+ import { type Cause, Layer } from "effect";
2
+ import * as AsyncResult from "effect/reactivity/AsyncResult";
3
+ import * as Atom from "effect/reactivity/Atom";
4
+ import { useContext, useEffect, useInsertionEffect, useRef } from "react";
5
+ import type { NoticeDescriptor } from "#notices.ts";
6
+ import { NoticeContext } from "./NoticeContext.ts";
7
+ import { publishNotice } from "./noticeStore.ts";
8
+
9
+ const runtime = Atom.runtime(Layer.empty);
10
+
11
+ export function useQueryNotice<A, E>(
12
+ result: AsyncResult.AsyncResult<A, E>,
13
+ refreshing: boolean,
14
+ notice: ((failure: { readonly cause: Cause.Cause<E>; readonly refreshing: boolean }) => NoticeDescriptor | undefined) | undefined,
15
+ ) {
16
+ const store = useContext(NoticeContext);
17
+ const latestRef = useRef(notice);
18
+ useInsertionEffect(() => {
19
+ latestRef.current = notice;
20
+ }, [notice]);
21
+ const seenRef = useRef<AsyncResult.AsyncResult<A, E> | undefined>(undefined);
22
+ useEffect(() => {
23
+ if (result.waiting || seenRef.current === result) {
24
+ return;
25
+ }
26
+ seenRef.current = result;
27
+ if (store?.active && AsyncResult.isFailure(result)) {
28
+ const descriptor = latestRef.current?.({ cause: result.cause, refreshing });
29
+ publishNotice(store, runtime, descriptor);
30
+ }
31
+ }, [result, refreshing, store]);
32
+ }