@alepha/react 0.9.3 → 0.9.5

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