@alepha/react 0.9.4 → 0.10.0

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/src/index.ts CHANGED
@@ -2,7 +2,8 @@ import { $module } from "@alepha/core";
2
2
  import { AlephaServer, type ServerRequest } from "@alepha/server";
3
3
  import { AlephaServerCache } from "@alepha/server-cache";
4
4
  import { AlephaServerLinks } from "@alepha/server-links";
5
- import { $page } from "./descriptors/$page.ts";
5
+ import type { ReactNode } from "react";
6
+ import { $page, type PageAnimation } from "./descriptors/$page.ts";
6
7
  import type { ReactHydrationState } from "./providers/ReactBrowserProvider.ts";
7
8
  import {
8
9
  ReactPageProvider,
@@ -37,11 +38,15 @@ declare module "@alepha/core" {
37
38
  };
38
39
  // -----------------------------------------------------------------------------------------------------------------
39
40
  "react:browser:render": {
41
+ root: HTMLDivElement;
42
+ element: ReactNode;
40
43
  state: ReactRouterState;
41
44
  hydration?: ReactHydrationState;
42
45
  };
43
46
  "react:transition:begin": {
47
+ previous: ReactRouterState;
44
48
  state: ReactRouterState;
49
+ animation?: PageAnimation;
45
50
  };
46
51
  "react:transition:success": {
47
52
  state: ReactRouterState;
@@ -10,7 +10,6 @@ import {
10
10
  import { DateTimeProvider } from "@alepha/datetime";
11
11
  import { $logger } from "@alepha/logger";
12
12
  import { LinkProvider } from "@alepha/server-links";
13
- import { createRoot, hydrateRoot, type Root } from "react-dom/client";
14
13
  import { ReactBrowserRouterProvider } from "./ReactBrowserRouterProvider.ts";
15
14
  import type {
16
15
  PreviousLayerData,
@@ -37,7 +36,6 @@ export class ReactBrowserProvider {
37
36
  protected readonly alepha = $inject(Alepha);
38
37
  protected readonly router = $inject(ReactBrowserRouterProvider);
39
38
  protected readonly dateTimeProvider = $inject(DateTimeProvider);
40
- protected root?: Root;
41
39
 
42
40
  public options: ReactBrowserRendererOptions = {
43
41
  scrollRestoration: "top",
@@ -63,7 +61,7 @@ export class ReactBrowserProvider {
63
61
  };
64
62
 
65
63
  public get state(): ReactRouterState {
66
- return this.alepha.state("react.router.state")!;
64
+ return this.alepha.state.get("react.router.state")!;
67
65
  }
68
66
 
69
67
  /**
@@ -150,6 +148,7 @@ export class ReactBrowserProvider {
150
148
  await this.render({
151
149
  url,
152
150
  previous: options.force ? [] : this.state.layers,
151
+ meta: options.meta,
153
152
  });
154
153
 
155
154
  // when redirecting in browser
@@ -161,9 +160,7 @@ export class ReactBrowserProvider {
161
160
  this.pushState(url, options.replace);
162
161
  }
163
162
 
164
- protected async render(
165
- options: { url?: string; previous?: PreviousLayerData[] } = {},
166
- ): Promise<void> {
163
+ protected async render(options: RouterRenderOptions = {}): Promise<void> {
167
164
  const previous = options.previous ?? this.state.layers;
168
165
  const url = options.url ?? this.url;
169
166
  const start = this.dateTimeProvider.now();
@@ -180,6 +177,7 @@ export class ReactBrowserProvider {
180
177
  const redirect = await this.router.transition(
181
178
  new URL(`http://localhost${url}`),
182
179
  previous,
180
+ options.meta,
183
181
  );
184
182
 
185
183
  if (redirect) {
@@ -215,7 +213,8 @@ export class ReactBrowserProvider {
215
213
  handler: () => {
216
214
  if (
217
215
  this.options.scrollRestoration === "top" &&
218
- typeof window !== "undefined"
216
+ typeof window !== "undefined" &&
217
+ !this.alepha.isTest()
219
218
  ) {
220
219
  this.log.trace("Restoring scroll position to top");
221
220
  window.scrollTo(0, 0);
@@ -233,7 +232,7 @@ export class ReactBrowserProvider {
233
232
  // low budget, but works for now
234
233
  for (const [key, value] of Object.entries(hydration)) {
235
234
  if (key !== "layers") {
236
- this.alepha.state(key as keyof State, value);
235
+ this.alepha.state.set(key as keyof State, value);
237
236
  }
238
237
  }
239
238
  }
@@ -241,14 +240,13 @@ export class ReactBrowserProvider {
241
240
  await this.render({ previous });
242
241
 
243
242
  const element = this.router.root(this.state);
244
- if (hydration?.layers) {
245
- this.root = hydrateRoot(this.getRootElement(), element);
246
- this.log.info("Hydrated root element");
247
- } else {
248
- this.root ??= createRoot(this.getRootElement());
249
- this.root.render(element);
250
- this.log.info("Created root element");
251
- }
243
+
244
+ await this.alepha.events.emit("react:browser:render", {
245
+ element,
246
+ root: this.getRootElement(),
247
+ hydration,
248
+ state: this.state,
249
+ });
252
250
 
253
251
  window.addEventListener("popstate", () => {
254
252
  // when you update silently queryParams or hash, skip rendering
@@ -274,6 +272,7 @@ export interface RouterGoOptions {
274
272
  match?: TransitionOptions;
275
273
  params?: Record<string, string>;
276
274
  query?: Record<string, string>;
275
+ meta?: Record<string, any>;
277
276
 
278
277
  /**
279
278
  * Recreate the whole page, ignoring the current state.
@@ -286,3 +285,9 @@ export type ReactHydrationState = {
286
285
  } & {
287
286
  [key: string]: any;
288
287
  };
288
+
289
+ export interface RouterRenderOptions {
290
+ url?: string;
291
+ previous?: PreviousLayerData[];
292
+ meta?: Record<string, any>;
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
+ }
@@ -43,6 +43,7 @@ export class ReactBrowserRouterProvider extends RouterProvider<BrowserRoute> {
43
43
  public async transition(
44
44
  url: URL,
45
45
  previous: PreviousLayerData[] = [],
46
+ meta = {},
46
47
  ): Promise<string | void> {
47
48
  const { pathname, search } = url;
48
49
 
@@ -52,11 +53,15 @@ export class ReactBrowserRouterProvider extends RouterProvider<BrowserRoute> {
52
53
  params: {},
53
54
  layers: [],
54
55
  onError: () => null,
56
+ meta,
55
57
  };
56
58
 
57
59
  const state = entry as ReactRouterState;
58
60
 
59
- await this.alepha.emit("react:transition:begin", { state });
61
+ await this.alepha.events.emit("react:transition:begin", {
62
+ previous: this.alepha.state.get("react.router.state")!,
63
+ state,
64
+ });
60
65
 
61
66
  try {
62
67
  const { route, params } = this.match(pathname);
@@ -91,7 +96,7 @@ export class ReactBrowserRouterProvider extends RouterProvider<BrowserRoute> {
91
96
  });
92
97
  }
93
98
 
94
- await this.alepha.emit("react:transition:success", { state });
99
+ await this.alepha.events.emit("react:transition:success", { state });
95
100
  } catch (e) {
96
101
  this.log.error("Transition has failed", e);
97
102
  state.layers = [
@@ -103,7 +108,7 @@ export class ReactBrowserRouterProvider extends RouterProvider<BrowserRoute> {
103
108
  },
104
109
  ];
105
110
 
106
- await this.alepha.emit("react:transition:error", {
111
+ await this.alepha.events.emit("react:transition:error", {
107
112
  error: e as Error,
108
113
  state,
109
114
  });
@@ -119,11 +124,11 @@ export class ReactBrowserRouterProvider extends RouterProvider<BrowserRoute> {
119
124
  }
120
125
  }
121
126
 
122
- await this.alepha.emit("react:transition:end", {
127
+ this.alepha.state.set("react.router.state", state);
128
+
129
+ await this.alepha.events.emit("react:transition:end", {
123
130
  state,
124
131
  });
125
-
126
- this.alepha.state("react.router.state", state);
127
132
  }
128
133
 
129
134
  public root(state: ReactRouterState): ReactNode {
@@ -1,4 +1,12 @@
1
- import { $env, $hook, $inject, Alepha, type Static, t } from "@alepha/core";
1
+ import {
2
+ $env,
3
+ $hook,
4
+ $inject,
5
+ Alepha,
6
+ type Static,
7
+ type TSchema,
8
+ t,
9
+ } from "@alepha/core";
2
10
  import { $logger } from "@alepha/logger";
3
11
  import { createElement, type ReactNode, StrictMode } from "react";
4
12
  import ClientOnly from "../components/ClientOnly.tsx";
@@ -99,6 +107,30 @@ export class ReactPageProvider {
99
107
  return root;
100
108
  }
101
109
 
110
+ protected convertStringObjectToObject = (
111
+ schema?: TSchema,
112
+ value?: any,
113
+ ): any => {
114
+ if (t.schema.isObject(schema) && typeof value === "object") {
115
+ for (const key in schema.properties) {
116
+ if (
117
+ t.schema.isObject(schema.properties[key]) &&
118
+ typeof value[key] === "string"
119
+ ) {
120
+ try {
121
+ value[key] = this.alepha.parse(
122
+ schema.properties[key],
123
+ decodeURIComponent(value[key]),
124
+ );
125
+ } catch (e) {
126
+ // ignore
127
+ }
128
+ }
129
+ }
130
+ }
131
+ return value;
132
+ };
133
+
102
134
  /**
103
135
  * Create a new RouterState based on a given route and request.
104
136
  * This method resolves the layers for the route, applying any query and params schemas defined in the route.
@@ -126,6 +158,7 @@ export class ReactPageProvider {
126
158
  const config: Record<string, any> = {};
127
159
 
128
160
  try {
161
+ this.convertStringObjectToObject(route.schema?.query, state.query);
129
162
  config.query = route.schema?.query
130
163
  ? this.alepha.parse(route.schema.query, state.query)
131
164
  : {};
@@ -331,6 +364,12 @@ export class ReactPageProvider {
331
364
  page: PageRoute,
332
365
  props: Record<string, any>,
333
366
  ): Promise<ReactNode> {
367
+ if (page.lazy && page.component) {
368
+ this.log.warn(
369
+ `Page ${page.name} has both lazy and component options, lazy will be used`,
370
+ );
371
+ }
372
+
334
373
  if (page.lazy) {
335
374
  const component = await page.lazy(); // load component
336
375
  return createElement(component.default, props);
@@ -605,6 +644,11 @@ export interface ReactRouterState {
605
644
  * Query parameters extracted from the URL for the current page.
606
645
  */
607
646
  query: Record<string, string>;
647
+
648
+ /**
649
+ * Optional meta information associated with the current page.
650
+ */
651
+ meta: Record<string, any>;
608
652
  }
609
653
 
610
654
  export interface RouterStackItem {
@@ -12,6 +12,7 @@ import {
12
12
  import { $logger } from "@alepha/logger";
13
13
  import {
14
14
  type ServerHandler,
15
+ ServerProvider,
15
16
  ServerRouterProvider,
16
17
  ServerTimingProvider,
17
18
  } from "@alepha/server";
@@ -21,6 +22,7 @@ import { renderToString } from "react-dom/server";
21
22
  import {
22
23
  $page,
23
24
  type PageDescriptorRenderOptions,
25
+ type PageDescriptorRenderResult,
24
26
  } from "../descriptors/$page.ts";
25
27
  import { Redirection } from "../errors/Redirection.ts";
26
28
  import type { ReactHydrationState } from "./ReactBrowserProvider.ts";
@@ -53,6 +55,7 @@ export class ReactServerProvider {
53
55
  protected readonly log = $logger();
54
56
  protected readonly alepha = $inject(Alepha);
55
57
  protected readonly pageApi = $inject(ReactPageProvider);
58
+ protected readonly serverProvider = $inject(ServerProvider);
56
59
  protected readonly serverStaticProvider = $inject(ServerStaticProvider);
57
60
  protected readonly serverRouterProvider = $inject(ServerRouterProvider);
58
61
  protected readonly serverTimingProvider = $inject(ServerTimingProvider);
@@ -61,6 +64,7 @@ export class ReactServerProvider {
61
64
  `<div([^>]*)\\s+id=["']${this.env.REACT_ROOT_ID}["']([^>]*)>(.*?)<\\/div>`,
62
65
  "is",
63
66
  );
67
+ protected preprocessedTemplate: PreprocessedTemplate | null = null;
64
68
 
65
69
  public readonly onConfigure = $hook({
66
70
  on: "configure",
@@ -70,10 +74,23 @@ export class ReactServerProvider {
70
74
  const ssrEnabled =
71
75
  pages.length > 0 && this.env.REACT_SSR_ENABLED !== false;
72
76
 
73
- this.alepha.state("react.server.ssr", ssrEnabled);
77
+ this.alepha.state.set("react.server.ssr", ssrEnabled);
74
78
 
75
79
  for (const page of pages) {
76
80
  page.render = this.createRenderFunction(page.name);
81
+ page.fetch = async (options) => {
82
+ const response = await fetch(
83
+ `${this.serverProvider.hostname}/${page.pathname(options)}`,
84
+ );
85
+ const html = await response.text();
86
+ if (options?.html) return { html, response };
87
+ // take only text inside the root div
88
+ const match = html.match(this.ROOT_DIV_REGEX);
89
+ if (match) {
90
+ return { html: match[3], response };
91
+ }
92
+ throw new AlephaError("Invalid HTML response");
93
+ };
77
94
  }
78
95
 
79
96
  // development mode
@@ -134,6 +151,12 @@ export class ReactServerProvider {
134
151
  }
135
152
 
136
153
  protected async registerPages(templateLoader: TemplateLoader) {
154
+ // Preprocess template once
155
+ const template = await templateLoader();
156
+ if (template) {
157
+ this.preprocessedTemplate = this.preprocessTemplate(template);
158
+ }
159
+
137
160
  for (const page of this.pageApi.getPages()) {
138
161
  if (page.children?.length) {
139
162
  continue;
@@ -194,7 +217,9 @@ export class ReactServerProvider {
194
217
  * For testing purposes, creates a render function that can be used.
195
218
  */
196
219
  protected createRenderFunction(name: string, withIndex = false) {
197
- return async (options: PageDescriptorRenderOptions = {}) => {
220
+ return async (
221
+ options: PageDescriptorRenderOptions = {},
222
+ ): Promise<PageDescriptorRenderResult> => {
198
223
  const page = this.pageApi.page(name);
199
224
  const url = new URL(this.pageApi.url(name, options));
200
225
 
@@ -204,6 +229,7 @@ export class ReactServerProvider {
204
229
  query: options.query ?? {},
205
230
  onError: () => null,
206
231
  layers: [],
232
+ meta: {},
207
233
  };
208
234
 
209
235
  const state = entry as ReactRouterState;
@@ -212,7 +238,7 @@ export class ReactServerProvider {
212
238
  url,
213
239
  });
214
240
 
215
- await this.alepha.emit("react:server:render:begin", {
241
+ await this.alepha.events.emit("react:server:render:begin", {
216
242
  state,
217
243
  });
218
244
 
@@ -222,11 +248,11 @@ export class ReactServerProvider {
222
248
  );
223
249
 
224
250
  if (redirect) {
225
- throw new AlephaError("Redirection is not supported in this context");
251
+ return { state, html: "", redirect };
226
252
  }
227
253
 
228
254
  if (!withIndex && !options.html) {
229
- this.alepha.state("react.router.state", state);
255
+ this.alepha.state.set("react.router.state", state);
230
256
 
231
257
  return {
232
258
  state,
@@ -234,14 +260,11 @@ export class ReactServerProvider {
234
260
  };
235
261
  }
236
262
 
237
- const html = this.renderToHtml(
238
- this.template ?? "",
239
- state,
240
- options.hydration,
241
- );
263
+ const template = this.template ?? "";
264
+ const html = this.renderToHtml(template, state, options.hydration);
242
265
 
243
266
  if (html instanceof Redirection) {
244
- throw new Error("Redirection is not supported in this context");
267
+ return { state, html: "", redirect };
245
268
  }
246
269
 
247
270
  const result = {
@@ -249,7 +272,7 @@ export class ReactServerProvider {
249
272
  html,
250
273
  };
251
274
 
252
- await this.alepha.emit("react:server:render:end", result);
275
+ await this.alepha.events.emit("react:server:render:end", result);
253
276
 
254
277
  return result;
255
278
  };
@@ -281,7 +304,7 @@ export class ReactServerProvider {
281
304
  const state = entry as ReactRouterState;
282
305
 
283
306
  if (this.alepha.has(ServerLinksProvider)) {
284
- this.alepha.state(
307
+ this.alepha.state.set(
285
308
  "api",
286
309
  await this.alepha.inject(ServerLinksProvider).getUserApiLinks({
287
310
  user: (serverRequest as any).user, // TODO: fix type
@@ -312,7 +335,7 @@ export class ReactServerProvider {
312
335
  // return;
313
336
  // }
314
337
 
315
- await this.alepha.emit("react:server:render:begin", {
338
+ await this.alepha.events.emit("react:server:render:begin", {
316
339
  request: serverRequest,
317
340
  state,
318
341
  });
@@ -352,7 +375,7 @@ export class ReactServerProvider {
352
375
  html,
353
376
  };
354
377
 
355
- await this.alepha.emit("react:server:render:end", event);
378
+ await this.alepha.events.emit("react:server:render:end", event);
356
379
 
357
380
  route.onServerResponse?.(serverRequest);
358
381
 
@@ -372,7 +395,7 @@ export class ReactServerProvider {
372
395
  const element = this.pageApi.root(state);
373
396
 
374
397
  // attach react router state to the http request context
375
- this.alepha.state("react.router.state", state);
398
+ this.alepha.state.set("react.router.state", state);
376
399
 
377
400
  this.serverTimingProvider.beginTiming("renderToString");
378
401
  let app = "";
@@ -433,36 +456,80 @@ export class ReactServerProvider {
433
456
  return response.html;
434
457
  }
435
458
 
459
+ protected preprocessTemplate(template: string): PreprocessedTemplate {
460
+ // Find the body close tag for script injection
461
+ const bodyCloseMatch = template.match(/<\/body>/i);
462
+ const bodyCloseIndex = bodyCloseMatch?.index ?? template.length;
463
+
464
+ const beforeScript = template.substring(0, bodyCloseIndex);
465
+ const afterScript = template.substring(bodyCloseIndex);
466
+
467
+ // Check if there's an existing root div
468
+ const rootDivMatch = beforeScript.match(this.ROOT_DIV_REGEX);
469
+
470
+ if (rootDivMatch) {
471
+ // Split around the existing root div content
472
+ const beforeDiv = beforeScript.substring(0, rootDivMatch.index!);
473
+ const afterDivStart = rootDivMatch.index! + rootDivMatch[0].length;
474
+ const afterDiv = beforeScript.substring(afterDivStart);
475
+
476
+ const beforeApp = `${beforeDiv}<div${rootDivMatch[1]} id="${this.env.REACT_ROOT_ID}"${rootDivMatch[2]}>`;
477
+ const afterApp = `</div>${afterDiv}`;
478
+
479
+ return { beforeApp, afterApp, beforeScript: "", afterScript };
480
+ }
481
+
482
+ // No existing root div, find body tag to inject new div
483
+ const bodyMatch = beforeScript.match(/<body([^>]*)>/i);
484
+ if (bodyMatch) {
485
+ const beforeBody = beforeScript.substring(
486
+ 0,
487
+ bodyMatch.index! + bodyMatch[0].length,
488
+ );
489
+ const afterBody = beforeScript.substring(
490
+ bodyMatch.index! + bodyMatch[0].length,
491
+ );
492
+
493
+ const beforeApp = `${beforeBody}<div id="${this.env.REACT_ROOT_ID}">`;
494
+ const afterApp = `</div>${afterBody}`;
495
+
496
+ return { beforeApp, afterApp, beforeScript: "", afterScript };
497
+ }
498
+
499
+ // Fallback: no body tag found, just wrap everything
500
+ return {
501
+ beforeApp: `<div id="${this.env.REACT_ROOT_ID}">`,
502
+ afterApp: `</div>`,
503
+ beforeScript,
504
+ afterScript,
505
+ };
506
+ }
507
+
436
508
  protected fillTemplate(
437
509
  response: { html: string },
438
510
  app: string,
439
511
  script: string,
440
512
  ) {
441
- if (this.ROOT_DIV_REGEX.test(response.html)) {
442
- // replace contents of the existing <div id="root">
443
- response.html = response.html.replace(
444
- this.ROOT_DIV_REGEX,
445
- (_match, beforeId, afterId) => {
446
- return `<div${beforeId} id="${this.env.REACT_ROOT_ID}"${afterId}>${app}</div>`;
447
- },
448
- );
449
- } else {
450
- const bodyOpenTag = /<body([^>]*)>/i;
451
- if (bodyOpenTag.test(response.html)) {
452
- response.html = response.html.replace(bodyOpenTag, (match) => {
453
- return `${match}<div id="${this.env.REACT_ROOT_ID}">${app}</div>`;
454
- });
455
- }
513
+ if (!this.preprocessedTemplate) {
514
+ // Fallback to old logic if preprocessing failed
515
+ this.preprocessedTemplate = this.preprocessTemplate(response.html);
456
516
  }
457
517
 
458
- const bodyCloseTagRegex = /<\/body>/i;
459
- if (bodyCloseTagRegex.test(response.html)) {
460
- response.html = response.html.replace(
461
- bodyCloseTagRegex,
462
- `${script}</body>`,
463
- );
464
- }
518
+ // Pure concatenation - no regex replacements needed
519
+ response.html =
520
+ this.preprocessedTemplate.beforeApp +
521
+ app +
522
+ this.preprocessedTemplate.afterApp +
523
+ script +
524
+ this.preprocessedTemplate.afterScript;
465
525
  }
466
526
  }
467
527
 
468
528
  type TemplateLoader = () => Promise<string | undefined>;
529
+
530
+ interface PreprocessedTemplate {
531
+ beforeApp: string;
532
+ afterApp: string;
533
+ beforeScript: string;
534
+ afterScript: string;
535
+ }
@@ -15,7 +15,7 @@ export class ReactRouter<T extends object> {
15
15
  protected readonly pageApi = $inject(ReactPageProvider);
16
16
 
17
17
  public get state(): ReactRouterState {
18
- return this.alepha.state("react.router.state")!;
18
+ return this.alepha.state.get("react.router.state")!;
19
19
  }
20
20
 
21
21
  public get pages() {
@@ -33,8 +33,8 @@ export class ReactRouter<T extends object> {
33
33
  public path(
34
34
  name: keyof VirtualRouter<T>,
35
35
  config: {
36
- params?: Record<string, string>;
37
- query?: Record<string, string>;
36
+ params?: Record<string, any>;
37
+ query?: Record<string, any>;
38
38
  } = {},
39
39
  ): string {
40
40
  return this.pageApi.pathname(name as string, {
@@ -116,17 +116,14 @@ export class ReactRouter<T extends object> {
116
116
  await this.browser?.go(path as string, options);
117
117
  }
118
118
 
119
- public anchor(
120
- path: string,
121
- options?: { params?: Record<string, any> },
122
- ): AnchorProps;
119
+ public anchor(path: string, options?: RouterGoOptions): AnchorProps;
123
120
  public anchor(
124
121
  path: keyof VirtualRouter<T>,
125
- options?: { params?: Record<string, any> },
122
+ options?: RouterGoOptions,
126
123
  ): AnchorProps;
127
124
  public anchor(
128
125
  path: string | keyof VirtualRouter<T>,
129
- options: { params?: Record<string, any> } = {},
126
+ options: RouterGoOptions = {},
130
127
  ): AnchorProps {
131
128
  let href = path as string;
132
129