@alepha/react 0.9.2 → 0.9.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.
Files changed (40) hide show
  1. package/README.md +46 -0
  2. package/dist/index.browser.js +378 -325
  3. package/dist/index.browser.js.map +1 -1
  4. package/dist/index.cjs +570 -458
  5. package/dist/index.cjs.map +1 -1
  6. package/dist/index.d.cts +305 -213
  7. package/dist/index.d.cts.map +1 -1
  8. package/dist/index.d.ts +304 -212
  9. package/dist/index.d.ts.map +1 -1
  10. package/dist/index.js +567 -460
  11. package/dist/index.js.map +1 -1
  12. package/package.json +16 -13
  13. package/src/components/ErrorViewer.tsx +1 -1
  14. package/src/components/Link.tsx +4 -24
  15. package/src/components/NestedView.tsx +20 -9
  16. package/src/components/NotFound.tsx +5 -2
  17. package/src/descriptors/$page.ts +86 -12
  18. package/src/errors/Redirection.ts +13 -0
  19. package/src/hooks/useActive.ts +28 -30
  20. package/src/hooks/useAlepha.ts +16 -2
  21. package/src/hooks/useClient.ts +7 -2
  22. package/src/hooks/useInject.ts +4 -1
  23. package/src/hooks/useQueryParams.ts +9 -6
  24. package/src/hooks/useRouter.ts +18 -30
  25. package/src/hooks/useRouterEvents.ts +7 -4
  26. package/src/hooks/useRouterState.ts +8 -20
  27. package/src/hooks/useSchema.ts +10 -15
  28. package/src/hooks/useStore.ts +9 -8
  29. package/src/index.browser.ts +11 -11
  30. package/src/index.shared.ts +4 -5
  31. package/src/index.ts +21 -30
  32. package/src/providers/ReactBrowserProvider.ts +155 -65
  33. package/src/providers/ReactBrowserRouterProvider.ts +132 -0
  34. package/src/providers/{PageDescriptorProvider.ts → ReactPageProvider.ts} +164 -112
  35. package/src/providers/ReactServerProvider.ts +100 -68
  36. package/src/{hooks/RouterHookApi.ts → services/ReactRouter.ts} +75 -61
  37. package/src/contexts/RouterContext.ts +0 -14
  38. package/src/errors/RedirectionError.ts +0 -10
  39. package/src/providers/BrowserRouterProvider.ts +0 -146
  40. package/src/providers/ReactBrowserRenderer.ts +0 -93
@@ -1,10 +1,12 @@
1
- import { $env, $hook, $inject, $logger, $module, Alepha, Descriptor, KIND, NotImplementedError, createDescriptor, t } from "@alepha/core";
1
+ import { $env, $hook, $inject, $module, Alepha, AlephaError, Descriptor, KIND, createDescriptor, t } from "@alepha/core";
2
2
  import { AlephaServer, HttpClient } from "@alepha/server";
3
3
  import { AlephaServerLinks, LinkProvider } from "@alepha/server-links";
4
+ import { DateTimeProvider } from "@alepha/datetime";
5
+ import { $logger } from "@alepha/logger";
6
+ import { createRoot, hydrateRoot } from "react-dom/client";
4
7
  import { RouterProvider } from "@alepha/router";
5
8
  import React, { StrictMode, createContext, createElement, useContext, useEffect, useMemo, useState } from "react";
6
9
  import { jsx, jsxs } from "react/jsx-runtime";
7
- import { createRoot, hydrateRoot } from "react-dom/client";
8
10
 
9
11
  //#region src/descriptors/$page.ts
10
12
  /**
@@ -14,6 +16,12 @@ const $page = (options) => {
14
16
  return createDescriptor(PageDescriptor, options);
15
17
  };
16
18
  var PageDescriptor = class extends Descriptor {
19
+ onInit() {
20
+ if (this.options.static) this.options.cache ??= {
21
+ provider: "memory",
22
+ ttl: [1, "week"]
23
+ };
24
+ }
17
25
  get name() {
18
26
  return this.options.name ?? this.config.propertyKey;
19
27
  }
@@ -22,14 +30,20 @@ var PageDescriptor = class extends Descriptor {
22
30
  * Only valid for server-side rendering, it will throw an error if called on the client-side.
23
31
  */
24
32
  async render(options) {
25
- throw new NotImplementedError("");
33
+ throw new Error("render method is not implemented in this environment");
34
+ }
35
+ match(url) {
36
+ return false;
37
+ }
38
+ pathname(config) {
39
+ return this.options.path || "";
26
40
  }
27
41
  };
28
42
  $page[KIND] = PageDescriptor;
29
43
 
30
44
  //#endregion
31
45
  //#region src/components/NotFound.tsx
