@alepha/react 0.9.4 → 0.9.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 CHANGED
@@ -10,12 +10,6 @@ This package is part of the Alepha framework and can be installed via the all-in
10
10
  npm install alepha
11
11
  ```
12
12
 
13
- Alternatively, you can install it individually:
14
-
15
- ```bash
16
- npm install @alepha/core @alepha/react
17
- ```
18
-
19
13
  ## Module
20
14
 
21
15
  Provides full-stack React development with declarative routing, server-side rendering, and client-side hydration.
@@ -24,16 +18,34 @@ The React module enables building modern React applications using the `$page` de
24
18
  It delivers seamless server-side rendering, automatic code splitting, and client-side navigation with full
25
19
  type safety and schema validation for route parameters and data.
26
20
 
21
+ This module can be imported and used as follows:
22
+
23
+ ```typescript
24
+ import { Alepha, run } from "alepha";
25
+ import { AlephaReact } from "alepha/react";
26
+
27
+ const alepha = Alepha.create()
28
+ .with(AlephaReact);
29
+
30
+ run(alepha);
31
+ ```
32
+
27
33
  ## API Reference
28
34
 
29
35
  ### Descriptors
30
36
 
37
+ Descriptors are functions that define and configure various aspects of your application. They follow the convention of starting with `$` and return configured descriptor instances.
38
+
39
+ For more details, see the [Descriptors documentation](https://feunard.github.io/alepha/docs/descriptors).
40
+
31
41
  #### $page()
32
42
 
33
43
  Main descriptor for defining a React route in the application.
34
44
 
35
45
  ### Hooks
36
46
 
47
+ Hooks provide a way to tap into various lifecycle events and extend functionality. They follow the convention of starting with `use` and return configured hook instances.
48
+
37
49
  #### useAlepha()
38
50
 
39
51
  Main Alepha hook.
@@ -1,12 +1,12 @@
1
- import { $env, $hook, $inject, $module, Alepha, AlephaError, Descriptor, KIND, createDescriptor, t } from "@alepha/core";
1
+ import { $env, $hook, $inject, $module, Alepha, AlephaError, Descriptor, KIND, TypeGuard, createDescriptor, t } from "@alepha/core";
2
2
  import { AlephaServer, HttpClient } from "@alepha/server";
3
3
  import { AlephaServerLinks, LinkProvider } from "@alepha/server-links";
4
4
  import { DateTimeProvider } from "@alepha/datetime";
5
5
  import { $logger } from "@alepha/logger";
6
- import { createRoot, hydrateRoot } from "react-dom/client";
7
6
  import { RouterProvider } from "@alepha/router";
8
- import React, { StrictMode, createContext, createElement, useContext, useEffect, useMemo, useState } from "react";
9
- import { jsx, jsxs } from "react/jsx-runtime";
7
+ import React, { StrictMode, createContext, createElement, memo, use, useContext, useEffect, useMemo, useRef, useState } from "react";
8
+ import { Fragment, jsx, jsxs } from "react/jsx-runtime";
9
+ import { createRoot, hydrateRoot } from "react-dom/client";
10
10
 
11
11
  //#region src/descriptors/$page.ts
12
12
  /**
@@ -30,7 +30,10 @@ var PageDescriptor = class extends Descriptor {
30
30
  * Only valid for server-side rendering, it will throw an error if called on the client-side.
31
31
  */
32
32
  async render(options) {
33
- throw new Error("render method is not implemented in this environment");
33
+ throw new AlephaError("render() method is not implemented in this environment");
34
+ }
35
+ async fetch(options) {
36
+ throw new AlephaError("fetch() method is not implemented in this environment");
34
37
  }
35
38
  match(url) {
36
39
  return false;
@@ -84,7 +87,6 @@ const ClientOnly = (props) => {
84
87
  if (props.disabled) return props.children;
85
88
  return mounted ? props.children : props.fallback;
86
89
  };
87
- var ClientOnly_default = ClientOnly;
88
90
 
89
91
  //#endregion
90
92
  //#region src/components/ErrorViewer.tsx
@@ -192,7 +194,6 @@ const ErrorViewer = ({ error, alepha }) => {
192
194
  })] })]
193
195
  });
194
196
  };
195
- var ErrorViewer_default = ErrorViewer;
196
197
  const ErrorViewerProduction = () => {
197
198
  const styles = {
198
199
  container: {
@@ -286,19 +287,55 @@ const useRouterEvents = (opts = {}, deps = []) => {
286
287
  const alepha = useAlepha();
287
288
  useEffect(() => {
288
289
  if (!alepha.isBrowser()) return;
290
+ const cb = (callback) => {
291
+ if (typeof callback === "function") return { callback };
292
+ return callback;
293
+ };
289
294
  const subs = [];
290
295
  const onBegin = opts.onBegin;
291
296
  const onEnd = opts.onEnd;
292
297
  const onError = opts.onError;
293
- if (onBegin) subs.push(alepha.on("react:transition:begin", { callback: onBegin }));
294
- if (onEnd) subs.push(alepha.on("react:transition:end", { callback: onEnd }));
295
- if (onError) subs.push(alepha.on("react:transition:error", { callback: onError }));
298
+ const onSuccess = opts.onSuccess;
299
+ if (onBegin) subs.push(alepha.on("react:transition:begin", cb(onBegin)));
300
+ if (onEnd) subs.push(alepha.on("react:transition:end", cb(onEnd)));
301
+ if (onError) subs.push(alepha.on("react:transition:error", cb(onError)));
302
+ if (onSuccess) subs.push(alepha.on("react:transition:success", cb(onSuccess)));
296
303
  return () => {
297
304
  for (const sub of subs) sub();
298
305
  };
299
306
  }, deps);
300
307
  };
301
308
 
309
+ //#endregion
310
+ //#region src/hooks/useStore.ts
311
+ /**
312
+ * Hook to access and mutate the Alepha state.
313
+ */
314
+ const useStore = (key, defaultValue) => {
315
+ const alepha = useAlepha();
316
+ useMemo(() => {
317
+ if (defaultValue != null && alepha.state(key) == null) alepha.state(key, defaultValue);
318
+ }, [defaultValue]);
319
+ const [state, setState] = useState(alepha.state(key));
320
+ useEffect(() => {
321
+ if (!alepha.isBrowser()) return;
322
+ return alepha.on("state:mutate", (ev) => {
323
+ if (ev.key === key) setState(ev.value);
324
+ });
325
+ }, []);
326
+ return [state, (value) => {
327
+ alepha.state(key, value);
328
+ }];
329
+ };
330
+
331
+ //#endregion
332
+ //#region src/hooks/useRouterState.ts
333
+ const useRouterState = () => {
334
+ const [state] = useStore("react.router.state");
335
+ if (!state) throw new AlephaError("Missing react router state");
336
+ return state;
337
+ };
338
+
302
339
  //#endregion
303
340
  //#region src/components/ErrorBoundary.tsx
304
341
  /**
@@ -328,7 +365,6 @@ var ErrorBoundary = class extends React.Component {
328
365
  return this.props.children;
329
366
  }
330
367
  };
331
- var ErrorBoundary_default = ErrorBoundary;
332
368
 
333
369
  //#endregion
334
370
  //#region src/components/NestedView.tsx
@@ -339,7 +375,7 @@ var ErrorBoundary_default = ErrorBoundary;
339
375
  *
340
376
  * @example
341
377
  * ```tsx
