@alepha/react 0.11.3 → 0.11.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.
- package/README.md +23 -4
- package/dist/index.browser.js +322 -31
- package/dist/index.browser.js.map +1 -1
- package/dist/index.d.ts +373 -64
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +388 -89
- package/dist/index.js.map +1 -1
- package/package.json +13 -12
- package/src/components/NestedView.tsx +4 -4
- package/src/descriptors/$page.ts +21 -25
- package/src/hooks/useAction.ts +467 -0
- package/src/hooks/useActive.ts +1 -7
- package/src/hooks/useEvents.ts +51 -0
- package/src/index.browser.ts +4 -0
- package/src/index.shared.ts +2 -1
- package/src/index.ts +73 -1
- package/src/providers/ReactBrowserRouterProvider.ts +14 -0
- package/src/providers/ReactPageProvider.ts +34 -1
- package/src/providers/ReactServerProvider.ts +48 -68
- package/src/services/ReactPageServerService.ts +43 -0
- package/src/services/ReactPageService.ts +24 -0
- package/src/services/ReactRouter.ts +21 -0
- package/src/hooks/useRouterEvents.ts +0 -66
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@alepha/react",
|
|
3
3
|
"description": "Build server-side rendered (SSR) or single-page React applications.",
|
|
4
|
-
"version": "0.11.
|
|
4
|
+
"version": "0.11.5",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"engines": {
|
|
7
7
|
"node": ">=22.0.0"
|
|
@@ -17,24 +17,25 @@
|
|
|
17
17
|
"src"
|
|
18
18
|
],
|
|
19
19
|
"dependencies": {
|
|
20
|
-
"@alepha/core": "0.11.
|
|
21
|
-
"@alepha/datetime": "0.11.
|
|
22
|
-
"@alepha/logger": "0.11.
|
|
23
|
-
"@alepha/router": "0.11.
|
|
24
|
-
"@alepha/server": "0.11.
|
|
25
|
-
"@alepha/server-cache": "0.11.
|
|
26
|
-
"@alepha/server-links": "0.11.
|
|
27
|
-
"@alepha/server-static": "0.11.
|
|
20
|
+
"@alepha/core": "0.11.5",
|
|
21
|
+
"@alepha/datetime": "0.11.5",
|
|
22
|
+
"@alepha/logger": "0.11.5",
|
|
23
|
+
"@alepha/router": "0.11.5",
|
|
24
|
+
"@alepha/server": "0.11.5",
|
|
25
|
+
"@alepha/server-cache": "0.11.5",
|
|
26
|
+
"@alepha/server-links": "0.11.5",
|
|
27
|
+
"@alepha/server-static": "0.11.5"
|
|
28
28
|
},
|
|
29
29
|
"devDependencies": {
|
|
30
|
-
"@
|
|
30
|
+
"@alepha/testing": "0.11.5",
|
|
31
|
+
"@biomejs/biome": "^2.3.3",
|
|
31
32
|
"@types/react": "^19.2.2",
|
|
32
33
|
"@types/react-dom": "^19.2.2",
|
|
33
34
|
"react": "^19.2.0",
|
|
34
35
|
"react-dom": "^19.2.0",
|
|
35
|
-
"tsdown": "^0.
|
|
36
|
+
"tsdown": "^0.16.0",
|
|
36
37
|
"typescript": "^5.9.3",
|
|
37
|
-
"vitest": "^
|
|
38
|
+
"vitest": "^4.0.6"
|
|
38
39
|
},
|
|
39
40
|
"peerDependencies": {
|
|
40
41
|
"react": "*",
|
|
@@ -2,7 +2,7 @@ import { memo, type ReactNode, use, useRef, useState } from "react";
|
|
|
2
2
|
import { RouterLayerContext } from "../contexts/RouterLayerContext.ts";
|
|
3
3
|
import type { PageAnimation } from "../descriptors/$page.ts";
|
|
4
4
|
import { Redirection } from "../errors/Redirection.ts";
|
|
5
|
-
import {
|
|
5
|
+
import { useEvents } from "../hooks/useEvents.ts";
|
|
6
6
|
import { useRouterState } from "../hooks/useRouterState.ts";
|
|
7
7
|
import type { ReactRouterState } from "../providers/ReactPageProvider.ts";
|
|
8
8
|
import ErrorBoundary from "./ErrorBoundary.tsx";
|
|
@@ -45,9 +45,9 @@ const NestedView = (props: NestedViewProps) => {
|
|
|
45
45
|
const animationExitDuration = useRef<number>(0);
|
|
46
46
|
const animationExitNow = useRef<number>(0);
|
|
47
47
|
|
|
48
|
-
|
|
48
|
+
useEvents(
|
|
49
49
|
{
|
|
50
|
-
|
|
50
|
+
"react:transition:begin": async ({ previous, state }) => {
|
|
51
51
|
// --------- Animations Begin ---------
|
|
52
52
|
const layer = previous.layers[index];
|
|
53
53
|
if (`${state.url.pathname}/`.startsWith(`${layer?.path}/`)) {
|
|
@@ -72,7 +72,7 @@ const NestedView = (props: NestedViewProps) => {
|
|
|
72
72
|
}
|
|
73
73
|
// --------- Animations End ---------
|
|
74
74
|
},
|
|
75
|
-
|
|
75
|
+
"react:transition:end": async ({ state }) => {
|
|
76
76
|
const layer = state.layers[index];
|
|
77
77
|
|
|
78
78
|
// --------- Animations Begin ---------
|
package/src/descriptors/$page.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import {
|
|
2
|
-
|
|
2
|
+
$inject,
|
|
3
3
|
type Async,
|
|
4
4
|
createDescriptor,
|
|
5
5
|
Descriptor,
|
|
@@ -13,6 +13,7 @@ import type { FC, ReactNode } from "react";
|
|
|
13
13
|
import type { ClientOnlyProps } from "../components/ClientOnly.tsx";
|
|
14
14
|
import type { Redirection } from "../errors/Redirection.ts";
|
|
15
15
|
import type { ReactRouterState } from "../providers/ReactPageProvider.ts";
|
|
16
|
+
import { ReactPageService } from "../services/ReactPageService.ts";
|
|
16
17
|
|
|
17
18
|
/**
|
|
18
19
|
* Main descriptor for defining a React route in the application.
|
|
@@ -123,17 +124,12 @@ export interface PageDescriptorOptions<
|
|
|
123
124
|
TPropsParent extends object = TPropsParentDefault,
|
|
124
125
|
> {
|
|
125
126
|
/**
|
|
126
|
-
*
|
|
127
|
+
* Identifier name for the page. Must be unique.
|
|
127
128
|
*
|
|
128
129
|
* @default Descriptor key
|
|
129
130
|
*/
|
|
130
131
|
name?: string;
|
|
131
132
|
|
|
132
|
-
/**
|
|
133
|
-
* Optional description of the page.
|
|
134
|
-
*/
|
|
135
|
-
description?: string;
|
|
136
|
-
|
|
137
133
|
/**
|
|
138
134
|
* Add a pathname to the page.
|
|
139
135
|
*
|
|
@@ -183,15 +179,15 @@ export interface PageDescriptorOptions<
|
|
|
183
179
|
lazy?: () => Promise<{ default: FC<TProps & TPropsParent> }>;
|
|
184
180
|
|
|
185
181
|
/**
|
|
186
|
-
*
|
|
187
|
-
*
|
|
188
|
-
* /!\ Parent page can't be rendered directly. /!\
|
|
189
|
-
*
|
|
190
|
-
* If you still want to render at this pathname, add a child page with an empty path.
|
|
182
|
+
* Attach child pages to create nested routes.
|
|
183
|
+
* This will make the page a parent route.
|
|
191
184
|
*/
|
|
192
185
|
children?: Array<PageDescriptor> | (() => Array<PageDescriptor>);
|
|
193
186
|
|
|
194
|
-
|
|
187
|
+
/**
|
|
188
|
+
* Define a parent page for nested routing.
|
|
189
|
+
*/
|
|
190
|
+
parent?: PageDescriptor<PageConfigSchema, TPropsParent, any>;
|
|
195
191
|
|
|
196
192
|
can?: () => boolean;
|
|
197
193
|
|
|
@@ -238,9 +234,9 @@ export interface PageDescriptorOptions<
|
|
|
238
234
|
* If true, the page will be considered as a static page, immutable and cacheable.
|
|
239
235
|
* Replace boolean by an object to define static entries. (e.g. list of params/query)
|
|
240
236
|
*
|
|
241
|
-
*
|
|
237
|
+
* Browser-side: it only works with `@alepha/vite`, which can pre-render the page at build time.
|
|
242
238
|
*
|
|
243
|
-
* It will act as timeless cached page
|
|
239
|
+
* Server-side: It will act as timeless cached page. You can use `cache` to configure the cache behavior.
|
|
244
240
|
*/
|
|
245
241
|
static?:
|
|
246
242
|
| boolean
|
|
@@ -251,15 +247,15 @@ export interface PageDescriptorOptions<
|
|
|
251
247
|
cache?: ServerRouteCache;
|
|
252
248
|
|
|
253
249
|
/**
|
|
254
|
-
* If true, force the page to be rendered only on the client-side.
|
|
250
|
+
* If true, force the page to be rendered only on the client-side (browser).
|
|
255
251
|
* It uses the `<ClientOnly/>` component to render the page.
|
|
256
252
|
*/
|
|
257
253
|
client?: boolean | ClientOnlyProps;
|
|
258
254
|
|
|
259
255
|
/**
|
|
260
|
-
* Called before the server response is sent to the client.
|
|
256
|
+
* Called before the server response is sent to the client. (server only)
|
|
261
257
|
*/
|
|
262
|
-
onServerResponse?: (request: ServerRequest) =>
|
|
258
|
+
onServerResponse?: (request: ServerRequest) => unknown;
|
|
263
259
|
|
|
264
260
|
/**
|
|
265
261
|
* Called when user leaves the page. (browser only)
|
|
@@ -321,6 +317,8 @@ export class PageDescriptor<
|
|
|
321
317
|
TProps extends object = TPropsDefault,
|
|
322
318
|
TPropsParent extends object = TPropsParentDefault,
|
|
323
319
|
> extends Descriptor<PageDescriptorOptions<TConfig, TProps, TPropsParent>> {
|
|
320
|
+
protected readonly reactPageService = $inject(ReactPageService);
|
|
321
|
+
|
|
324
322
|
protected onInit() {
|
|
325
323
|
if (this.options.static) {
|
|
326
324
|
this.options.cache ??= {
|
|
@@ -337,24 +335,22 @@ export class PageDescriptor<
|
|
|
337
335
|
}
|
|
338
336
|
|
|
339
337
|
/**
|
|
340
|
-
* For testing or build purposes
|
|
338
|
+
* For testing or build purposes.
|
|
339
|
+
*
|
|
340
|
+
* This will render the page (HTML layout included or not) and return the HTML + context.
|
|
341
341
|
* Only valid for server-side rendering, it will throw an error if called on the client-side.
|
|
342
342
|
*/
|
|
343
343
|
public async render(
|
|
344
344
|
options?: PageDescriptorRenderOptions,
|
|
345
345
|
): Promise<PageDescriptorRenderResult> {
|
|
346
|
-
|
|
347
|
-
"render() method is not implemented in this environment",
|
|
348
|
-
);
|
|
346
|
+
return this.reactPageService.render(this.name, options);
|
|
349
347
|
}
|
|
350
348
|
|
|
351
349
|
public async fetch(options?: PageDescriptorRenderOptions): Promise<{
|
|
352
350
|
html: string;
|
|
353
351
|
response: Response;
|
|
354
352
|
}> {
|
|
355
|
-
|
|
356
|
-
"fetch() method is not implemented in this environment",
|
|
357
|
-
);
|
|
353
|
+
return this.reactPageService.fetch(this.options.path || "", options);
|
|
358
354
|
}
|
|
359
355
|
|
|
360
356
|
public match(url: string): boolean {
|
|
@@ -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
|
+
}
|
package/src/hooks/useActive.ts
CHANGED
|
@@ -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,
|