@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
package/src/index.ts CHANGED
@@ -3,65 +3,55 @@ import { AlephaServer, type ServerRequest } from "@alepha/server";
3
3
  import { AlephaServerCache } from "@alepha/server-cache";
4
4
  import { AlephaServerLinks } from "@alepha/server-links";
5
5
  import { $page } from "./descriptors/$page.ts";
6
+ import type { ReactHydrationState } from "./providers/ReactBrowserProvider.ts";
6
7
  import {
7
- PageDescriptorProvider,
8
- type PageReactContext,
9
- type PageRequest,
10
- type RouterState,
11
- } from "./providers/PageDescriptorProvider.ts";
12
- import {
13
- ReactBrowserProvider,
14
- type ReactHydrationState,
15
- } from "./providers/ReactBrowserProvider.ts";
8
+ ReactPageProvider,
9
+ type ReactRouterState,
10
+ } from "./providers/ReactPageProvider.ts";
16
11
  import { ReactServerProvider } from "./providers/ReactServerProvider.ts";
12
+ import { ReactRouter } from "./services/ReactRouter.ts";
17
13
 
18
14
  // ---------------------------------------------------------------------------------------------------------------------
19
15
 
20
16
  export * from "./index.shared.ts";
21
- export * from "./providers/PageDescriptorProvider.ts";
22
17
  export * from "./providers/ReactBrowserProvider.ts";
18
+ export * from "./providers/ReactPageProvider.ts";
23
19
  export * from "./providers/ReactServerProvider.ts";
24
20
 
25
21
  // ---------------------------------------------------------------------------------------------------------------------
26
22
 
27
23
  declare module "@alepha/core" {
24
+ interface State {
25
+ "react.router.state"?: ReactRouterState;
26
+ }
27
+
28
28
  interface Hooks {
29
- "react:router:createLayers": {
30
- request: ServerRequest;
31
- context: PageRequest;
32
- layers: PageRequest[];
33
- };
34
29
  "react:server:render:begin": {
35
30
  request?: ServerRequest;
36
- context: PageRequest;
31
+ state: ReactRouterState;
37
32
  };
38
33
  "react:server:render:end": {
39
34
  request?: ServerRequest;
40
- context: PageRequest;
41
- state: RouterState;
35
+ state: ReactRouterState;
42
36
  html: string;
43
37
  };
38
+ // -----------------------------------------------------------------------------------------------------------------
44
39
  "react:browser:render": {
45
- state: RouterState;
46
- context: PageReactContext;
40
+ state: ReactRouterState;
47
41
  hydration?: ReactHydrationState;
48
42
  };
49
43
  "react:transition:begin": {
50
- state: RouterState;
51
- context: PageReactContext;
44
+ state: ReactRouterState;
52
45
  };
53
46
  "react:transition:success": {
54
- state: RouterState;
55
- context: PageReactContext;
47
+ state: ReactRouterState;
56
48
  };
57
49
  "react:transition:error": {
50
+ state: ReactRouterState;
58
51
  error: Error;
59
- state: RouterState;
60
- context: PageReactContext;
61
52
  };
62
53
  "react:transition:end": {
63
- state: RouterState;
64
- context: PageReactContext;
54
+ state: ReactRouterState;
65
55
  };
66
56
  }
67
57
  }
@@ -81,12 +71,13 @@ declare module "@alepha/core" {
81
71
  export const AlephaReact = $module({
82
72
  name: "alepha.react",
83
73
  descriptors: [$page],
84
- services: [ReactServerProvider, PageDescriptorProvider, ReactBrowserProvider],
74
+ services: [ReactServerProvider, ReactPageProvider, ReactRouter],
85
75
  register: (alepha) =>
86
76
  alepha
87
77
  .with(AlephaServer)
88
78
  .with(AlephaServerCache)
89
79
  .with(AlephaServerLinks)
90
80
  .with(ReactServerProvider)
91
- .with(PageDescriptorProvider),
81
+ .with(ReactPageProvider)
82
+ .with(ReactRouter),
92
83
  });
@@ -1,74 +1,124 @@
1
- import { $hook, $inject, $logger, Alepha, type State } from "@alepha/core";
2
- import type { ApiLinksResponse } from "@alepha/server";
1
+ import {
2
+ $env,
3
+ $hook,
4
+ $inject,
5
+ Alepha,
6
+ type State,
7
+ type Static,
8
+ t,
9
+ } from "@alepha/core";
10
+ import { DateTimeProvider } from "@alepha/datetime";
11
+ import { $logger } from "@alepha/logger";
3
12
  import { LinkProvider } from "@alepha/server-links";
4
- import type { Root } from "react-dom/client";
5
- import { BrowserRouterProvider } from "./BrowserRouterProvider.ts";
13
+ import { createRoot, hydrateRoot, type Root } from "react-dom/client";
14
+ import { ReactBrowserRouterProvider } from "./ReactBrowserRouterProvider.ts";
6
15
  import type {
7
16
  PreviousLayerData,
8
- RouterRenderResult,
9
- RouterState,
17
+ ReactRouterState,
10
18
  TransitionOptions,
11
- } from "./PageDescriptorProvider.ts";
19
+ } from "./ReactPageProvider.ts";
20
+
21
+ const envSchema = t.object({
22
+ REACT_ROOT_ID: t.string({ default: "root" }),
23
+ });
24
+
25
+ declare module "@alepha/core" {
26
+ interface Env extends Partial<Static<typeof envSchema>> {}
27
+ }
28
+
29
+ export interface ReactBrowserRendererOptions {
30
+ scrollRestoration?: "top" | "manual";
31
+ }
12
32
 