32
- function NotFoundPage() {
46
+ function NotFoundPage(props) {
33
47
  return /* @__PURE__ */ jsx("div", {
34
48
  style: {
35
49
  height: "100vh",
@@ -39,14 +53,15 @@ function NotFoundPage() {
39
53
  alignItems: "center",
40
54
  textAlign: "center",
41
55
  fontFamily: "sans-serif",
42
- padding: "1rem"
56
+ padding: "1rem",
57
+ ...props.style
43
58
  },
44
59
  children: /* @__PURE__ */ jsx("h1", {
45
60
  style: {
46
61
  fontSize: "1rem",
47
62
  marginBottom: "0.5rem"
48
63
  },
49
- children: "This page does not exist"
64
+ children: "404 - This page does not exist"
50
65
  })
51
66
  });
52
67
  }
@@ -100,7 +115,7 @@ const ErrorViewer = ({ error, alepha }) => {
100
115
  heading: {
101
116
  fontSize: "20px",
102
117
  fontWeight: "bold",
103
- marginBottom: "4px"
118
+ marginBottom: "10px"
104
119
  },
105
120
  name: {
106
121
  fontSize: "16px",
@@ -219,28 +234,54 @@ const ErrorViewerProduction = () => {
219
234
  });
220
235
  };
221
236
 
222
- //#endregion
223
- //#region src/contexts/RouterContext.ts
224
- const RouterContext = createContext(void 0);
225
-
226
237
  //#endregion
227
238
  //#region src/contexts/RouterLayerContext.ts
228
239
  const RouterLayerContext = createContext(void 0);
229
240
 
241
+ //#endregion
242
+ //#region src/errors/Redirection.ts
243
+ /**
244
+ * Used for Redirection during the page loading.
245
+ *
246
+ * Depends on the context, it can be thrown or just returned.
247
+ */
248
+ var Redirection = class extends Error {
249
+ redirect;
250
+ constructor(redirect) {
251
+ super("Redirection");
252
+ this.redirect = redirect;
253
+ }
254
+ };
255
+
230
256
  //#endregion
231
257
  //#region src/contexts/AlephaContext.ts
232
258
  const AlephaContext = createContext(void 0);
233
259
 
234
260
  //#endregion
235
261
  //#region src/hooks/useAlepha.ts
262
+ /**
263
+ * Main Alepha hook.
264
+ *
265
+ * It provides access to the Alepha instance within a React component.
266
+ *
267
+ * With Alepha, you can access the core functionalities of the framework:
268
+ *
269
+ * - alepha.state() for state management
270
+ * - alepha.inject() for dependency injection
271
+ * - alepha.emit() for event handling
272
+ * etc...
273
+ */
236
274
  const useAlepha = () => {
237
275
  const alepha = useContext(AlephaContext);
238
- if (!alepha) throw new Error("useAlepha must be used within an AlephaContext.Provider");
276
+ if (!alepha) throw new AlephaError("Hook 'useAlepha()' must be used within an AlephaContext.Provider");
239
277
  return alepha;
240
278
  };
241
279
 
242
280
  //#endregion
243
281
  //#region src/hooks/useRouterEvents.ts
282
+ /**
283
+ * Subscribe to various router events.
284
+ */
244
285
  const useRouterEvents = (opts = {}, deps = []) => {
245
286
  const alepha = useAlepha();
246
287
  useEffect(() => {
@@ -313,36 +354,31 @@ var ErrorBoundary_default = ErrorBoundary;
313
354
  * ```
314
355
  */
315
356
  const NestedView = (props) => {
316
- const app = useContext(RouterContext);
317
357
  const layer = useContext(RouterLayerContext);
318
358
  const index = layer?.index ?? 0;
319
- const [view, setView] = useState(app?.state.layers[index]?.element);
320
- useRouterEvents({ onEnd: ({ state }) => {
321
- if (!state.layers[index]?.cache) setView(state.layers[index]?.element);
322
- } }, [app]);
323
- if (!app) throw new Error("NestedView must be used within a RouterContext.");
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.");
362
+ 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
+ } }, []);
324
366
  const element = view ?? props.children ?? null;
325
367
  return /* @__PURE__ */ jsx(ErrorBoundary_default, {
326
- fallback: app.context.onError,
368
+ fallback: (error) => {
369
+ const result = state.onError(error, state);
370
+ if (result instanceof Redirection) return "Redirection inside ErrorBoundary is not allowed.";
371
+ return result;
372
+ },
327
373
  children: element
328
374
  });
329
375
  };
330
376
  var NestedView_default = NestedView;
331
377
 
332
378
  //#endregion
333
- //#region src/errors/RedirectionError.ts
334
- var RedirectionError = class extends Error {
335
- page;
336
- constructor(page) {
337
- super("Redirection");
338
- this.page = page;
339
- }
340
- };
341
-
342
- //#endregion
343
- //#region src/providers/PageDescriptorProvider.ts
379
+ //#region src/providers/ReactPageProvider.ts
344
380
  const envSchema$1 = t.object({ REACT_STRICT_MODE: t.boolean({ default: true }) });
345
- var PageDescriptorProvider = class {
381
+ var ReactPageProvider = class {
346
382
  log = $logger();
347
383
  env = $env(envSchema$1);
348
384
  alepha = $inject(Alepha);
@@ -354,7 +390,7 @@ var PageDescriptorProvider = class {
354
390
  for (const page of this.pages) if (page.name === name) return page;
355
391
  throw new Error(`Page ${name} not found`);
356
392
  }
357
- url(name, options = {}) {
393
+ pathname(name, options = {}) {
358
394
  const page = this.page(name);
359
395
  if (!page) throw new Error(`Page ${name} not found`);
360
396
  let url = page.path ?? "";
@@ -364,22 +400,28 @@ var PageDescriptorProvider = class {
364
400
  parent = parent.parent;
365
401
  }
366
402
  url = this.compile(url, options.params ?? {});
367
- return new URL(url.replace(/\/\/+/g, "/") || "/", options.base ?? `http://localhost`);
403
+ if (options.query) {
404
+ const query = new URLSearchParams(options.query);
405
+ if (query.toString()) url += `?${query.toString()}`;
406
+ }
407
+ return url.replace(/\/\/+/g, "/") || "/";
368
408
  }
369
- root(state, context) {
370
- const root = createElement(AlephaContext.Provider, { value: this.alepha }, createElement(RouterContext.Provider, { value: {
371
- state,
372
- context
373
- } }, createElement(NestedView_default, {}, state.layers[0]?.element)));
409
+ url(name, options = {}) {
410
+ return new URL(this.pathname(name, options), options.host ?? `http://localhost`);
411
+ }
412
+ root(state) {
413
+ const root = createElement(AlephaContext.Provider, { value: this.alepha }, createElement(NestedView_default, {}, state.layers[0]?.element));
374
414
  if (this.env.REACT_STRICT_MODE) return createElement(StrictMode, {}, root);
375
415
  return root;
376
416
  }
377
- async createLayers(route, request) {
378
- const { pathname, search } = request.url;
379
- const layers = [];
417
+ /**
418
+ * Create a new RouterState based on a given route and request.
419
+ * This method resolves the layers for the route, applying any query and params schemas defined in the route.
420
+ * It also handles errors and redirects.
421
+ */
422
+ async createLayers(route, state, previous = []) {
380
423
  let context = {};
381
424
  const stack = [{ route }];
382
- request.onError = (error) => this.renderError(error);
383
425
  let parent = route.parent;
384
426
  while (parent) {
385
427
  stack.unshift({ route: parent });
@@ -391,19 +433,18 @@ var PageDescriptorProvider = class {
391
433
  const route$1 = it.route;
392
434
  const config = {};
393
435
  try {
394
- config.query = route$1.schema?.query ? this.alepha.parse(route$1.schema.query, request.query) : {};
436
+ config.query = route$1.schema?.query ? this.alepha.parse(route$1.schema.query, state.query) : {};
395
437
  } catch (e) {
396
438
  it.error = e;
397
439
  break;
398
440
  }
399
441
  try {
400
- config.params = route$1.schema?.params ? this.alepha.parse(route$1.schema.params, request.params) : {};
442
+ config.params = route$1.schema?.params ? this.alepha.parse(route$1.schema.params, state.params) : {};
401
443
  } catch (e) {
402
444
  it.error = e;
403
445
  break;
404
446
  }
405
447
  it.config = { ...config };
406
- const previous = request.previous;
407
448
  if (previous?.[i] && !forceRefresh && previous[i].name === route$1.name) {
408
449
  const url = (str) => str ? str.replace(/\/\/+/g, "/") : "/";
409
450
  const prev = JSON.stringify({
@@ -429,7 +470,7 @@ var PageDescriptorProvider = class {
429
470
  if (!route$1.resolve) continue;
430
471
  try {
431
472
  const props = await route$1.resolve?.({
432
- ...request,
473
+ ...state,
433
474
  ...config,
434
475
  ...context
435
476
  }) ?? {};
@@ -439,13 +480,8 @@ var PageDescriptorProvider = class {
439
480
  ...props
440
481
  };
441
482
  } catch (e) {
442
- if (e instanceof RedirectionError) return {
443
- layers: [],
444
- redirect: typeof e.page === "string" ? e.page : this.href(e.page),
445
- pathname,
446
- search
447
- };
448
- this.log.error(e);
483
+ if (e instanceof Redirection) return { redirect: e.redirect };
484
+ this.log.error("Page resolver has failed", e);
449
485
  it.error = e;
450
486
  break;
451
487
  }
@@ -460,44 +496,59 @@ var PageDescriptorProvider = class {
460
496
  acc += it.route.path ? this.compile(it.route.path, params) : "";
461
497
  const path = acc.replace(/\/+/, "/");
462
498
  const localErrorHandler = this.getErrorHandler(it.route);
463
- if (localErrorHandler) request.onError = localErrorHandler;
464
- if (it.error) {
465
- let element$1 = await request.onError(it.error);
466
- if (element$1 === null) element$1 = this.renderError(it.error);
467
- layers.push({
499
+ if (localErrorHandler) {
500
+ const onErrorParent = state.onError;
501
+ state.onError = (error, context$1) => {
502
+ const result = localErrorHandler(error, context$1);
503
+ if (result === void 0) return onErrorParent(error, context$1);
504
+ return result;
505
+ };
506
+ }
507
+ if (!it.error) try {
508
+ const element = await this.createElement(it.route, {
509
+ ...props,
510
+ ...context
511
+ });
512
+ state.layers.push({
513
+ name: it.route.name,
514
+ props,
515
+ part: it.route.path,
516
+ config: it.config,
517
+ element: this.renderView(i + 1, path, element, it.route),
518
+ index: i + 1,
519
+ path,
520
+ route: it.route,
521
+ cache: it.cache
522
+ });
523
+ } catch (e) {
524
+ it.error = e;
525
+ }
526
+ if (it.error) try {
527
+ let element = await state.onError(it.error, state);
528
+ if (element === void 0) throw it.error;
529
+ if (element instanceof Redirection) return { redirect: element.redirect };
530
+ if (element === null) element = this.renderError(it.error);
531
+ state.layers.push({
468
532
  props,
469
533
  error: it.error,
470
534
  name: it.route.name,
471
535
  part: it.route.path,
472
536
  config: it.config,
473
- element: this.renderView(i + 1, path, element$1, it.route),
537
+ element: this.renderView(i + 1, path, element, it.route),
474
538
  index: i + 1,
475
539
  path,
476
540
  route: it.route
477
541
  });
478
542
  break;
543
+ } catch (e) {
544
+ if (e instanceof Redirection) return { redirect: e.redirect };
545
+ throw e;
479
546
  }
480
- const element = await this.createElement(it.route, {
481
- ...props,
482
- ...context
483
- });
484
- layers.push({
485
- name: it.route.name,
486
- props,
487
- part: it.route.path,
488
- config: it.config,
489
- element: this.renderView(i + 1, path, element, it.route),
490
- index: i + 1,
491
- path,
492
- route: it.route,
493
- cache: it.cache
494
- });
495
547
  }
496
- return {
497
- layers,
498
- pathname,
499
- search
500
- };
548
+ return { state };
549
+ }
550
+ createRedirectionLayer(redirect) {
551
+ return { redirect };
501
552
  }
502
553
  getErrorHandler(route) {
503
554
  if (route.errorHandler) return route.errorHandler;
@@ -554,6 +605,7 @@ var PageDescriptorProvider = class {
554
605
  let hasNotFoundHandler = false;
555
606
  const pages = this.alepha.descriptors($page);
556
607
  const hasParent = (it) => {
608
+ if (it.options.parent) return true;
557
609
  for (const page of pages) {
558
610
  const children = page.options.children ? Array.isArray(page.options.children) ? page.options.children : page.options.children() : [];
559
611
  if (children.includes(it)) return true;
@@ -569,7 +621,7 @@ var PageDescriptorProvider = class {
569
621
  name: "notFound",
570
622
  cache: true,
571
623
  component: NotFoundPage,
572
- afterHandler: ({ reply }) => {
624
+ onServerResponse: ({ reply }) => {
573
625
  reply.status = 404;
574
626
  }
575
627
  });
@@ -577,6 +629,12 @@ var PageDescriptorProvider = class {
577
629
  });
578
630
  map(pages, target) {
579
631
  const children = target.options.children ? Array.isArray(target.options.children) ? target.options.children : target.options.children() : [];
632
+ const getChildrenFromParent = (it) => {
633
+ const children$1 = [];
634
+ for (const page of pages) if (page.options.parent === it) children$1.push(page);
635
+ return children$1;
636
+ };
637
+ children.push(...getChildrenFromParent(target));
580
638
  return {
581
639
  ...target.options,
582
640
  name: target.name,
@@ -617,57 +675,43 @@ const isPageRoute = (it) => {
617
675
  };
618
676
 
619
677
  //#endregion
620
- //#region src/providers/BrowserRouterProvider.ts
621
- var BrowserRouterProvider = class extends RouterProvider {
678
+ //#region src/providers/ReactBrowserRouterProvider.ts
679
+ var ReactBrowserRouterProvider = class extends RouterProvider {
622
680
  log = $logger();
623
681
  alepha = $inject(Alepha);
624
- pageDescriptorProvider = $inject(PageDescriptorProvider);
682
+ pageApi = $inject(ReactPageProvider);
625
683
  add(entry) {
626
- this.pageDescriptorProvider.add(entry);
684
+ this.pageApi.add(entry);
627
685
  }
628
686
  configure = $hook({
629
687
  on: "configure",
630
688
  handler: async () => {
631
- for (const page of this.pageDescriptorProvider.getPages()) if (page.component || page.lazy) this.push({
689
+ for (const page of this.pageApi.getPages()) if (page.component || page.lazy) this.push({
632
690
  path: page.match,
633
691
  page
634
692
  });
635
693
  }
636
694
  });
637
- async transition(url, options = {}) {
695
+ async transition(url, previous = []) {
638
696
  const { pathname, search } = url;
639
- const state = {
640
- pathname,
641
- search,
642
- layers: []
643
- };
644
- const context = {
697
+ const entry = {
645
698
  url,
646
699
  query: {},
647
700
  params: {},
648
- onError: () => null,
649
- ...options.context ?? {}
701
+ layers: [],
702
+ onError: () => null
650
703
  };
651
- await this.alepha.emit("react:transition:begin", {
652
- state,
653
- context
654
- });
704
+ const state = entry;
705
+ await this.alepha.emit("react:transition:begin", { state });
655
706
  try {
656
- const previous = options.previous;
657
707
  const { route, params } = this.match(pathname);
658
708
  const query = {};
659
709
  if (search) for (const [key, value] of new URLSearchParams(search).entries()) query[key] = String(value);
660
- context.query = query;
661
- context.params = params ?? {};
662
- context.previous = previous;
710
+ state.query = query;
711
+ state.params = params ?? {};
663
712
  if (isPageRoute(route)) {
664
- const result = await this.pageDescriptorProvider.createLayers(route.page, context);
665
- if (result.redirect) return {
666
- redirect: result.redirect,
667
- state,
668
- context
669
- };
670
- state.layers = result.layers;
713
+ const { redirect } = await this.pageApi.createLayers(route.page, state, previous);
714
+ if (redirect) return redirect;
671
715
  }
672
716
  if (state.layers.length === 0) state.layers.push({
673
717
  name: "not-found",
@@ -675,82 +719,92 @@ var BrowserRouterProvider = class extends RouterProvider {
675
719
  index: 0,
676
720
  path: "/"
677
721
  });
678
- await this.alepha.emit("react:transition:success", {
679
- state,
680
- context
681
- });
722
+ await this.alepha.emit("react:transition:success", { state });
682
723
  } catch (e) {
683
- this.log.error(e);
724
+ this.log.error("Transition has failed", e);
684
725
  state.layers = [{
685
726
  name: "error",
686
- element: this.pageDescriptorProvider.renderError(e),
727
+ element: this.pageApi.renderError(e),
687
728
  index: 0,
688
729
  path: "/"
689
730
  }];
690
731
  await this.alepha.emit("react:transition:error", {
691
732
  error: e,
692
- state,
693
- context
733
+ state
694
734
  });
695
735
  }
696
- if (options.state) {
697
- options.state.layers = state.layers;
698
- options.state.pathname = state.pathname;
699
- options.state.search = state.search;
736
+ if (previous) for (let i = 0; i < previous.length; i++) {
737
+ const layer = previous[i];
738
+ if (state.layers[i]?.name !== layer.name) this.pageApi.page(layer.name)?.onLeave?.();
700
739
  }
701
- await this.alepha.emit("react:transition:end", {
702
- state: options.state,
703
- context
704
- });
705
- return {
706
- context,
707
- state
708
- };
740
+ await this.alepha.emit("react:transition:end", { state });
741
+ this.alepha.state("react.router.state", state);
709
742
  }
710
- root(state, context) {
711
- return this.pageDescriptorProvider.root(state, context);
743
+ root(state) {
744
+ return this.pageApi.root(state);
712
745
  }
713
746
  };
714
747
 
715
748
  //#endregion
716
749
  //#region src/providers/ReactBrowserProvider.ts
750
+ const envSchema = t.object({ REACT_ROOT_ID: t.string({ default: "root" }) });
717
751
  var ReactBrowserProvider = class {
752
+ env = $env(envSchema);
718
753
  log = $logger();
719
754
  client = $inject(LinkProvider);
720
755
  alepha = $inject(Alepha);
721
- router = $inject(BrowserRouterProvider);
756
+ router = $inject(ReactBrowserRouterProvider);
757
+ dateTimeProvider = $inject(DateTimeProvider);
722
758
  root;
759
+ options = { scrollRestoration: "top" };
760
+ getRootElement() {
761
+ const root = this.document.getElementById(this.env.REACT_ROOT_ID);
762
+ if (root) return root;
763
+ const div = this.document.createElement("div");
764
+ div.id = this.env.REACT_ROOT_ID;
765
+ this.document.body.prepend(div);
766
+ return div;
767
+ }
723
768
  transitioning;
724
- state = {
725
- layers: [],
726
- pathname: "",
727
- search: ""
728
- };
769
+ get state() {
770
+ return this.alepha.state("react.router.state");
771
+ }
772
+ /**
773
+ * Accessor for Document DOM API.
774
+ */
729
775
  get document() {
730
776
  return window.document;
731
777
  }
778
+ /**
779
+ * Accessor for History DOM API.
780
+ */
732
781
  get history() {
733
782
  return window.history;
734
783
  }
784
+ /**
785
+ * Accessor for Location DOM API.
786
+ */
735
787
  get location() {
736
788
  return window.location;
737
789
  }
790
+ get base() {
791
+ const base = import.meta.env?.BASE_URL;
792
+ if (!base || base === "/") return "";
793
+ return base;
794
+ }
738
795
  get url() {
739
- let url = this.location.pathname + this.location.search;
740
- if (import.meta?.env?.BASE_URL) {
741
- url = url.replace(import.meta.env?.BASE_URL, "");
742
- if (!url.startsWith("/")) url = `/${url}`;
743
- }
796
+ const url = this.location.pathname + this.location.search;
797
+ if (this.base) return url.replace(this.base, "");
744
798
  return url;
745
799
  }
746
- pushState(url, replace) {
747
- let path = url;
748
- if (import.meta?.env?.BASE_URL) path = (import.meta.env?.BASE_URL + path).replaceAll("//", "/");
749
- if (replace) this.history.replaceState({}, "", path);
750
- else this.history.pushState({}, "", path);
800
+ pushState(path, replace) {
801
+ const url = this.base + path;
802
+ if (replace) this.history.replaceState({}, "", url);
803
+ else this.history.pushState({}, "", url);
751
804
  }
752
805
  async invalidate(props) {
753
806
  const previous = [];
807
+ this.log.trace("Invalidating layers");
754
808
  if (props) {
755
809
  const [key] = Object.keys(props);
756
810
  const value = props[key];
@@ -771,28 +825,37 @@ var ReactBrowserProvider = class {
771
825
  await this.render({ previous });
772
826
  }
773
827
  async go(url, options = {}) {
774
- const result = await this.render({ url });
775
- if (result.context.url.pathname !== url) {
776
- this.pushState(result.context.url.pathname);
777
- return;
778
- }
779
- if (options.replace) {
780
- this.pushState(url);
828
+ this.log.trace(`Going to ${url}`, {
829
+ url,
830
+ options
831
+ });
832
+ await this.render({
833
+ url,
834
+ previous: options.force ? [] : this.state.layers
835
+ });
836
+ if (this.state.url.pathname + this.state.url.search !== url) {
837
+ this.pushState(this.state.url.pathname + this.state.url.search);
781
838
  return;
782
839
  }
783
- this.pushState(url);
840
+ this.pushState(url, options.replace);
784
841
  }
785
842
  async render(options = {}) {
786
843
  const previous = options.previous ?? this.state.layers;
787
844
  const url = options.url ?? this.url;
788
- this.transitioning = { to: url };
789
- const result = await this.router.transition(new URL(`http://localhost${url}`), {
790
- previous,
791
- state: this.state
792
- });
793
- if (result.redirect) return await this.render({ url: result.redirect });
845
+ const start = this.dateTimeProvider.now();
846
+ this.transitioning = {
847
+ to: url,
848
+ from: this.state?.url.pathname
849
+ };
850
+ this.log.debug("Transitioning...", { to: url });
851
+ const redirect = await this.router.transition(new URL(`http://localhost${url}`), previous);
852
+ if (redirect) {
853
+ this.log.info("Redirecting to", { redirect });
854
+ return await this.render({ url: redirect });
855
+ }
856
+ const ms = this.dateTimeProvider.now().diff(start);
857
+ this.log.info(`Transition OK [${ms}ms]`, this.transitioning);
794
858
  this.transitioning = void 0;
795
- return result;
796
859
  }
797
860
  /**
798
861
  * Get embedded layers from the server.
@@ -804,48 +867,25 @@ var ReactBrowserProvider = class {
804
867
  console.error(error);
805
868
  }
806
869
  }
870
+ onTransitionEnd = $hook({
871
+ on: "react:transition:end",
872
+ handler: () => {
873
+ if (this.options.scrollRestoration === "top" && typeof window !== "undefined") {
874
+ this.log.trace("Restoring scroll position to top");
875
+ window.scrollTo(0, 0);
876
+ }
877
+ }
878
+ });
807
879
  ready = $hook({
808
880
  on: "ready",
809
881
  handler: async () => {
810
882
  const hydration = this.getHydrationState();
811
883
  const previous = hydration?.layers ?? [];
812
- if (hydration?.links) for (const link of hydration.links.links) this.client.pushLink(link);
813
- const { context } = await this.render({ previous });
814
- await this.alepha.emit("react:browser:render", {
815
- state: this.state,
816
- context,
817
- hydration
818
- });
819
- window.addEventListener("popstate", () => {
820
- if (this.state.pathname === this.url) return;
821
- this.render();
822
- });
823
- }
824
- });
825
- };
826
-
827
- //#endregion
828
- //#region src/providers/ReactBrowserRenderer.ts
829
- const envSchema = t.object({ REACT_ROOT_ID: t.string({ default: "root" }) });
830
- var ReactBrowserRenderer = class {
831
- browserProvider = $inject(ReactBrowserProvider);
832
- browserRouterProvider = $inject(BrowserRouterProvider);
833
- env = $env(envSchema);
834
- log = $logger();
835
- root;
836
- options = { scrollRestoration: "top" };
837
- getRootElement() {
838
- const root = this.browserProvider.document.getElementById(this.env.REACT_ROOT_ID);
839
- if (root) return root;
840
- const div = this.browserProvider.document.createElement("div");
841
- div.id = this.env.REACT_ROOT_ID;
842
- this.browserProvider.document.body.prepend(div);
843
- return div;
844
- }
845
- ready = $hook({
846
- on: "react:browser:render",
847
- handler: async ({ state, context, hydration }) => {
848
- const element = this.browserRouterProvider.root(state, context);
884
+ if (hydration) {
885
+ for (const [key, value] of Object.entries(hydration)) if (key !== "layers") this.alepha.state(key, value);
886
+ }
887
+ await this.render({ previous });
888
+ const element = this.router.root(this.state);
849
889
  if (hydration?.layers) {
850
890
  this.root = hydrateRoot(this.getRootElement(), element);
851
891
  this.log.info("Hydrated root element");
@@ -854,28 +894,41 @@ var ReactBrowserRenderer = class {
854
894
  this.root.render(element);
855
895
  this.log.info("Created root element");
856
896
  }
857
- }
858
- });
859
- onTransitionEnd = $hook({
860
- on: "react:transition:end",
861
- handler: () => {
862
- if (this.options.scrollRestoration === "top" && typeof window !== "undefined") window.scrollTo(0, 0);
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
+ });
863
902
  }
864
903
  });
865
904
  };
866
905
 
867
906
  //#endregion
868
- //#region src/hooks/RouterHookApi.ts
869
- var RouterHookApi = class {
870
- constructor(pages, context, state, layer, browser) {
871
- this.pages = pages;
872
- this.context = context;
873
- this.state = state;
874
- this.layer = layer;
875
- this.browser = browser;
907
+ //#region src/services/ReactRouter.ts
908
+ var ReactRouter = class {
909
+ alepha = $inject(Alepha);
910
+ pageApi = $inject(ReactPageProvider);
911
+ get state() {
912
+ return this.alepha.state("react.router.state");
913
+ }
914
+ get pages() {
915
+ return this.pageApi.getPages();
916
+ }
917
+ get browser() {
918
+ if (this.alepha.isBrowser()) return this.alepha.inject(ReactBrowserProvider);
919
+ return void 0;
920
+ }
921
+ path(name, config = {}) {
922
+ return this.pageApi.pathname(name, {
923
+ params: {
924
+ ...this.state.params,
925
+ ...config.params
926
+ },
927
+ query: config.query
928
+ });
876
929
  }
877
930
  getURL() {
878
- if (!this.browser) return this.context.url;
931
+ if (!this.browser) return this.state.url;
879
932
  return new URL(this.location.href);
880
933
  }
881
934
  get location() {
@@ -886,11 +939,11 @@ var RouterHookApi = class {
886
939
  return this.state;
887
940
  }
888
941
  get pathname() {
889
- return this.state.pathname;
942
+ return this.state.url.pathname;
890
943
  }
891
944
  get query() {
892
945
  const query = {};
893
- for (const [key, value] of new URLSearchParams(this.state.search).entries()) query[key] = String(value);
946
+ for (const [key, value] of new URLSearchParams(this.state.url.search).entries()) query[key] = String(value);
894
947
  return query;
895
948
  }
896
949
  async back() {
@@ -902,39 +955,33 @@ var RouterHookApi = class {
902
955
  async invalidate(props) {
903
956
  await this.browser?.invalidate(props);
904
957
  }
905
- /**
906
- * Create a valid href for the given pathname.
907
- *
908
- * @param pathname
909
- * @param layer
910
- */
911
- createHref(pathname, layer = this.layer, options = {}) {
912
- if (typeof pathname === "object") pathname = pathname.options.path ?? "";
913
- if (options.params) for (const [key, value] of Object.entries(options.params)) pathname = pathname.replace(`:${key}`, String(value));
914
- return pathname.startsWith("/") ? pathname : `${layer.path}/${pathname}`.replace(/\/\/+/g, "/");
915
- }
916
958
  async go(path, options) {
917
959
  for (const page of this.pages) if (page.name === path) {
918
- path = page.path ?? "";
919
- break;
960
+ await this.browser?.go(this.path(path, options), options);
961
+ return;
920
962
  }
921
- await this.browser?.go(this.createHref(path, this.layer, options), options);
963
+ await this.browser?.go(path, options);
922
964
  }
923
965
  anchor(path, options = {}) {
966
+ let href = path;
924
967
  for (const page of this.pages) if (page.name === path) {
925
- path = page.path ?? "";
968
+ href = this.path(path, options);
926
969
  break;
927
970
  }
928
- const href = this.createHref(path, this.layer, options);
929
971
  return {
930
- href,
972
+ href: this.base(href),
931
973
  onClick: (ev) => {
932
974
  ev.stopPropagation();
933
975
  ev.preventDefault();
934
- this.go(path, options).catch(console.error);
976
+ this.go(href, options).catch(console.error);
935
977
  }
936
978
  };
937
979
  }
980
+ base(path) {
981
+ const base = import.meta.env?.BASE_URL;
982
+ if (!base || base === "/") return path;
983
+ return base + path;
984
+ }
938
985
  /**
939
986
  * Set query params.
940
987
  *
@@ -950,90 +997,131 @@ var RouterHookApi = class {
950
997
  }
951
998
  };
952
999
 
1000
+ //#endregion
1001
+ //#region src/hooks/useInject.ts
1002
+ /**
1003
+ * Hook to inject a service instance.
1004
+ * It's a wrapper of `useAlepha().inject(service)` with a memoization.
1005
+ */
1006
+ const useInject = (service) => {
1007
+ const alepha = useAlepha();
1008
+ return useMemo(() => alepha.inject(service), []);
1009
+ };
1010
+
953
1011
  //#endregion
954
1012
  //#region src/hooks/useRouter.ts
1013
+ /**
1014
+ * Use this hook to access the React Router instance.
1015
+ *
1016
+ * You can add a type parameter to specify the type of your application.
1017
+ * This will allow you to use the router in a typesafe way.
1018
+ *
1019
+ * @example
1020
+ * class App {
1021
+ * home = $page();
1022
+ * }
1023
+ *
1024
+ * const router = useRouter<App>();
1025
+ * router.go("home"); // typesafe
1026
+ */
955
1027
  const useRouter = () => {
956
- const alepha = useAlepha();
957
- const ctx = useContext(RouterContext);
958
- const layer = useContext(RouterLayerContext);
959
- if (!ctx || !layer) throw new Error("useRouter must be used within a RouterProvider");
960
- const pages = useMemo(() => {
961
- return alepha.inject(PageDescriptorProvider).getPages();
962
- }, []);
963
- return useMemo(() => new RouterHookApi(pages, ctx.context, ctx.state, layer, alepha.isBrowser() ? alepha.inject(ReactBrowserProvider) : void 0), [layer]);
1028
+ return useInject(ReactRouter);
964
1029
  };
965
1030
 
966
1031
  //#endregion
967
1032
  //#region src/components/Link.tsx
968
1033
  const Link = (props) => {
969
- React.useContext(RouterContext);
970
1034
  const router = useRouter();
971
- const to = typeof props.to === "string" ? props.to : props.to.options.path;
972
- if (!to) return null;
973
- const can = typeof props.to === "string" ? void 0 : props.to.options.can;
974
- if (can && !can()) return null;
975
- const name = typeof props.to === "string" ? void 0 : props.to.options.name;
976
- const anchorProps = {
977
- ...props,
978
- to: void 0
979
- };
1035
+ const { to,...anchorProps } = props;
980
1036
  return /* @__PURE__ */ jsx("a", {
981
1037
  ...router.anchor(to),
982
1038
  ...anchorProps,
983
- children: props.children ?? name
1039
+ children: props.children
984
1040
  });
985
1041
  };
986
1042
  var Link_default = Link;
987
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
+
988
1074
  //#endregion
989
1075
  //#region src/hooks/useActive.ts
990
- const useActive = (path) => {
1076
+ const useActive = (args) => {
991
1077
  const router = useRouter();
992
- const ctx = useContext(RouterContext);
993
- const layer = useContext(RouterLayerContext);
994
- if (!ctx || !layer) throw new Error("useRouter must be used within a RouterProvider");
995
- let name;
996
- if (typeof path === "object" && path.options.name) name = path.options.name;
997
- const [current, setCurrent] = useState(ctx.state.pathname);
998
- const href = useMemo(() => router.createHref(path, layer), [path, layer]);
999
1078
  const [isPending, setPending] = useState(false);
1000
- const isActive = current === href;
1001
- useRouterEvents({ onEnd: ({ state }) => setCurrent(state.pathname) });
1079
+ const state = useRouterState();
1080
+ const current = state.url.pathname;
1081
+ const options = typeof args === "string" ? { href: args } : {
1082
+ ...args,
1083
+ href: args.href
1084
+ };
1085
+ const href = options.href;
1086
+ let isActive = current === href || current === `${href}/` || `${current}/` === href;
1087
+ if (options.startWith && !isActive) isActive = current.startsWith(href);
1002
1088
  return {
1003
- name,
1004
1089
  isPending,
1005
1090
  isActive,
1006
1091
  anchorProps: {
1007
- href,
1008
- onClick: (ev) => {
1009
- ev.stopPropagation();
1010
- ev.preventDefault();
1092
+ href: router.base(href),
1093
+ onClick: async (ev) => {
1094
+ ev?.stopPropagation();
1095
+ ev?.preventDefault();
1011
1096
  if (isActive) return;
1012
1097
  if (isPending) return;
1013
1098
  setPending(true);
1014
- router.go(href).then(() => {
1099
+ try {
1100
+ await router.go(href);
1101
+ } finally {
1015
1102
  setPending(false);
1016
- });
1103
+ }
1017
1104
  }
1018
1105
  }
1019
1106
  };
1020
1107
  };
1021
1108
 
1022
- //#endregion
1023
- //#region src/hooks/useInject.ts
1024
- const useInject = (service) => {
1025
- const alepha = useAlepha();
1026
- return useMemo(() => alepha.inject(service), []);
1027
- };
1028
-
1029
1109
  //#endregion
1030
1110
  //#region src/hooks/useClient.ts
1031
- const useClient = (_scope) => {
1032
- return useInject(LinkProvider).client();
1111
+ /**
1112
+ * Hook to get a virtual client for the specified scope.
1113
+ *
1114
+ * It's the React-hook version of `$client()`, from `AlephaServerLinks` module.
1115
+ */
1116
+ const useClient = (scope) => {
1117
+ return useInject(LinkProvider).client(scope);
1033
1118
  };
1034
1119
 
1035
1120
  //#endregion
1036
1121
  //#region src/hooks/useQueryParams.ts
1122
+ /**
1123
+ * Not well tested. Use with caution.
1124
+ */
1037
1125
  const useQueryParams = (schema, options = {}) => {
1038
1126
  const alepha = useAlepha();
1039
1127
  const key = options.key ?? "q";
@@ -1064,29 +1152,17 @@ const decode = (alepha, schema, data) => {
1064
1152
  }
1065
1153
  };
1066
1154
 
1067
- //#endregion
1068
- //#region src/hooks/useRouterState.ts
1069
- const useRouterState = () => {
1070
- const router = useContext(RouterContext);
1071
- const layer = useContext(RouterLayerContext);
1072
- if (!router || !layer) throw new Error("useRouterState must be used within a RouterContext.Provider");
1073
- const [state, setState] = useState(router.state);
1074
- useRouterEvents({ onEnd: ({ state: state$1 }) => setState({ ...state$1 }) });
1075
- return state;
1076
- };
1077
-
1078
1155
  //#endregion
1079
1156
  //#region src/hooks/useSchema.ts
1080
1157
  const useSchema = (action) => {
1081
1158
  const name = action.name;
1082
1159
  const alepha = useAlepha();
1083
1160
  const httpClient = useInject(HttpClient);
1084
- const linkProvider = useInject(LinkProvider);
1085
1161
  const [schema, setSchema] = useState(ssrSchemaLoading(alepha, name));
1086
1162
  useEffect(() => {
1087
1163
  if (!schema.loading) return;
1088
1164
  const opts = { cache: true };
1089
- httpClient.fetch(`${linkProvider.URL_LINKS}/${name}/schema`, {}, opts).then((it) => setSchema(it.data));
1165
+ httpClient.fetch(`${LinkProvider.path.apiLinks}/${name}/schema`, {}, opts).then((it) => setSchema(it.data));
1090
1166
  }, [name]);
1091
1167
  return schema;
1092
1168
  };
@@ -1095,10 +1171,10 @@ const useSchema = (action) => {
1095
1171
  */
1096
1172
  const ssrSchemaLoading = (alepha, name) => {
1097
1173
  if (!alepha.isBrowser()) {
1098
- const links = alepha.context.get("links")?.links ?? [];
1099
- const can = links.find((it) => it.name === name);
1174
+ const linkProvider = alepha.inject(LinkProvider);
1175
+ const can = linkProvider.getServerLinks().find((link) => link.name === name);
1100
1176
  if (can) {
1101
- const schema$1 = alepha.inject(LinkProvider).links?.find((it) => it.name === name)?.schema;
1177
+ const schema$1 = linkProvider.links.find((it) => it.name === name)?.schema;
1102
1178
  if (schema$1) {
1103
1179
  can.schema = schema$1;
1104
1180
  return schema$1;
@@ -1106,48 +1182,25 @@ const ssrSchemaLoading = (alepha, name) => {
1106
1182
  }
1107
1183
  return { loading: true };
1108
1184
  }
1109
- const schema = alepha.inject(LinkProvider).links?.find((it) => it.name === name)?.schema;
1185
+ const schema = alepha.inject(LinkProvider).links.find((it) => it.name === name)?.schema;
1110
1186
  if (schema) return schema;
1111
1187
  return { loading: true };
1112
1188
  };
1113
1189
 
1114
- //#endregion
1115
- //#region src/hooks/useStore.ts
1116
- /**
1117
- * Hook to access and mutate the Alepha state.
1118
- */
1119
- const useStore = (key) => {
1120
- const alepha = useAlepha();
1121
- const [state, setState] = useState(alepha.state(key));
1122
- useEffect(() => {
1123
- if (!alepha.isBrowser()) return;
1124
- return alepha.on("state:mutate", (ev) => {
1125
- if (ev.key === key) setState(ev.value);
1126
- });
1127
- }, []);
1128
- if (!alepha.isBrowser()) {
1129
- const value = alepha.context.get(key);
1130
- if (value !== null) return [value, (_) => {}];
1131
- }
1132
- return [state, (value) => {
1133
- alepha.state(key, value);
1134
- }];
1135
- };
1136
-
1137
1190
  //#endregion
1138
1191
  //#region src/index.browser.ts
1139
1192
  const AlephaReact = $module({
1140
1193
  name: "alepha.react",
1141
1194
  descriptors: [$page],
1142
1195
  services: [
1143
- PageDescriptorProvider,
1144
- ReactBrowserRenderer,
1145
- BrowserRouterProvider,
1146
- ReactBrowserProvider
1196
+ ReactPageProvider,
1197
+ ReactBrowserRouterProvider,
1198
+ ReactBrowserProvider,
1199
+ ReactRouter
1147
1200
  ],
1148
- register: (alepha) => alepha.with(AlephaServer).with(AlephaServerLinks).with(PageDescriptorProvider).with(ReactBrowserProvider).with(BrowserRouterProvider).with(ReactBrowserRenderer)
1201
+ register: (alepha) => alepha.with(AlephaServer).with(AlephaServerLinks).with(ReactPageProvider).with(ReactBrowserProvider).with(ReactBrowserRouterProvider).with(ReactRouter)
1149
1202
  });
1150
1203
 
1151
1204
  //#endregion
1152
- export { $page, AlephaContext, AlephaReact, BrowserRouterProvider, ClientOnly_default as ClientOnly, ErrorBoundary_default as ErrorBoundary, Link_default as Link, NestedView_default as NestedView, NotFoundPage as NotFound, PageDescriptor, PageDescriptorProvider, ReactBrowserProvider, RedirectionError, RouterContext, RouterHookApi, RouterLayerContext, isPageRoute, ssrSchemaLoading, useActive, useAlepha, useClient, useInject, useQueryParams, useRouter, useRouterEvents, useRouterState, useSchema, useStore };
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 };
1153
1206
  //# sourceMappingURL=index.browser.js.map