@alepha/react 0.10.6 → 0.10.7

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.
@@ -1,300 +1,300 @@
1
1
  import {
2
- $env,
3
- $hook,
4
- $inject,
5
- Alepha,
6
- type State,
7
- type Static,
8
- t,
2
+ $env,
3
+ $hook,
4
+ $inject,
5
+ Alepha,
6
+ type State,
7
+ type Static,
8
+ t,
9
9
  } from "@alepha/core";
10
10
  import { DateTimeProvider } from "@alepha/datetime";
11
11
  import { $logger } from "@alepha/logger";
12
12
  import { LinkProvider } from "@alepha/server-links";
13
13
  import { ReactBrowserRouterProvider } from "./ReactBrowserRouterProvider.ts";
14
14
  import type {
15
- PreviousLayerData,
16
- ReactRouterState,
17
- TransitionOptions,
15
+ PreviousLayerData,
16
+ ReactRouterState,
17
+ TransitionOptions,
18
18
  } from "./ReactPageProvider.ts";
19
19
 
20
20
  const envSchema = t.object({
21
- REACT_ROOT_ID: t.text({ default: "root" }),
21
+ REACT_ROOT_ID: t.text({ default: "root" }),
22
22
  });
23
23
 
24
24
  declare module "@alepha/core" {
25
- interface Env extends Partial<Static<typeof envSchema>> {}
25
+ interface Env extends Partial<Static<typeof envSchema>> {}
26
26
  }
27
27
 
28
28
  export interface ReactBrowserRendererOptions {
29
- scrollRestoration?: "top" | "manual";
29
+ scrollRestoration?: "top" | "manual";
30
30
  }
31
31
 
