@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.
- package/README.md +64 -6
- package/dist/index.browser.js +442 -328
- package/dist/index.browser.js.map +1 -1
- package/dist/index.cjs +644 -482
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +402 -339
- package/dist/index.d.cts.map +1 -1
- package/dist/index.d.ts +412 -349
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +641 -484
- package/dist/index.js.map +1 -1
- package/package.json +16 -11
- package/src/components/Link.tsx +2 -5
- package/src/components/NestedView.tsx +164 -19
- package/src/components/NotFound.tsx +1 -1
- package/src/descriptors/$page.ts +100 -5
- package/src/errors/Redirection.ts +8 -5
- package/src/hooks/useActive.ts +25 -35
- package/src/hooks/useAlepha.ts +16 -2
- package/src/hooks/useClient.ts +7 -4
- package/src/hooks/useInject.ts +4 -1
- package/src/hooks/useQueryParams.ts +9 -6
- package/src/hooks/useRouter.ts +18 -31
- package/src/hooks/useRouterEvents.ts +30 -22
- package/src/hooks/useRouterState.ts +8 -20
- package/src/hooks/useSchema.ts +10 -15
- package/src/hooks/useStore.ts +0 -7
- package/src/index.browser.ts +14 -11
- package/src/index.shared.ts +2 -3
- package/src/index.ts +27 -31
- package/src/providers/ReactBrowserProvider.ts +151 -62
- package/src/providers/ReactBrowserRendererProvider.ts +22 -0
- package/src/providers/ReactBrowserRouterProvider.ts +137 -0
- package/src/providers/{PageDescriptorProvider.ts → ReactPageProvider.ts} +121 -104
- package/src/providers/ReactServerProvider.ts +90 -76
- package/src/{hooks/RouterHookApi.ts → services/ReactRouter.ts} +49 -62
- package/src/contexts/RouterContext.ts +0 -14
- package/src/providers/BrowserRouterProvider.ts +0 -155
- package/src/providers/ReactBrowserRenderer.ts +0 -93
|
@@ -1,74 +1,122 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
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
|
|
5
|
-
import { BrowserRouterProvider } from "./BrowserRouterProvider.ts";
|
|
13
|
+
import { ReactBrowserRouterProvider } from "./ReactBrowserRouterProvider.ts";
|
|
6
14
|
import type {
|
|
7
15
|
PreviousLayerData,
|
|
8
|
-
|
|
9
|
-
RouterState,
|
|
16
|
+
ReactRouterState,
|
|
10
17
|
TransitionOptions,
|
|
11
|
-
} from "./
|
|
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(
|
|
18
|
-
protected
|
|
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:
|
|
25
|
-
|
|
26
|
-
|
|
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
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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
|
|
94
|
+
return base;
|
|
53
95
|
}
|
|
54
96
|
|
|
55
|
-
public
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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({}, "",
|
|
109
|
+
this.history.replaceState({}, "", url);
|
|
64
110
|
} else {
|
|
65
|
-
this.history.pushState({}, "",
|
|
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
|
-
|
|
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 (
|
|
101
|
-
this.pushState(
|
|
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 = {
|
|
168
|
+
this.transitioning = {
|
|
169
|
+
to: url,
|
|
170
|
+
from: this.state?.url.pathname,
|
|
171
|
+
};
|
|
115
172
|
|
|
116
|
-
|
|
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
|
-
|
|
120
|
-
state: this.state,
|
|
121
|
-
},
|
|
179
|
+
previous,
|
|
180
|
+
options.meta,
|
|
122
181
|
);
|
|
123
182
|
|
|
124
|
-
if (
|
|
125
|
-
|
|
183
|
+
if (redirect) {
|
|
184
|
+
this.log.info("Redirecting to", {
|
|
185
|
+
redirect,
|
|
186
|
+
});
|
|
187
|
+
return await this.render({ url: redirect });
|
|
126
188
|
}
|
|
127
189
|
|
|
128
|
-
|
|
190
|
+
const ms = this.dateTimeProvider.now().diff(start);
|
|
191
|
+
this.log.info(`Transition OK [${ms}ms]`, this.transitioning);
|
|
129
192
|
|
|
130
|
-
|
|
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"
|
|
234
|
+
if (key !== "layers") {
|
|
157
235
|
this.alepha.state(key as keyof State, value);
|
|
158
236
|
}
|
|
159
237
|
}
|
|
160
238
|
}
|
|
161
239
|
|
|
162
|
-
|
|
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
|
|
242
|
+
const element = this.router.root(this.state);
|
|
172
243
|
|
|
173
244
|
await this.alepha.emit("react:browser:render", {
|
|
174
|
-
|
|
175
|
-
|
|
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
|
|
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.
|
|
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
|
|
283
|
+
export type ReactHydrationState = {
|
|
202
284
|
layers?: Array<PreviousLayerData>;
|
|
203
|
-
|
|
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
|
+
}
|