@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.
package/README.md CHANGED
@@ -147,6 +147,29 @@ Hook to get a virtual client for the specified scope.
147
147
 
148
148
  It's the React-hook version of `$client()`, from `AlephaServerLinks` module.
149
149
 
150
+ #### useEvents()
151
+
152
+ Allow subscribing to multiple Alepha events. See {@link Hooks} for available events.
153
+
154
+ useEvents is fully typed to ensure correct event callback signatures.
155
+
156
+ ```tsx
157
+ useEvents(
158
+ {
159
+ "react:transition:begin": (ev) => {
160
+ console.log("Transition began to:", ev.to);
161
+ },
162
+ "react:transition:error": {
163
+ priority: "first",
164
+ callback: (ev) => {
165
+ console.error("Transition error:", ev.error);
166
+ },
167
+ },
168
+ },
169
+ [],
170
+ );
171
+ ```
172
+
150
173
  #### useInject()
151
174
 
152
175
  Hook to inject a service instance.
@@ -170,10 +193,6 @@ class App {
170
193
  const router = useRouter<App>();
171
194
  router.go("home"); // typesafe
172
195
 
173
- #### useRouterEvents()
174
-
175
- Subscribe to various router events.
176
-
177
196
  #### useStore()
178
197
 
179
198
  Hook to access and mutate the Alepha state.
@@ -1,13 +1,24 @@
1
1
  import { $env, $hook, $inject, $module, Alepha, AlephaError, Descriptor, KIND, createDescriptor, t } from "@alepha/core";
2
+ import { AlephaDateTime, DateTimeProvider } from "@alepha/datetime";
2
3
  import { AlephaServer, HttpClient } from "@alepha/server";
3
4
  import { AlephaServerLinks, LinkProvider } from "@alepha/server-links";
4
- import { DateTimeProvider } from "@alepha/datetime";
5
5
  import { $logger } from "@alepha/logger";
6
6
  import { RouterProvider } from "@alepha/router";
7
- import React, { StrictMode, createContext, createElement, memo, use, useContext, useEffect, useMemo, useRef, useState } from "react";
7
+ import React, { StrictMode, createContext, createElement, memo, use, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
8
8
  import { Fragment, jsx, jsxs } from "react/jsx-runtime";
9
9
  import { createRoot, hydrateRoot } from "react-dom/client";
10
10
 
11
+ //#region src/services/ReactPageService.ts
12
+ var ReactPageService = class {
13
+ fetch(pathname, options = {}) {
14
+ throw new AlephaError("Fetch is not available for this environment.");
15
+ }
16
+ render(name, options = {}) {
17
+ throw new AlephaError("Render is not available for this environment.");
18
+ }
19
+ };
20
+
21
+ //#endregion
11
22
  //#region src/descriptors/$page.ts
12
23
  /**
13
24
  * Main descriptor for defining a React route in the application.
@@ -101,6 +112,7 @@ const $page = (options) => {
101
112
  return createDescriptor(PageDescriptor, options);
102
113
  };
103
114
  var PageDescriptor = class extends Descriptor {
115
+ reactPageService = $inject(ReactPageService);
104
116
  onInit() {
105
117
  if (this.options.static) this.options.cache ??= { store: {
106
118
  provider: "memory",
@@ -111,14 +123,16 @@ var PageDescriptor = class extends Descriptor {
111
123
  return this.options.name ?? this.config.propertyKey;
112
124
  }
113
125
  /**
114
- * For testing or build purposes, this will render the page (with or without the HTML layout) and return the HTML and context.
126
+ * For testing or build purposes.
127
+ *
128
+ * This will render the page (HTML layout included or not) and return the HTML + context.
115
129
  * Only valid for server-side rendering, it will throw an error if called on the client-side.
116
130
  */
117
131
  async render(options) {
118
- throw new AlephaError("render() method is not implemented in this environment");
132
+ return this.reactPageService.render(this.name, options);
119
133
  }
120
134
  async fetch(options) {
121
- throw new AlephaError("fetch() method is not implemented in this environment");
135
+ return this.reactPageService.fetch(this.options.path || "", options);
122
136
  }
123
137
  match(url) {
124
138
  return false;
@@ -365,29 +379,38 @@ const useAlepha = () => {
365
379
  };
366
380
 
367
381
  //#endregion
368
- //#region src/hooks/useRouterEvents.ts
382
+ //#region src/hooks/useEvents.ts
369
383
  /**
370
- * Subscribe to various router events.
384
+ * Allow subscribing to multiple Alepha events. See {@link Hooks} for available events.
385
+ *
386
+ * useEvents is fully typed to ensure correct event callback signatures.
387
+ *
388
+ * @example
389
+ * ```tsx
390
+ * useEvents(
391
+ * {
392
+ * "react:transition:begin": (ev) => {
393
+ * console.log("Transition began to:", ev.to);
394
+ * },
395
+ * "react:transition:error": {
396
+ * priority: "first",
397
+ * callback: (ev) => {
398
+ * console.error("Transition error:", ev.error);
399
+ * },
400
+ * },
401
+ * },
402
+ * [],
403
+ * );
404
+ * ```
371
405
  */
372
- const useRouterEvents = (opts = {}, deps = []) => {
406
+ const useEvents = (opts, deps) => {
373
407
  const alepha = useAlepha();
374
408
  useEffect(() => {
375
409
  if (!alepha.isBrowser()) return;
376
- const cb = (callback) => {
377
- if (typeof callback === "function") return { callback };
378
- return callback;
379
- };
380
410
  const subs = [];
381
- const onBegin = opts.onBegin;
382
- const onEnd = opts.onEnd;
383
- const onError = opts.onError;
384
- const onSuccess = opts.onSuccess;
385
- if (onBegin) subs.push(alepha.events.on("react:transition:begin", cb(onBegin)));
386
- if (onEnd) subs.push(alepha.events.on("react:transition:end", cb(onEnd)));
387
- if (onError) subs.push(alepha.events.on("react:transition:error", cb(onError)));
388
- if (onSuccess) subs.push(alepha.events.on("react:transition:success", cb(onSuccess)));
411
+ for (const [name, hook] of Object.entries(opts)) subs.push(alepha.events.on(name, hook));
389
412
  return () => {
390
- for (const sub of subs) sub();
413
+ for (const clear of subs) clear();
391
414
  };
392
415
  }, deps);
393
416
  };
@@ -483,8 +506,8 @@ const NestedView = (props) => {
483
506
  const [animation, setAnimation] = useState("");
484
507
  const animationExitDuration = useRef(0);
485
508
  const animationExitNow = useRef(0);
486
- useRouterEvents({
487
- onBegin: async ({ previous, state: state$1 }) => {
509
+ useEvents({
510
+ "react:transition:begin": async ({ previous, state: state$1 }) => {
488
511
  const layer = previous.layers[index];
489
512
  if (`${state$1.url.pathname}/`.startsWith(`${layer?.path}/`)) return;
490
513
  const animationExit = parseAnimation(layer.route?.animation, state$1, "exit");
@@ -499,7 +522,7 @@ const NestedView = (props) => {
499
522
  setAnimation("");
500
523
  }
501
524
  },
502
- onEnd: async ({ state: state$1 }) => {
525
+ "react:transition:end": async ({ state: state$1 }) => {
503
526
  const layer = state$1.layers[index];
504
527
  if (animationExitNow.current) {
505
528
  const duration = animationExitDuration.current;
@@ -586,9 +609,34 @@ var ReactPageProvider = class {
586
609
  getPages() {
587
610
  return this.pages;
588
611
  }
612
+ getConcretePages() {
613
+ const pages = [];
614
+ for (const page of this.pages) {
615
+ if (page.children && page.children.length > 0) continue;
616
+ const fullPath = this.pathname(page.name);
617
+ if (fullPath.includes(":") || fullPath.includes("*")) {
618
+ if (typeof page.static === "object") {
619
+ const entries = page.static.entries;
620
+ if (entries && entries.length > 0) for (const entry of entries) {
621
+ const params = entry.params;
622
+ const path = this.compile(page.path ?? "", params);
623
+ if (!path.includes(":") && !path.includes("*")) pages.push({
624
+ ...page,
625
+ name: params[Object.keys(params)[0]],
626
+ path,
627
+ ...entry
628
+ });
629
+ }
630
+ }
631
+ continue;
632
+ }
633
+ pages.push(page);
634
+ }
635
+ return pages;
636
+ }
589
637
  page(name) {
590
638
  for (const page of this.pages) if (page.name === name) return page;
591
- throw new Error(`Page ${name} not found`);
639
+ throw new AlephaError(`Page '${name}' not found`);
592
640
  }
593
641
  pathname(name, options = {}) {
594
642
  const page = this.page(name);
@@ -901,6 +949,7 @@ var ReactBrowserRouterProvider = class extends RouterProvider {
901
949
  onError: () => null,
902
950
  meta
903
951
  };
952
+ await this.alepha.events.emit("react:action:begin", { type: "transition" });
904
953
  await this.alepha.events.emit("react:transition:begin", {
905
954
  previous: this.alepha.state.get("react.router.state"),
906
955
  state
@@ -921,6 +970,7 @@ var ReactBrowserRouterProvider = class extends RouterProvider {
921
970
  index: 0,
922
971
  path: "/"
923
972
  });
973
+ await this.alepha.events.emit("react:action:success", { type: "transition" });
924
974
  await this.alepha.events.emit("react:transition:success", { state });
925
975
  } catch (e) {
926
976
  this.log.error("Transition has failed", e);
@@ -930,6 +980,10 @@ var ReactBrowserRouterProvider = class extends RouterProvider {
930
980
  index: 0,
931
981
  path: "/"
932
982
  }];
983
+ await this.alepha.events.emit("react:action:error", {
984
+ type: "transition",
985
+ error: e
986
+ });
933
987
  await this.alepha.events.emit("react:transition:error", {
934
988
  error: e,
935
989
  state
@@ -940,6 +994,7 @@ var ReactBrowserRouterProvider = class extends RouterProvider {
940
994
  if (state.layers[i]?.name !== layer.name) this.pageApi.page(layer.name)?.onLeave?.();
941
995
  }
942
996
  this.alepha.state.set("react.router.state", state);
997
+ await this.alepha.events.emit("react:action:end", { type: "transition" });
943
998
  await this.alepha.events.emit("react:transition:end", { state });
944
999
  }
945
1000
  root(state) {
@@ -1135,9 +1190,18 @@ var ReactRouter = class {
1135
1190
  get pages() {
1136
1191
  return this.pageApi.getPages();
1137
1192
  }
1193
+ get concretePages() {
1194
+ return this.pageApi.getConcretePages();
1195
+ }
1138
1196
  get browser() {
1139
1197
  if (this.alepha.isBrowser()) return this.alepha.inject(ReactBrowserProvider);
1140
1198
  }
1199
+ isActive(href, options = {}) {
1200
+ const current = this.state.url.pathname;
1201
+ let isActive = current === href || current === `${href}/` || `${current}/` === href;
1202
+ if (options.startWith && !isActive) isActive = current.startsWith(href);
1203
+ return isActive;
1204
+ }
1141
1205
  path(name, config = {}) {
1142
1206
  return this.pageApi.pathname(name, {
1143
1207
  params: {
@@ -1271,19 +1335,245 @@ const Link = (props) => {
1271
1335
  };
1272
1336
  var Link_default = Link;
1273
1337
 
1338
+ //#endregion
1339
+ //#region src/hooks/useAction.ts
1340
+ /**
1341
+ * Hook for handling async actions with automatic error handling and event emission.
1342
+ *
1343
+ * By default, prevents concurrent executions - if an action is running and you call it again,
1344
+ * the second call will be ignored. Use `debounce` option to delay execution instead.
1345
+ *
1346
+ * Emits lifecycle events:
1347
+ * - `react:action:begin` - When action starts
1348
+ * - `react:action:success` - When action completes successfully
1349
+ * - `react:action:error` - When action throws an error
1350
+ * - `react:action:end` - Always emitted at the end
1351
+ *
1352
+ * @example Basic usage
1353
+ * ```tsx
1354
+ * const action = useAction({
1355
+ * handler: async (data) => {
1356
+ * await api.save(data);
1357
+ * }
1358
+ * }, []);
1359
+ *
1360
+ * <button onClick={() => action.run(data)} disabled={action.loading}>
1361
+ * Save
1362
+ * </button>
1363
+ * ```
1364
+ *
1365
+ * @example With debounce (search input)
1366
+ * ```tsx
1367
+ * const search = useAction({
1368
+ * handler: async (query: string) => {
1369
+ * await api.search(query);
1370
+ * },
1371
+ * debounce: 300 // Wait 300ms after last call
1372
+ * }, []);
1373
+ *
1374
+ * <input onChange={(e) => search.run(e.target.value)} />
1375
+ * ```
1376
+ *
1377
+ * @example Run on component mount
1378
+ * ```tsx
1379
+ * const fetchData = useAction({
1380
+ * handler: async () => {
1381
+ * const data = await api.getData();
1382
+ * return data;
1383
+ * },
1384
+ * runOnInit: true // Runs once when component mounts
1385
+ * }, []);
1386
+ * ```
1387
+ *
1388
+ * @example Run periodically (polling)
1389
+ * ```tsx
1390
+ * const pollStatus = useAction({
1391
+ * handler: async () => {
1392
+ * const status = await api.getStatus();
1393
+ * return status;
1394
+ * },
1395
+ * runEvery: 5000 // Run every 5 seconds
1396
+ * }, []);
1397
+ *
1398
+ * // Or with duration tuple
1399
+ * const pollStatus = useAction({
1400
+ * handler: async () => {
1401
+ * const status = await api.getStatus();
1402
+ * return status;
1403
+ * },
1404
+ * runEvery: [30, 'seconds'] // Run every 30 seconds
1405
+ * }, []);
1406
+ * ```
1407
+ *
1408
+ * @example With AbortController
1409
+ * ```tsx
1410
+ * const fetch = useAction({
1411
+ * handler: async (url, { signal }) => {
1412
+ * const response = await fetch(url, { signal });
1413
+ * return response.json();
1414
+ * }
1415
+ * }, []);
1416
+ * // Automatically cancelled on unmount or when new request starts
1417
+ * ```
1418
+ *
1419
+ * @example With error handling
1420
+ * ```tsx
1421
+ * const deleteAction = useAction({
1422
+ * handler: async (id: string) => {
1423
+ * await api.delete(id);
1424
+ * },
1425
+ * onError: (error) => {
1426
+ * if (error.code === 'NOT_FOUND') {
1427
+ * // Custom error handling
1428
+ * }
1429
+ * }
1430
+ * }, []);
1431
+ *
1432
+ * {deleteAction.error && <div>Error: {deleteAction.error.message}</div>}
1433
+ * ```
1434
+ *
1435
+ * @example Global error handling
1436
+ * ```tsx
1437
+ * // In your root app setup
1438
+ * alepha.events.on("react:action:error", ({ error }) => {
1439
+ * toast.danger(error.message);
1440
+ * Sentry.captureException(error);
1441
+ * });
1442
+ * ```
1443
+ */
1444
+ function useAction(options, deps) {
1445
+ const alepha = useAlepha();
1446
+ const dateTimeProvider = useInject(DateTimeProvider);
1447
+ const [loading, setLoading] = useState(false);
1448
+ const [error, setError] = useState();
1449
+ const isExecutingRef = useRef(false);
1450
+ const debounceTimerRef = useRef(void 0);
1451
+ const abortControllerRef = useRef(void 0);
1452
+ const isMountedRef = useRef(true);
1453
+ const intervalRef = useRef(void 0);
1454
+ useEffect(() => {
1455
+ return () => {
1456
+ isMountedRef.current = false;
1457
+ if (debounceTimerRef.current) {
1458
+ dateTimeProvider.clearTimeout(debounceTimerRef.current);
1459
+ debounceTimerRef.current = void 0;
1460
+ }
1461
+ if (intervalRef.current) {
1462
+ dateTimeProvider.clearInterval(intervalRef.current);
1463
+ intervalRef.current = void 0;
1464
+ }
1465
+ if (abortControllerRef.current) {
1466
+ abortControllerRef.current.abort();
1467
+ abortControllerRef.current = void 0;
1468
+ }
1469
+ };
1470
+ }, []);
1471
+ const executeAction = useCallback(async (...args) => {
1472
+ if (isExecutingRef.current) return;
1473
+ if (abortControllerRef.current) abortControllerRef.current.abort();
1474
+ const abortController = new AbortController();
1475
+ abortControllerRef.current = abortController;
1476
+ isExecutingRef.current = true;
1477
+ setLoading(true);
1478
+ setError(void 0);
1479
+ await alepha.events.emit("react:action:begin", {
1480
+ type: "custom",
1481
+ id: options.id
1482
+ });
1483
+ try {
1484
+ const result = await options.handler(...args, { signal: abortController.signal });
1485
+ if (!isMountedRef.current || abortController.signal.aborted) return;
1486
+ await alepha.events.emit("react:action:success", {
1487
+ type: "custom",
1488
+ id: options.id
1489
+ });
1490
+ if (options.onSuccess) await options.onSuccess(result);
1491
+ return result;
1492
+ } catch (err) {
1493
+ if (err instanceof Error && err.name === "AbortError") return;
1494
+ if (!isMountedRef.current) return;
1495
+ const error$1 = err;
1496
+ setError(error$1);
1497
+ await alepha.events.emit("react:action:error", {
1498
+ type: "custom",
1499
+ id: options.id,
1500
+ error: error$1
1501
+ });
1502
+ if (options.onError) await options.onError(error$1);
1503
+ else throw error$1;
1504
+ } finally {
1505
+ isExecutingRef.current = false;
1506
+ setLoading(false);
1507
+ await alepha.events.emit("react:action:end", {
1508
+ type: "custom",
1509
+ id: options.id
1510
+ });
1511
+ if (abortControllerRef.current === abortController) abortControllerRef.current = void 0;
1512
+ }
1513
+ }, [
1514
+ ...deps,
1515
+ options.id,
1516
+ options.onError,
1517
+ options.onSuccess
1518
+ ]);
1519
+ const handler = useCallback(async (...args) => {
1520
+ if (options.debounce) {
1521
+ if (debounceTimerRef.current) dateTimeProvider.clearTimeout(debounceTimerRef.current);
1522
+ return new Promise((resolve) => {
1523
+ debounceTimerRef.current = dateTimeProvider.createTimeout(async () => {
1524
+ resolve(await executeAction(...args));
1525
+ }, options.debounce ?? 0);
1526
+ });
1527
+ }
1528
+ return executeAction(...args);
1529
+ }, [executeAction, options.debounce]);
1530
+ const cancel = useCallback(() => {
1531
+ if (debounceTimerRef.current) {
1532
+ dateTimeProvider.clearTimeout(debounceTimerRef.current);
1533
+ debounceTimerRef.current = void 0;
1534
+ }
1535
+ if (abortControllerRef.current) {
1536
+ abortControllerRef.current.abort();
1537
+ abortControllerRef.current = void 0;
1538
+ }
1539
+ if (isMountedRef.current) {
1540
+ isExecutingRef.current = false;
1541
+ setLoading(false);
1542
+ }
1543
+ }, []);
1544
+ useEffect(() => {
1545
+ if (options.runOnInit) handler(...[]);
1546
+ }, []);
1547
+ useEffect(() => {
1548
+ if (!options.runEvery) return;
1549
+ intervalRef.current = dateTimeProvider.createInterval(() => handler(...[]), options.runEvery, true);
1550
+ return () => {
1551
+ if (intervalRef.current) {
1552
+ dateTimeProvider.clearInterval(intervalRef.current);
1553
+ intervalRef.current = void 0;
1554
+ }
1555
+ };
1556
+ }, [handler, options.runEvery]);
1557
+ return {
1558
+ run: handler,
1559
+ loading,
1560
+ error,
1561
+ cancel
1562
+ };
1563
+ }
1564
+
1274
1565
  //#endregion
1275
1566
  //#region src/hooks/useActive.ts
1276
1567
  const useActive = (args) => {
1277
1568
  const router = useRouter();
1278
1569
  const [isPending, setPending] = useState(false);
1279
- const current = useRouterState().url.pathname;
1570
+ useRouterState().url.pathname;
1280
1571
  const options = typeof args === "string" ? { href: args } : {
1281
1572
  ...args,
1282
1573
  href: args.href
1283
1574
  };
1284
1575
  const href = options.href;
1285
- let isActive = current === href || current === `${href}/` || `${current}/` === href;
1286
- if (options.startWith && !isActive) isActive = current.startsWith(href);
1576
+ const isActive = router.isActive(href, options);
1287
1577
  return {
1288
1578
  isPending,
1289
1579
  isActive,
@@ -1395,11 +1685,12 @@ const AlephaReact = $module({
1395
1685
  ReactBrowserRouterProvider,
1396
1686
  ReactBrowserProvider,
1397
1687
  ReactRouter,
1398
- ReactBrowserRendererProvider
1688
+ ReactBrowserRendererProvider,
1689
+ ReactPageService
1399
1690
  ],
1400
- register: (alepha) => alepha.with(AlephaServer).with(AlephaServerLinks).with(ReactPageProvider).with(ReactBrowserProvider).with(ReactBrowserRouterProvider).with(ReactBrowserRendererProvider).with(ReactRouter)
1691
+ register: (alepha) => alepha.with(AlephaDateTime).with(AlephaServer).with(AlephaServerLinks).with(ReactPageProvider).with(ReactBrowserProvider).with(ReactBrowserRouterProvider).with(ReactBrowserRendererProvider).with(ReactRouter)
1401
1692
  });
1402
1693
 
1403
1694
  //#endregion
1404
- export { $page, AlephaContext, AlephaReact, ClientOnly_default as ClientOnly, ErrorBoundary_default as ErrorBoundary, ErrorViewer_default as ErrorViewer, Link_default as Link, NestedView_default as NestedView, NotFoundPage as NotFound, PageDescriptor, ReactBrowserProvider, ReactBrowserRouterProvider, ReactPageProvider, ReactRouter, Redirection, RouterLayerContext, isPageRoute, ssrSchemaLoading, useActive, useAlepha, useClient, useInject, useQueryParams, useRouter, useRouterEvents, useRouterState, useSchema, useStore };
1695
+ export { $page, AlephaContext, AlephaReact, ClientOnly_default as ClientOnly, ErrorBoundary_default as ErrorBoundary, ErrorViewer_default as ErrorViewer, Link_default as Link, NestedView_default as NestedView, NotFoundPage as NotFound, PageDescriptor, ReactBrowserProvider, ReactBrowserRouterProvider, ReactPageProvider, ReactRouter, Redirection, RouterLayerContext, isPageRoute, ssrSchemaLoading, useAction, useActive, useAlepha, useClient, useEvents, useInject, useQueryParams, useRouter, useRouterState, useSchema, useStore };
1405
1696
  //# sourceMappingURL=index.browser.js.map