@alepha/react 0.9.4 → 0.10.0

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/dist/index.cjs CHANGED
@@ -33,12 +33,96 @@ const node_path = __toESM(require("node:path"));
33
33
  const __alepha_server_static = __toESM(require("@alepha/server-static"));
34
34
  const react_dom_server = __toESM(require("react-dom/server"));
35
35
  const __alepha_datetime = __toESM(require("@alepha/datetime"));
36
- const react_dom_client = __toESM(require("react-dom/client"));
37
36
  const __alepha_router = __toESM(require("@alepha/router"));
38
37
 
39
38
  //#region src/descriptors/$page.ts
40
39
  /**
41
40
  * Main descriptor for defining a React route in the application.
41
+ *
42
+ * The $page descriptor is the core building block for creating type-safe, SSR-enabled React routes.
43
+ * It provides a declarative way to define pages with powerful features:
44
+ *
45
+ * **Routing & Navigation**
46
+ * - URL pattern matching with parameters (e.g., `/users/:id`)
47
+ * - Nested routing with parent-child relationships
48
+ * - Type-safe URL parameter and query string validation
49
+ *
50
+ * **Data Loading**
51
+ * - Server-side data fetching with the `resolve` function
52
+ * - Automatic serialization and hydration for SSR
53
+ * - Access to request context, URL params, and parent data
54
+ *
55
+ * **Component Loading**
56
+ * - Direct component rendering or lazy loading for code splitting
57
+ * - Client-only rendering when browser APIs are needed
58
+ * - Automatic fallback handling during hydration
59
+ *
60
+ * **Performance Optimization**
61
+ * - Static generation for pre-rendered pages at build time
62
+ * - Server-side caching with configurable TTL and providers
63
+ * - Code splitting through lazy component loading
64
+ *
65
+ * **Error Handling**
66
+ * - Custom error handlers with support for redirects
67
+ * - Hierarchical error handling (child → parent)
68
+ * - HTTP status code handling (404, 401, etc.)
69
+ *
70
+ * **Page Animations**
71
+ * - CSS-based enter/exit animations
72
+ * - Dynamic animations based on page state
73
+ * - Custom timing and easing functions
74
+ *
75
+ * **Lifecycle Management**
76
+ * - Server response hooks for headers and status codes
77
+ * - Page leave handlers for cleanup (browser only)
78
+ * - Permission-based access control
79
+ *
80
+ * @example Simple page with data fetching
81
+ * ```typescript
82
+ * const userProfile = $page({
83
+ * path: "/users/:id",
84
+ * schema: {
85
+ * params: t.object({ id: t.int() }),
86
+ * query: t.object({ tab: t.optional(t.string()) })
87
+ * },
88
+ * resolve: async ({ params }) => {
89
+ * const user = await userApi.getUser(params.id);
90
+ * return { user };
91
+ * },
92
+ * lazy: () => import("./UserProfile.tsx")
93
+ * });
94
+ * ```
95
+ *
96
+ * @example Nested routing with error handling
97
+ * ```typescript
98
+ * const projectSection = $page({
99
+ * path: "/projects/:id",
100
+ * children: () => [projectBoard, projectSettings],
101
+ * resolve: async ({ params }) => {
102
+ * const project = await projectApi.get(params.id);
103
+ * return { project };
104
+ * },
105
+ * errorHandler: (error) => {
106
+ * if (HttpError.is(error, 404)) {
107
+ * return <ProjectNotFound />;
108
+ * }
109
+ * }
110
+ * });
111
+ * ```
112
+ *
113
+ * @example Static generation with caching
114
+ * ```typescript
115
+ * const blogPost = $page({
116
+ * path: "/blog/:slug",
117
+ * static: {
118
+ * entries: posts.map(p => ({ params: { slug: p.slug } }))
119
+ * },
120
+ * resolve: async ({ params }) => {
121
+ * const post = await loadPost(params.slug);
122
+ * return { post };
123
+ * }
124
+ * });
125
+ * ```
42
126
  */
43
127
  const $page = (options) => {
44
128
  return (0, __alepha_core.createDescriptor)(PageDescriptor, options);
@@ -58,7 +142,10 @@ var PageDescriptor = class extends __alepha_core.Descriptor {
58
142
  * Only valid for server-side rendering, it will throw an error if called on the client-side.
59
143
  */
60
144
  async render(options) {
61
- throw new Error("render method is not implemented in this environment");
145
+ throw new __alepha_core.AlephaError("render() method is not implemented in this environment");
146
+ }
147
+ async fetch(options) {
148
+ throw new __alepha_core.AlephaError("fetch() method is not implemented in this environment");
62
149
  }
63
150
  match(url) {
64
151
  return false;
@@ -87,7 +174,6 @@ const ClientOnly = (props) => {
87
174
  if (props.disabled) return props.children;
88
175
  return mounted ? props.children : props.fallback;
89
176
  };
90
- var ClientOnly_default = ClientOnly;
91
177
 
92
178
  //#endregion
93
179
  //#region src/components/ErrorViewer.tsx
@@ -195,7 +281,6 @@ const ErrorViewer = ({ error, alepha }) => {
195
281
  })] })]
