atomirx 0.0.4 → 0.0.5

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.
@@ -1033,4 +1033,183 @@ describe("derived", () => {
1033
1033
  });
1034
1034
  });
1035
1035
  });
1036
+
1037
+ describe("onError callback", () => {
1038
+ it("should call onError when computation throws synchronously", async () => {
1039
+ const onError = vi.fn();
1040
+ const source$ = atom(0);
1041
+
1042
+ const derived$ = derived(
1043
+ ({ read }) => {
1044
+ const val = read(source$);
1045
+ if (val > 0) {
1046
+ throw new Error("Value too high");
1047
+ }
1048
+ return val;
1049
+ },
1050
+ { onError }
1051
+ );
1052
+
1053
+ // Initial value - no error
1054
+ await derived$.get();
1055
+ expect(onError).not.toHaveBeenCalled();
1056
+
1057
+ // Trigger error - catch the rejection to avoid unhandled rejection warning
1058
+ source$.set(5);
1059
+ derived$.get().catch(() => {}); // Catch expected rejection
1060
+ await new Promise((r) => setTimeout(r, 0));
1061
+
1062
+ expect(onError).toHaveBeenCalledTimes(1);
1063
+ expect(onError).toHaveBeenCalledWith(expect.any(Error));
1064
+ expect((onError.mock.calls[0][0] as Error).message).toBe("Value too high");
1065
+ });
1066
+
1067
+ it("should call onError when async atom dependency rejects", async () => {
1068
+ const onError = vi.fn();
1069
+
1070
+ // Create an atom with a rejecting Promise
1071
+ const asyncSource$ = atom(Promise.reject(new Error("Async error")));
1072
+
1073
+ const derived$ = derived(
1074
+ ({ read }) => {
1075
+ return read(asyncSource$);
1076
+ },
1077
+ { onError }
1078
+ );
1079
+
1080
+ // Access to trigger computation
1081
+ derived$.get().catch(() => {}); // Catch to avoid unhandled rejection
1082
+
1083
+ await new Promise((r) => setTimeout(r, 20));
1084
+
1085
+ expect(onError).toHaveBeenCalledTimes(1);
1086
+ expect((onError.mock.calls[0][0] as Error).message).toBe("Async error");
1087
+ });
1088
+
1089
+ it("should call onError on each recomputation that throws", async () => {
1090
+ const onError = vi.fn();
1091
+ const source$ = atom(0);
1092
+
1093
+ const derived$ = derived(
1094
+ ({ read }) => {
1095
+ const val = read(source$);
1096
+ if (val > 0) {
1097
+ throw new Error(`Error for ${val}`);
1098
+ }
1099
+ return val;
1100
+ },
1101
+ { onError }
1102
+ );
1103
+
1104
+ await derived$.get();
1105
+ expect(onError).not.toHaveBeenCalled();
1106
+
1107
+ // First error - catch to avoid unhandled rejection
1108
+ source$.set(1);
1109
+ derived$.get().catch(() => {});
1110
+ await new Promise((r) => setTimeout(r, 0));
1111
+ expect(onError).toHaveBeenCalledTimes(1);
1112
+
1113
+ // Second error - catch to avoid unhandled rejection
1114
+ source$.set(2);
1115
+ derived$.get().catch(() => {});
1116
+ await new Promise((r) => setTimeout(r, 0));
1117
+ expect(onError).toHaveBeenCalledTimes(2);
1118
+ expect((onError.mock.calls[1][0] as Error).message).toBe("Error for 2");
1119
+ });
1120
+
1121
+ it("should not call onError when computation succeeds", async () => {
1122
+ const onError = vi.fn();
1123
+ const source$ = atom(5);
1124
+
1125
+ const derived$ = derived(({ read }) => read(source$) * 2, { onError });
1126
+
1127
+ await derived$.get();
1128
+ source$.set(10);
1129
+ await derived$.get();
1130
+ source$.set(15);
1131
+ await derived$.get();
1132
+
1133
+ expect(onError).not.toHaveBeenCalled();
1134
+ });
1135
+
1136
+ it("should not call onError for Promise throws (Suspense)", async () => {
1137
+ const onError = vi.fn();
1138
+ let resolvePromise: (value: number) => void;
1139
+ const asyncSource$ = atom(
1140
+ new Promise<number>((resolve) => {
1141
+ resolvePromise = resolve;
1142
+ })
1143
+ );
1144
+
1145
+ const derived$ = derived(({ read }) => read(asyncSource$) * 2, {
1146
+ onError,
1147
+ });
1148
+
1149
+ // Still loading - onError should NOT be called
1150
+ await new Promise((r) => setTimeout(r, 10));
1151
+ expect(onError).not.toHaveBeenCalled();
1152
+
1153
+ // Resolve successfully
1154
+ resolvePromise!(5);
1155
+ expect(await derived$.get()).toBe(10);
1156
+ expect(onError).not.toHaveBeenCalled();
1157
+ });
1158
+
1159
+ it("should work without onError callback", async () => {
1160
+ const source$ = atom(0);
1161
+
1162
+ const derived$ = derived(({ read }) => {
1163
+ const val = read(source$);
1164
+ if (val > 0) {
1165
+ throw new Error("Error");
1166
+ }
1167
+ return val;
1168
+ });
1169
+
1170
+ // Should not throw even without onError
1171
+ await derived$.get();
1172
+ source$.set(5);
1173
+ derived$.get().catch(() => {}); // Catch expected rejection
1174
+ await new Promise((r) => setTimeout(r, 0));
1175
+
1176
+ expect(derived$.state().status).toBe("error");
1177
+ });
1178
+
1179
+ it("should allow error recovery and call onError again on subsequent errors", async () => {
1180
+ const onError = vi.fn();
1181
+ const source$ = atom(0);
1182
+
1183
+ const derived$ = derived(
1184
+ ({ read }) => {
1185
+ const val = read(source$);
1186
+ if (val === 1) {
1187
+ throw new Error("First error");
1188
+ }
1189
+ if (val === 3) {
1190
+ throw new Error("Second error");
1191
+ }
1192
+ return val * 2;
1193
+ },
1194
+ { onError }
1195
+ );
1196
+
1197
+ await derived$.get(); // 0 -> success
1198
+ expect(onError).not.toHaveBeenCalled();
1199
+
1200
+ source$.set(1); // error
1201
+ derived$.get().catch(() => {}); // Catch expected rejection
1202
+ await new Promise((r) => setTimeout(r, 0));
1203
+ expect(onError).toHaveBeenCalledTimes(1);
1204
+
1205
+ source$.set(2); // recover
1206
+ expect(await derived$.get()).toBe(4);
1207
+ expect(onError).toHaveBeenCalledTimes(1); // still 1
1208
+
1209
+ source$.set(3); // error again
1210
+ derived$.get().catch(() => {}); // Catch expected rejection
1211
+ await new Promise((r) => setTimeout(r, 0));
1212
+ expect(onError).toHaveBeenCalledTimes(2);
1213
+ });
1214
+ });
1036
1215
  });
