@alepha/react 0.6.10 → 0.7.1

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 (35) hide show
  1. package/README.md +1 -1
  2. package/dist/index.browser.cjs +21 -20
  3. package/dist/index.browser.js +2 -3
  4. package/dist/index.cjs +168 -82
  5. package/dist/index.d.ts +415 -232
  6. package/dist/index.js +146 -62
  7. package/dist/{useActive-4QlZKGbw.cjs → useRouterState-AdK-XeM2.cjs} +358 -170
  8. package/dist/{useActive-ClUsghB5.js → useRouterState-qoMq7Y9J.js} +358 -172
  9. package/package.json +11 -10
  10. package/src/components/ClientOnly.tsx +35 -0
  11. package/src/components/ErrorBoundary.tsx +72 -0
  12. package/src/components/ErrorViewer.tsx +161 -0
  13. package/src/components/Link.tsx +10 -4
  14. package/src/components/NestedView.tsx +28 -4
  15. package/src/descriptors/$page.ts +143 -38
  16. package/src/errors/RedirectionError.ts +4 -1
  17. package/src/hooks/RouterHookApi.ts +58 -35
  18. package/src/hooks/useAlepha.ts +12 -0
  19. package/src/hooks/useClient.ts +8 -6
  20. package/src/hooks/useInject.ts +3 -9
  21. package/src/hooks/useQueryParams.ts +4 -7
  22. package/src/hooks/useRouter.ts +6 -0
  23. package/src/index.browser.ts +1 -1
  24. package/src/index.shared.ts +11 -4
  25. package/src/index.ts +7 -4
  26. package/src/providers/BrowserRouterProvider.ts +27 -33
  27. package/src/providers/PageDescriptorProvider.ts +90 -40
  28. package/src/providers/ReactBrowserProvider.ts +21 -27
  29. package/src/providers/ReactServerProvider.ts +215 -77
  30. package/dist/index.browser.cjs.map +0 -1
  31. package/dist/index.browser.js.map +0 -1
  32. package/dist/index.cjs.map +0 -1
  33. package/dist/index.js.map +0 -1
  34. package/dist/useActive-4QlZKGbw.cjs.map +0 -1
  35. package/dist/useActive-ClUsghB5.js.map +0 -1
@@ -1,6 +1,9 @@
1
- import { $hook, $inject, $logger, Alepha, OPTIONS } from "@alepha/core";
2
- import type { HttpClientLink } from "@alepha/server";
3
- import { type ReactNode, createElement } from "react";
1
+ import type { Static } from "@alepha/core";
2
+ import { $hook, $inject, $logger, Alepha, OPTIONS, t } from "@alepha/core";
3
+ import type { ApiLinksResponse } from "@alepha/server";
4
+ import { createElement, type ReactNode, StrictMode } from "react";
5
+ import ClientOnly from "../components/ClientOnly.tsx";
6
+ import ErrorViewer from "../components/ErrorViewer.tsx";
4
7
  import NestedView from "../components/NestedView.tsx";
5
8
  import { RouterContext } from "../contexts/RouterContext.ts";
6
9
  import { RouterLayerContext } from "../contexts/RouterLayerContext.ts";
@@ -11,8 +14,17 @@ import {
11
14
  } from "../descriptors/$page.ts";
12
15
  import { RedirectionError } from "../errors/RedirectionError.ts";
13
16
 
