@alepha/react 0.9.3 → 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 (37) hide show
  1. package/README.md +46 -0
  2. package/dist/index.browser.js +315 -320
  3. package/dist/index.browser.js.map +1 -1
  4. package/dist/index.cjs +496 -457
  5. package/dist/index.cjs.map +1 -1
  6. package/dist/index.d.cts +276 -258
  7. package/dist/index.d.cts.map +1 -1
  8. package/dist/index.d.ts +274 -256
  9. package/dist/index.d.ts.map +1 -1
  10. package/dist/index.js +494 -460
  11. package/dist/index.js.map +1 -1
  12. package/package.json +13 -10
  13. package/src/components/NestedView.tsx +15 -13
  14. package/src/components/NotFound.tsx +1 -1
  15. package/src/descriptors/$page.ts +16 -4
  16. package/src/errors/Redirection.ts +8 -5
  17. package/src/hooks/useActive.ts +25 -34
  18. package/src/hooks/useAlepha.ts +16 -2
  19. package/src/hooks/useClient.ts +7 -4
  20. package/src/hooks/useInject.ts +4 -1
  21. package/src/hooks/useQueryParams.ts +9 -6
  22. package/src/hooks/useRouter.ts +18 -31
  23. package/src/hooks/useRouterEvents.ts +7 -7
  24. package/src/hooks/useRouterState.ts +8 -20
  25. package/src/hooks/useSchema.ts +10 -15
  26. package/src/hooks/useStore.ts +0 -7
  27. package/src/index.browser.ts +11 -11
  28. package/src/index.shared.ts +2 -3
  29. package/src/index.ts +21 -30
  30. package/src/providers/ReactBrowserProvider.ts +149 -65
  31. package/src/providers/ReactBrowserRouterProvider.ts +132 -0
  32. package/src/providers/{PageDescriptorProvider.ts → ReactPageProvider.ts} +84 -112
  33. package/src/providers/ReactServerProvider.ts +69 -74
  34. package/src/{hooks/RouterHookApi.ts → services/ReactRouter.ts} +44 -54
  35. package/src/contexts/RouterContext.ts +0 -14
  36. package/src/providers/BrowserRouterProvider.ts +0 -155
  37. package/src/providers/ReactBrowserRenderer.ts +0 -93
