@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.js CHANGED
@@ -1,21 +1,105 @@
1
1
  import { $env, $hook, $inject, $module, Alepha, AlephaError, Descriptor, KIND, createDescriptor, t } from "@alepha/core";
2
- import { AlephaServer, HttpClient, ServerRouterProvider, ServerTimingProvider } from "@alepha/server";
2
+ import { AlephaServer, HttpClient, ServerProvider, ServerRouterProvider, ServerTimingProvider } from "@alepha/server";
3
3
  import { AlephaServerCache } from "@alepha/server-cache";
4
4
  import { AlephaServerLinks, LinkProvider, ServerLinksProvider } from "@alepha/server-links";
5
5
  import { $logger } from "@alepha/logger";
6
- import React, { StrictMode, createContext, createElement, useContext, useEffect, useMemo, useState } from "react";
7
- import { jsx, jsxs } from "react/jsx-runtime";
6
+ import React, { StrictMode, createContext, createElement, memo, use, useContext, useEffect, useMemo, useRef, useState } from "react";
7
+ import { Fragment, jsx, jsxs } from "react/jsx-runtime";
8
8
  import { existsSync } from "node:fs";
9
9
  import { join } from "node:path";
10
10
  import { ServerStaticProvider } from "@alepha/server-static";
11
11
  import { renderToString } from "react-dom/server";
12
12
  import { DateTimeProvider } from "@alepha/datetime";
13
- import { createRoot, hydrateRoot } from "react-dom/client";
14
13
  import { RouterProvider } from "@alepha/router";
15
14
 
16
15
  //#region src/descriptors/$page.ts
17
16
  /**
18
17
  * Main descriptor for defining a React route in the application.
18
+ *
19
+ * The $page descriptor is the core building block for creating type-safe, SSR-enabled React routes.
20
+ * It provides a declarative way to define pages with powerful features:
21
+ *
22
+ * **Routing & Navigation**
23
+ * - URL pattern matching with parameters (e.g., `/users/:id`)
24
+ * - Nested routing with parent-child relationships
25
+ * - Type-safe URL parameter and query string validation
26
+ *
27
+ * **Data Loading**
28
+ * - Server-side data fetching with the `resolve` function
29
+ * - Automatic serialization and hydration for SSR
30
+ * - Access to request context, URL params, and parent data
31
+ *
32
+ * **Component Loading**
33
+ * - Direct component rendering or lazy loading for code splitting
34
+ * - Client-only rendering when browser APIs are needed
35
+ * - Automatic fallback handling during hydration
36
+ *
37
+ * **Performance Optimization**
38
+ * - Static generation for pre-rendered pages at build time
39
+ * - Server-side caching with configurable TTL and providers
40
+ * - Code splitting through lazy component loading
41
+ *
42
+ * **Error Handling**
43
+ * - Custom error handlers with support for redirects
44
+ * - Hierarchical error handling (child → parent)
45
+ * - HTTP status code handling (404, 401, etc.)
46
+ *
47
+ * **Page Animations**
48
+ * - CSS-based enter/exit animations
49
+ * - Dynamic animations based on page state
50
+ * - Custom timing and easing functions
51
+ *
52
+ * **Lifecycle Management**
53
+ * - Server response hooks for headers and status codes
54
+ * - Page leave handlers for cleanup (browser only)
55
+ * - Permission-based access control
56
+ *
57
+ * @example Simple page with data fetching
58
+ * ```typescript
59
+ * const userProfile = $page({
60
+ * path: "/users/:id",
61
+ * schema: {
62
+ * params: t.object({ id: t.int() }),
63
+ * query: t.object({ tab: t.optional(t.string()) })
64
+ * },
65
+ * resolve: async ({ params }) => {
66
+ * const user = await userApi.getUser(params.id);
67
+ * return { user };
68
+ * },
69
+ * lazy: () => import("./UserProfile.tsx")
70
+ * });
71
+ * ```
72
+ *
73
+ * @example Nested routing with error handling
74
+ * ```typescript
75
+ * const projectSection = $page({
76
+ * path: "/projects/:id",
77
+ * children: () => [projectBoard, projectSettings],
78
+ * resolve: async ({ params }) => {
79
+ * const project = await projectApi.get(params.id);
80
+ * return { project };
81
+ * },
82
+ * errorHandler: (error) => {
83
+ * if (HttpError.is(error, 404)) {
84
+ * return <ProjectNotFound />;
85
+ * }
86
+ * }
87
+ * });
88
+ * ```
89
+ *
90
+ * @example Static generation with caching
91
+ * ```typescript
92
+ * const blogPost = $page({
93
+ * path: "/blog/:slug",
94
+ * static: {
95
+ * entries: posts.map(p => ({ params: { slug: p.slug } }))
96
+ * },
97
+ * resolve: async ({ params }) => {
98
+ * const post = await loadPost(params.slug);
99
+ * return { post };
100
+ * }
101
+ * });
102
+ * ```
19
103
  */
