@alepha/react 0.9.5 → 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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@alepha/react",
3
3
  "description": "Build server-side rendered (SSR) or single-page React applications.",
4
- "version": "0.9.5",
4
+ "version": "0.10.0",
5
5
  "type": "module",
6
6
  "engines": {
7
7
  "node": ">=22.0.0"
@@ -17,27 +17,28 @@
17
17
  "src"
18
18
  ],
19
19
  "dependencies": {
20
- "@alepha/core": "0.9.5",
21
- "@alepha/datetime": "0.9.5",
22
- "@alepha/logger": "0.9.5",
23
- "@alepha/router": "0.9.5",
24
- "@alepha/server": "0.9.5",
25
- "@alepha/server-cache": "0.9.5",
26
- "@alepha/server-links": "0.9.5",
27
- "@alepha/server-static": "0.9.5",
28
- "react-dom": "^19.1.1"
20
+ "@alepha/core": "0.10.0",
21
+ "@alepha/datetime": "0.10.0",
22
+ "@alepha/logger": "0.10.0",
23
+ "@alepha/router": "0.10.0",
24
+ "@alepha/server": "0.10.0",
25
+ "@alepha/server-cache": "0.10.0",
26
+ "@alepha/server-links": "0.10.0",
27
+ "@alepha/server-static": "0.10.0"
29
28
  },
30
29
  "devDependencies": {
31
30
  "@biomejs/biome": "^2.2.4",
32
31
  "@types/react": "^19.1.13",
33
32
  "@types/react-dom": "^19.1.9",
34
33
  "react": "^19.1.1",
35
- "tsdown": "^0.15.1",
34
+ "react-dom": "^19.1.1",
35
+ "tsdown": "^0.15.3",
36
36
  "typescript": "^5.9.2",
37
37
  "vitest": "^3.2.4"
38
38
  },
39
39
  "peerDependencies": {
40
- "react": "^19"
40
+ "react": "*",
41
+ "react-dom": "*"
41
42
  },
