@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.
- package/README.md +1 -1
- package/dist/index.browser.cjs +21 -20
- package/dist/index.browser.js +2 -3
- package/dist/index.cjs +168 -82
- package/dist/index.d.ts +415 -232
- package/dist/index.js +146 -62
- package/dist/{useActive-4QlZKGbw.cjs → useRouterState-AdK-XeM2.cjs} +358 -170
- package/dist/{useActive-ClUsghB5.js → useRouterState-qoMq7Y9J.js} +358 -172
- package/package.json +11 -10
- package/src/components/ClientOnly.tsx +35 -0
- package/src/components/ErrorBoundary.tsx +72 -0
- package/src/components/ErrorViewer.tsx +161 -0
- package/src/components/Link.tsx +10 -4
- package/src/components/NestedView.tsx +28 -4
- package/src/descriptors/$page.ts +143 -38
- package/src/errors/RedirectionError.ts +4 -1
- package/src/hooks/RouterHookApi.ts +58 -35
- package/src/hooks/useAlepha.ts +12 -0
- package/src/hooks/useClient.ts +8 -6
- package/src/hooks/useInject.ts +3 -9
- package/src/hooks/useQueryParams.ts +4 -7
- package/src/hooks/useRouter.ts +6 -0
- package/src/index.browser.ts +1 -1
- package/src/index.shared.ts +11 -4
- package/src/index.ts +7 -4
- package/src/providers/BrowserRouterProvider.ts +27 -33
- package/src/providers/PageDescriptorProvider.ts +90 -40
- package/src/providers/ReactBrowserProvider.ts +21 -27
- package/src/providers/ReactServerProvider.ts +215 -77
- package/dist/index.browser.cjs.map +0 -1
- package/dist/index.browser.js.map +0 -1
- package/dist/index.cjs.map +0 -1
- package/dist/index.js.map +0 -1
- package/dist/useActive-4QlZKGbw.cjs.map +0 -1
- package/dist/useActive-ClUsghB5.js.map +0 -1
|
@@ -1,6 +1,9 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import
|
|
3
|
-
import
|
|
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
|
|
34
|
-
|
|
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
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
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
|
|
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,
|
|
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,
|
|
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(
|
|
300
|
-
return createElement(
|
|
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
|
|
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
|
-
|
|
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
|
|
475
|
-
onClick
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
90
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
174
|
+
for (const link of hydration.links.links) {
|
|
175
|
+
this.client.pushLink(link);
|
|
176
|
+
}
|
|
177
177
|
}
|
|
178
178
|
|
|
179
|
-
const {
|
|
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 ({
|
|
217
|
-
this.headProvider.renderHead(this.document,
|
|
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?:
|
|
224
|
+
layers?: Array<PreviousLayerData>;
|
|
225
|
+
links?: ApiLinksResponse;
|
|
232
226
|
}
|