20
104
  const $page = (options) => {
21
105
  return createDescriptor(PageDescriptor, options);
@@ -35,7 +119,10 @@ var PageDescriptor = class extends Descriptor {
35
119
  * Only valid for server-side rendering, it will throw an error if called on the client-side.
36
120
  */
37
121
  async render(options) {
38
- throw new Error("render method is not implemented in this environment");
122
+ throw new AlephaError("render() method is not implemented in this environment");
123
+ }
124
+ async fetch(options) {
125
+ throw new AlephaError("fetch() method is not implemented in this environment");
39
126
  }
40
127
  match(url) {
41
128
  return false;
@@ -64,7 +151,6 @@ const ClientOnly = (props) => {
64
151
  if (props.disabled) return props.children;
65
152
  return mounted ? props.children : props.fallback;
66
153
  };
67
- var ClientOnly_default = ClientOnly;
68
154
 
69
155
  //#endregion
70
156
  //#region src/components/ErrorViewer.tsx
@@ -172,7 +258,6 @@ const ErrorViewer = ({ error, alepha }) => {
172
258
  })] })]
173
259
  });
174
260
  };
175
- var ErrorViewer_default = ErrorViewer;
176
261
  const ErrorViewerProduction = () => {
177
262
  const styles = {
178
263
  container: {
@@ -248,7 +333,7 @@ const AlephaContext = createContext(void 0);
248
333
  *
249
334
  * - alepha.state() for state management
250
335
  * - alepha.inject() for dependency injection
251
- * - alepha.emit() for event handling
336
+ * - alepha.events.emit() for event handling
252
337
  * etc...
253
338
  */
254
339
  const useAlepha = () => {
@@ -266,19 +351,55 @@ const useRouterEvents = (opts = {}, deps = []) => {
266
351
  const alepha = useAlepha();
267
352
  useEffect(() => {
268
353
  if (!alepha.isBrowser()) return;
354
+ const cb = (callback) => {
355
+ if (typeof callback === "function") return { callback };
356
+ return callback;
357
+ };
269
358
  const subs = [];
270
359
  const onBegin = opts.onBegin;
271
360
  const onEnd = opts.onEnd;
272
361
  const onError = opts.onError;
273
- if (onBegin) subs.push(alepha.on("react:transition:begin", { callback: onBegin }));
274
- if (onEnd) subs.push(alepha.on("react:transition:end", { callback: onEnd }));
275
- if (onError) subs.push(alepha.on("react:transition:error", { callback: onError }));
362
+ const onSuccess = opts.onSuccess;
363
+ if (onBegin) subs.push(alepha.events.on("react:transition:begin", cb(onBegin)));
364
+ if (onEnd) subs.push(alepha.events.on("react:transition:end", cb(onEnd)));
365
+ if (onError) subs.push(alepha.events.on("react:transition:error", cb(onError)));
366
+ if (onSuccess) subs.push(alepha.events.on("react:transition:success", cb(onSuccess)));
276
367
  return () => {
277
368
  for (const sub of subs) sub();
278
369
  };
279
370
  }, deps);
280
371
  };
281
372
 
373
+ //#endregion
374
+ //#region src/hooks/useStore.ts
375
+ /**
376
+ * Hook to access and mutate the Alepha state.
377
+ */
378
+ const useStore = (key, defaultValue) => {
379
+ const alepha = useAlepha();
380
+ useMemo(() => {
381
+ if (defaultValue != null && alepha.state.get(key) == null) alepha.state.set(key, defaultValue);
382
+ }, [defaultValue]);
383
+ const [state, setState] = useState(alepha.state.get(key));
384
+ useEffect(() => {
385
+ if (!alepha.isBrowser()) return;
386
+ return alepha.events.on("state:mutate", (ev) => {
387
+ if (ev.key === key) setState(ev.value);
388
+ });
389
+ }, []);
390
+ return [state, (value) => {
391
+ alepha.state.set(key, value);
392
+ }];
393
+ };
394
+
395
+ //#endregion
396
+ //#region src/hooks/useRouterState.ts
397
+ const useRouterState = () => {
398
+ const [state] = useStore("react.router.state");
399
+ if (!state) throw new AlephaError("Missing react router state");
400
+ return state;
401
+ };
402
+
282
403
  //#endregion
283
404
  //#region src/components/ErrorBoundary.tsx
284
405
  /**
@@ -308,7 +429,6 @@ var ErrorBoundary = class extends React.Component {
308
429
  return this.props.children;
309
430
  }
310
431
  };
311
- var ErrorBoundary_default = ErrorBoundary;
312
432
 
313
433
  //#endregion
314
434
  //#region src/components/NestedView.tsx
@@ -319,7 +439,7 @@ var ErrorBoundary_default = ErrorBoundary;
319
439
  *
320
440
  * @example
321
441
  * ```tsx
322
- * import { NestedView } from "@alepha/react";
442
+ * import { NestedView } from "alepha/react";
323
443
  *
324
444
  * class App {
325
445
  * parent = $page({
@@ -334,17 +454,69 @@ var ErrorBoundary_default = ErrorBoundary;
334
454
  * ```
335
455
  */
336
456
  const NestedView = (props) => {
337
- const layer = useContext(RouterLayerContext);
338
- const index = layer?.index ?? 0;
339
- const alepha = useAlepha();
340
- const state = alepha.state("react.router.state");
341
- if (!state) throw new Error("<NestedView/> must be used inside a RouterLayerContext.");
457
+ const index = use(RouterLayerContext)?.index ?? 0;
458
+ const state = useRouterState();
342
459
  const [view, setView] = useState(state.layers[index]?.element);
343
- useRouterEvents({ onEnd: ({ state: state$1 }) => {
344
- if (!state$1.layers[index]?.cache) setView(state$1.layers[index]?.element);
345
- } }, []);
346
- const element = view ?? props.children ?? null;
347
- return /* @__PURE__ */ jsx(ErrorBoundary_default, {
460
+ const [animation, setAnimation] = useState("");
461
+ const animationExitDuration = useRef(0);
462
+ const animationExitNow = useRef(0);
463
+ useRouterEvents({
464
+ onBegin: async ({ previous, state: state$1 }) => {
465
+ const layer = previous.layers[index];
466
+ if (`${state$1.url.pathname}/`.startsWith(`${layer?.path}/`)) return;
467
+ const animationExit = parseAnimation(layer.route?.animation, state$1, "exit");
468
+ if (animationExit) {
469
+ const duration = animationExit.duration || 200;
470
+ animationExitNow.current = Date.now();
471
+ animationExitDuration.current = duration;
472
+ setAnimation(animationExit.animation);
473
+ } else {
474
+ animationExitNow.current = 0;
475
+ animationExitDuration.current = 0;
476
+ setAnimation("");
477
+ }
478
+ },
479
+ onEnd: async ({ state: state$1 }) => {
480
+ const layer = state$1.layers[index];
481
+ if (animationExitNow.current) {
482
+ const duration = animationExitDuration.current;
483
+ const diff = Date.now() - animationExitNow.current;
484
+ if (diff < duration) await new Promise((resolve) => setTimeout(resolve, duration - diff));
485
+ }
486
+ if (!layer?.cache) {
487
+ setView(layer?.element);
488
+ const animationEnter = parseAnimation(layer?.route?.animation, state$1, "enter");
489
+ if (animationEnter) setAnimation(animationEnter.animation);
490
+ else setAnimation("");
491
+ }
492
+ }
493
+ }, []);
494
+ let element = view ?? props.children ?? null;
495
+ if (animation) element = /* @__PURE__ */ jsx("div", {
496
+ style: {
497
+ display: "flex",
498
+ flex: 1,
499
+ height: "100%",
500
+ width: "100%",
501
+ position: "relative",
502
+ overflow: "hidden"
503
+ },
504
+ children: /* @__PURE__ */ jsx("div", {
505
+ style: {
506
+ height: "100%",
507
+ width: "100%",
508
+ display: "flex",
509
+ animation
510
+ },
511
+ children: element
512
+ })
513
+ });
514
+ if (props.errorBoundary === false) return /* @__PURE__ */ jsx(Fragment, { children: element });
515
+ if (props.errorBoundary) return /* @__PURE__ */ jsx(ErrorBoundary, {
516
+ fallback: props.errorBoundary,
517
+ children: element
518
+ });
519
+ return /* @__PURE__ */ jsx(ErrorBoundary, {
348
520
  fallback: (error) => {
349
521
  const result = state.onError(error, state);
350
522
  if (result instanceof Redirection) return "Redirection inside ErrorBoundary is not allowed.";
@@ -353,7 +525,37 @@ const NestedView = (props) => {
353
525
  children: element
354
526
  });
355
527
  };
356
- var NestedView_default = NestedView;
528
+ var NestedView_default = memo(NestedView);
529
+ function parseAnimation(animationLike, state, type = "enter") {
530
+ if (!animationLike) return void 0;
531
+ const DEFAULT_DURATION = 300;
532
+ const animation = typeof animationLike === "function" ? animationLike(state) : animationLike;
533
+ if (typeof animation === "string") {
534
+ if (type === "exit") return;
535
+ return {
536
+ duration: DEFAULT_DURATION,
537
+ animation: `${DEFAULT_DURATION}ms ${animation}`
538
+ };
539
+ }
540
+ if (typeof animation === "object") {
541
+ const anim = animation[type];
542
+ const duration = typeof anim === "object" ? anim.duration ?? DEFAULT_DURATION : DEFAULT_DURATION;
543
+ const name = typeof anim === "object" ? anim.name : anim;
544
+ if (type === "exit") {
545
+ const timing$1 = typeof anim === "object" ? anim.timing ?? "" : "";
546
+ return {
547
+ duration,
548
+ animation: `${duration}ms ${timing$1} ${name}`
549
+ };
550
+ }
551
+ const timing = typeof anim === "object" ? anim.timing ?? "" : "";
552
+ return {
553
+ duration,
554
+ animation: `${duration}ms ${timing} ${name}`
555
+ };
556
+ }
557
+ return void 0;
558
+ }
357
559
 
358
560
  //#endregion
359
561
  //#region src/components/NotFound.tsx
@@ -419,6 +621,14 @@ var ReactPageProvider = class {
419
621
  if (this.env.REACT_STRICT_MODE) return createElement(StrictMode, {}, root);
420
622
  return root;
421
623
  }
624
+ convertStringObjectToObject = (schema, value) => {
625
+ if (t.schema.isObject(schema) && typeof value === "object") {
626
+ for (const key in schema.properties) if (t.schema.isObject(schema.properties[key]) && typeof value[key] === "string") try {
627
+ value[key] = this.alepha.parse(schema.properties[key], decodeURIComponent(value[key]));
628
+ } catch (e) {}
629
+ }
630
+ return value;
631
+ };
422
632
  /**
423
633
  * Create a new RouterState based on a given route and request.
424
634
  * This method resolves the layers for the route, applying any query and params schemas defined in the route.
@@ -438,6 +648,7 @@ var ReactPageProvider = class {
438
648
  const route$1 = it.route;
439
649
  const config = {};
440
650
  try {
651
+ this.convertStringObjectToObject(route$1.schema?.query, state.query);
441
652
  config.query = route$1.schema?.query ? this.alepha.parse(route$1.schema.query, state.query) : {};
442
653
  } catch (e) {
443
654
  it.error = e;
@@ -564,6 +775,7 @@ var ReactPageProvider = class {
564
775
  }
565
776
  }
566
777
  async createElement(page, props) {
778
+ if (page.lazy && page.component) this.log.warn(`Page ${page.name} has both lazy and component options, lazy will be used`);
567
779
  if (page.lazy) {
568
780
  const component = await page.lazy();
569
781
  return createElement(component.default, props);
@@ -572,7 +784,7 @@ var ReactPageProvider = class {
572
784
  return void 0;
573
785
  }
574
786
  renderError(error) {
575
- return createElement(ErrorViewer_default, {
787
+ return createElement(ErrorViewer, {
576
788
  error,
577
789
  alepha: this.alepha
578
790
  });
@@ -598,7 +810,7 @@ var ReactPageProvider = class {
598
810
  }
599
811
  renderView(index, path, view, page) {
600
812
  view ??= this.renderEmptyView();
601
- const element = page.client ? createElement(ClientOnly_default, typeof page.client === "object" ? page.client : {}, view) : view;
813
+ const element = page.client ? createElement(ClientOnly, typeof page.client === "object" ? page.client : {}, view) : view;
602
814
  return createElement(RouterLayerContext.Provider, { value: {
603
815
  index,
604
816
  path
@@ -692,18 +904,36 @@ var ReactServerProvider = class {
692
904
  log = $logger();
693
905
  alepha = $inject(Alepha);
694
906
  pageApi = $inject(ReactPageProvider);
907
+ serverProvider = $inject(ServerProvider);
695
908
  serverStaticProvider = $inject(ServerStaticProvider);
696
909
  serverRouterProvider = $inject(ServerRouterProvider);
697
910
  serverTimingProvider = $inject(ServerTimingProvider);
698
911
  env = $env(envSchema$1);
699
912
  ROOT_DIV_REGEX = new RegExp(`<div([^>]*)\\s+id=["']${this.env.REACT_ROOT_ID}["']([^>]*)>(.*?)<\\/div>`, "is");
913
+ preprocessedTemplate = null;
700
914
  onConfigure = $hook({
701
915
  on: "configure",
702
916
  handler: async () => {
703
917
  const pages = this.alepha.descriptors($page);
704
918
  const ssrEnabled = pages.length > 0 && this.env.REACT_SSR_ENABLED !== false;
705
- this.alepha.state("react.server.ssr", ssrEnabled);
706
- for (const page of pages) page.render = this.createRenderFunction(page.name);
919
+ this.alepha.state.set("react.server.ssr", ssrEnabled);
920
+ for (const page of pages) {
921
+ page.render = this.createRenderFunction(page.name);
922
+ page.fetch = async (options) => {
923
+ const response = await fetch(`${this.serverProvider.hostname}/${page.pathname(options)}`);
924
+ const html = await response.text();
925
+ if (options?.html) return {
926
+ html,
927
+ response
928
+ };
929
+ const match = html.match(this.ROOT_DIV_REGEX);
930
+ if (match) return {
931
+ html: match[3],
932
+ response
933
+ };
934
+ throw new AlephaError("Invalid HTML response");
935
+ };
936
+ }
707
937
  if (this.alepha.isServerless() === "vite") {
708
938
  await this.configureVite(ssrEnabled);
709
939
  return;
@@ -742,6 +972,8 @@ var ReactServerProvider = class {
742
972
  return this.alepha.env.REACT_SERVER_TEMPLATE ?? "<!DOCTYPE html><html lang='en'><head></head><body></body></html>";
743
973
  }
744
974
  async registerPages(templateLoader) {
975
+ const template = await templateLoader();
976
+ if (template) this.preprocessedTemplate = this.preprocessTemplate(template);
745
977
  for (const page of this.pageApi.getPages()) {
746
978
  if (page.children?.length) continue;
747
979
  this.log.debug(`+ ${page.match} -> ${page.name}`);
@@ -783,27 +1015,37 @@ var ReactServerProvider = class {
783
1015
  params: options.params ?? {},
784
1016
  query: options.query ?? {},
785
1017
  onError: () => null,
786
- layers: []
1018
+ layers: [],
1019
+ meta: {}
787
1020
  };
788
1021
  const state = entry;
789
1022
  this.log.trace("Rendering", { url });
790
- await this.alepha.emit("react:server:render:begin", { state });
1023
+ await this.alepha.events.emit("react:server:render:begin", { state });
791
1024
  const { redirect } = await this.pageApi.createLayers(page, state);
792
- if (redirect) throw new AlephaError("Redirection is not supported in this context");
1025
+ if (redirect) return {
1026
+ state,
1027
+ html: "",
1028
+ redirect
1029
+ };
793
1030
  if (!withIndex && !options.html) {
794
- this.alepha.state("react.router.state", state);
1031
+ this.alepha.state.set("react.router.state", state);
795
1032
  return {
796
1033
  state,
797
1034
  html: renderToString(this.pageApi.root(state))
798
1035
  };
799
1036
  }
800
- const html = this.renderToHtml(this.template ?? "", state, options.hydration);
801
- if (html instanceof Redirection) throw new Error("Redirection is not supported in this context");
1037
+ const template = this.template ?? "";
1038
+ const html = this.renderToHtml(template, state, options.hydration);
1039
+ if (html instanceof Redirection) return {
1040
+ state,
1041
+ html: "",
1042
+ redirect
1043
+ };
802
1044
  const result = {
803
1045
  state,
804
1046
  html
805
1047
  };
806
- await this.alepha.emit("react:server:render:end", result);
1048
+ await this.alepha.events.emit("react:server:render:end", result);
807
1049
  return result;
808
1050
  };
809
1051
  }
@@ -821,7 +1063,7 @@ var ReactServerProvider = class {
821
1063
  layers: []
822
1064
  };
823
1065
  const state = entry;
824
- if (this.alepha.has(ServerLinksProvider)) this.alepha.state("api", await this.alepha.inject(ServerLinksProvider).getUserApiLinks({
1066
+ if (this.alepha.has(ServerLinksProvider)) this.alepha.state.set("api", await this.alepha.inject(ServerLinksProvider).getUserApiLinks({
825
1067
  user: serverRequest.user,
826
1068
  authorization: serverRequest.headers.authorization
827
1069
  }));
@@ -834,7 +1076,7 @@ var ReactServerProvider = class {
834
1076
  }
835
1077
  target = target.parent;
836
1078
  }
837
- await this.alepha.emit("react:server:render:begin", {
1079
+ await this.alepha.events.emit("react:server:render:begin", {
838
1080
  request: serverRequest,
839
1081
  state
840
1082
  });
@@ -856,7 +1098,7 @@ var ReactServerProvider = class {
856
1098
  state,
857
1099
  html
858
1100
  };
859
- await this.alepha.emit("react:server:render:end", event);
1101
+ await this.alepha.events.emit("react:server:render:end", event);
860
1102
  route.onServerResponse?.(serverRequest);
861
1103
  this.log.trace("Page rendered", { name: route.name });
862
1104
  return event.html;
@@ -864,7 +1106,7 @@ var ReactServerProvider = class {
864
1106
  }
865
1107
  renderToHtml(template, state, hydration = true) {
866
1108
  const element = this.pageApi.root(state);
867
- this.alepha.state("react.router.state", state);
1109
+ this.alepha.state.set("react.router.state", state);
868
1110
  this.serverTimingProvider.beginTiming("renderToString");
869
1111
  let app = "";
870
1112
  try {
@@ -902,18 +1144,48 @@ var ReactServerProvider = class {
902
1144
  }
903
1145
  return response.html;
904
1146
  }
905
- fillTemplate(response, app, script) {
906
- if (this.ROOT_DIV_REGEX.test(response.html)) response.html = response.html.replace(this.ROOT_DIV_REGEX, (_match, beforeId, afterId) => {
907
- return `<div${beforeId} id="${this.env.REACT_ROOT_ID}"${afterId}>${app}</div>`;
908
- });
909
- else {
910
- const bodyOpenTag = /<body([^>]*)>/i;
911
- if (bodyOpenTag.test(response.html)) response.html = response.html.replace(bodyOpenTag, (match) => {
912
- return `${match}<div id="${this.env.REACT_ROOT_ID}">${app}</div>`;
913
- });
1147
+ preprocessTemplate(template) {
1148
+ const bodyCloseMatch = template.match(/<\/body>/i);
1149
+ const bodyCloseIndex = bodyCloseMatch?.index ?? template.length;
1150
+ const beforeScript = template.substring(0, bodyCloseIndex);
1151
+ const afterScript = template.substring(bodyCloseIndex);
1152
+ const rootDivMatch = beforeScript.match(this.ROOT_DIV_REGEX);
1153
+ if (rootDivMatch) {
1154
+ const beforeDiv = beforeScript.substring(0, rootDivMatch.index);
1155
+ const afterDivStart = rootDivMatch.index + rootDivMatch[0].length;
1156
+ const afterDiv = beforeScript.substring(afterDivStart);
1157
+ const beforeApp = `${beforeDiv}<div${rootDivMatch[1]} id="${this.env.REACT_ROOT_ID}"${rootDivMatch[2]}>`;
1158
+ const afterApp = `</div>${afterDiv}`;
1159
+ return {
1160
+ beforeApp,
1161
+ afterApp,
1162
+ beforeScript: "",
1163
+ afterScript
1164
+ };
1165
+ }
1166
+ const bodyMatch = beforeScript.match(/<body([^>]*)>/i);
1167
+ if (bodyMatch) {
1168
+ const beforeBody = beforeScript.substring(0, bodyMatch.index + bodyMatch[0].length);
1169
+ const afterBody = beforeScript.substring(bodyMatch.index + bodyMatch[0].length);
1170
+ const beforeApp = `${beforeBody}<div id="${this.env.REACT_ROOT_ID}">`;
1171
+ const afterApp = `</div>${afterBody}`;
1172
+ return {
1173
+ beforeApp,
1174
+ afterApp,
1175
+ beforeScript: "",
1176
+ afterScript
1177
+ };
914
1178
  }
915
- const bodyCloseTagRegex = /<\/body>/i;
916
- if (bodyCloseTagRegex.test(response.html)) response.html = response.html.replace(bodyCloseTagRegex, `${script}</body>`);
1179
+ return {
1180
+ beforeApp: `<div id="${this.env.REACT_ROOT_ID}">`,
1181
+ afterApp: `</div>`,
1182
+ beforeScript,
1183
+ afterScript
1184
+ };
1185
+ }
1186
+ fillTemplate(response, app, script) {
1187
+ if (!this.preprocessedTemplate) this.preprocessedTemplate = this.preprocessTemplate(response.html);
1188
+ response.html = this.preprocessedTemplate.beforeApp + app + this.preprocessedTemplate.afterApp + script + this.preprocessedTemplate.afterScript;
917
1189
  }
918
1190
  };
919
1191
 
@@ -935,17 +1207,21 @@ var ReactBrowserRouterProvider = class extends RouterProvider {
935
1207
  });
936
1208
  }
937
1209
  });
938
- async transition(url, previous = []) {
1210
+ async transition(url, previous = [], meta = {}) {
939
1211
  const { pathname, search } = url;
940
1212
  const entry = {
941
1213
  url,
942
1214
  query: {},
943
1215
  params: {},
944
1216
  layers: [],
945
- onError: () => null
1217
+ onError: () => null,
1218
+ meta
946
1219
  };
947
1220
  const state = entry;
948
- await this.alepha.emit("react:transition:begin", { state });
1221
+ await this.alepha.events.emit("react:transition:begin", {
1222
+ previous: this.alepha.state.get("react.router.state"),
1223
+ state
1224
+ });
949
1225
  try {
950
1226
  const { route, params } = this.match(pathname);
951
1227
  const query = {};
@@ -962,7 +1238,7 @@ var ReactBrowserRouterProvider = class extends RouterProvider {
962
1238
  index: 0,
963
1239
  path: "/"
964
1240
  });
965
- await this.alepha.emit("react:transition:success", { state });
1241
+ await this.alepha.events.emit("react:transition:success", { state });
966
1242
  } catch (e) {
967
1243
  this.log.error("Transition has failed", e);
968
1244
  state.layers = [{
@@ -971,7 +1247,7 @@ var ReactBrowserRouterProvider = class extends RouterProvider {
971
1247
  index: 0,
972
1248
  path: "/"
973
1249
  }];
974
- await this.alepha.emit("react:transition:error", {
1250
+ await this.alepha.events.emit("react:transition:error", {
975
1251
  error: e,
976
1252
  state
977
1253
  });
@@ -980,8 +1256,8 @@ var ReactBrowserRouterProvider = class extends RouterProvider {
980
1256
  const layer = previous[i];
981
1257
  if (state.layers[i]?.name !== layer.name) this.pageApi.page(layer.name)?.onLeave?.();
982
1258
  }
983
- await this.alepha.emit("react:transition:end", { state });
984
- this.alepha.state("react.router.state", state);
1259
+ this.alepha.state.set("react.router.state", state);
1260
+ await this.alepha.events.emit("react:transition:end", { state });
985
1261
  }
986
1262
  root(state) {
987
1263
  return this.pageApi.root(state);
@@ -998,7 +1274,6 @@ var ReactBrowserProvider = class {
998
1274
  alepha = $inject(Alepha);
999
1275
  router = $inject(ReactBrowserRouterProvider);
1000
1276
  dateTimeProvider = $inject(DateTimeProvider);
1001
- root;
1002
1277
  options = { scrollRestoration: "top" };
1003
1278
  getRootElement() {
1004
1279
  const root = this.document.getElementById(this.env.REACT_ROOT_ID);
@@ -1010,7 +1285,7 @@ var ReactBrowserProvider = class {
1010
1285
  }
1011
1286
  transitioning;
1012
1287
  get state() {
1013
- return this.alepha.state("react.router.state");
1288
+ return this.alepha.state.get("react.router.state");
1014
1289
  }
1015
1290
  /**
1016
1291
  * Accessor for Document DOM API.
@@ -1074,7 +1349,8 @@ var ReactBrowserProvider = class {
1074
1349
  });
1075
1350
  await this.render({
1076
1351
  url,
1077
- previous: options.force ? [] : this.state.layers
1352
+ previous: options.force ? [] : this.state.layers,
1353
+ meta: options.meta
1078
1354
  });
1079
1355
  if (this.state.url.pathname + this.state.url.search !== url) {
1080
1356
  this.pushState(this.state.url.pathname + this.state.url.search);
@@ -1091,7 +1367,7 @@ var ReactBrowserProvider = class {
1091
1367
  from: this.state?.url.pathname
1092
1368
  };
1093
1369
  this.log.debug("Transitioning...", { to: url });
1094
- const redirect = await this.router.transition(new URL(`http://localhost${url}`), previous);
1370
+ const redirect = await this.router.transition(new URL(`http://localhost${url}`), previous, options.meta);
1095
1371
  if (redirect) {
1096
1372
  this.log.info("Redirecting to", { redirect });
1097
1373
  return await this.render({ url: redirect });
@@ -1113,7 +1389,7 @@ var ReactBrowserProvider = class {
1113
1389
  onTransitionEnd = $hook({
1114
1390
  on: "react:transition:end",
1115
1391
  handler: () => {
1116
- if (this.options.scrollRestoration === "top" && typeof window !== "undefined") {
1392
+ if (this.options.scrollRestoration === "top" && typeof window !== "undefined" && !this.alepha.isTest()) {
1117
1393
  this.log.trace("Restoring scroll position to top");
1118
1394
  window.scrollTo(0, 0);
1119
1395
  }
@@ -1125,18 +1401,16 @@ var ReactBrowserProvider = class {
1125
1401
  const hydration = this.getHydrationState();
1126
1402
  const previous = hydration?.layers ?? [];
1127
1403
  if (hydration) {
1128
- for (const [key, value] of Object.entries(hydration)) if (key !== "layers") this.alepha.state(key, value);
1404
+ for (const [key, value] of Object.entries(hydration)) if (key !== "layers") this.alepha.state.set(key, value);
1129
1405
  }
1130
1406
  await this.render({ previous });
1131
1407
  const element = this.router.root(this.state);
1132
- if (hydration?.layers) {
1133
- this.root = hydrateRoot(this.getRootElement(), element);
1134
- this.log.info("Hydrated root element");
1135
- } else {
1136
- this.root ??= createRoot(this.getRootElement());
1137
- this.root.render(element);
1138
- this.log.info("Created root element");
1139
- }
1408
+ await this.alepha.events.emit("react:browser:render", {
1409
+ element,
1410
+ root: this.getRootElement(),
1411
+ hydration,
1412
+ state: this.state
1413
+ });
1140
1414
  window.addEventListener("popstate", () => {
1141
1415
  if (this.base + this.state.url.pathname === this.location.pathname) return;
1142
1416
  this.log.debug("Popstate event triggered - rendering new state", { url: this.location.pathname + this.location.search });
@@ -1152,7 +1426,7 @@ var ReactRouter = class {
1152
1426
  alepha = $inject(Alepha);
1153
1427
  pageApi = $inject(ReactPageProvider);
1154
1428
  get state() {
1155
- return this.alepha.state("react.router.state");
1429
+ return this.alepha.state.get("react.router.state");
1156
1430
  }
1157
1431
  get pages() {
1158
1432
  return this.pageApi.getPages();
@@ -1275,44 +1549,12 @@ const useRouter = () => {
1275
1549
  //#region src/components/Link.tsx
1276
1550
  const Link = (props) => {
1277
1551
  const router = useRouter();
1278
- const { to,...anchorProps } = props;
1279
1552
  return /* @__PURE__ */ jsx("a", {
1280
- ...router.anchor(to),
1281
- ...anchorProps,
1553
+ ...props,
1554
+ ...router.anchor(props.href),
1282
1555
  children: props.children
1283
1556
  });
1284
1557
  };
1285
- var Link_default = Link;
1286
-
1287
- //#endregion
1288
- //#region src/hooks/useStore.ts
1289
- /**
1290
- * Hook to access and mutate the Alepha state.
1291
- */
1292
- const useStore = (key, defaultValue) => {
1293
- const alepha = useAlepha();
1294
- useMemo(() => {
1295
- if (defaultValue != null && alepha.state(key) == null) alepha.state(key, defaultValue);
1296
- }, [defaultValue]);
1297
- const [state, setState] = useState(alepha.state(key));
1298
- useEffect(() => {
1299
- if (!alepha.isBrowser()) return;
1300
- return alepha.on("state:mutate", (ev) => {
1301
- if (ev.key === key) setState(ev.value);
1302
- });
1303
- }, []);
1304
- return [state, (value) => {
1305
- alepha.state(key, value);
1306
- }];
1307
- };
1308
-
1309
- //#endregion
1310
- //#region src/hooks/useRouterState.ts
1311
- const useRouterState = () => {
1312
- const [state] = useStore("react.router.state");
1313
- if (!state) throw new AlephaError("Missing react router state");
1314
- return state;
1315
- };
1316
1558
 
1317
1559
  //#endregion
1318
1560
  //#region src/hooks/useActive.ts
@@ -1370,7 +1612,7 @@ const useQueryParams = (schema, options = {}) => {
1370
1612
  const key = options.key ?? "q";
1371
1613
  const router = useRouter();
1372
1614
  const querystring = router.query[key];
1373
- const [queryParams, setQueryParams] = useState(decode(alepha, schema, router.query[key]));
1615
+ const [queryParams = {}, setQueryParams] = useState(decode(alepha, schema, router.query[key]));
1374
1616
  useEffect(() => {
1375
1617
  setQueryParams(decode(alepha, schema, querystring));
1376
1618
  }, [querystring]);
@@ -1390,8 +1632,8 @@ const encode = (alepha, schema, data) => {
1390
1632
  const decode = (alepha, schema, data) => {
1391
1633
  try {
1392
1634
  return alepha.parse(schema, JSON.parse(atob(decodeURIComponent(data))));
1393
- } catch (_error) {
1394
- return {};
1635
+ } catch {
1636
+ return;
1395
1637
  }
1396
1638
  };
1397
1639
 
@@ -1454,5 +1696,5 @@ const AlephaReact = $module({
1454
1696
  });
1455
1697
 
1456
1698
  //#endregion
1457
- 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, ReactPageProvider, ReactRouter, ReactServerProvider, Redirection, RouterLayerContext, isPageRoute, ssrSchemaLoading, useActive, useAlepha, useClient, useInject, useQueryParams, useRouter, useRouterEvents, useRouterState, useSchema, useStore };
1699
+ export { $page, AlephaContext, AlephaReact, ClientOnly, ErrorBoundary, ErrorViewer, Link, NestedView_default as NestedView, NotFoundPage as NotFound, PageDescriptor, ReactBrowserProvider, ReactPageProvider, ReactRouter, ReactServerProvider, Redirection, RouterLayerContext, isPageRoute, ssrSchemaLoading, useActive, useAlepha, useClient, useInject, useQueryParams, useRouter, useRouterEvents, useRouterState, useSchema, useStore };
1458
1700
  //# sourceMappingURL=index.js.map