13
33
  export class ReactBrowserProvider {
34
+ protected readonly env = $env(envSchema);
14
35
  protected readonly log = $logger();
15
36
  protected readonly client = $inject(LinkProvider);
16
37
  protected readonly alepha = $inject(Alepha);
17
- protected readonly router = $inject(BrowserRouterProvider);
18
- protected root!: Root;
38
+ protected readonly router = $inject(ReactBrowserRouterProvider);
39
+ protected readonly dateTimeProvider = $inject(DateTimeProvider);
40
+ protected root?: Root;
41
+
42
+ public options: ReactBrowserRendererOptions = {
43
+ scrollRestoration: "top",
44
+ };
45
+
46
+ protected getRootElement() {
47
+ const root = this.document.getElementById(this.env.REACT_ROOT_ID);
48
+ if (root) {
49
+ return root;
50
+ }
51
+
52
+ const div = this.document.createElement("div");
53
+ div.id = this.env.REACT_ROOT_ID;
54
+
55
+ this.document.body.prepend(div);
56
+
57
+ return div;
58
+ }
19
59
 
20
60
  public transitioning?: {
21
61
  to: string;
62
+ from?: string;
22
63
  };
23
64
 
24
- public state: RouterState = {
25
- layers: [],
26
- pathname: "",
27
- search: "",
28
- };
65
+ public get state(): ReactRouterState {
66
+ return this.alepha.state("react.router.state")!;
67
+ }
29
68
 
69
+ /**
70
+ * Accessor for Document DOM API.
71
+ */
30
72
  public get document() {
31
73
  return window.document;
32
74
  }
33
75
 
76
+ /**
77
+ * Accessor for History DOM API.
78
+ */
34
79
  public get history() {
35
80
  return window.history;
36
81
  }
37
82
 
83
+ /**
84
+ * Accessor for Location DOM API.
85
+ */
38
86
  public get location() {
39
87
  return window.location;
40
88
  }
41
89
 
42
- public get url(): string {
43
- let url = this.location.pathname + this.location.search;
44
-
45
- if (import.meta?.env?.BASE_URL) {
46
- url = url.replace(import.meta.env?.BASE_URL, "");
47
- if (!url.startsWith("/")) {
48
- url = `/${url}`;
49
- }
90
+ public get base() {
91
+ const base = import.meta.env?.BASE_URL;
92
+ if (!base || base === "/") {
93
+ return "";
50
94
  }
51
95
 
52
- return url;
96
+ return base;
53
97
  }
54
98
 
55
- public pushState(url: string, replace?: boolean) {
56
- let path = url;
57
-
58
- if (import.meta?.env?.BASE_URL) {
59
- path = (import.meta.env?.BASE_URL + path).replaceAll("//", "/");
99
+ public get url(): string {
100
+ const url = this.location.pathname + this.location.search;
101
+ if (this.base) {
102
+ return url.replace(this.base, "");
60
103
  }
104
+ return url;
105
+ }
106
+
107
+ public pushState(path: string, replace?: boolean) {
108
+ const url = this.base + path;
61
109
 
62
110
  if (replace) {
63
- this.history.replaceState({}, "", path);
111
+ this.history.replaceState({}, "", url);
64
112
  } else {
65
- this.history.pushState({}, "", path);
113
+ this.history.pushState({}, "", url);
66
114
  }
67
115
  }
68
116
 
69
117
  public async invalidate(props?: Record<string, any>) {
70
118
  const previous: PreviousLayerData[] = [];
71
119
 
120
+ this.log.trace("Invalidating layers");
121
+
72
122
  if (props) {
73
123
  const [key] = Object.keys(props);
74
124
  const value = props[key];
@@ -92,13 +142,19 @@ export class ReactBrowserProvider {
92
142
  }
93
143
 
94
144
  public async go(url: string, options: RouterGoOptions = {}): Promise<void> {
95
- const result = await this.render({
145
+ this.log.trace(`Going to ${url}`, {
146
+ url,
147
+ options,
148
+ });
149
+
150
+ await this.render({
96
151
  url,
152
+ previous: options.force ? [] : this.state.layers,
97
153
  });
98
154
 
99
155
  // when redirecting in browser
100
- if (result.context.url.pathname + result.context.url.search !== url) {
101
- this.pushState(result.context.url.pathname + result.context.url.search);
156
+ if (this.state.url.pathname + this.state.url.search !== url) {
157
+ this.pushState(this.state.url.pathname + this.state.url.search);
102
158
  return;
103
159
  }
104
160
 
@@ -107,27 +163,36 @@ export class ReactBrowserProvider {
107
163
 
108
164
  protected async render(
109
165
  options: { url?: string; previous?: PreviousLayerData[] } = {},
110
- ): Promise<RouterRenderResult> {
166
+ ): Promise<void> {
111
167
  const previous = options.previous ?? this.state.layers;
112
168
  const url = options.url ?? this.url;
169
+ const start = this.dateTimeProvider.now();
113
170
 
114
- this.transitioning = { to: url };
171
+ this.transitioning = {
172
+ to: url,
173
+ from: this.state?.url.pathname,
174
+ };
115
175
 
116
- const result = await this.router.transition(
176
+ this.log.debug("Transitioning...", {
177
+ to: url,
178
+ });
179
+
180
+ const redirect = await this.router.transition(
117
181
  new URL(`http://localhost${url}`),
118
- {
119
- previous,
120
- state: this.state,
121
- },
182
+ previous,
122
183
  );
123
184
 
124
- if (result.redirect) {
125
- return await this.render({ url: result.redirect });
185
+ if (redirect) {
186
+ this.log.info("Redirecting to", {
187
+ redirect,
188
+ });
189
+ return await this.render({ url: redirect });
126
190
  }
127
191
 
128
- this.transitioning = undefined;
192
+ const ms = this.dateTimeProvider.now().diff(start);
193
+ this.log.info(`Transition OK [${ms}ms]`, this.transitioning);
129
194
 
130
- return result;
195
+ this.transitioning = undefined;
131
196
  }
132
197
 
133
198
  /**
@@ -145,6 +210,19 @@ export class ReactBrowserProvider {
145
210
 
146
211
  // -------------------------------------------------------------------------------------------------------------------
147
212
 
213
+ protected readonly onTransitionEnd = $hook({
214
+ on: "react:transition:end",
215
+ handler: () => {
216
+ if (
217
+ this.options.scrollRestoration === "top" &&
218
+ typeof window !== "undefined"
219
+ ) {
220
+ this.log.trace("Restoring scroll position to top");
221
+ window.scrollTo(0, 0);
222
+ }
223
+ },
224
+ });
225
+
148
226
  public readonly ready = $hook({
149
227
  on: "ready",
150
228
  handler: async () => {
@@ -152,37 +230,37 @@ export class ReactBrowserProvider {
152
230
  const previous = hydration?.layers ?? [];
153
231
 
154
232
  if (hydration) {
233
+ // low budget, but works for now
155
234
  for (const [key, value] of Object.entries(hydration)) {
156
- if (key !== "layers" && key !== "links") {
235
+ if (key !== "layers") {
157
236
  this.alepha.state(key as keyof State, value);
158
237
  }
159
238
  }
160
239
  }
161
240
 
162
- if (hydration?.links) {
163
- for (const link of hydration.links.links) {
164
- this.client.pushLink({
165
- ...link,
166
- prefix: hydration.links.prefix,
167
- });
168
- }
169
- }
170
-
171
- const { context } = await this.render({ previous });
241
+ await this.render({ previous });
172
242
 
173
- await this.alepha.emit("react:browser:render", {
174
- state: this.state,
175
- context,
176
- hydration,
177
- });
243
+ const element = this.router.root(this.state);
244
+ if (hydration?.layers) {
245
+ this.root = hydrateRoot(this.getRootElement(), element);
246
+ this.log.info("Hydrated root element");
247
+ } else {
248
+ this.root ??= createRoot(this.getRootElement());
249
+ this.root.render(element);
250
+ this.log.info("Created root element");
251
+ }
178
252
 
179
253
  window.addEventListener("popstate", () => {
180
- // when you update silently queryparams or hash, skip rendering
254
+ // when you update silently queryParams or hash, skip rendering
181
255
  // if you want to force a rendering, use #go()
182
- if (this.state.pathname === this.url) {
256
+ if (this.base + this.state.url.pathname === this.location.pathname) {
183
257
  return;
184
258
  }
185
259
 
260
+ this.log.debug("Popstate event triggered - rendering new state", {
261
+ url: this.location.pathname + this.location.search,
262
+ });
263
+
186
264
  this.render();
187
265
  });
188
266
  },
@@ -196,9 +274,15 @@ export interface RouterGoOptions {
196
274
  match?: TransitionOptions;
197
275
  params?: Record<string, string>;
198
276
  query?: Record<string, string>;
277
+
278
+ /**
279
+ * Recreate the whole page, ignoring the current state.
280
+ */
281
+ force?: boolean;
199
282
  }
200
283
 
201
- export interface ReactHydrationState {
284
+ export type ReactHydrationState = {
202
285
  layers?: Array<PreviousLayerData>;
203
- links?: ApiLinksResponse;
204
- }
286
+ } & {
287
+ [key: string]: any;
288
+ };
@@ -0,0 +1,132 @@
1
+ import { $hook, $inject, Alepha } from "@alepha/core";
2
+ import { $logger } from "@alepha/logger";
3
+ import { type Route, RouterProvider } from "@alepha/router";
4
+ import { createElement, type ReactNode } from "react";
5
+ import NotFoundPage from "../components/NotFound.tsx";
6
+ import {
7
+ isPageRoute,
8
+ type PageRoute,
9
+ type PageRouteEntry,
10
+ type PreviousLayerData,
11
+ ReactPageProvider,
12
+ type ReactRouterState,
13
+ } from "./ReactPageProvider.ts";
14
+
15
+ export interface BrowserRoute extends Route {
16
+ page: PageRoute;
17
+ }
18
+
19
+ export class ReactBrowserRouterProvider extends RouterProvider<BrowserRoute> {
20
+ protected readonly log = $logger();
21
+ protected readonly alepha = $inject(Alepha);
22
+ protected readonly pageApi = $inject(ReactPageProvider);
23
+
24
+ public add(entry: PageRouteEntry) {
25
+ this.pageApi.add(entry);
26
+ }
27
+
28
+ protected readonly configure = $hook({
29
+ on: "configure",
30
+ handler: async () => {
31
+ for (const page of this.pageApi.getPages()) {
32
+ // mount only if a view is provided
33
+ if (page.component || page.lazy) {
34
+ this.push({
35
+ path: page.match,
36
+ page,
37
+ });
38
+ }
39
+ }
40
+ },
41
+ });
42
+
43
+ public async transition(
44
+ url: URL,
45
+ previous: PreviousLayerData[] = [],
46
+ ): Promise<string | void> {
47
+ const { pathname, search } = url;
48
+
49
+ const entry: Partial<ReactRouterState> = {
50
+ url,
51
+ query: {},
52
+ params: {},
53
+ layers: [],
54
+ onError: () => null,
55
+ };
56
+
57
+ const state = entry as ReactRouterState;
58
+
59
+ await this.alepha.emit("react:transition:begin", { state });
60
+
61
+ try {
62
+ const { route, params } = this.match(pathname);
63
+
64
+ const query: Record<string, string> = {};
65
+ if (search) {
66
+ for (const [key, value] of new URLSearchParams(search).entries()) {
67
+ query[key] = String(value);
68
+ }
69
+ }
70
+
71
+ state.query = query;
72
+ state.params = params ?? {};
73
+
74
+ if (isPageRoute(route)) {
75
+ const { redirect } = await this.pageApi.createLayers(
76
+ route.page,
77
+ state,
78
+ previous,
79
+ );
80
+ if (redirect) {
81
+ return redirect;
82
+ }
83
+ }
84
+
85
+ if (state.layers.length === 0) {
86
+ state.layers.push({
87
+ name: "not-found",
88
+ element: createElement(NotFoundPage),
89
+ index: 0,
90
+ path: "/",
91
+ });
92
+ }
93
+
94
+ await this.alepha.emit("react:transition:success", { state });
95
+ } catch (e) {
96
+ this.log.error("Transition has failed", e);
97
+ state.layers = [
98
+ {
99
+ name: "error",
100
+ element: this.pageApi.renderError(e as Error),
101
+ index: 0,
102
+ path: "/",
103
+ },
104
+ ];
105
+
106
+ await this.alepha.emit("react:transition:error", {
107
+ error: e as Error,
108
+ state,
109
+ });
110
+ }
111
+
112
+ // [feature]: local hook for leaving a page
113
+ if (previous) {
114
+ for (let i = 0; i < previous.length; i++) {
115
+ const layer = previous[i];
116
+ if (state.layers[i]?.name !== layer.name) {
117
+ this.pageApi.page(layer.name)?.onLeave?.();
118
+ }
119
+ }
120
+ }
121
+
122
+ await this.alepha.emit("react:transition:end", {
123
+ state,
124
+ });
125
+
126
+ this.alepha.state("react.router.state", state);
127
+ }
128
+
129
+ public root(state: ReactRouterState): ReactNode {
130
+ return this.pageApi.root(state);
131
+ }
132
+ }