@@ -1,10 +1,12 @@
1
- import { $env, $hook, $inject, $logger, $module, Alepha, Descriptor, KIND, 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
  /**
@@ -30,6 +32,12 @@ var PageDescriptor = class extends Descriptor {
30
32
  async render(options) {
31
33
  throw new Error("render method is not implemented in this environment");
32
34
  }
35
+ match(url) {
36
+ return false;
37
+ }
38
+ pathname(config) {
39
+ return this.options.path || "";
40
+ }
33
41
  };
34
42
  $page[KIND] = PageDescriptor;
35
43
 
@@ -53,7 +61,7 @@ function NotFoundPage(props) {
53
61
  fontSize: "1rem",
54
62
  marginBottom: "0.5rem"
55
63
  },
56
- children: "This page does not exist"
64
+ children: "404 - This page does not exist"
57
65
  })
58
66
  });
59
67
  }
@@ -226,28 +234,54 @@ const ErrorViewerProduction = () => {
226
234
  });
227
235
  };
228
236
 
229
- //#endregion
230
- //#region src/contexts/RouterContext.ts
231
- const RouterContext = createContext(void 0);
232
-
233
237
  //#endregion
234
238
  //#region src/contexts/RouterLayerContext.ts
235
239
  const RouterLayerContext = createContext(void 0);
236
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
+
237
256
  //#endregion
238
257
  //#region src/contexts/AlephaContext.ts
239
258
  const AlephaContext = createContext(void 0);
240
259
 
241
260
  //#endregion
242
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
+ */
243
274
  const useAlepha = () => {
244
275
  const alepha = useContext(AlephaContext);
245
- 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");
246
277
  return alepha;
247
278
  };
248
279
 
249
280
  //#endregion
250
281
  //#region src/hooks/useRouterEvents.ts
282
+ /**
283
+ * Subscribe to various router events.
284
+ */
251
285
  const useRouterEvents = (opts = {}, deps = []) => {
252
286
  const alepha = useAlepha();
253
287
  useEffect(() => {
@@ -320,19 +354,21 @@ var ErrorBoundary_default = ErrorBoundary;
320
354
  * ```
321
355
  */
322
356
  const NestedView = (props) => {
323
- const app = useContext(RouterContext);
324
357
  const layer = useContext(RouterLayerContext);
325
358
  const index = layer?.index ?? 0;
326
- const [view, setView] = useState(app?.state.layers[index]?.element);
327
- useRouterEvents({ onEnd: ({ state, context }) => {
328
- if (app) app.context = context;
329
- if (!state.layers[index]?.cache) setView(state.layers[index]?.element);
330
- } }, [app]);
331
- 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
+ } }, []);
332
366
  const element = view ?? props.children ?? null;
333
367
  return /* @__PURE__ */ jsx(ErrorBoundary_default, {
334
368
  fallback: (error) => {
335
- return app.context.onError?.(error, app.context);
369
+ const result = state.onError(error, state);
370
+ if (result instanceof Redirection) return "Redirection inside ErrorBoundary is not allowed.";
371
+ return result;
336
372
  },
337
373
  children: element
338
374
  });
@@ -340,19 +376,9 @@ const NestedView = (props) => {
340
376
  var NestedView_default = NestedView;
341
377
 
342
378
  //#endregion
343
- //#region src/errors/Redirection.ts
344
- var Redirection = class extends Error {
345
- page;
346
- constructor(page) {
347
- super("Redirection");
348
- this.page = page;
349
- }
350
- };
351
-
352
- //#endregion
353
- //#region src/providers/PageDescriptorProvider.ts
379
+ //#region src/providers/ReactPageProvider.ts
354
380
  const envSchema$1 = t.object({ REACT_STRICT_MODE: t.boolean({ default: true }) });
355
- var PageDescriptorProvider = class {
381
+ var ReactPageProvider = class {
356
382
  log = $logger();
357
383
  env = $env(envSchema$1);
358
384
  alepha = $inject(Alepha);
@@ -381,22 +407,21 @@ var PageDescriptorProvider = class {
381
407
  return url.replace(/\/\/+/g, "/") || "/";
382
408
  }
383
409
  url(name, options = {}) {
384
- return new URL(this.pathname(name, options), options.base ?? `http://localhost`);
410
+ return new URL(this.pathname(name, options), options.host ?? `http://localhost`);
385
411
  }
386
- root(state, context) {
387
- const root = createElement(AlephaContext.Provider, { value: this.alepha }, createElement(RouterContext.Provider, { value: {
388
- state,
389
- context
390
- } }, createElement(NestedView_default, {}, state.layers[0]?.element)));
412
+ root(state) {
413
+ const root = createElement(AlephaContext.Provider, { value: this.alepha }, createElement(NestedView_default, {}, state.layers[0]?.element));
391
414
  if (this.env.REACT_STRICT_MODE) return createElement(StrictMode, {}, root);
392
415
  return root;
393
416
  }
394
- async createLayers(route, request) {
395
- const { pathname, search } = request.url;
396
- 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 = []) {
397
423
  let context = {};
398
424
  const stack = [{ route }];
399
- request.onError = (error) => this.renderError(error);
400
425
  let parent = route.parent;
401
426
  while (parent) {
402
427
  stack.unshift({ route: parent });
@@ -408,19 +433,18 @@ var PageDescriptorProvider = class {
408
433
  const route$1 = it.route;
409
434
  const config = {};
410
435
  try {
411
- 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) : {};
412
437
  } catch (e) {
413
438
  it.error = e;
414
439
  break;
415
440
  }
416
441
  try {
417
- 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) : {};
418
443
  } catch (e) {
419
444
  it.error = e;
420
445
  break;
421
446
  }
422
447
  it.config = { ...config };
423
- const previous = request.previous;
424
448
  if (previous?.[i] && !forceRefresh && previous[i].name === route$1.name) {
425
449
  const url = (str) => str ? str.replace(/\/\/+/g, "/") : "/";
426
450
  const prev = JSON.stringify({
@@ -446,7 +470,7 @@ var PageDescriptorProvider = class {
446
470
  if (!route$1.resolve) continue;
447
471
  try {
448
472
  const props = await route$1.resolve?.({
449
- ...request,
473
+ ...state,
450
474
  ...config,
451
475
  ...context
452
476
  }) ?? {};
@@ -456,11 +480,8 @@ var PageDescriptorProvider = class {
456
480
  ...props
457
481
  };
458
482
  } catch (e) {
459
- if (e instanceof Redirection) return this.createRedirectionLayer(e.page, {
460
- pathname,
461
- search
462
- });
463
- this.log.error(e);
483
+ if (e instanceof Redirection) return { redirect: e.redirect };
484
+ this.log.error("Page resolver has failed", e);
464
485
  it.error = e;
465
486
  break;
466
487
  }
@@ -476,69 +497,58 @@ var PageDescriptorProvider = class {
476
497
  const path = acc.replace(/\/+/, "/");
477
498
  const localErrorHandler = this.getErrorHandler(it.route);
478
499
  if (localErrorHandler) {
479
- const onErrorParent = request.onError;
480
- request.onError = (error, context$1) => {
500
+ const onErrorParent = state.onError;
501
+ state.onError = (error, context$1) => {
481
502
  const result = localErrorHandler(error, context$1);
482
503
  if (result === void 0) return onErrorParent(error, context$1);
483
504
  return result;
484
505
  };
485
506
  }
486
- if (it.error) try {
487
- let element$1 = await request.onError(it.error, request);
488
- if (element$1 === void 0) throw it.error;
489
- if (element$1 instanceof Redirection) return this.createRedirectionLayer(element$1.page, {
490
- pathname,
491
- search
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
492
522
  });
493
- if (element$1 === null) element$1 = this.renderError(it.error);
494
- layers.push({
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({
495
532
  props,
496
533
  error: it.error,
497
534
  name: it.route.name,
498
535
  part: it.route.path,
499
536
  config: it.config,
500
- element: this.renderView(i + 1, path, element$1, it.route),
537
+ element: this.renderView(i + 1, path, element, it.route),
501
538
  index: i + 1,
502
539
  path,
503
540
  route: it.route
504
541
  });
505
542
  break;
506
543
  } catch (e) {
507
- if (e instanceof Redirection) return this.createRedirectionLayer(e.page, {
508
- pathname,
509
- search
510
- });
544
+ if (e instanceof Redirection) return { redirect: e.redirect };
511
545
  throw e;
512
546
  }
513
- const element = await this.createElement(it.route, {
514
- ...props,
515
- ...context
516
- });
517
- layers.push({
518
- name: it.route.name,
519
- props,
520
- part: it.route.path,
521
- config: it.config,
522
- element: this.renderView(i + 1, path, element, it.route),
523
- index: i + 1,
524
- path,
525
- route: it.route,
526
- cache: it.cache
527
- });
528
547
  }
529
- return {
530
- layers,
531
- pathname,
532
- search
533
- };
548
+ return { state };
534
549
  }
535
- createRedirectionLayer(href, context) {
536
- return {
537
- layers: [],
538
- redirect: typeof href === "string" ? href : this.href(href),
539
- pathname: context.pathname,
540
- search: context.search
541
- };
550
+ createRedirectionLayer(redirect) {
551
+ return { redirect };
542
552
  }
543
553
  getErrorHandler(route) {
544
554
  if (route.errorHandler) return route.errorHandler;
@@ -665,57 +675,43 @@ const isPageRoute = (it) => {
665
675
  };
666
676
 
667
677
  //#endregion
668
- //#region src/providers/BrowserRouterProvider.ts
669
- var BrowserRouterProvider = class extends RouterProvider {
678
+ //#region src/providers/ReactBrowserRouterProvider.ts
679
+ var ReactBrowserRouterProvider = class extends RouterProvider {
670
680
  log = $logger();
671
681
  alepha = $inject(Alepha);
672
- pageDescriptorProvider = $inject(PageDescriptorProvider);
682
+ pageApi = $inject(ReactPageProvider);
673
683
  add(entry) {
674
- this.pageDescriptorProvider.add(entry);
684
+ this.pageApi.add(entry);
675
685
  }
676
686
  configure = $hook({
677
687
  on: "configure",
678
688
  handler: async () => {
679
- 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({
680
690
  path: page.match,
681
691
  page
682
692
  });
683
693
  }
684
694
  });
685
- async transition(url, options = {}) {
695
+ async transition(url, previous = []) {
686
696
  const { pathname, search } = url;
687
- const state = {
688
- pathname,
689
- search,
690
- layers: []
691
- };
692
- const context = {
697
+ const entry = {
693
698
  url,
694
699
  query: {},
695
700
  params: {},
696
- onError: () => null,
697
- ...options.context ?? {}
701
+ layers: [],
702
+ onError: () => null
698
703
  };
699
- await this.alepha.emit("react:transition:begin", {
700
- state,
701
- context
702
- });
704
+ const state = entry;
705
+ await this.alepha.emit("react:transition:begin", { state });
703
706
  try {
704
- const previous = options.previous;
705
707
  const { route, params } = this.match(pathname);
706
708
  const query = {};
707
709
  if (search) for (const [key, value] of new URLSearchParams(search).entries()) query[key] = String(value);
708
- context.query = query;
709
- context.params = params ?? {};
710
- context.previous = previous;
710
+ state.query = query;
711
+ state.params = params ?? {};
711
712
  if (isPageRoute(route)) {
712
- const result = await this.pageDescriptorProvider.createLayers(route.page, context);
713
- if (result.redirect) return {
714
- redirect: result.redirect,
715
- state,
716
- context
717
- };
718
- state.layers = result.layers;
713
+ const { redirect } = await this.pageApi.createLayers(route.page, state, previous);
714
+ if (redirect) return redirect;
719
715
  }
720
716
  if (state.layers.length === 0) state.layers.push({
721
717
  name: "not-found",
@@ -723,86 +719,92 @@ var BrowserRouterProvider = class extends RouterProvider {
723
719
  index: 0,
724
720
  path: "/"
725
721
  });
726
- await this.alepha.emit("react:transition:success", {
727
- state,
728
- context
729
- });
722
+ await this.alepha.emit("react:transition:success", { state });
730
723
  } catch (e) {
731
- this.log.error(e);
724
+ this.log.error("Transition has failed", e);
732
725
  state.layers = [{
733
726
  name: "error",
734
- element: this.pageDescriptorProvider.renderError(e),
727
+ element: this.pageApi.renderError(e),
735
728
  index: 0,
736
729
  path: "/"
737
730
  }];
738
731
  await this.alepha.emit("react:transition:error", {
739
732
  error: e,
740
- state,
741
- context
733
+ state
742
734
  });
743
735
  }
744
- if (options.state) {
745
- options.state.layers = state.layers;
746
- options.state.pathname = state.pathname;
747
- 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?.();
748
739
  }
749
- if (options.previous) for (let i = 0; i < options.previous.length; i++) {
750
- const layer = options.previous[i];
751
- if (state.layers[i]?.name !== layer.name) this.pageDescriptorProvider.page(layer.name)?.onLeave?.();
752
- }
753
- await this.alepha.emit("react:transition:end", {
754
- state: options.state,
755
- context
756
- });
757
- return {
758
- context,
759
- state
760
- };
740
+ await this.alepha.emit("react:transition:end", { state });
741
+ this.alepha.state("react.router.state", state);
761
742
  }
762
- root(state, context) {
763
- return this.pageDescriptorProvider.root(state, context);
743
+ root(state) {
744
+ return this.pageApi.root(state);
764
745
  }
765
746
  };
766
747
 
767
748
  //#endregion
768
749
  //#region src/providers/ReactBrowserProvider.ts
750
+ const envSchema = t.object({ REACT_ROOT_ID: t.string({ default: "root" }) });
769
751
  var ReactBrowserProvider = class {
752
+ env = $env(envSchema);
770
753
  log = $logger();
771
754
  client = $inject(LinkProvider);
772
755
  alepha = $inject(Alepha);
773
- router = $inject(BrowserRouterProvider);
756
+ router = $inject(ReactBrowserRouterProvider);
757
+ dateTimeProvider = $inject(DateTimeProvider);
774
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
+ }
775
768
  transitioning;
776
- state = {
777
- layers: [],
778
- pathname: "",
779
- search: ""
780
- };
769
+ get state() {
770
+ return this.alepha.state("react.router.state");
771
+ }
772
+ /**
773
+ * Accessor for Document DOM API.
774
+ */
781
775
  get document() {
782
776
  return window.document;
783
777
  }
778
+ /**
779
+ * Accessor for History DOM API.
780
+ */
784
781
  get history() {
785
782
  return window.history;
786
783
  }
784
+ /**
785
+ * Accessor for Location DOM API.
786
+ */
787
787
  get location() {
788
788
  return window.location;
789
789
  }
790
+ get base() {
791
+ const base = import.meta.env?.BASE_URL;
792
+ if (!base || base === "/") return "";
793
+ return base;
794
+ }
790
795
  get url() {
791
- let url = this.location.pathname + this.location.search;
792
- if (import.meta?.env?.BASE_URL) {
793
- url = url.replace(import.meta.env?.BASE_URL, "");
794
- if (!url.startsWith("/")) url = `/${url}`;
795
- }
796
+ const url = this.location.pathname + this.location.search;
797
+ if (this.base) return url.replace(this.base, "");
796
798
  return url;
797
799
  }
798
- pushState(url, replace) {
799
- let path = url;
800
- if (import.meta?.env?.BASE_URL) path = (import.meta.env?.BASE_URL + path).replaceAll("//", "/");
801
- if (replace) this.history.replaceState({}, "", path);
802
- 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);
803
804
  }
804
805
  async invalidate(props) {
805
806
  const previous = [];
807
+ this.log.trace("Invalidating layers");
806
808
  if (props) {
807
809
  const [key] = Object.keys(props);
808
810
  const value = props[key];
@@ -823,9 +825,16 @@ var ReactBrowserProvider = class {
823
825
  await this.render({ previous });
824
826
  }
825
827
  async go(url, options = {}) {
826
- const result = await this.render({ url });
827
- if (result.context.url.pathname + result.context.url.search !== url) {
828
- this.pushState(result.context.url.pathname + result.context.url.search);
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);
829
838
  return;
830
839
  }
831
840
  this.pushState(url, options.replace);
@@ -833,14 +842,20 @@ var ReactBrowserProvider = class {
833
842
  async render(options = {}) {
834
843
  const previous = options.previous ?? this.state.layers;
835
844
  const url = options.url ?? this.url;
836
- this.transitioning = { to: url };
837
- const result = await this.router.transition(new URL(`http://localhost${url}`), {
838
- previous,
839
- state: this.state
840
- });
841
- 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);
842
858
  this.transitioning = void 0;
843
- return result;
844
859
  }
845
860
  /**
846
861
  * Get embedded layers from the server.
@@ -852,54 +867,25 @@ var ReactBrowserProvider = class {
852
867
  console.error(error);
853
868
  }
854
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
+ });
855
879
  ready = $hook({
856
880
  on: "ready",
857
881
  handler: async () => {
858
882
  const hydration = this.getHydrationState();
859
883
  const previous = hydration?.layers ?? [];
860
884
  if (hydration) {
861
- for (const [key, value] of Object.entries(hydration)) if (key !== "layers" && key !== "links") this.alepha.state(key, value);
885
+ for (const [key, value] of Object.entries(hydration)) if (key !== "layers") this.alepha.state(key, value);
862
886
  }
863
- if (hydration?.links) for (const link of hydration.links.links) this.client.pushLink({
864
- ...link,
865
- prefix: hydration.links.prefix
866
- });
867
- const { context } = await this.render({ previous });
868
- await this.alepha.emit("react:browser:render", {
869
- state: this.state,
870
- context,
871
- hydration
872
- });
873
- window.addEventListener("popstate", () => {
874
- if (this.state.pathname === this.url) return;
875
- this.render();
876
- });
877
- }
878
- });
879
- };
880
-
881
- //#endregion
882
- //#region src/providers/ReactBrowserRenderer.ts
883
- const envSchema = t.object({ REACT_ROOT_ID: t.string({ default: "root" }) });
884
- var ReactBrowserRenderer = class {
885
- browserProvider = $inject(ReactBrowserProvider);
886
- browserRouterProvider = $inject(BrowserRouterProvider);
887
- env = $env(envSchema);
888
- log = $logger();
889
- root;
890
- options = { scrollRestoration: "top" };
891
- getRootElement() {
892
- const root = this.browserProvider.document.getElementById(this.env.REACT_ROOT_ID);
893
- if (root) return root;
894
- const div = this.browserProvider.document.createElement("div");
895
- div.id = this.env.REACT_ROOT_ID;
896
- this.browserProvider.document.body.prepend(div);
897
- return div;
898
- }
899
- ready = $hook({
900
- on: "react:browser:render",
901
- handler: async ({ state, context, hydration }) => {
902
- const element = this.browserRouterProvider.root(state, context);
887
+ await this.render({ previous });
888
+ const element = this.router.root(this.state);
903
889
  if (hydration?.layers) {
904
890
  this.root = hydrateRoot(this.getRootElement(), element);
905
891
  this.log.info("Hydrated root element");
@@ -908,38 +894,41 @@ var ReactBrowserRenderer = class {
908
894
  this.root.render(element);
909
895
  this.log.info("Created root element");
910
896
  }
911
- }
912
- });
913
- onTransitionEnd = $hook({
914
- on: "react:transition:end",
915
- handler: () => {
916
- 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
+ });
917
902
  }
918
903
  });
919
904
  };
920
905
 
921
906
  //#endregion
922
- //#region src/hooks/RouterHookApi.ts
923
- var RouterHookApi = class {
924
- constructor(pages, context, state, layer, pageApi, browser) {
925
- this.pages = pages;
926
- this.context = context;
927
- this.state = state;
928
- this.layer = layer;
929
- this.pageApi = pageApi;
930
- 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;
931
920
  }
932
921
  path(name, config = {}) {
933
922
  return this.pageApi.pathname(name, {
934
923
  params: {
935
- ...this.context.params,
924
+ ...this.state.params,
936
925
  ...config.params
937
926
  },
938
927
  query: config.query
939
928
  });
940
929
  }
941
930
  getURL() {
942
- if (!this.browser) return this.context.url;
931
+ if (!this.browser) return this.state.url;
943
932
  return new URL(this.location.href);
944
933
  }
945
934
  get location() {
@@ -950,11 +939,11 @@ var RouterHookApi = class {
950
939
  return this.state;
951
940
  }
952
941
  get pathname() {
953
- return this.state.pathname;
942
+ return this.state.url.pathname;
954
943
  }
955
944
  get query() {
956
945
  const query = {};
957
- 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);
958
947
  return query;
959
948
  }
960
949
  async back() {
@@ -966,17 +955,6 @@ var RouterHookApi = class {
966
955
  async invalidate(props) {
967
956
  await this.browser?.invalidate(props);
968
957
  }
969
- /**
970
- * Create a valid href for the given pathname.
971
- *
972
- * @param pathname
973
- * @param layer
974
- */
975
- createHref(pathname, layer = this.layer, options = {}) {
976
- if (typeof pathname === "object") pathname = pathname.options.path ?? "";
977
- if (options.params) for (const [key, value] of Object.entries(options.params)) pathname = pathname.replace(`:${key}`, String(value));
978
- return pathname.startsWith("/") ? pathname : `${layer.path}/${pathname}`.replace(/\/\/+/g, "/");
979
- }
980
958
  async go(path, options) {
981
959
  for (const page of this.pages) if (page.name === path) {
982
960
  await this.browser?.go(this.path(path, options), options);
@@ -991,7 +969,7 @@ var RouterHookApi = class {
991
969
  break;
992
970
  }
993
971
  return {
994
- href,
972
+ href: this.base(href),
995
973
  onClick: (ev) => {
996
974
  ev.stopPropagation();
997
975
  ev.preventDefault();
@@ -999,6 +977,11 @@ var RouterHookApi = class {
999
977
  }
1000
978
  };
1001
979
  }
980
+ base(path) {
981
+ const base = import.meta.env?.BASE_URL;
982
+ if (!base || base === "/") return path;
983
+ return base + path;
984
+ }
1002
985
  /**
1003
986
  * Set query params.
1004
987
  *
@@ -1014,17 +997,35 @@ var RouterHookApi = class {
1014
997
  }
1015
998
  };
1016
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
+
1017
1011
  //#endregion
1018
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
+ */
1019
1027
  const useRouter = () => {
1020
- const alepha = useAlepha();
1021
- const ctx = useContext(RouterContext);
1022
- const layer = useContext(RouterLayerContext);
1023
- if (!ctx || !layer) throw new Error("useRouter must be used within a RouterProvider");
1024
- const pages = useMemo(() => {
1025
- return alepha.inject(PageDescriptorProvider).getPages();
1026
- }, []);
1027
- return useMemo(() => new RouterHookApi(pages, ctx.context, ctx.state, layer, alepha.inject(PageDescriptorProvider), alepha.isBrowser() ? alepha.inject(ReactBrowserProvider) : void 0), [layer]);
1028
+ return useInject(ReactRouter);
1028
1029
  };
1029
1030
 
1030
1031
  //#endregion
@@ -1040,46 +1041,6 @@ const Link = (props) => {
1040
1041
  };
1041
1042
  var Link_default = Link;
1042
1043
 
1043
- //#endregion
1044
- //#region src/hooks/useActive.ts
1045
- const useActive = (path) => {
1046
- const router = useRouter();
1047
- const ctx = useContext(RouterContext);
1048
- const layer = useContext(RouterLayerContext);
1049
- if (!ctx || !layer) throw new Error("useRouter must be used within a RouterProvider");
1050
- const [current, setCurrent] = useState(ctx.state.pathname);
1051
- const href = useMemo(() => router.createHref(path ?? "", layer), [path, layer]);
1052
- const [isPending, setPending] = useState(false);
1053
- const isActive = current === href || current === `${href}/` || `${current}/` === href;
1054
- useRouterEvents({ onEnd: ({ state }) => {
1055
- path && setCurrent(state.pathname);
1056
- } }, [path]);
1057
- return {
1058
- isPending,
1059
- isActive,
1060
- anchorProps: {
1061
- href,
1062
- onClick: (ev) => {
1063
- ev?.stopPropagation();
1064
- ev?.preventDefault();
1065
- if (isActive) return;
1066
- if (isPending) return;
1067
- setPending(true);
1068
- router.go(href).then(() => {
1069
- setPending(false);
1070
- });
1071
- }
1072
- }
1073
- };
1074
- };
1075
-
1076
- //#endregion
1077
- //#region src/hooks/useInject.ts
1078
- const useInject = (service) => {
1079
- const alepha = useAlepha();
1080
- return useMemo(() => alepha.inject(service), []);
1081
- };
1082
-
1083
1044
  //#endregion
1084
1045
  //#region src/hooks/useStore.ts
1085
1046
  /**
@@ -1097,24 +1058,70 @@ const useStore = (key, defaultValue) => {
1097
1058
  if (ev.key === key) setState(ev.value);
1098
1059
  });
1099
1060
  }, []);
1100
- if (!alepha.isBrowser()) {
1101
- const value = alepha.context.get(key);
1102
- if (value !== null) return [value, (_) => {}];
1103
- }
1104
1061
  return [state, (value) => {
1105
1062
  alepha.state(key, value);
1106
1063
  }];
1107
1064
  };
1108
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
+
1074
+ //#endregion
1075
+ //#region src/hooks/useActive.ts
1076
+ const useActive = (args) => {
1077
+ const router = useRouter();
1078
+ const [isPending, setPending] = useState(false);
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);
1088
+ return {
1089
+ isPending,
1090
+ isActive,
1091
+ anchorProps: {
1092
+ href: router.base(href),
1093
+ onClick: async (ev) => {
1094
+ ev?.stopPropagation();
1095
+ ev?.preventDefault();
1096
+ if (isActive) return;
1097
+ if (isPending) return;
1098
+ setPending(true);
1099
+ try {
1100
+ await router.go(href);
1101
+ } finally {
1102
+ setPending(false);
1103
+ }
1104
+ }
1105
+ }
1106
+ };
1107
+ };
1108
+
1109
1109
  //#endregion
1110
1110
  //#region src/hooks/useClient.ts
1111
- const useClient = (_scope) => {
1112
- useStore("user");
1113
- 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);
1114
1118
  };
1115
1119
 
1116
1120
  //#endregion
1117
1121
  //#region src/hooks/useQueryParams.ts
1122
+ /**
1123
+ * Not well tested. Use with caution.
1124
+ */
1118
1125
  const useQueryParams = (schema, options = {}) => {
1119
1126
  const alepha = useAlepha();
1120
1127
  const key = options.key ?? "q";
@@ -1145,29 +1152,17 @@ const decode = (alepha, schema, data) => {
1145
1152
  }
1146
1153
  };
1147
1154
 
1148
- //#endregion
1149
- //#region src/hooks/useRouterState.ts
1150
- const useRouterState = () => {
1151
- const router = useContext(RouterContext);
1152
- const layer = useContext(RouterLayerContext);
1153
- if (!router || !layer) throw new Error("useRouterState must be used within a RouterContext.Provider");
1154
- const [state, setState] = useState(router.state);
1155
- useRouterEvents({ onEnd: ({ state: state$1 }) => setState({ ...state$1 }) });
1156
- return state;
1157
- };
1158
-
1159
1155
  //#endregion
1160
1156
  //#region src/hooks/useSchema.ts
1161
1157
  const useSchema = (action) => {
1162
1158
  const name = action.name;
1163
1159
  const alepha = useAlepha();
1164
1160
  const httpClient = useInject(HttpClient);
1165
- const linkProvider = useInject(LinkProvider);
1166
1161
  const [schema, setSchema] = useState(ssrSchemaLoading(alepha, name));
1167
1162
  useEffect(() => {
1168
1163
  if (!schema.loading) return;
1169
1164
  const opts = { cache: true };
1170
- 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));
1171
1166
  }, [name]);
1172
1167
  return schema;
1173
1168
  };
@@ -1176,10 +1171,10 @@ const useSchema = (action) => {
1176
1171
  */
1177
1172
  const ssrSchemaLoading = (alepha, name) => {
1178
1173
  if (!alepha.isBrowser()) {
1179
- const links = alepha.context.get("links")?.links ?? [];
1180
- const can = links.find((it) => it.name === name);
1174
+ const linkProvider = alepha.inject(LinkProvider);
1175
+ const can = linkProvider.getServerLinks().find((link) => link.name === name);
1181
1176
  if (can) {
1182
- 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;
1183
1178
  if (schema$1) {
1184
1179
  can.schema = schema$1;
1185
1180
  return schema$1;
@@ -1187,7 +1182,7 @@ const ssrSchemaLoading = (alepha, name) => {
1187
1182
  }
1188
1183
  return { loading: true };
1189
1184
  }
1190
- 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;
1191
1186
  if (schema) return schema;
1192
1187
  return { loading: true };
1193
1188
  };
@@ -1198,14 +1193,14 @@ const AlephaReact = $module({
1198
1193
  name: "alepha.react",
1199
1194
  descriptors: [$page],
1200
1195
  services: [
1201
- PageDescriptorProvider,
1202
- ReactBrowserRenderer,
1203
- BrowserRouterProvider,
1204
- ReactBrowserProvider
1196
+ ReactPageProvider,
1197
+ ReactBrowserRouterProvider,
1198
+ ReactBrowserProvider,
1199
+ ReactRouter
1205
1200
  ],
1206
- 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)
1207
1202
  });
1208
1203
 
1209
1204
  //#endregion
1210
- 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, Redirection, 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 };
1211
1206
  //# sourceMappingURL=index.browser.js.map