17
+ const envSchema = t.object({
18
+ REACT_STRICT_MODE: t.boolean({ default: true }),
19
+ });
20
+
21
+ declare module "@alepha/core" {
22
+ export interface Env extends Partial<Static<typeof envSchema>> {}
23
+ }
24
+
14
25
  export class PageDescriptorProvider {
15
26
  protected readonly log = $logger();
27
+ protected readonly env = $inject(envSchema);
16
28
  protected readonly alepha = $inject(Alepha);
17
29
  protected readonly pages: PageRoute[] = [];
18
30
 
@@ -30,8 +42,32 @@ export class PageDescriptorProvider {
30
42
  throw new Error(`Page ${name} not found`);
31
43
  }
32
44
 
33
- public root(state: RouterState, context: PageReactContext = {}): ReactNode {
34
- return createElement(
45
+ public url(
46
+ name: string,
47
+ options: { params?: Record<string, string>; base?: string } = {},
48
+ ): URL {
49
+ const page = this.page(name);
50
+ if (!page) {
51
+ throw new Error(`Page ${name} not found`);
52
+ }
53
+
54
+ let url = page.path ?? "";
55
+ let parent = page.parent;
56
+ while (parent) {
57
+ url = `${parent.path ?? ""}/${url}`;
58
+ parent = parent.parent;
59
+ }
60
+
61
+ url = this.compile(url, options.params ?? {});
62
+
63
+ return new URL(
64
+ url.replace(/\/\/+/g, "/") || "/",
65
+ options.base ?? `http://localhost`,
66
+ );
67
+ }
68
+
69
+ public root(state: RouterState, context: PageReactContext): ReactNode {
70
+ const root = createElement(
35
71
  RouterContext.Provider,
36
72
  {
37
73
  value: {
@@ -42,6 +78,12 @@ export class PageDescriptorProvider {
42
78
  },
43
79
  createElement(NestedView, {}, state.layers[0]?.element),
44
80
  );
81
+
82
+ if (this.env.REACT_STRICT_MODE) {
83
+ return createElement(StrictMode, {}, root);
84
+ }
85
+
86
+ return root;
45
87
  }
46
88
 
47
89
  public async createLayers(
@@ -52,6 +94,7 @@ export class PageDescriptorProvider {
52
94
  const layers: Layer[] = []; // result layers
53
95
  let context: Record<string, any> = {}; // all props
54
96
  const stack: Array<RouterStackItem> = [{ route }]; // stack of routes
97
+ request.onError = (error) => this.renderError(error); // error handler
55
98
 
56
99
  let parent = route.parent;
57
100
  while (parent) {
@@ -147,7 +190,6 @@ export class PageDescriptorProvider {
147
190
  return {
148
191
  layers: [],
149
192
  redirect: typeof e.page === "string" ? e.page : this.href(e.page),
150
- head: request.head,
151
193
  pathname,
152
194
  search,
153
195
  };
@@ -180,17 +222,17 @@ export class PageDescriptorProvider {
180
222
  acc += "/";
181
223
  acc += it.route.path ? this.compile(it.route.path, params) : "";
182
224
  const path = acc.replace(/\/+/, "/");
225
+ const localErrorHandler = this.getErrorHandler(it.route);
226
+ if (localErrorHandler) {
227
+ request.onError = localErrorHandler;
228
+ }
183
229
 
184
230
  // handler has thrown an error, render an error view
185
231
  if (it.error) {
186
- const errorHandler = this.getErrorHandler(it.route);
187
- const element = await (errorHandler
188
- ? errorHandler({
189
- ...it.config,
190
- error: it.error,
191
- url: "",
192
- })
193
- : this.renderError(it.error));
232
+ let element: ReactNode = await request.onError(it.error);
233
+ if (element === null) {
234
+ element = this.renderError(it.error);
235
+ }
194
236
 
195
237
  layers.push({
196
238
  props,
@@ -198,7 +240,7 @@ export class PageDescriptorProvider {
198
240
  name: it.route.name,
199
241
  part: it.route.path,
200
242
  config: it.config,
201
- element: this.renderView(i + 1, path, element),
243
+ element: this.renderView(i + 1, path, element, it.route),
202
244
  index: i + 1,
203
245
  path,
204
246
  });
@@ -207,7 +249,7 @@ export class PageDescriptorProvider {
207
249
 
208
250
  // normal use case
209
251
 
210
- const layer = await this.createElement(it.route, {
252
+ const element = await this.createElement(it.route, {
211
253
  ...props,
212
254
  ...context,
213
255
  });
@@ -217,13 +259,13 @@ export class PageDescriptorProvider {
217
259
  props,
218
260
  part: it.route.path,
219
261
  config: it.config,
220
- element: this.renderView(i + 1, path, layer),
262
+ element: this.renderView(i + 1, path, element, it.route),
221
263
  index: i + 1,
222
264
  path,
223
265
  });
224
266
  }
225
267
 
226
- return { layers, head: request.head, pathname, search };
268
+ return { layers, pathname, search };
227
269
  }
228
270
 
229
271
  protected getErrorHandler(route: PageRoute) {
@@ -296,8 +338,8 @@ export class PageDescriptorProvider {
296
338
  }
297
339
  }
298
340
 
299
- public renderError(e: Error): ReactNode {
300
- return createElement("pre", { style: { overflow: "auto" } }, `${e.stack}`);
341
+ public renderError(error: Error): ReactNode {
342
+ return createElement(ErrorViewer, { error });
301
343
  }
302
344
 
303
345
  public renderEmptyView(): ReactNode {
@@ -335,8 +377,19 @@ export class PageDescriptorProvider {
335
377
  protected renderView(
336
378
  index: number,
337
379
  path: string,
338
- view: ReactNode = this.renderEmptyView(),
380
+ view: ReactNode | undefined,
381
+ page: PageRoute,
339
382
  ): ReactNode {
383
+ view ??= this.renderEmptyView();
384
+
385
+ const element = page.client
386
+ ? createElement(
387
+ ClientOnly,
388
+ typeof page.client === "object" ? page.client : {},
389
+ view,
390
+ )
391
+ : view;
392
+
340
393
  return createElement(
341
394
  RouterLayerContext.Provider,
342
395
  {
@@ -345,7 +398,7 @@ export class PageDescriptorProvider {
345
398
  path,
346
399
  },
347
400
  },
348
- view,
401
+ element,
349
402
  );
350
403
  }
351
404
 
@@ -355,7 +408,8 @@ export class PageDescriptorProvider {
355
408
  const pages = this.alepha.getDescriptorValues($page);
356
409
  for (const { value, key } of pages) {
357
410
  value[OPTIONS].name ??= key;
358
-
411
+ }
412
+ for (const { value } of pages) {
359
413
  // skip children, we only want root pages
360
414
  if (value[OPTIONS].parent) {
361
415
  continue;
@@ -372,12 +426,6 @@ export class PageDescriptorProvider {
372
426
  ): PageRouteEntry {
373
427
  const children = target[OPTIONS].children ?? [];
374
428
 
375
- for (const it of pages) {
376
- if (it.value[OPTIONS].parent === target) {
377
- children.push(it.value);
378
- }
379
- }
380
-
381
429
  return {
382
430
  ...target[OPTIONS],
383
431
  parent: undefined,
@@ -468,18 +516,17 @@ export interface Layer {
468
516
  path: string;
469
517
  }
470
518
 
471
- export type PreviousLayerData = Omit<Layer, "element">;
519
+ export type PreviousLayerData = Omit<Layer, "element" | "index" | "path">;
472
520
 
473
521
  export interface AnchorProps {
474
- href?: string;
475
- onClick?: (ev: any) => any;
522
+ href: string;
523
+ onClick: (ev: any) => any;
476
524
  }
477
525
 
478
526
  export interface RouterState {
479
527
  pathname: string;
480
528
  search: string;
481
529
  layers: Array<Layer>;
482
- head: Head;
483
530
  }
484
531
 
485
532
  export interface TransitionOptions {
@@ -496,17 +543,14 @@ export interface RouterStackItem {
496
543
  }
497
544
 
498
545
  export interface RouterRenderResult {
546
+ state: RouterState;
547
+ context: PageReactContext;
499
548
  redirect?: string;
500
- layers: Layer[];
501
- head: Head;
502
- element: ReactNode;
503
549
  }
504
550
 
505
551
  export interface PageRequest extends PageReactContext {
506
- url: URL;
507
552
  params: Record<string, any>;
508
553
  query: Record<string, string>;
509
- head: Head;
510
554
 
511
555
  // previous layers (browser history or browser hydration, always null on server)
512
556
  previous?: PreviousLayerData[];
@@ -516,7 +560,13 @@ export interface CreateLayersResult extends RouterState {
516
560
  redirect?: string;
517
561
  }
518
562
 
519
- // will be passed to ReactContext
563
+ /**
564
+ * It's like RouterState, but publicly available in React context.
565
+ * This is where we store all plugin data!
566
+ */
520
567
  export interface PageReactContext {
521
- links?: HttpClientLink[];
568
+ url: URL;
569
+ head: Head;
570
+ onError: (error: Error) => ReactNode;
571
+ links?: ApiLinksResponse;
522
572
  }
@@ -1,12 +1,12 @@
1
1
  import { $hook, $inject, $logger, Alepha, type Static, t } from "@alepha/core";
2
- import { HttpClient, type HttpClientLink } from "@alepha/server";
2
+ import { type ApiLinksResponse, HttpClient } from "@alepha/server";
3
3
  import type { Root } from "react-dom/client";
4
4
  import { createRoot, hydrateRoot } from "react-dom/client";
5
- import type { Head } from "../descriptors/$page.ts";
6
5
  import { BrowserHeadProvider } from "./BrowserHeadProvider.ts";
7
6
  import { BrowserRouterProvider } from "./BrowserRouterProvider.ts";
8
7
  import type {
9
8
  PreviousLayerData,
9
+ RouterRenderResult,
10
10
  RouterState,
11
11
  TransitionOptions,
12
12
  } from "./PageDescriptorProvider.ts";
@@ -36,7 +36,6 @@ export class ReactBrowserProvider {
36
36
  layers: [],
37
37
  pathname: "",
38
38
  search: "",
39
- head: {},
40
39
  };
41
40
 
42
41
  public get document() {
@@ -86,8 +85,10 @@ export class ReactBrowserProvider {
86
85
  url,
87
86
  });
88
87
 
89
- if (result.url !== url) {
90
- this.history.replaceState({}, "", result.url);
88
+ // when redirecting in browser
89
+ if (result.context.url.pathname !== url) {
90
+ // TODO: check if losing search params is acceptable?
91
+ this.history.replaceState({}, "", result.context.url.pathname);
91
92
  return;
92
93
  }
93
94
 
@@ -100,11 +101,8 @@ export class ReactBrowserProvider {
100
101
  }
101
102
 
102
103
  protected async render(
103
- options: {
104
- url?: string;
105
- previous?: PreviousLayerData[];
106
- } = {},
107
- ): Promise<{ url: string; head: Head }> {
104
+ options: { url?: string; previous?: PreviousLayerData[] } = {},
105
+ ): Promise<RouterRenderResult> {
108
106
  const previous = options.previous ?? this.state.layers;
109
107
  const url = options.url ?? this.url;
110
108
 
@@ -124,7 +122,7 @@ export class ReactBrowserProvider {
124
122
 
125
123
  this.transitioning = undefined;
126
124
 
127
- return { url, head: result.head };
125
+ return result;
128
126
  }
129
127
 
130
128
  /**
@@ -173,17 +171,18 @@ export class ReactBrowserProvider {
173
171
  const previous = hydration?.layers ?? [];
174
172
 
175
173
  if (hydration?.links) {
176
- this.client.links = hydration.links as HttpClientLink[];
174
+ for (const link of hydration.links.links) {
175
+ this.client.pushLink(link);
176
+ }
177
177
  }
178
178
 
179
- const { head } = await this.render({ previous });
180
- if (head) {
181
- this.headProvider.renderHead(this.document, head);
179
+ const { context } = await this.render({ previous });
180
+ if (context.head) {
181
+ this.headProvider.renderHead(this.document, context.head);
182
182
  }
183
183
 
184
- const context = {};
185
-
186
184
  await this.alepha.emit("react:browser:render", {
185
+ state: this.state,
187
186
  context,
188
187
  hydration,
189
188
  });
@@ -202,19 +201,13 @@ export class ReactBrowserProvider {
202
201
  window.addEventListener("popstate", () => {
203
202
  this.render();
204
203
  });
205
-
206
- this.alepha.on("react:transition:end", {
207
- callback: ({ state }) => {
208
- this.headProvider.renderHead(this.document, state.head);
209
- },
210
- });
211
204
  },
212
205
  });
213
206
 
214
207
  public readonly onTransitionEnd = $hook({
215
208
  name: "react:transition:end",
216
- handler: async ({ state }) => {
217
- this.headProvider.renderHead(this.document, state.head);
209
+ handler: async ({ context }) => {
210
+ this.headProvider.renderHead(this.document, context.head);
218
211
  },
219
212
  });
220
213
  }
@@ -224,9 +217,10 @@ export class ReactBrowserProvider {
224
217
  export interface RouterGoOptions {
225
218
  replace?: boolean;
226
219
  match?: TransitionOptions;
220
+ params?: Record<string, string>;
227
221
  }
228
222
 
229
223
  export interface ReactHydrationState {
230
- layers?: PreviousLayerData[];
231
- links?: HttpClientLink[];
224
+ layers?: Array<PreviousLayerData>;
225
+ links?: ApiLinksResponse;
232
226
  }