342
- * import { NestedView } from "@alepha/react";
378
+ * import { NestedView } from "alepha/react";
343
379
  *
344
380
  * class App {
345
381
  * parent = $page({
@@ -354,17 +390,69 @@ var ErrorBoundary_default = ErrorBoundary;
354
390
  * ```
355
391
  */
356
392
  const NestedView = (props) => {
357
- const layer = useContext(RouterLayerContext);
358
- const index = layer?.index ?? 0;
359
- const alepha = useAlepha();
360
- const state = alepha.state("react.router.state");
361
- if (!state) throw new Error("<NestedView/> must be used inside a RouterLayerContext.");
393
+ const index = use(RouterLayerContext)?.index ?? 0;
394
+ const state = useRouterState();
362
395
  const [view, setView] = useState(state.layers[index]?.element);
363
- useRouterEvents({ onEnd: ({ state: state$1 }) => {
364
- if (!state$1.layers[index]?.cache) setView(state$1.layers[index]?.element);
365
- } }, []);
366
- const element = view ?? props.children ?? null;
367
- return /* @__PURE__ */ jsx(ErrorBoundary_default, {
396
+ const [animation, setAnimation] = useState("");
397
+ const animationExitDuration = useRef(0);
398
+ const animationExitNow = useRef(0);
399
+ useRouterEvents({
400
+ onBegin: async ({ previous, state: state$1 }) => {
401
+ const layer = previous.layers[index];
402
+ if (`${state$1.url.pathname}/`.startsWith(`${layer?.path}/`)) return;
403
+ const animationExit = parseAnimation(layer.route?.animation, state$1, "exit");
404
+ if (animationExit) {
405
+ const duration = animationExit.duration || 200;
406
+ animationExitNow.current = Date.now();
407
+ animationExitDuration.current = duration;
408
+ setAnimation(animationExit.animation);
409
+ } else {
410
+ animationExitNow.current = 0;
411
+ animationExitDuration.current = 0;
412
+ setAnimation("");
413
+ }
414
+ },
415
+ onEnd: async ({ state: state$1 }) => {
416
+ const layer = state$1.layers[index];
417
+ if (animationExitNow.current) {
418
+ const duration = animationExitDuration.current;
419
+ const diff = Date.now() - animationExitNow.current;
420
+ if (diff < duration) await new Promise((resolve) => setTimeout(resolve, duration - diff));
421
+ }
422
+ if (!layer?.cache) {
423
+ setView(layer?.element);
424
+ const animationEnter = parseAnimation(layer?.route?.animation, state$1, "enter");
425
+ if (animationEnter) setAnimation(animationEnter.animation);
426
+ else setAnimation("");
427
+ }
428
+ }
429
+ }, []);
430
+ let element = view ?? props.children ?? null;
431
+ if (animation) element = /* @__PURE__ */ jsx("div", {
432
+ style: {
433
+ display: "flex",
434
+ flex: 1,
435
+ height: "100%",
436
+ width: "100%",
437
+ position: "relative",
438
+ overflow: "hidden"
439
+ },
440
+ children: /* @__PURE__ */ jsx("div", {
441
+ style: {
442
+ height: "100%",
443
+ width: "100%",
444
+ display: "flex",
445
+ animation
446
+ },
447
+ children: element
448
+ })
449
+ });
450
+ if (props.errorBoundary === false) return /* @__PURE__ */ jsx(Fragment, { children: element });
451
+ if (props.errorBoundary) return /* @__PURE__ */ jsx(ErrorBoundary, {
452
+ fallback: props.errorBoundary,
453
+ children: element
454
+ });
455
+ return /* @__PURE__ */ jsx(ErrorBoundary, {
368
456
  fallback: (error) => {
369
457
  const result = state.onError(error, state);
370
458
  if (result instanceof Redirection) return "Redirection inside ErrorBoundary is not allowed.";
@@ -373,7 +461,37 @@ const NestedView = (props) => {
373
461
  children: element
374
462
  });
375
463
  };
376
- var NestedView_default = NestedView;
464
+ var NestedView_default = memo(NestedView);
465
+ function parseAnimation(animationLike, state, type = "enter") {
466
+ if (!animationLike) return void 0;
467
+ const DEFAULT_DURATION = 300;
468
+ const animation = typeof animationLike === "function" ? animationLike(state) : animationLike;
469
+ if (typeof animation === "string") {
470
+ if (type === "exit") return;
471
+ return {
472
+ duration: DEFAULT_DURATION,
473
+ animation: `${DEFAULT_DURATION}ms ${animation}`
474
+ };
475
+ }
476
+ if (typeof animation === "object") {
477
+ const anim = animation[type];
478
+ const duration = typeof anim === "object" ? anim.duration ?? DEFAULT_DURATION : DEFAULT_DURATION;
479
+ const name = typeof anim === "object" ? anim.name : anim;
480
+ if (type === "exit") {
481
+ const timing$1 = typeof anim === "object" ? anim.timing ?? "" : "";
482
+ return {
483
+ duration,
484
+ animation: `${duration}ms ${timing$1} ${name}`
485
+ };
486
+ }
487
+ const timing = typeof anim === "object" ? anim.timing ?? "" : "";
488
+ return {
489
+ duration,
490
+ animation: `${duration}ms ${timing} ${name}`
491
+ };
492
+ }
493
+ return void 0;
494
+ }
377
495
 
378
496
  //#endregion
379
497
  //#region src/providers/ReactPageProvider.ts
@@ -414,6 +532,14 @@ var ReactPageProvider = class {
414
532
  if (this.env.REACT_STRICT_MODE) return createElement(StrictMode, {}, root);
415
533
  return root;
416
534
  }
535
+ convertStringObjectToObject = (schema, value) => {
536
+ if (TypeGuard.IsObject(schema) && typeof value === "object") {
537
+ for (const key in schema.properties) if (TypeGuard.IsObject(schema.properties[key]) && typeof value[key] === "string") try {
538
+ value[key] = this.alepha.parse(schema.properties[key], decodeURIComponent(value[key]));
539
+ } catch (e) {}
540
+ }
541
+ return value;
542
+ };
417
543
  /**
418
544
  * Create a new RouterState based on a given route and request.
419
545
  * This method resolves the layers for the route, applying any query and params schemas defined in the route.
@@ -433,6 +559,7 @@ var ReactPageProvider = class {
433
559
  const route$1 = it.route;
434
560
  const config = {};
435
561
  try {
562
+ this.convertStringObjectToObject(route$1.schema?.query, state.query);
436
563
  config.query = route$1.schema?.query ? this.alepha.parse(route$1.schema.query, state.query) : {};
437
564
  } catch (e) {
438
565
  it.error = e;
@@ -559,6 +686,7 @@ var ReactPageProvider = class {
559
686
  }
560
687
  }
561
688
  async createElement(page, props) {
689
+ if (page.lazy && page.component) this.log.warn(`Page ${page.name} has both lazy and component options, lazy will be used`);
562
690
  if (page.lazy) {
563
691
  const component = await page.lazy();
564
692
  return createElement(component.default, props);
@@ -567,7 +695,7 @@ var ReactPageProvider = class {
567
695
  return void 0;
568
696
  }
569
697
  renderError(error) {
570
- return createElement(ErrorViewer_default, {
698
+ return createElement(ErrorViewer, {
571
699
  error,
572
700
  alepha: this.alepha
573
701
  });
@@ -593,7 +721,7 @@ var ReactPageProvider = class {
593
721
  }
594
722
  renderView(index, path, view, page) {
595
723
  view ??= this.renderEmptyView();
596
- const element = page.client ? createElement(ClientOnly_default, typeof page.client === "object" ? page.client : {}, view) : view;
724
+ const element = page.client ? createElement(ClientOnly, typeof page.client === "object" ? page.client : {}, view) : view;
597
725
  return createElement(RouterLayerContext.Provider, { value: {
598
726
  index,
599
727
  path
@@ -692,17 +820,21 @@ var ReactBrowserRouterProvider = class extends RouterProvider {
692
820
  });
693
821
  }
694
822
  });
695
- async transition(url, previous = []) {
823
+ async transition(url, previous = [], meta = {}) {
696
824
  const { pathname, search } = url;
697
825
  const entry = {
698
826
  url,
699
827
  query: {},
700
828
  params: {},
701
829
  layers: [],
702
- onError: () => null
830
+ onError: () => null,
831
+ meta
703
832
  };
704
833
  const state = entry;
705
- await this.alepha.emit("react:transition:begin", { state });
834
+ await this.alepha.emit("react:transition:begin", {
835
+ previous: this.alepha.state("react.router.state"),
836
+ state
837
+ });
706
838
  try {
707
839
  const { route, params } = this.match(pathname);
708
840
  const query = {};
@@ -737,8 +869,8 @@ var ReactBrowserRouterProvider = class extends RouterProvider {
737
869
  const layer = previous[i];
738
870
  if (state.layers[i]?.name !== layer.name) this.pageApi.page(layer.name)?.onLeave?.();
739
871
  }
740
- await this.alepha.emit("react:transition:end", { state });
741
872
  this.alepha.state("react.router.state", state);
873
+ await this.alepha.emit("react:transition:end", { state });
742
874
  }
743
875
  root(state) {
744
876
  return this.pageApi.root(state);
@@ -755,7 +887,6 @@ var ReactBrowserProvider = class {
755
887
  alepha = $inject(Alepha);
756
888
  router = $inject(ReactBrowserRouterProvider);
757
889
  dateTimeProvider = $inject(DateTimeProvider);
758
- root;
759
890
  options = { scrollRestoration: "top" };
760
891
  getRootElement() {
761
892
  const root = this.document.getElementById(this.env.REACT_ROOT_ID);
@@ -831,7 +962,8 @@ var ReactBrowserProvider = class {
831
962
  });
832
963
  await this.render({
833
964
  url,
834
- previous: options.force ? [] : this.state.layers
965
+ previous: options.force ? [] : this.state.layers,
966
+ meta: options.meta
835
967
  });
836
968
  if (this.state.url.pathname + this.state.url.search !== url) {
837
969
  this.pushState(this.state.url.pathname + this.state.url.search);
@@ -848,7 +980,7 @@ var ReactBrowserProvider = class {
848
980
  from: this.state?.url.pathname
849
981
  };
850
982
  this.log.debug("Transitioning...", { to: url });
851
- const redirect = await this.router.transition(new URL(`http://localhost${url}`), previous);
983
+ const redirect = await this.router.transition(new URL(`http://localhost${url}`), previous, options.meta);
852
984
  if (redirect) {
853
985
  this.log.info("Redirecting to", { redirect });
854
986
  return await this.render({ url: redirect });
@@ -870,7 +1002,7 @@ var ReactBrowserProvider = class {
870
1002
  onTransitionEnd = $hook({
871
1003
  on: "react:transition:end",
872
1004
  handler: () => {
873
- if (this.options.scrollRestoration === "top" && typeof window !== "undefined") {
1005
+ if (this.options.scrollRestoration === "top" && typeof window !== "undefined" && !this.alepha.isTest()) {
874
1006
  this.log.trace("Restoring scroll position to top");
875
1007
  window.scrollTo(0, 0);
876
1008
  }
@@ -886,19 +1018,37 @@ var ReactBrowserProvider = class {
886
1018
  }
887
1019
  await this.render({ previous });
888
1020
  const element = this.router.root(this.state);
1021
+ await this.alepha.emit("react:browser:render", {
1022
+ element,
1023
+ root: this.getRootElement(),
1024
+ hydration,
1025
+ state: this.state
1026
+ });
1027
+ window.addEventListener("popstate", () => {
1028
+ if (this.base + this.state.url.pathname === this.location.pathname) return;
1029
+ this.log.debug("Popstate event triggered - rendering new state", { url: this.location.pathname + this.location.search });
1030
+ this.render();
1031
+ });
1032
+ }
1033
+ });
1034
+ };
1035
+
1036
+ //#endregion
1037
+ //#region src/providers/ReactBrowserRendererProvider.ts
1038
+ var ReactBrowserRendererProvider = class {
1039
+ log = $logger();
1040
+ root;
1041
+ onBrowserRender = $hook({
1042
+ on: "react:browser:render",
1043
+ handler: async ({ hydration, root, element }) => {
889
1044
  if (hydration?.layers) {
890
- this.root = hydrateRoot(this.getRootElement(), element);
1045
+ this.root = hydrateRoot(root, element);
891
1046
  this.log.info("Hydrated root element");
892
1047
  } else {
893
- this.root ??= createRoot(this.getRootElement());
1048
+ this.root ??= createRoot(root);
894
1049
  this.root.render(element);
895
1050
  this.log.info("Created root element");
896
1051
  }
897
- window.addEventListener("popstate", () => {
898
- if (this.base + this.state.url.pathname === this.location.pathname) return;
899
- this.log.debug("Popstate event triggered - rendering new state", { url: this.location.pathname + this.location.search });
900
- this.render();
901
- });
902
1052
  }
903
1053
  });
904
1054
  };
@@ -1032,44 +1182,12 @@ const useRouter = () => {
1032
1182
  //#region src/components/Link.tsx
1033
1183
  const Link = (props) => {
1034
1184
  const router = useRouter();
1035
- const { to,...anchorProps } = props;
1036
1185
  return /* @__PURE__ */ jsx("a", {
1037
- ...router.anchor(to),
1038
- ...anchorProps,
1186
+ ...props,
1187
+ ...router.anchor(props.href),
1039
1188
  children: props.children
1040
1189
  });
1041
1190
  };
1042
- var Link_default = Link;
1043
-
1044
- //#endregion
1045
- //#region src/hooks/useStore.ts
1046
- /**
1047
- * Hook to access and mutate the Alepha state.
1048
- */
1049
- const useStore = (key, defaultValue) => {
1050
- const alepha = useAlepha();
1051
- useMemo(() => {
1052
- if (defaultValue != null && alepha.state(key) == null) alepha.state(key, defaultValue);
1053
- }, [defaultValue]);
1054
- const [state, setState] = useState(alepha.state(key));
1055
- useEffect(() => {
1056
- if (!alepha.isBrowser()) return;
1057
- return alepha.on("state:mutate", (ev) => {
1058
- if (ev.key === key) setState(ev.value);
1059
- });
1060
- }, []);
1061
- return [state, (value) => {
1062
- alepha.state(key, value);
1063
- }];
1064
- };
1065
-
1066
- //#endregion
1067
- //#region src/hooks/useRouterState.ts
1068
- const useRouterState = () => {
1069
- const [state] = useStore("react.router.state");
1070
- if (!state) throw new AlephaError("Missing react router state");
1071
- return state;
1072
- };
1073
1191
 
1074
1192
  //#endregion
1075
1193
  //#region src/hooks/useActive.ts
@@ -1196,11 +1314,12 @@ const AlephaReact = $module({
1196
1314
  ReactPageProvider,
1197
1315
  ReactBrowserRouterProvider,
1198
1316
  ReactBrowserProvider,
1199
- ReactRouter
1317
+ ReactRouter,
1318
+ ReactBrowserRendererProvider
1200
1319
  ],
1201
- register: (alepha) => alepha.with(AlephaServer).with(AlephaServerLinks).with(ReactPageProvider).with(ReactBrowserProvider).with(ReactBrowserRouterProvider).with(ReactRouter)
1320
+ register: (alepha) => alepha.with(AlephaServer).with(AlephaServerLinks).with(ReactPageProvider).with(ReactBrowserProvider).with(ReactBrowserRouterProvider).with(ReactBrowserRendererProvider).with(ReactRouter)
1202
1321
  });
1203
1322
 
1204
1323
  //#endregion
1205
- 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 };
1324
+ export { $page, AlephaContext, AlephaReact, ClientOnly, ErrorBoundary, ErrorViewer, 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 };
1206
1325
  //# sourceMappingURL=index.browser.js.map