42
43
  "scripts": {
43
44
  "check": "tsc",
@@ -16,6 +16,91 @@ import type { ReactRouterState } from "../providers/ReactPageProvider.ts";
16
16
 
17
17
  /**
18
18
  * Main descriptor for defining a React route in the application.
19
+ *
20
+ * The $page descriptor is the core building block for creating type-safe, SSR-enabled React routes.
21
+ * It provides a declarative way to define pages with powerful features:
22
+ *
23
+ * **Routing & Navigation**
24
+ * - URL pattern matching with parameters (e.g., `/users/:id`)
25
+ * - Nested routing with parent-child relationships
26
+ * - Type-safe URL parameter and query string validation
27
+ *
28
+ * **Data Loading**
29
+ * - Server-side data fetching with the `resolve` function
30
+ * - Automatic serialization and hydration for SSR
31
+ * - Access to request context, URL params, and parent data
32
+ *
33
+ * **Component Loading**
34
+ * - Direct component rendering or lazy loading for code splitting
35
+ * - Client-only rendering when browser APIs are needed
36
+ * - Automatic fallback handling during hydration
37
+ *
38
+ * **Performance Optimization**
39
+ * - Static generation for pre-rendered pages at build time
40
+ * - Server-side caching with configurable TTL and providers
41
+ * - Code splitting through lazy component loading
42
+ *
43
+ * **Error Handling**
44
+ * - Custom error handlers with support for redirects
45
+ * - Hierarchical error handling (child → parent)
46
+ * - HTTP status code handling (404, 401, etc.)
47
+ *
48
+ * **Page Animations**
49
+ * - CSS-based enter/exit animations
50
+ * - Dynamic animations based on page state
51
+ * - Custom timing and easing functions
52
+ *
53
+ * **Lifecycle Management**
54
+ * - Server response hooks for headers and status codes
55
+ * - Page leave handlers for cleanup (browser only)
56
+ * - Permission-based access control
57
+ *
58
+ * @example Simple page with data fetching
59
+ * ```typescript
60
+ * const userProfile = $page({
61
+ * path: "/users/:id",
62
+ * schema: {
63
+ * params: t.object({ id: t.int() }),
64
+ * query: t.object({ tab: t.optional(t.string()) })
65
+ * },
66
+ * resolve: async ({ params }) => {
67
+ * const user = await userApi.getUser(params.id);
68
+ * return { user };
69
+ * },
70
+ * lazy: () => import("./UserProfile.tsx")
71
+ * });
72
+ * ```
73
+ *
74
+ * @example Nested routing with error handling
75
+ * ```typescript
76
+ * const projectSection = $page({
77
+ * path: "/projects/:id",
78
+ * children: () => [projectBoard, projectSettings],
79
+ * resolve: async ({ params }) => {
80
+ * const project = await projectApi.get(params.id);
81
+ * return { project };
82
+ * },
83
+ * errorHandler: (error) => {
84
+ * if (HttpError.is(error, 404)) {
85
+ * return <ProjectNotFound />;
86
+ * }
87
+ * }
88
+ * });
89
+ * ```
90
+ *
91
+ * @example Static generation with caching
92
+ * ```typescript
93
+ * const blogPost = $page({
94
+ * path: "/blog/:slug",
95
+ * static: {
96
+ * entries: posts.map(p => ({ params: { slug: p.slug } }))
97
+ * },
98
+ * resolve: async ({ params }) => {
99
+ * const post = await loadPost(params.slug);
100
+ * return { post };
101
+ * }
102
+ * });
103
+ * ```
19
104
  */
20
105
  export const $page = <
21
106
  TConfig extends PageConfigSchema = PageConfigSchema,
@@ -11,7 +11,7 @@ import { AlephaContext } from "../contexts/AlephaContext.ts";
11
11
  *
12
12
  * - alepha.state() for state management
13
13
  * - alepha.inject() for dependency injection
14
- * - alepha.emit() for event handling
14
+ * - alepha.events.emit() for event handling
15
15
  * etc...
16
16
  */
17
17
  export const useAlepha = (): Alepha => {
@@ -9,14 +9,14 @@ import { useRouter } from "./useRouter.ts";
9
9
  export const useQueryParams = <T extends TObject>(
10
10
  schema: T,
11
11
  options: UseQueryParamsHookOptions = {},
12
- ): [Static<T>, (data: Static<T>) => void] => {
12
+ ): [Partial<Static<T>>, (data: Static<T>) => void] => {
13
13
  const alepha = useAlepha();
14
14
 
15
15
  const key = options.key ?? "q";
16
16
  const router = useRouter();
17
17
  const querystring = router.query[key];
18
18
 
19
- const [queryParams, setQueryParams] = useState(
19
+ const [queryParams = {}, setQueryParams] = useState<Static<T> | undefined>(
20
20
  decode(alepha, schema, router.query[key]),
21
21
  );
22
22
 
@@ -47,10 +47,14 @@ const encode = (alepha: Alepha, schema: TObject, data: any) => {
47
47
  return btoa(JSON.stringify(alepha.parse(schema, data)));
48
48
  };
49
49
 
50
- const decode = (alepha: Alepha, schema: TObject, data: any) => {
50
+ const decode = <T extends TObject>(
51
+ alepha: Alepha,
52
+ schema: T,
53
+ data: any,
54
+ ): Static<T> | undefined => {
51
55
  try {
52
56
  return alepha.parse(schema, JSON.parse(atob(decodeURIComponent(data))));
53
- } catch (_error) {
54
- return {};
57
+ } catch {
58
+ return;
55
59
  }
56
60
  };
@@ -42,19 +42,19 @@ export const useRouterEvents = (
42
42
  const onSuccess = opts.onSuccess;
43
43
 
44
44
  if (onBegin) {
45
- subs.push(alepha.on("react:transition:begin", cb(onBegin)));
45
+ subs.push(alepha.events.on("react:transition:begin", cb(onBegin)));
46
46
  }
47
47
 
48
48
  if (onEnd) {
49
- subs.push(alepha.on("react:transition:end", cb(onEnd)));
49
+ subs.push(alepha.events.on("react:transition:end", cb(onEnd)));
50
50
  }
51
51
 
52
52
  if (onError) {
53
- subs.push(alepha.on("react:transition:error", cb(onError)));
53
+ subs.push(alepha.events.on("react:transition:error", cb(onError)));
54
54
  }
55
55
 
56
56
  if (onSuccess) {
57
- subs.push(alepha.on("react:transition:success", cb(onSuccess)));
57
+ subs.push(alepha.events.on("react:transition:success", cb(onSuccess)));
58
58
  }
59
59
 
60
60
  return () => {
@@ -12,19 +12,19 @@ export const useStore = <Key extends keyof State>(
12
12
  const alepha = useAlepha();
13
13
 
14
14
  useMemo(() => {
15
- if (defaultValue != null && alepha.state(key) == null) {
16
- alepha.state(key, defaultValue);
15
+ if (defaultValue != null && alepha.state.get(key) == null) {
16
+ alepha.state.set(key, defaultValue);
17
17
  }
18
18
  }, [defaultValue]);
19
19
 
20
- const [state, setState] = useState(alepha.state(key));
20
+ const [state, setState] = useState(alepha.state.get(key));
21
21
 
22
22
  useEffect(() => {
23
23
  if (!alepha.isBrowser()) {
24
24
  return;
25
25
  }
26
26
 
27
- return alepha.on("state:mutate", (ev) => {
27
+ return alepha.events.on("state:mutate", (ev) => {
28
28
  if (ev.key === key) {
29
29
  setState(ev.value);
30
30
  }
@@ -34,7 +34,7 @@ export const useStore = <Key extends keyof State>(
34
34
  return [
35
35
  state,
36
36
  (value: State[Key]) => {
37
- alepha.state(key, value);
37
+ alepha.state.set(key, value);
38
38
  },
39
39
  ] as const;
40
40
  };
@@ -61,7 +61,7 @@ export class ReactBrowserProvider {
61
61
  };
62
62
 
63
63
  public get state(): ReactRouterState {
64
- return this.alepha.state("react.router.state")!;
64
+ return this.alepha.state.get("react.router.state")!;
65
65
  }
66
66
 
67
67
  /**
@@ -232,7 +232,7 @@ export class ReactBrowserProvider {
232
232
  // low budget, but works for now
233
233
  for (const [key, value] of Object.entries(hydration)) {
234
234
  if (key !== "layers") {
235
- this.alepha.state(key as keyof State, value);
235
+ this.alepha.state.set(key as keyof State, value);
236
236
  }
237
237
  }
238
238
  }
@@ -241,7 +241,7 @@ export class ReactBrowserProvider {
241
241
 
242
242
  const element = this.router.root(this.state);
243
243
 
244
- await this.alepha.emit("react:browser:render", {
244
+ await this.alepha.events.emit("react:browser:render", {
245
245
  element,
246
246
  root: this.getRootElement(),
247
247
  hydration,
@@ -58,8 +58,8 @@ export class ReactBrowserRouterProvider extends RouterProvider<BrowserRoute> {
58
58
 
59
59
  const state = entry as ReactRouterState;
60
60
 
61
- await this.alepha.emit("react:transition:begin", {
62
- previous: this.alepha.state("react.router.state")!,
61
+ await this.alepha.events.emit("react:transition:begin", {
62
+ previous: this.alepha.state.get("react.router.state")!,
63
63
  state,
64
64
  });
65
65
 
@@ -96,7 +96,7 @@ export class ReactBrowserRouterProvider extends RouterProvider<BrowserRoute> {
96
96
  });
97
97
  }
98
98
 
99
- await this.alepha.emit("react:transition:success", { state });
99
+ await this.alepha.events.emit("react:transition:success", { state });
100
100
  } catch (e) {
101
101
  this.log.error("Transition has failed", e);
102
102
  state.layers = [
@@ -108,7 +108,7 @@ export class ReactBrowserRouterProvider extends RouterProvider<BrowserRoute> {
108
108
  },
109
109
  ];
110
110
 
111
- await this.alepha.emit("react:transition:error", {
111
+ await this.alepha.events.emit("react:transition:error", {
112
112
  error: e as Error,
113
113
  state,
114
114
  });
@@ -124,9 +124,9 @@ export class ReactBrowserRouterProvider extends RouterProvider<BrowserRoute> {
124
124
  }
125
125
  }
126
126
 
127
- this.alepha.state("react.router.state", state);
127
+ this.alepha.state.set("react.router.state", state);
128
128
 
129
- await this.alepha.emit("react:transition:end", {
129
+ await this.alepha.events.emit("react:transition:end", {
130
130
  state,
131
131
  });
132
132
  }
@@ -5,7 +5,6 @@ import {
5
5
  Alepha,
6
6
  type Static,
7
7
  type TSchema,
8
- TypeGuard,
9
8
  t,
10
9
  } from "@alepha/core";
11
10
  import { $logger } from "@alepha/logger";
@@ -112,10 +111,10 @@ export class ReactPageProvider {
112
111
  schema?: TSchema,
113
112
  value?: any,
114
113
  ): any => {
115
- if (TypeGuard.IsObject(schema) && typeof value === "object") {
114
+ if (t.schema.isObject(schema) && typeof value === "object") {
116
115
  for (const key in schema.properties) {
117
116
  if (
118
- TypeGuard.IsObject(schema.properties[key]) &&
117
+ t.schema.isObject(schema.properties[key]) &&
119
118
  typeof value[key] === "string"
120
119
  ) {
121
120
  try {
@@ -64,6 +64,7 @@ export class ReactServerProvider {
64
64
  `<div([^>]*)\\s+id=["']${this.env.REACT_ROOT_ID}["']([^>]*)>(.*?)<\\/div>`,
65
65
  "is",
66
66
  );
67
+ protected preprocessedTemplate: PreprocessedTemplate | null = null;
67
68
 
68
69
  public readonly onConfigure = $hook({
69
70
  on: "configure",
@@ -73,7 +74,7 @@ export class ReactServerProvider {
73
74
  const ssrEnabled =
74
75
  pages.length > 0 && this.env.REACT_SSR_ENABLED !== false;
75
76
 
76
- this.alepha.state("react.server.ssr", ssrEnabled);
77
+ this.alepha.state.set("react.server.ssr", ssrEnabled);
77
78
 
78
79
  for (const page of pages) {
79
80
  page.render = this.createRenderFunction(page.name);
@@ -150,6 +151,12 @@ export class ReactServerProvider {
150
151
  }
151
152
 
152
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
+
153
160
  for (const page of this.pageApi.getPages()) {
154
161
  if (page.children?.length) {
155
162
  continue;
@@ -231,7 +238,7 @@ export class ReactServerProvider {
231
238
  url,
232
239
  });
233
240
 
234
- await this.alepha.emit("react:server:render:begin", {
241
+ await this.alepha.events.emit("react:server:render:begin", {
235
242
  state,
236
243
  });
237
244
 
@@ -245,7 +252,7 @@ export class ReactServerProvider {
245
252
  }
246
253
 
247
254
  if (!withIndex && !options.html) {
248
- this.alepha.state("react.router.state", state);
255
+ this.alepha.state.set("react.router.state", state);
249
256
 
250
257
  return {
251
258
  state,
@@ -253,11 +260,8 @@ export class ReactServerProvider {
253
260
  };
254
261
  }
255
262
 
256
- const html = this.renderToHtml(
257
- this.template ?? "",
258
- state,
259
- options.hydration,
260
- );
263
+ const template = this.template ?? "";
264
+ const html = this.renderToHtml(template, state, options.hydration);
261
265
 
262
266
  if (html instanceof Redirection) {
263
267
  return { state, html: "", redirect };
@@ -268,7 +272,7 @@ export class ReactServerProvider {
268
272
  html,
269
273
  };
270
274
 
271
- await this.alepha.emit("react:server:render:end", result);
275
+ await this.alepha.events.emit("react:server:render:end", result);
272
276
 
273
277
  return result;
274
278
  };
@@ -300,7 +304,7 @@ export class ReactServerProvider {
300
304
  const state = entry as ReactRouterState;
301
305
 
302
306
  if (this.alepha.has(ServerLinksProvider)) {
303
- this.alepha.state(
307
+ this.alepha.state.set(
304
308
  "api",
305
309
  await this.alepha.inject(ServerLinksProvider).getUserApiLinks({
306
310
  user: (serverRequest as any).user, // TODO: fix type
@@ -331,7 +335,7 @@ export class ReactServerProvider {
331
335
  // return;
332
336
  // }
333
337
 
334
- await this.alepha.emit("react:server:render:begin", {
338
+ await this.alepha.events.emit("react:server:render:begin", {
335
339
  request: serverRequest,
336
340
  state,
337
341
  });
@@ -371,7 +375,7 @@ export class ReactServerProvider {
371
375
  html,
372
376
  };
373
377
 
374
- await this.alepha.emit("react:server:render:end", event);
378
+ await this.alepha.events.emit("react:server:render:end", event);
375
379
 
376
380
  route.onServerResponse?.(serverRequest);
377
381
 
@@ -391,7 +395,7 @@ export class ReactServerProvider {
391
395
  const element = this.pageApi.root(state);
392
396
 
393
397
  // attach react router state to the http request context
394
- this.alepha.state("react.router.state", state);
398
+ this.alepha.state.set("react.router.state", state);
395
399
 
396
400
  this.serverTimingProvider.beginTiming("renderToString");
397
401
  let app = "";
@@ -452,36 +456,80 @@ export class ReactServerProvider {
452
456
  return response.html;
453
457
  }
454
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
+
455
508
  protected fillTemplate(
456
509
  response: { html: string },
457
510
  app: string,
458
511
  script: string,
459
512
  ) {
460
- if (this.ROOT_DIV_REGEX.test(response.html)) {
461
- // replace contents of the existing <div id="root">
462
- response.html = response.html.replace(
463
- this.ROOT_DIV_REGEX,
464
- (_match, beforeId, afterId) => {
465
- return `<div${beforeId} id="${this.env.REACT_ROOT_ID}"${afterId}>${app}</div>`;
466
- },
467
- );
468
- } else {
469
- const bodyOpenTag = /<body([^>]*)>/i;
470
- if (bodyOpenTag.test(response.html)) {
471
- response.html = response.html.replace(bodyOpenTag, (match) => {
472
- return `${match}<div id="${this.env.REACT_ROOT_ID}">${app}</div>`;
473
- });
474
- }
513
+ if (!this.preprocessedTemplate) {
514
+ // Fallback to old logic if preprocessing failed
515
+ this.preprocessedTemplate = this.preprocessTemplate(response.html);
475
516
  }
476
517
 
477
- const bodyCloseTagRegex = /<\/body>/i;
478
- if (bodyCloseTagRegex.test(response.html)) {
479
- response.html = response.html.replace(
480
- bodyCloseTagRegex,
481
- `${script}</body>`,
482
- );
483
- }
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;
484
525
  }
485
526
  }
486
527
 
487
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() {