196
282
  });
197
283
  };
198
- var ErrorViewer_default = ErrorViewer;
199
284
  const ErrorViewerProduction = () => {
200
285
  const styles = {
201
286
  container: {
@@ -271,7 +356,7 @@ const AlephaContext = (0, react.createContext)(void 0);
271
356
  *
272
357
  * - alepha.state() for state management
273
358
  * - alepha.inject() for dependency injection
274
- * - alepha.emit() for event handling
359
+ * - alepha.events.emit() for event handling
275
360
  * etc...
276
361
  */
277
362
  const useAlepha = () => {
@@ -289,19 +374,55 @@ const useRouterEvents = (opts = {}, deps = []) => {
289
374
  const alepha = useAlepha();
290
375
  (0, react.useEffect)(() => {
291
376
  if (!alepha.isBrowser()) return;
377
+ const cb = (callback) => {
378
+ if (typeof callback === "function") return { callback };
379
+ return callback;
380
+ };
292
381
  const subs = [];
293
382
  const onBegin = opts.onBegin;
294
383
  const onEnd = opts.onEnd;
295
384
  const onError = opts.onError;
296
- if (onBegin) subs.push(alepha.on("react:transition:begin", { callback: onBegin }));
297
- if (onEnd) subs.push(alepha.on("react:transition:end", { callback: onEnd }));
298
- if (onError) subs.push(alepha.on("react:transition:error", { callback: onError }));
385
+ const onSuccess = opts.onSuccess;
386
+ if (onBegin) subs.push(alepha.events.on("react:transition:begin", cb(onBegin)));
387
+ if (onEnd) subs.push(alepha.events.on("react:transition:end", cb(onEnd)));
388
+ if (onError) subs.push(alepha.events.on("react:transition:error", cb(onError)));
389
+ if (onSuccess) subs.push(alepha.events.on("react:transition:success", cb(onSuccess)));
299
390
  return () => {
300
391
  for (const sub of subs) sub();
301
392
  };
302
393
  }, deps);
303
394
  };
304
395
 
396
+ //#endregion
397
+ //#region src/hooks/useStore.ts
398
+ /**
399
+ * Hook to access and mutate the Alepha state.
400
+ */
401
+ const useStore = (key, defaultValue) => {
402
+ const alepha = useAlepha();
403
+ (0, react.useMemo)(() => {
404
+ if (defaultValue != null && alepha.state.get(key) == null) alepha.state.set(key, defaultValue);
405
+ }, [defaultValue]);
406
+ const [state, setState] = (0, react.useState)(alepha.state.get(key));
407
+ (0, react.useEffect)(() => {
408
+ if (!alepha.isBrowser()) return;
409
+ return alepha.events.on("state:mutate", (ev) => {
410
+ if (ev.key === key) setState(ev.value);
411
+ });
412
+ }, []);
413
+ return [state, (value) => {
414
+ alepha.state.set(key, value);
415
+ }];
416
+ };
417
+
418
+ //#endregion
419
+ //#region src/hooks/useRouterState.ts
420
+ const useRouterState = () => {
421
+ const [state] = useStore("react.router.state");
422
+ if (!state) throw new __alepha_core.AlephaError("Missing react router state");
423
+ return state;
424
+ };
425
+
305
426
  //#endregion
306
427
  //#region src/components/ErrorBoundary.tsx
307
428
  /**
@@ -331,7 +452,6 @@ var ErrorBoundary = class extends react.default.Component {
331
452
  return this.props.children;
332
453
  }
333
454
  };
334
- var ErrorBoundary_default = ErrorBoundary;
335
455
 
336
456
  //#endregion
337
457
  //#region src/components/NestedView.tsx
@@ -342,7 +462,7 @@ var ErrorBoundary_default = ErrorBoundary;
342
462
  *
343
463
  * @example
344
464
  * ```tsx
345
- * import { NestedView } from "@alepha/react";
465
+ * import { NestedView } from "alepha/react";
346
466
  *
347
467
  * class App {
348
468
  * parent = $page({
@@ -357,17 +477,69 @@ var ErrorBoundary_default = ErrorBoundary;
357
477
  * ```
358
478
  */
359
479
  const NestedView = (props) => {
360
- const layer = (0, react.useContext)(RouterLayerContext);
361
- const index = layer?.index ?? 0;
362
- const alepha = useAlepha();
363
- const state = alepha.state("react.router.state");
364
- if (!state) throw new Error("<NestedView/> must be used inside a RouterLayerContext.");
480
+ const index = (0, react.use)(RouterLayerContext)?.index ?? 0;
481
+ const state = useRouterState();
365
482
  const [view, setView] = (0, react.useState)(state.layers[index]?.element);
366
- useRouterEvents({ onEnd: ({ state: state$1 }) => {
367
- if (!state$1.layers[index]?.cache) setView(state$1.layers[index]?.element);
368
- } }, []);
369
- const element = view ?? props.children ?? null;
370
- return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ErrorBoundary_default, {
483
+ const [animation, setAnimation] = (0, react.useState)("");
484
+ const animationExitDuration = (0, react.useRef)(0);
485
+ const animationExitNow = (0, react.useRef)(0);
486
+ useRouterEvents({
487
+ onBegin: async ({ previous, state: state$1 }) => {
488
+ const layer = previous.layers[index];
489
+ if (`${state$1.url.pathname}/`.startsWith(`${layer?.path}/`)) return;
490
+ const animationExit = parseAnimation(layer.route?.animation, state$1, "exit");
491
+ if (animationExit) {
492
+ const duration = animationExit.duration || 200;
493
+ animationExitNow.current = Date.now();
494
+ animationExitDuration.current = duration;
495
+ setAnimation(animationExit.animation);
496
+ } else {
497
+ animationExitNow.current = 0;
498
+ animationExitDuration.current = 0;
499
+ setAnimation("");
500
+ }
501
+ },
502
+ onEnd: async ({ state: state$1 }) => {
503
+ const layer = state$1.layers[index];
504
+ if (animationExitNow.current) {
505
+ const duration = animationExitDuration.current;
506
+ const diff = Date.now() - animationExitNow.current;
507
+ if (diff < duration) await new Promise((resolve) => setTimeout(resolve, duration - diff));
508
+ }
509
+ if (!layer?.cache) {
510
+ setView(layer?.element);
511
+ const animationEnter = parseAnimation(layer?.route?.animation, state$1, "enter");
512
+ if (animationEnter) setAnimation(animationEnter.animation);
513
+ else setAnimation("");
514
+ }
515
+ }
516
+ }, []);
517
+ let element = view ?? props.children ?? null;
518
+ if (animation) element = /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
519
+ style: {
520
+ display: "flex",
521
+ flex: 1,
522
+ height: "100%",
523
+ width: "100%",
524
+ position: "relative",
525
+ overflow: "hidden"
526
+ },
527
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
528
+ style: {
529
+ height: "100%",
530
+ width: "100%",
531
+ display: "flex",
532
+ animation
533
+ },
534
+ children: element
535
+ })
536
+ });
537
+ if (props.errorBoundary === false) return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(react_jsx_runtime.Fragment, { children: element });
538
+ if (props.errorBoundary) return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ErrorBoundary, {
539
+ fallback: props.errorBoundary,
540
+ children: element
541
+ });
542
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ErrorBoundary, {
371
543
  fallback: (error) => {
372
544
  const result = state.onError(error, state);
373
545
  if (result instanceof Redirection) return "Redirection inside ErrorBoundary is not allowed.";
@@ -376,7 +548,37 @@ const NestedView = (props) => {
376
548
  children: element
377
549
  });
378
550
  };
379
- var NestedView_default = NestedView;
551
+ var NestedView_default = (0, react.memo)(NestedView);
552
+ function parseAnimation(animationLike, state, type = "enter") {
553
+ if (!animationLike) return void 0;
554
+ const DEFAULT_DURATION = 300;
555
+ const animation = typeof animationLike === "function" ? animationLike(state) : animationLike;
556
+ if (typeof animation === "string") {
557
+ if (type === "exit") return;
558
+ return {
559
+ duration: DEFAULT_DURATION,
560
+ animation: `${DEFAULT_DURATION}ms ${animation}`
561
+ };
562
+ }
563
+ if (typeof animation === "object") {
564
+ const anim = animation[type];
565
+ const duration = typeof anim === "object" ? anim.duration ?? DEFAULT_DURATION : DEFAULT_DURATION;
566
+ const name = typeof anim === "object" ? anim.name : anim;
567
+ if (type === "exit") {
568
+ const timing$1 = typeof anim === "object" ? anim.timing ?? "" : "";
569
+ return {
570
+ duration,
571
+ animation: `${duration}ms ${timing$1} ${name}`
572
+ };
573
+ }
574
+ const timing = typeof anim === "object" ? anim.timing ?? "" : "";
575
+ return {
576
+ duration,
577
+ animation: `${duration}ms ${timing} ${name}`
578
+ };
579
+ }
580
+ return void 0;
581
+ }
380
582
 
381
583
  //#endregion
382
584
  //#region src/components/NotFound.tsx
@@ -442,6 +644,14 @@ var ReactPageProvider = class {
442
644
  if (this.env.REACT_STRICT_MODE) return (0, react.createElement)(react.StrictMode, {}, root);
443
645
  return root;
444
646
  }
647
+ convertStringObjectToObject = (schema, value) => {
648
+ if (__alepha_core.t.schema.isObject(schema) && typeof value === "object") {
649
+ for (const key in schema.properties) if (__alepha_core.t.schema.isObject(schema.properties[key]) && typeof value[key] === "string") try {
650
+ value[key] = this.alepha.parse(schema.properties[key], decodeURIComponent(value[key]));
651
+ } catch (e) {}
652
+ }
653
+ return value;
654
+ };
445
655
  /**
446
656
  * Create a new RouterState based on a given route and request.
447
657
  * This method resolves the layers for the route, applying any query and params schemas defined in the route.
@@ -461,6 +671,7 @@ var ReactPageProvider = class {
461
671
  const route$1 = it.route;
462
672
  const config = {};
463
673
  try {
674
+ this.convertStringObjectToObject(route$1.schema?.query, state.query);
464
675
  config.query = route$1.schema?.query ? this.alepha.parse(route$1.schema.query, state.query) : {};
465
676
  } catch (e) {
466
677
  it.error = e;
@@ -587,6 +798,7 @@ var ReactPageProvider = class {
587
798
  }
588
799
  }
589
800
  async createElement(page, props) {
801
+ if (page.lazy && page.component) this.log.warn(`Page ${page.name} has both lazy and component options, lazy will be used`);
590
802
  if (page.lazy) {
591
803
  const component = await page.lazy();
592
804
  return (0, react.createElement)(component.default, props);
@@ -595,7 +807,7 @@ var ReactPageProvider = class {
595
807
  return void 0;
596
808
  }
597
809
  renderError(error) {
598
- return (0, react.createElement)(ErrorViewer_default, {
810
+ return (0, react.createElement)(ErrorViewer, {
599
811
  error,
600
812
  alepha: this.alepha
601
813
  });
@@ -621,7 +833,7 @@ var ReactPageProvider = class {
621
833
  }
622
834
  renderView(index, path, view, page) {
623
835
  view ??= this.renderEmptyView();
624
- const element = page.client ? (0, react.createElement)(ClientOnly_default, typeof page.client === "object" ? page.client : {}, view) : view;
836
+ const element = page.client ? (0, react.createElement)(ClientOnly, typeof page.client === "object" ? page.client : {}, view) : view;
625
837
  return (0, react.createElement)(RouterLayerContext.Provider, { value: {
626
838
  index,
627
839
  path
@@ -715,18 +927,36 @@ var ReactServerProvider = class {
715
927
  log = (0, __alepha_logger.$logger)();
716
928
  alepha = (0, __alepha_core.$inject)(__alepha_core.Alepha);
717
929
  pageApi = (0, __alepha_core.$inject)(ReactPageProvider);
930
+ serverProvider = (0, __alepha_core.$inject)(__alepha_server.ServerProvider);
718
931
  serverStaticProvider = (0, __alepha_core.$inject)(__alepha_server_static.ServerStaticProvider);
719
932
  serverRouterProvider = (0, __alepha_core.$inject)(__alepha_server.ServerRouterProvider);
720
933
  serverTimingProvider = (0, __alepha_core.$inject)(__alepha_server.ServerTimingProvider);
721
934
  env = (0, __alepha_core.$env)(envSchema$1);
722
935
  ROOT_DIV_REGEX = new RegExp(`<div([^>]*)\\s+id=["']${this.env.REACT_ROOT_ID}["']([^>]*)>(.*?)<\\/div>`, "is");
936
+ preprocessedTemplate = null;
723
937
  onConfigure = (0, __alepha_core.$hook)({
724
938
  on: "configure",
725
939
  handler: async () => {
726
940
  const pages = this.alepha.descriptors($page);
727
941
  const ssrEnabled = pages.length > 0 && this.env.REACT_SSR_ENABLED !== false;
728
- this.alepha.state("react.server.ssr", ssrEnabled);
729
- for (const page of pages) page.render = this.createRenderFunction(page.name);
942
+ this.alepha.state.set("react.server.ssr", ssrEnabled);
943
+ for (const page of pages) {
944
+ page.render = this.createRenderFunction(page.name);
945
+ page.fetch = async (options) => {
946
+ const response = await fetch(`${this.serverProvider.hostname}/${page.pathname(options)}`);
947
+ const html = await response.text();
948
+ if (options?.html) return {
949
+ html,
950
+ response
951
+ };
952
+ const match = html.match(this.ROOT_DIV_REGEX);
953
+ if (match) return {
954
+ html: match[3],
955
+ response
956
+ };
957
+ throw new __alepha_core.AlephaError("Invalid HTML response");
958
+ };
959
+ }
730
960
  if (this.alepha.isServerless() === "vite") {
731
961
  await this.configureVite(ssrEnabled);
732
962
  return;
@@ -765,6 +995,8 @@ var ReactServerProvider = class {
765
995
  return this.alepha.env.REACT_SERVER_TEMPLATE ?? "<!DOCTYPE html><html lang='en'><head></head><body></body></html>";
766
996
  }
767
997
  async registerPages(templateLoader) {
998
+ const template = await templateLoader();
999
+ if (template) this.preprocessedTemplate = this.preprocessTemplate(template);
768
1000
  for (const page of this.pageApi.getPages()) {
769
1001
  if (page.children?.length) continue;
770
1002
  this.log.debug(`+ ${page.match} -> ${page.name}`);
@@ -806,27 +1038,37 @@ var ReactServerProvider = class {
806
1038
  params: options.params ?? {},
807
1039
  query: options.query ?? {},
808
1040
  onError: () => null,
809
- layers: []
1041
+ layers: [],
1042
+ meta: {}
810
1043
  };
811
1044
  const state = entry;
812
1045
  this.log.trace("Rendering", { url });
813
- await this.alepha.emit("react:server:render:begin", { state });
1046
+ await this.alepha.events.emit("react:server:render:begin", { state });
814
1047
  const { redirect } = await this.pageApi.createLayers(page, state);
815
- if (redirect) throw new __alepha_core.AlephaError("Redirection is not supported in this context");
1048
+ if (redirect) return {
1049
+ state,
1050
+ html: "",
1051
+ redirect
1052
+ };
816
1053
  if (!withIndex && !options.html) {
817
- this.alepha.state("react.router.state", state);
1054
+ this.alepha.state.set("react.router.state", state);
818
1055
  return {
819
1056
  state,
820
1057
  html: (0, react_dom_server.renderToString)(this.pageApi.root(state))
821
1058
  };
822
1059
  }
823
- const html = this.renderToHtml(this.template ?? "", state, options.hydration);
824
- if (html instanceof Redirection) throw new Error("Redirection is not supported in this context");
1060
+ const template = this.template ?? "";
1061
+ const html = this.renderToHtml(template, state, options.hydration);
1062
+ if (html instanceof Redirection) return {
1063
+ state,
1064
+ html: "",
1065
+ redirect
1066
+ };
825
1067
  const result = {
826
1068
  state,
827
1069
  html
828
1070
  };
829
- await this.alepha.emit("react:server:render:end", result);
1071
+ await this.alepha.events.emit("react:server:render:end", result);
830
1072
  return result;
831
1073
  };
832
1074
  }
@@ -844,7 +1086,7 @@ var ReactServerProvider = class {
844
1086
  layers: []
845
1087
  };
846
1088
  const state = entry;
847
- if (this.alepha.has(__alepha_server_links.ServerLinksProvider)) this.alepha.state("api", await this.alepha.inject(__alepha_server_links.ServerLinksProvider).getUserApiLinks({
1089
+ if (this.alepha.has(__alepha_server_links.ServerLinksProvider)) this.alepha.state.set("api", await this.alepha.inject(__alepha_server_links.ServerLinksProvider).getUserApiLinks({
848
1090
  user: serverRequest.user,
849
1091
  authorization: serverRequest.headers.authorization
850
1092
  }));
@@ -857,7 +1099,7 @@ var ReactServerProvider = class {
857
1099
  }
858
1100
  target = target.parent;
859
1101
  }
860
- await this.alepha.emit("react:server:render:begin", {
1102
+ await this.alepha.events.emit("react:server:render:begin", {
861
1103
  request: serverRequest,
862
1104
  state
863
1105
  });
@@ -879,7 +1121,7 @@ var ReactServerProvider = class {
879
1121
  state,
880
1122
  html
881
1123
  };
882
- await this.alepha.emit("react:server:render:end", event);
1124
+ await this.alepha.events.emit("react:server:render:end", event);
883
1125
  route.onServerResponse?.(serverRequest);
884
1126
  this.log.trace("Page rendered", { name: route.name });
885
1127
  return event.html;
@@ -887,7 +1129,7 @@ var ReactServerProvider = class {
887
1129
  }
888
1130
  renderToHtml(template, state, hydration = true) {
889
1131
  const element = this.pageApi.root(state);
890
- this.alepha.state("react.router.state", state);
1132
+ this.alepha.state.set("react.router.state", state);
891
1133
  this.serverTimingProvider.beginTiming("renderToString");
892
1134
  let app = "";
893
1135
  try {
@@ -925,18 +1167,48 @@ var ReactServerProvider = class {
925
1167
  }
926
1168
  return response.html;
927
1169
  }
928
- fillTemplate(response, app, script) {
929
- if (this.ROOT_DIV_REGEX.test(response.html)) response.html = response.html.replace(this.ROOT_DIV_REGEX, (_match, beforeId, afterId) => {
930
- return `<div${beforeId} id="${this.env.REACT_ROOT_ID}"${afterId}>${app}</div>`;
931
- });
932
- else {
933
- const bodyOpenTag = /<body([^>]*)>/i;
934
- if (bodyOpenTag.test(response.html)) response.html = response.html.replace(bodyOpenTag, (match) => {
935
- return `${match}<div id="${this.env.REACT_ROOT_ID}">${app}</div>`;
936
- });
1170
+ preprocessTemplate(template) {
1171
+ const bodyCloseMatch = template.match(/<\/body>/i);
1172
+ const bodyCloseIndex = bodyCloseMatch?.index ?? template.length;
1173
+ const beforeScript = template.substring(0, bodyCloseIndex);
1174
+ const afterScript = template.substring(bodyCloseIndex);
1175
+ const rootDivMatch = beforeScript.match(this.ROOT_DIV_REGEX);
1176
+ if (rootDivMatch) {
1177
+ const beforeDiv = beforeScript.substring(0, rootDivMatch.index);
1178
+ const afterDivStart = rootDivMatch.index + rootDivMatch[0].length;
1179
+ const afterDiv = beforeScript.substring(afterDivStart);
1180
+ const beforeApp = `${beforeDiv}<div${rootDivMatch[1]} id="${this.env.REACT_ROOT_ID}"${rootDivMatch[2]}>`;
1181
+ const afterApp = `</div>${afterDiv}`;
1182
+ return {
1183
+ beforeApp,
1184
+ afterApp,
1185
+ beforeScript: "",
1186
+ afterScript
1187
+ };
1188
+ }
1189
+ const bodyMatch = beforeScript.match(/<body([^>]*)>/i);
1190
+ if (bodyMatch) {
1191
+ const beforeBody = beforeScript.substring(0, bodyMatch.index + bodyMatch[0].length);
1192
+ const afterBody = beforeScript.substring(bodyMatch.index + bodyMatch[0].length);
1193
+ const beforeApp = `${beforeBody}<div id="${this.env.REACT_ROOT_ID}">`;
1194
+ const afterApp = `</div>${afterBody}`;
1195
+ return {
1196
+ beforeApp,
1197
+ afterApp,
1198
+ beforeScript: "",
1199
+ afterScript
1200
+ };
937
1201
  }
938
- const bodyCloseTagRegex = /<\/body>/i;
939
- if (bodyCloseTagRegex.test(response.html)) response.html = response.html.replace(bodyCloseTagRegex, `${script}</body>`);
1202
+ return {
1203
+ beforeApp: `<div id="${this.env.REACT_ROOT_ID}">`,
1204
+ afterApp: `</div>`,
1205
+ beforeScript,
1206
+ afterScript
1207
+ };
1208
+ }
1209
+ fillTemplate(response, app, script) {
1210
+ if (!this.preprocessedTemplate) this.preprocessedTemplate = this.preprocessTemplate(response.html);
1211
+ response.html = this.preprocessedTemplate.beforeApp + app + this.preprocessedTemplate.afterApp + script + this.preprocessedTemplate.afterScript;
940
1212
  }
941
1213
  };
942
1214
 
@@ -958,17 +1230,21 @@ var ReactBrowserRouterProvider = class extends __alepha_router.RouterProvider {
958
1230
  });
959
1231
  }
960
1232
  });
961
- async transition(url, previous = []) {
1233
+ async transition(url, previous = [], meta = {}) {
962
1234
  const { pathname, search } = url;
963
1235
  const entry = {
964
1236
  url,
965
1237
  query: {},
966
1238
  params: {},
967
1239
  layers: [],
968
- onError: () => null
1240
+ onError: () => null,
1241
+ meta
969
1242
  };
970
1243
  const state = entry;
971
- await this.alepha.emit("react:transition:begin", { state });
1244
+ await this.alepha.events.emit("react:transition:begin", {
1245
+ previous: this.alepha.state.get("react.router.state"),
1246
+ state
1247
+ });
972
1248
  try {
973
1249
  const { route, params } = this.match(pathname);
974
1250
  const query = {};
@@ -985,7 +1261,7 @@ var ReactBrowserRouterProvider = class extends __alepha_router.RouterProvider {
985
1261
  index: 0,
986
1262
  path: "/"
987
1263
  });
988
- await this.alepha.emit("react:transition:success", { state });
1264
+ await this.alepha.events.emit("react:transition:success", { state });
989
1265
  } catch (e) {
990
1266
  this.log.error("Transition has failed", e);
991
1267
  state.layers = [{
@@ -994,7 +1270,7 @@ var ReactBrowserRouterProvider = class extends __alepha_router.RouterProvider {
994
1270
  index: 0,
995
1271
  path: "/"
996
1272
  }];
997
- await this.alepha.emit("react:transition:error", {
1273
+ await this.alepha.events.emit("react:transition:error", {
998
1274
  error: e,
999
1275
  state
1000
1276
  });
@@ -1003,8 +1279,8 @@ var ReactBrowserRouterProvider = class extends __alepha_router.RouterProvider {
1003
1279
  const layer = previous[i];
1004
1280
  if (state.layers[i]?.name !== layer.name) this.pageApi.page(layer.name)?.onLeave?.();
1005
1281
  }
1006
- await this.alepha.emit("react:transition:end", { state });
1007
- this.alepha.state("react.router.state", state);
1282
+ this.alepha.state.set("react.router.state", state);
1283
+ await this.alepha.events.emit("react:transition:end", { state });
1008
1284
  }
1009
1285
  root(state) {
1010
1286
  return this.pageApi.root(state);
@@ -1021,7 +1297,6 @@ var ReactBrowserProvider = class {
1021
1297
  alepha = (0, __alepha_core.$inject)(__alepha_core.Alepha);
1022
1298
  router = (0, __alepha_core.$inject)(ReactBrowserRouterProvider);
1023
1299
  dateTimeProvider = (0, __alepha_core.$inject)(__alepha_datetime.DateTimeProvider);
1024
- root;
1025
1300
  options = { scrollRestoration: "top" };
1026
1301
  getRootElement() {
1027
1302
  const root = this.document.getElementById(this.env.REACT_ROOT_ID);
@@ -1033,7 +1308,7 @@ var ReactBrowserProvider = class {
1033
1308
  }
1034
1309
  transitioning;
1035
1310
  get state() {
1036
- return this.alepha.state("react.router.state");
1311
+ return this.alepha.state.get("react.router.state");
1037
1312
  }
1038
1313
  /**
1039
1314
  * Accessor for Document DOM API.
@@ -1097,7 +1372,8 @@ var ReactBrowserProvider = class {
1097
1372
  });
1098
1373
  await this.render({
1099
1374
  url,
1100
- previous: options.force ? [] : this.state.layers
1375
+ previous: options.force ? [] : this.state.layers,
1376
+ meta: options.meta
1101
1377
  });
1102
1378
  if (this.state.url.pathname + this.state.url.search !== url) {
1103
1379
  this.pushState(this.state.url.pathname + this.state.url.search);
@@ -1114,7 +1390,7 @@ var ReactBrowserProvider = class {
1114
1390
  from: this.state?.url.pathname
1115
1391
  };
1116
1392
  this.log.debug("Transitioning...", { to: url });
1117
- const redirect = await this.router.transition(new URL(`http://localhost${url}`), previous);
1393
+ const redirect = await this.router.transition(new URL(`http://localhost${url}`), previous, options.meta);
1118
1394
  if (redirect) {
1119
1395
  this.log.info("Redirecting to", { redirect });
1120
1396
  return await this.render({ url: redirect });
@@ -1136,7 +1412,7 @@ var ReactBrowserProvider = class {
1136
1412
  onTransitionEnd = (0, __alepha_core.$hook)({
1137
1413
  on: "react:transition:end",
1138
1414
  handler: () => {
1139
- if (this.options.scrollRestoration === "top" && typeof window !== "undefined") {
1415
+ if (this.options.scrollRestoration === "top" && typeof window !== "undefined" && !this.alepha.isTest()) {
1140
1416
  this.log.trace("Restoring scroll position to top");
1141
1417
  window.scrollTo(0, 0);
1142
1418
  }
@@ -1148,18 +1424,16 @@ var ReactBrowserProvider = class {
1148
1424
  const hydration = this.getHydrationState();
1149
1425
  const previous = hydration?.layers ?? [];
1150
1426
  if (hydration) {
1151
- for (const [key, value] of Object.entries(hydration)) if (key !== "layers") this.alepha.state(key, value);
1427
+ for (const [key, value] of Object.entries(hydration)) if (key !== "layers") this.alepha.state.set(key, value);
1152
1428
  }
1153
1429
  await this.render({ previous });
1154
1430
  const element = this.router.root(this.state);
1155
- if (hydration?.layers) {
1156
- this.root = (0, react_dom_client.hydrateRoot)(this.getRootElement(), element);
1157
- this.log.info("Hydrated root element");
1158
- } else {
1159
- this.root ??= (0, react_dom_client.createRoot)(this.getRootElement());
1160
- this.root.render(element);
1161
- this.log.info("Created root element");
1162
- }
1431
+ await this.alepha.events.emit("react:browser:render", {
1432
+ element,
1433
+ root: this.getRootElement(),
1434
+ hydration,
1435
+ state: this.state
1436
+ });
1163
1437
  window.addEventListener("popstate", () => {
1164
1438
  if (this.base + this.state.url.pathname === this.location.pathname) return;
1165
1439
  this.log.debug("Popstate event triggered - rendering new state", { url: this.location.pathname + this.location.search });
@@ -1175,7 +1449,7 @@ var ReactRouter = class {
1175
1449
  alepha = (0, __alepha_core.$inject)(__alepha_core.Alepha);
1176
1450
  pageApi = (0, __alepha_core.$inject)(ReactPageProvider);
1177
1451
  get state() {
1178
- return this.alepha.state("react.router.state");
1452
+ return this.alepha.state.get("react.router.state");
1179
1453
  }
1180
1454
  get pages() {
1181
1455
  return this.pageApi.getPages();
@@ -1298,44 +1572,12 @@ const useRouter = () => {
1298
1572
  //#region src/components/Link.tsx
1299
1573
  const Link = (props) => {
1300
1574
  const router = useRouter();
1301
- const { to,...anchorProps } = props;
1302
1575
  return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("a", {
1303
- ...router.anchor(to),
1304
- ...anchorProps,
1576
+ ...props,
1577
+ ...router.anchor(props.href),
1305
1578
  children: props.children
1306
1579
  });
1307
1580
  };
1308
- var Link_default = Link;
1309
-
1310
- //#endregion
1311
- //#region src/hooks/useStore.ts
1312
- /**
1313
- * Hook to access and mutate the Alepha state.
1314
- */
1315
- const useStore = (key, defaultValue) => {
1316
- const alepha = useAlepha();
1317
- (0, react.useMemo)(() => {
1318
- if (defaultValue != null && alepha.state(key) == null) alepha.state(key, defaultValue);
1319
- }, [defaultValue]);
1320
- const [state, setState] = (0, react.useState)(alepha.state(key));
1321
- (0, react.useEffect)(() => {
1322
- if (!alepha.isBrowser()) return;
1323
- return alepha.on("state:mutate", (ev) => {
1324
- if (ev.key === key) setState(ev.value);
1325
- });
1326
- }, []);
1327
- return [state, (value) => {
1328
- alepha.state(key, value);
1329
- }];
1330
- };
1331
-
1332
- //#endregion
1333
- //#region src/hooks/useRouterState.ts
1334
- const useRouterState = () => {
1335
- const [state] = useStore("react.router.state");
1336
- if (!state) throw new __alepha_core.AlephaError("Missing react router state");
1337
- return state;
1338
- };
1339
1581
 
1340
1582
  //#endregion
1341
1583
  //#region src/hooks/useActive.ts
@@ -1393,7 +1635,7 @@ const useQueryParams = (schema, options = {}) => {
1393
1635
  const key = options.key ?? "q";
1394
1636
  const router = useRouter();
1395
1637
  const querystring = router.query[key];
1396
- const [queryParams, setQueryParams] = (0, react.useState)(decode(alepha, schema, router.query[key]));
1638
+ const [queryParams = {}, setQueryParams] = (0, react.useState)(decode(alepha, schema, router.query[key]));
1397
1639
  (0, react.useEffect)(() => {
1398
1640
  setQueryParams(decode(alepha, schema, querystring));
1399
1641
  }, [querystring]);
@@ -1413,8 +1655,8 @@ const encode = (alepha, schema, data) => {
1413
1655
  const decode = (alepha, schema, data) => {
1414
1656
  try {
1415
1657
  return alepha.parse(schema, JSON.parse(atob(decodeURIComponent(data))));
1416
- } catch (_error) {
1417
- return {};
1658
+ } catch {
1659
+ return;
1418
1660
  }
1419
1661
  };
1420
1662
 
@@ -1480,10 +1722,10 @@ const AlephaReact = (0, __alepha_core.$module)({
1480
1722
  exports.$page = $page;
1481
1723
  exports.AlephaContext = AlephaContext;
1482
1724
  exports.AlephaReact = AlephaReact;
1483
- exports.ClientOnly = ClientOnly_default;
1484
- exports.ErrorBoundary = ErrorBoundary_default;
1485
- exports.ErrorViewer = ErrorViewer_default;
1486
- exports.Link = Link_default;
1725
+ exports.ClientOnly = ClientOnly;
1726
+ exports.ErrorBoundary = ErrorBoundary;
1727
+ exports.ErrorViewer = ErrorViewer;
1728
+ exports.Link = Link;
1487
1729
  exports.NestedView = NestedView_default;
1488
1730
  exports.NotFound = NotFoundPage;
1489
1731
  exports.PageDescriptor = PageDescriptor;