32
32
  export class ReactBrowserProvider {
33
- protected readonly env = $env(envSchema);
34
- protected readonly log = $logger();
35
- protected readonly client = $inject(LinkProvider);
36
- protected readonly alepha = $inject(Alepha);
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
- }
57
-
58
- public transitioning?: {
59
- to: string;
60
- from?: string;
61
- };
62
-
63
- public get state(): ReactRouterState {
64
- return this.alepha.state.get("react.router.state")!;
65
- }
66
-
67
- /**
68
- * Accessor for Document DOM API.
69
- */
70
- public get document() {
71
- return window.document;
72
- }
73
-
74
- /**
75
- * Accessor for History DOM API.
76
- */
77
- public get history() {
78
- return window.history;
79
- }
80
-
81
- /**
82
- * Accessor for Location DOM API.
83
- */
84
- public get location() {
85
- return window.location;
86
- }
87
-
88
- public get base() {
89
- const base = import.meta.env?.BASE_URL;
90
- if (!base || base === "/") {
91
- return "";
92
- }
93
-
94
- return base;
95
- }
96
-
97
- public get url(): string {
98
- const url = this.location.pathname + this.location.search;
99
- if (this.base) {
100
- return url.replace(this.base, "");
101
- }
102
- return url;
103
- }
104
-
105
- public pushState(path: string, replace?: boolean) {
106
- const url = this.base + path;
107
-
108
- if (replace) {
109
- this.history.replaceState({}, "", url);
110
- } else {
111
- this.history.pushState({}, "", url);
112
- }
113
- }
114
-
115
- public async invalidate(props?: Record<string, any>) {
116
- const previous: PreviousLayerData[] = [];
117
-
118
- this.log.trace("Invalidating layers");
119
-
120
- if (props) {
121
- const [key] = Object.keys(props);
122
- const value = props[key];
123
-
124
- for (const layer of this.state.layers) {
125
- if (layer.props?.[key]) {
126
- previous.push({
127
- ...layer,
128
- props: {
129
- ...layer.props,
130
- [key]: value,
131
- },
132
- });
133
- break;
134
- }
135
- previous.push(layer);
136
- }
137
- }
138
-
139
- await this.render({ previous });
140
- }
141
-
142
- public async go(url: string, options: RouterGoOptions = {}): Promise<void> {
143
- this.log.trace(`Going to ${url}`, {
144
- url,
145
- options,
146
- });
147
-
148
- await this.render({
149
- url,
150
- previous: options.force ? [] : this.state.layers,
151
- meta: options.meta,
152
- });
153
-
154
- // when redirecting in browser
155
- if (this.state.url.pathname + this.state.url.search !== url) {
156
- this.pushState(this.state.url.pathname + this.state.url.search);
157
- return;
158
- }
159
-
160
- this.pushState(url, options.replace);
161
- }
162
-
163
- protected async render(options: RouterRenderOptions = {}): Promise<void> {
164
- const previous = options.previous ?? this.state.layers;
165
- const url = options.url ?? this.url;
166
- const start = this.dateTimeProvider.now();
167
-
168
- this.transitioning = {
169
- to: url,
170
- from: this.state?.url.pathname,
171
- };
172
-
173
- this.log.debug("Transitioning...", {
174
- to: url,
175
- });
176
-
177
- const redirect = await this.router.transition(
178
- new URL(`http://localhost${url}`),
179
- previous,
180
- options.meta,
181
- );
182
-
183
- if (redirect) {
184
- this.log.info("Redirecting to", {
185
- redirect,
186
- });
187
-
188
- // if redirect is an absolute URL, use window.location.href (full page reload)
189
- if (redirect.startsWith("http")) {
190
- window.location.href = redirect;
191
- } else {
192
- // if redirect is a relative URL, use render() (single page app)
193
- return await this.render({ url: redirect });
194
- }
195
- }
196
-
197
- const ms = this.dateTimeProvider.now().diff(start);
198
- this.log.info(`Transition OK [${ms}ms]`, this.transitioning);
199
-
200
- this.transitioning = undefined;
201
- }
202
-
203
- /**
204
- * Get embedded layers from the server.
205
- */
206
- protected getHydrationState(): ReactHydrationState | undefined {
207
- try {
208
- if ("__ssr" in window && typeof window.__ssr === "object") {
209
- return window.__ssr as ReactHydrationState;
210
- }
211
- } catch (error) {
212
- console.error(error);
213
- }
214
- }
215
-
216
- // -------------------------------------------------------------------------------------------------------------------
217
-
218
- protected readonly onTransitionEnd = $hook({
219
- on: "react:transition:end",
220
- handler: () => {
221
- if (
222
- this.options.scrollRestoration === "top" &&
223
- typeof window !== "undefined" &&
224
- !this.alepha.isTest()
225
- ) {
226
- this.log.trace("Restoring scroll position to top");
227
- window.scrollTo(0, 0);
228
- }
229
- },
230
- });
231
-
232
- public readonly ready = $hook({
233
- on: "ready",
234
- handler: async () => {
235
- const hydration = this.getHydrationState();
236
- const previous = hydration?.layers ?? [];
237
-
238
- if (hydration) {
239
- // low budget, but works for now
240
- for (const [key, value] of Object.entries(hydration)) {
241
- if (key !== "layers") {
242
- this.alepha.state.set(key as keyof State, value);
243
- }
244
- }
245
- }
246
-
247
- await this.render({ previous });
248
-
249
- const element = this.router.root(this.state);
250
-
251
- await this.alepha.events.emit("react:browser:render", {
252
- element,
253
- root: this.getRootElement(),
254
- hydration,
255
- state: this.state,
256
- });
257
-
258
- window.addEventListener("popstate", () => {
259
- // when you update silently queryParams or hash, skip rendering
260
- // if you want to force a rendering, use #go()
261
- if (this.base + this.state.url.pathname === this.location.pathname) {
262
- return;
263
- }
264
-
265
- this.log.debug("Popstate event triggered - rendering new state", {
266
- url: this.location.pathname + this.location.search,
267
- });
268
-
269
- this.render();
270
- });
271
- },
272
- });
33
+ protected readonly env = $env(envSchema);
34
+ protected readonly log = $logger();
35
+ protected readonly client = $inject(LinkProvider);
36
+ protected readonly alepha = $inject(Alepha);
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
+ }
57
+
58
+ public transitioning?: {
59
+ to: string;
60
+ from?: string;
61
+ };
62
+
63
+ public get state(): ReactRouterState {
64
+ return this.alepha.state.get("react.router.state")!;
65
+ }
66
+
67
+ /**
68
+ * Accessor for Document DOM API.
69
+ */
70
+ public get document() {
71
+ return window.document;
72
+ }
73
+
74
+ /**
75
+ * Accessor for History DOM API.
76
+ */
77
+ public get history() {
78
+ return window.history;
79
+ }
80
+
81
+ /**
82
+ * Accessor for Location DOM API.
83
+ */
84
+ public get location() {
85
+ return window.location;
86
+ }
87
+
88
+ public get base() {
89
+ const base = import.meta.env?.BASE_URL;
90
+ if (!base || base === "/") {
91
+ return "";
92
+ }
93
+
94
+ return base;
95
+ }
96
+
97
+ public get url(): string {
98
+ const url = this.location.pathname + this.location.search;
99
+ if (this.base) {
100
+ return url.replace(this.base, "");
101
+ }
102
+ return url;
103
+ }
104
+
105
+ public pushState(path: string, replace?: boolean) {
106
+ const url = this.base + path;
107
+
108
+ if (replace) {
109
+ this.history.replaceState({}, "", url);
110
+ } else {
111
+ this.history.pushState({}, "", url);
112
+ }
113
+ }
114
+
115
+ public async invalidate(props?: Record<string, any>) {
116
+ const previous: PreviousLayerData[] = [];
117
+
118
+ this.log.trace("Invalidating layers");
119
+
120
+ if (props) {
121
+ const [key] = Object.keys(props);
122
+ const value = props[key];
123
+
124
+ for (const layer of this.state.layers) {
125
+ if (layer.props?.[key]) {
126
+ previous.push({
127
+ ...layer,
128
+ props: {
129
+ ...layer.props,
130
+ [key]: value,
131
+ },
132
+ });
133
+ break;
134
+ }
135
+ previous.push(layer);
136
+ }
137
+ }
138
+
139
+ await this.render({ previous });
140
+ }
141
+
142
+ public async go(url: string, options: RouterGoOptions = {}): Promise<void> {
143
+ this.log.trace(`Going to ${url}`, {
144
+ url,
145
+ options,
146
+ });
147
+
148
+ await this.render({
149
+ url,
150
+ previous: options.force ? [] : this.state.layers,
151
+ meta: options.meta,
152
+ });
153
+
154
+ // when redirecting in browser
155
+ if (this.state.url.pathname + this.state.url.search !== url) {
156
+ this.pushState(this.state.url.pathname + this.state.url.search);
157
+ return;
158
+ }
159
+
160
+ this.pushState(url, options.replace);
161
+ }
162
+
163
+ protected async render(options: RouterRenderOptions = {}): Promise<void> {
164
+ const previous = options.previous ?? this.state.layers;
165
+ const url = options.url ?? this.url;
166
+ const start = this.dateTimeProvider.now();
167
+
168
+ this.transitioning = {
169
+ to: url,
170
+ from: this.state?.url.pathname,
171
+ };
172
+
173
+ this.log.debug("Transitioning...", {
174
+ to: url,
175
+ });
176
+
177
+ const redirect = await this.router.transition(
178
+ new URL(`http://localhost${url}`),
179
+ previous,
180
+ options.meta,
181
+ );
182
+
183
+ if (redirect) {
184
+ this.log.info("Redirecting to", {
185
+ redirect,
186
+ });
187
+
188
+ // if redirect is an absolute URL, use window.location.href (full page reload)
189
+ if (redirect.startsWith("http")) {
190
+ window.location.href = redirect;
191
+ } else {
192
+ // if redirect is a relative URL, use render() (single page app)
193
+ return await this.render({ url: redirect });
194
+ }
195
+ }
196
+
197
+ const ms = this.dateTimeProvider.now().diff(start);
198
+ this.log.info(`Transition OK [${ms}ms]`, this.transitioning);
199
+
200
+ this.transitioning = undefined;
201
+ }
202
+
203
+ /**
204
+ * Get embedded layers from the server.
205
+ */
206
+ protected getHydrationState(): ReactHydrationState | undefined {
207
+ try {
208
+ if ("__ssr" in window && typeof window.__ssr === "object") {
209
+ return window.__ssr as ReactHydrationState;
210
+ }
211
+ } catch (error) {
212
+ console.error(error);
213
+ }
214
+ }
215
+
216
+ // -------------------------------------------------------------------------------------------------------------------
217
+
218
+ protected readonly onTransitionEnd = $hook({
219
+ on: "react:transition:end",
220
+ handler: () => {
221
+ if (
222
+ this.options.scrollRestoration === "top" &&
223
+ typeof window !== "undefined" &&
224
+ !this.alepha.isTest()
225
+ ) {
226
+ this.log.trace("Restoring scroll position to top");
227
+ window.scrollTo(0, 0);
228
+ }
229
+ },
230
+ });
231
+
232
+ public readonly ready = $hook({
233
+ on: "ready",
234
+ handler: async () => {
235
+ const hydration = this.getHydrationState();
236
+ const previous = hydration?.layers ?? [];
237
+
238
+ if (hydration) {
239
+ // low budget, but works for now
240
+ for (const [key, value] of Object.entries(hydration)) {
241
+ if (key !== "layers") {
242
+ this.alepha.state.set(key as keyof State, value);
243
+ }
244
+ }
245
+ }
246
+
247
+ await this.render({ previous });
248
+
249
+ const element = this.router.root(this.state);
250
+
251
+ await this.alepha.events.emit("react:browser:render", {
252
+ element,
253
+ root: this.getRootElement(),
254
+ hydration,
255
+ state: this.state,
256
+ });
257
+
258
+ window.addEventListener("popstate", () => {
259
+ // when you update silently queryParams or hash, skip rendering
260
+ // if you want to force a rendering, use #go()
261
+ if (this.base + this.state.url.pathname === this.location.pathname) {
262
+ return;
263
+ }
264
+
265
+ this.log.debug("Popstate event triggered - rendering new state", {
266
+ url: this.location.pathname + this.location.search,
267
+ });
268
+
269
+ this.render();
270
+ });
271
+ },
272
+ });
273
273
  }