@@ -1,6 +1,7 @@
1
- import { onCreateHook } from "./onCreateHook";
1
+ import { CreateInfo, DerivedInfo, onCreateHook } from "./onCreateHook";
2
2
  import { emitter } from "./emitter";
3
3
  import { resolveEquality } from "./equality";
4
+ import { onErrorHook } from "./onErrorHook";
4
5
  import { scheduleNotifyHook } from "./scheduleNotifyHook";
5
6
  import { ReactiveSelector, select, SelectContext } from "./select";
6
7
  import {
@@ -14,6 +15,19 @@ import {
14
15
  } from "./types";
15
16
  import { withReady, WithReadySelectContext } from "./withReady";
16
17
 
18
+ /**
19
+ * Internal options for derived atoms.
20
+ * These are not part of the public API.
21
+ * @internal
22
+ */
23
+ export interface DerivedInternalOptions {
24
+ /**
25
+ * Override the error source for onErrorHook.
26
+ * Used by effect() to attribute errors to the effect instead of the internal derived.
27
+ */
28
+ _errorSource?: CreateInfo;
29
+ }
30
+
17
31
  /**
18
32
  * Context object passed to derived atom selector functions.
19
33
  * Provides utilities for reading atoms: `{ read, all, any, race, settled }`.
@@ -147,19 +161,19 @@ export interface DerivedContext extends SelectContext, WithReadySelectContext {}
147
161
  // Overload: Without fallback - staleValue is T | undefined
148
162
  export function derived<T>(
149
163
  fn: ReactiveSelector<T, DerivedContext>,
150
- options?: DerivedOptions<T>
164
+ options?: DerivedOptions<T> & DerivedInternalOptions
151
165
  ): DerivedAtom<T, false>;
152
166
 
153
167
  // Overload: With fallback - staleValue is guaranteed T
154
168
  export function derived<T>(
155
169
  fn: ReactiveSelector<T, DerivedContext>,
156
- options: DerivedOptions<T> & { fallback: T }
170
+ options: DerivedOptions<T> & { fallback: T } & DerivedInternalOptions
157
171
  ): DerivedAtom<T, true>;
158
172
 
159
173
  // Implementation
160
174
  export function derived<T>(
161
175
  fn: ReactiveSelector<T, DerivedContext>,
162
- options: DerivedOptions<T> & { fallback?: T } = {}
176
+ options: DerivedOptions<T> & { fallback?: T } & DerivedInternalOptions = {}
163
177
  ): DerivedAtom<T, boolean> {
164
178
  const changeEmitter = emitter();
165
179
  const eq = resolveEquality(options.equals as Equality<unknown>);
@@ -183,6 +197,23 @@ export function derived<T>(
183
197
  // Track current subscriptions (atom -> unsubscribe function)
184
198
  const subscriptions = new Map<Atom<unknown>, VoidFunction>();
185
199
 
200
+ // CreateInfo for this derived - stored for onErrorHook
201
+ // Will be set after derivedAtom is created
202
+ let createInfo: DerivedInfo;
203
+
204
+ /**
205
+ * Handles errors by calling both the user's onError callback and the global onErrorHook.
206
+ */
207
+ const handleError = (error: unknown) => {
208
+ // Invoke user's error callback if provided
209
+ options.onError?.(error);
210
+
211
+ // Invoke global error hook
212
+ // Use _errorSource if provided (for effect), otherwise use this derived's createInfo
213
+ const source = options._errorSource ?? createInfo;
214
+ onErrorHook.current?.({ source, error });
215
+ };
216
+
186
217
  /**
187
218
  * Schedules notification to all subscribers.
188
219
  */
@@ -235,6 +266,11 @@ export function derived<T>(
235
266
  resolvePromise = resolve;
236
267
  rejectPromise = reject;
237
268
  });
