@alepha/react 0.11.2 → 0.11.4

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.
@@ -0,0 +1,467 @@
1
+ import {
2
+ DateTimeProvider,
3
+ type DurationLike,
4
+ type Interval,
5
+ type Timeout,
6
+ } from "@alepha/datetime";
7
+ import {
8
+ type DependencyList,
9
+ useCallback,
10
+ useEffect,
11
+ useRef,
12
+ useState,
13
+ } from "react";
14
+ import { useAlepha } from "./useAlepha.ts";
15
+ import { useInject } from "./useInject.ts";
16
+
17
+ /**
18
+ * Hook for handling async actions with automatic error handling and event emission.
19
+ *
20
+ * By default, prevents concurrent executions - if an action is running and you call it again,
21
+ * the second call will be ignored. Use `debounce` option to delay execution instead.
22
+ *
23
+ * Emits lifecycle events:
24
+ * - `react:action:begin` - When action starts
25
+ * - `react:action:success` - When action completes successfully
26
+ * - `react:action:error` - When action throws an error
27
+ * - `react:action:end` - Always emitted at the end
28
+ *
29
+ * @example Basic usage
30
+ * ```tsx
31
+ * const action = useAction({
32
+ * handler: async (data) => {
33
+ * await api.save(data);
34
+ * }
35
+ * }, []);
36
+ *
37
+ * <button onClick={() => action.run(data)} disabled={action.loading}>
38
+ * Save
39
+ * </button>
40
+ * ```
41
+ *
42
+ * @example With debounce (search input)
43
+ * ```tsx
44
+ * const search = useAction({
45
+ * handler: async (query: string) => {
46
+ * await api.search(query);
47
+ * },
48
+ * debounce: 300 // Wait 300ms after last call
49
+ * }, []);
50
+ *
51
+ * <input onChange={(e) => search.run(e.target.value)} />
52
+ * ```
53
+ *
54
+ * @example Run on component mount
55
+ * ```tsx
56
+ * const fetchData = useAction({
57
+ * handler: async () => {
58
+ * const data = await api.getData();
59
+ * return data;
60
+ * },
61
+ * runOnInit: true // Runs once when component mounts
62
+ * }, []);
63
+ * ```
64
+ *
65
+ * @example Run periodically (polling)
66
+ * ```tsx
67
+ * const pollStatus = useAction({
68
+ * handler: async () => {
69
+ * const status = await api.getStatus();
70
+ * return status;
71
+ * },
72
+ * runEvery: 5000 // Run every 5 seconds
73
+ * }, []);
74
+ *
75
+ * // Or with duration tuple
76
+ * const pollStatus = useAction({
77
+ * handler: async () => {
78
+ * const status = await api.getStatus();
79
+ * return status;
80
+ * },
81
+ * runEvery: [30, 'seconds'] // Run every 30 seconds
82
+ * }, []);
83
+ * ```
84
+ *
85
+ * @example With AbortController
86
+ * ```tsx
87
+ * const fetch = useAction({
88
+ * handler: async (url, { signal }) => {
89
+ * const response = await fetch(url, { signal });
90
+ * return response.json();
91
+ * }
92
+ * }, []);
93
+ * // Automatically cancelled on unmount or when new request starts
94
+ * ```
95
+ *
96
+ * @example With error handling
97
+ * ```tsx
98
+ * const deleteAction = useAction({
99
+ * handler: async (id: string) => {
100
+ * await api.delete(id);
101
+ * },
102
+ * onError: (error) => {
103
+ * if (error.code === 'NOT_FOUND') {
104
+ * // Custom error handling
105
+ * }
106
+ * }
107
+ * }, []);
108
+ *
109
+ * {deleteAction.error && <div>Error: {deleteAction.error.message}</div>}
110
+ * ```
111
+ *
112
+ * @example Global error handling
113
+ * ```tsx
114
+ * // In your root app setup
115
+ * alepha.events.on("react:action:error", ({ error }) => {
116
+ * toast.danger(error.message);
117
+ * Sentry.captureException(error);
118
+ * });
119
+ * ```
120
+ */
121
+ export function useAction<Args extends any[], Result = void>(
122
+ options: UseActionOptions<Args, Result>,
123
+ deps: DependencyList,
124
+ ): UseActionReturn<Args, Result> {
125
+ const alepha = useAlepha();
126
+ const dateTimeProvider = useInject(DateTimeProvider);
127
+ const [loading, setLoading] = useState(false);
128
+ const [error, setError] = useState<Error | undefined>();
129
+ const isExecutingRef = useRef(false);
130
+ const debounceTimerRef = useRef<Timeout | undefined>(undefined);
131
+ const abortControllerRef = useRef<AbortController | undefined>(undefined);
132
+ const isMountedRef = useRef(true);
133
+ const intervalRef = useRef<Interval | undefined>(undefined);
134
+
135
+ // Cleanup on unmount
136
+ useEffect(() => {
137
+ return () => {
138
+ isMountedRef.current = false;
139
+
140
+ // clear debounce timer
141
+ if (debounceTimerRef.current) {
142
+ dateTimeProvider.clearTimeout(debounceTimerRef.current);
143
+ debounceTimerRef.current = undefined;
144
+ }
145
+
146
+ // clear interval
147
+ if (intervalRef.current) {
148
+ dateTimeProvider.clearInterval(intervalRef.current);
149
+ intervalRef.current = undefined;
150
+ }
151
+
152
+ // abort in-flight request
153
+ if (abortControllerRef.current) {
154
+ abortControllerRef.current.abort();
155
+ abortControllerRef.current = undefined;
156
+ }
157
+ };
158
+ }, []);
159
+
160
+ const executeAction = useCallback(
161
+ async (...args: Args): Promise<Result | undefined> => {
162
+ // Prevent concurrent executions
163
+ if (isExecutingRef.current) {
164
+ return;
165
+ }
166
+
167
+ // Abort previous request if still running
168
+ if (abortControllerRef.current) {
169
+ abortControllerRef.current.abort();
170
+ }
171
+
172
+ // Create new AbortController for this request
173
+ const abortController = new AbortController();
174
+ abortControllerRef.current = abortController;
175
+
176
+ isExecutingRef.current = true;
177
+ setLoading(true);
178
+ setError(undefined);
179
+
180
+ await alepha.events.emit("react:action:begin", {
181
+ type: "custom",
182
+ id: options.id,
183
+ });
184
+
185
+ try {
186
+ // Pass abort signal as last argument to handler
187
+ const result = await options.handler(...args, {
188
+ signal: abortController.signal,
189
+ } as any);
190
+
191
+ // Only update state if still mounted and not aborted
192
+ if (!isMountedRef.current || abortController.signal.aborted) {
193
+ return;
194
+ }
195
+
196
+ await alepha.events.emit("react:action:success", {
197
+ type: "custom",
198
+ id: options.id,
199
+ });
200
+
201
+ if (options.onSuccess) {
202
+ await options.onSuccess(result);
203
+ }
204
+
205
+ return result;
206
+ } catch (err) {
207
+ // Ignore abort errors
208
+ if (err instanceof Error && err.name === "AbortError") {
209
+ return;
210
+ }
211
+
212
+ // Only update state if still mounted
213
+ if (!isMountedRef.current) {
214
+ return;
215
+ }
216
+
217
+ const error = err as Error;
218
+ setError(error);
219
+
220
+ await alepha.events.emit("react:action:error", {
221
+ type: "custom",
222
+ id: options.id,
223
+ error,
224
+ });
225
+
226
+ if (options.onError) {
227
+ await options.onError(error);
228
+ } else {
229
+ // Re-throw if no custom error handler
230
+ throw error;
231
+ }
232
+ } finally {
233
+ isExecutingRef.current = false;
234
+ setLoading(false);
235
+
236
+ await alepha.events.emit("react:action:end", {
237
+ type: "custom",
238
+ id: options.id,
239
+ });
240
+
241
+ // Clean up abort controller
242
+ if (abortControllerRef.current === abortController) {
243
+ abortControllerRef.current = undefined;
244
+ }
245
+ }
246
+ },
247
+ [...deps, options.id, options.onError, options.onSuccess],
248
+ );
249
+
250
+ const handler = useCallback(
251
+ async (...args: Args): Promise<Result | undefined> => {
252
+ if (options.debounce) {
253
+ // clear existing timer
254
+ if (debounceTimerRef.current) {
255
+ dateTimeProvider.clearTimeout(debounceTimerRef.current);
256
+ }
257
+
258
+ // Set new timer
259
+ return new Promise((resolve) => {
260
+ debounceTimerRef.current = dateTimeProvider.createTimeout(
261
+ async () => {
262
+ const result = await executeAction(...args);
263
+ resolve(result);
264
+ },
265
+ options.debounce ?? 0,
266
+ );
267
+ });
268
+ }
269
+
270
+ return executeAction(...args);
271
+ },
272
+ [executeAction, options.debounce],
273
+ );
274
+
275
+ const cancel = useCallback(() => {
276
+ // clear debounce timer
277
+ if (debounceTimerRef.current) {
278
+ dateTimeProvider.clearTimeout(debounceTimerRef.current);
279
+ debounceTimerRef.current = undefined;
280
+ }
281
+
282
+ // abort in-flight request
283
+ if (abortControllerRef.current) {
284
+ abortControllerRef.current.abort();
285
+ abortControllerRef.current = undefined;
286
+ }
287
+
288
+ // reset state
289
+ if (isMountedRef.current) {
290
+ isExecutingRef.current = false;
291
+ setLoading(false);
292
+ }
293
+ }, []);
294
+
295
+ // Run action on mount if runOnInit is true
296
+ useEffect(() => {
297
+ if (options.runOnInit) {
298
+ handler(...([] as any));
299
+ }
300
+ }, []);
301
+
302
+ // Run action periodically if runEvery is specified
303
+ useEffect(() => {
304
+ if (!options.runEvery) {
305
+ return;
306
+ }
307
+
308
+ // Set up interval
309
+ intervalRef.current = dateTimeProvider.createInterval(
310
+ () => handler(...([] as any)),
311
+ options.runEvery,
312
+ true,
313
+ );
314
+
315
+ // cleanup on unmount or when runEvery changes
316
+ return () => {
317
+ if (intervalRef.current) {
318
+ dateTimeProvider.clearInterval(intervalRef.current);
319
+ intervalRef.current = undefined;
320
+ }
321
+ };
322
+ }, [handler, options.runEvery]);
323
+
324
+ return {
325
+ run: handler,
326
+ loading,
327
+ error,
328
+ cancel,
329
+ };
330
+ }
331
+
332
+ // ---------------------------------------------------------------------------------------------------------------------
333
+
334
+ /**
335
+ * Context object passed as the last argument to action handlers.
336
+ * Contains an AbortSignal that can be used to cancel the request.
337
+ */
338
+ export interface ActionContext {
339
+ /**
340
+ * AbortSignal that can be passed to fetch or other async operations.
341
+ * The signal will be aborted when:
342
+ * - The component unmounts
343
+ * - A new action is triggered (cancels previous)
344
+ * - The cancel() method is called
345
+ *
346
+ * @example
347
+ * ```tsx
348
+ * const action = useAction({
349
+ * handler: async (url, { signal }) => {
350
+ * const response = await fetch(url, { signal });
351
+ * return response.json();
352
+ * }
353
+ * }, []);
354
+ * ```
355
+ */
356
+ signal: AbortSignal;
357
+ }
358
+
359
+ export interface UseActionOptions<Args extends any[] = any[], Result = any> {
360
+ /**
361
+ * The async action handler function.
362
+ * Receives the action arguments plus an ActionContext as the last parameter.
363
+ */
364
+ handler: (...args: [...Args, ActionContext]) => Promise<Result>;
365
+
366
+ /**
367
+ * Custom error handler. If provided, prevents default error re-throw.
368
+ */
369
+ onError?: (error: Error) => void | Promise<void>;
370
+
371
+ /**
372
+ * Custom success handler.
373
+ */
374
+ onSuccess?: (result: Result) => void | Promise<void>;
375
+
376
+ /**
377
+ * Optional identifier for this action (useful for debugging/analytics)
378
+ */
379
+ id?: string;
380
+
381
+ /**
382
+ * Debounce delay in milliseconds. If specified, the action will only execute
383
+ * after the specified delay has passed since the last call. Useful for search inputs
384
+ * or other high-frequency events.
385
+ *
386
+ * @example
387
+ * ```tsx
388
+ * // Execute search 300ms after user stops typing
389
+ * const search = useAction({ handler: search, debounce: 300 }, [])
390
+ * ```
391
+ */
392
+ debounce?: number;
393
+
394
+ /**
395
+ * If true, the action will be executed once when the component mounts.
396
+ *
397
+ * @example
398
+ * ```tsx
399
+ * const fetchData = useAction({
400
+ * handler: async () => await api.getData(),
401
+ * runOnInit: true
402
+ * }, []);
403
+ * ```
404
+ */
405
+ runOnInit?: boolean;
406
+
407
+ /**
408
+ * If specified, the action will be executed periodically at the given interval.
409
+ * The interval is specified as a DurationLike value (number in ms, Duration object, or [number, unit] tuple).
410
+ *
411
+ * @example
412
+ * ```tsx
413
+ * // Run every 5 seconds
414
+ * const poll = useAction({
415
+ * handler: async () => await api.poll(),
416
+ * runEvery: 5000
417
+ * }, []);
418
+ * ```
419
+ *
420
+ * @example
421
+ * ```tsx
422
+ * // Run every 1 minute
423
+ * const poll = useAction({
424
+ * handler: async () => await api.poll(),
425
+ * runEvery: [1, 'minute']
426
+ * }, []);
427
+ * ```
428
+ */
429
+ runEvery?: DurationLike;
430
+ }
431
+
432
+ export interface UseActionReturn<Args extends any[], Result> {
433
+ /**
434
+ * Execute the action with the provided arguments.
435
+ *
436
+ * @example
437
+ * ```tsx
438
+ * const action = useAction({ handler: async (data) => { ... } }, []);
439
+ * action.run(data);
440
+ * ```
441
+ */
442
+ run: (...args: Args) => Promise<Result | undefined>;
443
+
444
+ /**
445
+ * Loading state - true when action is executing.
446
+ */
447
+ loading: boolean;
448
+
449
+ /**
450
+ * Error state - contains error if action failed, undefined otherwise.
451
+ */
452
+ error?: Error;
453
+
454
+ /**
455
+ * Cancel any pending debounced action or abort the current in-flight request.
456
+ *
457
+ * @example
458
+ * ```tsx
459
+ * const action = useAction({ ... }, []);
460
+ *
461
+ * <button onClick={action.cancel} disabled={!action.loading}>
462
+ * Cancel
463
+ * </button>
464
+ * ```
465
+ */
466
+ cancel: () => void;
467
+ }
@@ -17,13 +17,7 @@ export const useActive = (args: string | UseActiveOptions): UseActiveHook => {
17
17
  const options: UseActiveOptions =
18
18
  typeof args === "string" ? { href: args } : { ...args, href: args.href };
19
19
  const href = options.href;
20
-
21
- let isActive =
22
- current === href || current === `${href}/` || `${current}/` === href;
23
-
24
- if (options.startWith && !isActive) {
25
- isActive = current.startsWith(href);
26
- }
20
+ const isActive = router.isActive(href, options);
27
21
 
28
22
  return {
29
23
  isPending,
@@ -0,0 +1,51 @@
1
+ import type { Async, Hook, Hooks } from "@alepha/core";
2
+ import { type DependencyList, useEffect } from "react";
3
+ import { useAlepha } from "./useAlepha.ts";
4
+
5
+ /**
6
+ * Allow subscribing to multiple Alepha events. See {@link Hooks} for available events.
7
+ *
8
+ * useEvents is fully typed to ensure correct event callback signatures.
9
+ *
10
+ * @example
11
+ * ```tsx
12
+ * useEvents(
13
+ * {
14
+ * "react:transition:begin": (ev) => {
15
+ * console.log("Transition began to:", ev.to);
16
+ * },
17
+ * "react:transition:error": {
18
+ * priority: "first",
19
+ * callback: (ev) => {
20
+ * console.error("Transition error:", ev.error);
21
+ * },
22
+ * },
23
+ * },
24
+ * [],
25
+ * );
26
+ * ```
27
+ */
28
+ export const useEvents = (opts: UseEvents, deps: DependencyList) => {
29
+ const alepha = useAlepha();
30
+
31
+ useEffect(() => {
32
+ if (!alepha.isBrowser()) {
33
+ return;
34
+ }
35
+
36
+ const subs: Function[] = [];
37
+ for (const [name, hook] of Object.entries(opts)) {
38
+ subs.push(alepha.events.on(name as any, hook as any));
39
+ }
40
+
41
+ return () => {
42
+ for (const clear of subs) {
43
+ clear();
44
+ }
45
+ };
46
+ }, deps);
47
+ };
48
+
49
+ type UseEvents = {
50
+ [T in keyof Hooks]?: Hook<T> | ((payload: Hooks[T]) => Async<void>);
51
+ };
@@ -1,4 +1,5 @@
1
1
  import { $module } from "@alepha/core";
2
+ import { AlephaDateTime } from "@alepha/datetime";
2
3
  import { AlephaServer } from "@alepha/server";
3
4
  import { AlephaServerLinks } from "@alepha/server-links";
4
5
  import { $page } from "./descriptors/$page.ts";
@@ -6,6 +7,7 @@ import { ReactBrowserProvider } from "./providers/ReactBrowserProvider.ts";
6
7
  import { ReactBrowserRendererProvider } from "./providers/ReactBrowserRendererProvider.ts";
7
8
  import { ReactBrowserRouterProvider } from "./providers/ReactBrowserRouterProvider.ts";
8
9
  import { ReactPageProvider } from "./providers/ReactPageProvider.ts";
10
+ import { ReactPageService } from "./services/ReactPageService.ts";
9
11
  import { ReactRouter } from "./services/ReactRouter.ts";
10
12
 
11
13
  // ---------------------------------------------------------------------------------------------------------------------
@@ -26,9 +28,11 @@ export const AlephaReact = $module({
26
28
  ReactBrowserProvider,
27
29
  ReactRouter,
28
30
  ReactBrowserRendererProvider,
31
+ ReactPageService,
29
32
  ],
30
33
  register: (alepha) =>
31
34
  alepha
35
+ .with(AlephaDateTime)
32
36
  .with(AlephaServer)
33
37
  .with(AlephaServerLinks)
34
38
  .with(ReactPageProvider)
@@ -8,13 +8,14 @@ export * from "./contexts/AlephaContext.ts";
8
8
  export * from "./contexts/RouterLayerContext.ts";
9
9
  export * from "./descriptors/$page.ts";
10
10
  export * from "./errors/Redirection.ts";
11
+ export * from "./hooks/useAction.ts";
11
12
  export * from "./hooks/useActive.ts";
12
13
  export * from "./hooks/useAlepha.ts";
13
14
  export * from "./hooks/useClient.ts";
15
+ export * from "./hooks/useEvents.ts";
14
16
  export * from "./hooks/useInject.ts";
15
17
  export * from "./hooks/useQueryParams.ts";
16
18
  export * from "./hooks/useRouter.ts";
17
- export * from "./hooks/useRouterEvents.ts";
18
19
  export * from "./hooks/useRouterState.ts";
19
20
  export * from "./hooks/useSchema.ts";
20
21
  export * from "./hooks/useStore.ts";
package/src/index.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { $module } from "@alepha/core";
2
+ import { AlephaDateTime } from "@alepha/datetime";
2
3
  import { AlephaServer, type ServerRequest } from "@alepha/server";
3
4
  import { AlephaServerCache } from "@alepha/server-cache";
4
5
  import { AlephaServerLinks } from "@alepha/server-links";
@@ -10,6 +11,8 @@ import {
10
11
  type ReactRouterState,
11
12
  } from "./providers/ReactPageProvider.ts";
12
13
  import { ReactServerProvider } from "./providers/ReactServerProvider.ts";
14
+ import { ReactPageServerService } from "./services/ReactPageServerService.ts";
15
+ import { ReactPageService } from "./services/ReactPageService.ts";
13
16
  import { ReactRouter } from "./services/ReactRouter.ts";
14
17
 
15
18
  // ---------------------------------------------------------------------------------------------------------------------
@@ -27,34 +30,92 @@ declare module "@alepha/core" {
27
30
  }
28
31
 
29
32
  interface Hooks {
33
+ /**
34
+ * Fires when the React application is starting to be rendered on the server.
35
+ */
30
36
  "react:server:render:begin": {
31
37
  request?: ServerRequest;
32
38
  state: ReactRouterState;
33
39
  };
40
+ /**
41
+ * Fires when the React application has been rendered on the server.
42
+ */
34
43
  "react:server:render:end": {
35
44
  request?: ServerRequest;
36
45
  state: ReactRouterState;
37
46
  html: string;
38
47
  };
39
48
  // -----------------------------------------------------------------------------------------------------------------
49
+ /**
50
+ * Fires when the React application is being rendered on the browser.
51
+ */
40
52
  "react:browser:render": {
41
53
  root: HTMLElement;
42
54
  element: ReactNode;
43
55
  state: ReactRouterState;
44
56
  hydration?: ReactHydrationState;
45
57
  };
58
+ // -----------------------------------------------------------------------------------------------------------------
59
+ // TOP LEVEL: All user actions (forms, transitions, custom actions)
60
+ /**
61
+ * Fires when a user action is starting.
62
+ * Action can be a form submission, a route transition, or a custom action.
63
+ */
64
+ "react:action:begin": {
65
+ type: string;
66
+ id?: string;
67
+ };
68
+ /**
69
+ * Fires when a user action has succeeded.
70
+ * Action can be a form submission, a route transition, or a custom action.
71
+ */
72
+ "react:action:success": {
73
+ type: string;
74
+ id?: string;
75
+ };
76
+ /**
77
+ * Fires when a user action has failed.
78
+ * Action can be a form submission, a route transition, or a custom action.
79
+ */
80
+ "react:action:error": {
81
+ type: string;
82
+ id?: string;
83
+ error: Error;
84
+ };
85
+ /**
86
+ * Fires when a user action has completed, regardless of success or failure.
87
+ * Action can be a form submission, a route transition, or a custom action.
88
+ */
89
+ "react:action:end": {
90
+ type: string;
91
+ id?: string;
92
+ };
93
+ // -----------------------------------------------------------------------------------------------------------------
94
+ // SPECIFIC: Route transitions
95
+ /**
96
+ * Fires when a route transition is starting.
97
+ */
46
98
  "react:transition:begin": {
47
99
  previous: ReactRouterState;
48
100
  state: ReactRouterState;
49
101
  animation?: PageAnimation;
50
102
  };
103
+ /**
104
+ * Fires when a route transition has succeeded.
105
+ */
51
106
  "react:transition:success": {
52
107
  state: ReactRouterState;
53
108
  };
109
+ /**
110
+ * Fires when a route transition has failed.
111
+ */
54
112
  "react:transition:error": {
55
113
  state: ReactRouterState;
56
114
  error: Error;
57
115
  };
116
+ /**
117
+ * Fires when a route transition has completed, regardless of success or failure.
118
+ */
58
119
  "react:transition:end": {
59
120
  state: ReactRouterState;
60
121
  };
@@ -76,12 +137,23 @@ declare module "@alepha/core" {
76
137
  export const AlephaReact = $module({
77
138
  name: "alepha.react",
78
139
  descriptors: [$page],
79
- services: [ReactServerProvider, ReactPageProvider, ReactRouter],
140
+ services: [
141
+ ReactServerProvider,
142
+ ReactPageProvider,
143
+ ReactRouter,
144
+ ReactPageService,
145
+ ReactPageServerService,
146
+ ],
80
147
  register: (alepha) =>
81
148
  alepha
149
+ .with(AlephaDateTime)
82
150
  .with(AlephaServer)
83
151
  .with(AlephaServerCache)
84
152
  .with(AlephaServerLinks)
153
+ .with({
154
+ provide: ReactPageService,
155
+ use: ReactPageServerService,
156
+ })
85
157
  .with(ReactServerProvider)
86
158
  .with(ReactPageProvider)
87
159
  .with(ReactRouter),