274
274
 
275
275
  // ---------------------------------------------------------------------------------------------------------------------
276
276
 
277
277
  export interface RouterGoOptions {
278
- replace?: boolean;
279
- match?: TransitionOptions;
280
- params?: Record<string, string>;
281
- query?: Record<string, string>;
282
- meta?: Record<string, any>;
283
-
284
- /**
285
- * Recreate the whole page, ignoring the current state.
286
- */
287
- force?: boolean;
278
+ replace?: boolean;
279
+ match?: TransitionOptions;
280
+ params?: Record<string, string>;
281
+ query?: Record<string, string>;
282
+ meta?: Record<string, any>;
283
+
284
+ /**
285
+ * Recreate the whole page, ignoring the current state.
286
+ */
287
+ force?: boolean;
288
288
  }
289
289
 
290
290
  export type ReactHydrationState = {
291
- layers?: Array<PreviousLayerData>;
291
+ layers?: Array<PreviousLayerData>;
292
292
  } & {
293
- [key: string]: any;
293
+ [key: string]: any;
294
294
  };
295
295
 
296
296
  export interface RouterRenderOptions {
297
- url?: string;
298
- previous?: PreviousLayerData[];
299
- meta?: Record<string, any>;
297
+ url?: string;
298
+ previous?: PreviousLayerData[];
299
+ meta?: Record<string, any>;
300
300
  }
@@ -3,20 +3,20 @@ import { $logger } from "@alepha/logger";
3
3
  import { createRoot, hydrateRoot, type Root } from "react-dom/client";
4
4
 
5
5
  export class ReactBrowserRendererProvider {
6
- protected readonly log = $logger();
7
- protected root?: Root;
6
+ protected readonly log = $logger();
7
+ protected root?: Root;
8
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
- });
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
22
  }