269
+ // Prevent unhandled rejection warnings - errors are accessible via:
270
+ // 1. onError callback (if provided)
271
+ // 2. state() returning { status: "error", error }
272
+ // 3. .get().catch() by consumers
273
+ currentPromise.catch(() => {});
238
274
  }
239
275
 
240
276
  // Run select to compute value and track dependencies
@@ -267,6 +303,8 @@ export function derived<T>(
267
303
  // Clear resolve/reject so next computation creates new promise
268
304
  resolvePromise = null;
269
305
  rejectPromise = null;
306
+ // Invoke error handlers
307
+ handleError(error);
270
308
  // Always notify when promise rejects - subscribers need to know
271
309
  // state changed from loading to error
272
310
  notify();
@@ -280,6 +318,8 @@ export function derived<T>(
280
318
  // Clear resolve/reject so next computation creates new promise
281
319
  resolvePromise = null;
282
320
  rejectPromise = null;
321
+ // Invoke error handlers
322
+ handleError(result.error);
283
323
  if (!silent) notify();
284
324
  } else {
285
325
  // Success - update lastResolved and resolve
@@ -395,13 +435,16 @@ export function derived<T>(
395
435
  },
396
436
  };
397
437
 
398
- // Notify devtools/plugins of derived atom creation
399
- onCreateHook.current?.({
438
+ // Store createInfo for use in onErrorHook
439
+ createInfo = {
400
440
  type: "derived",
401
441
  key: options.meta?.key,
402
442
  meta: options.meta,
403
- atom: derivedAtom,
404
- });
443
+ instance: derivedAtom,
444
+ };
445
+
446
+ // Notify devtools/plugins of derived atom creation
447
+ onCreateHook.current?.(createInfo);
405
448
 
406
449
  return derivedAtom;
407
450
  }