@alepha/react 0.11.3 → 0.11.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.
@@ -0,0 +1,51 @@
1
+ import type { Async, Hook, Hooks } from "@alepha/core";
2
+ import { type DependencyList, useEffect } from "react";
3
+ import { useAlepha } from "./useAlepha.ts";
4
+
5
+ /**
6
+ * Allow subscribing to multiple Alepha events. See {@link Hooks} for available events.
7
+ *
8
+ * useEvents is fully typed to ensure correct event callback signatures.
9
+ *
10
+ * @example
11
+ * ```tsx
12
+ * useEvents(
13
+ * {
14
+ * "react:transition:begin": (ev) => {
15
+ * console.log("Transition began to:", ev.to);
16
+ * },
17
+ * "react:transition:error": {
18
+ * priority: "first",
19
+ * callback: (ev) => {
20
+ * console.error("Transition error:", ev.error);
21
+ * },
22
+ * },
23
+ * },
24
+ * [],
25
+ * );
26
+ * ```
27
+ */
28
+ export const useEvents = (opts: UseEvents, deps: DependencyList) => {
29
+ const alepha = useAlepha();
30
+
31
+ useEffect(() => {
32
+ if (!alepha.isBrowser()) {
33
+ return;
34
+ }
35
+
36
+ const subs: Function[] = [];
37
+ for (const [name, hook] of Object.entries(opts)) {
38
+ subs.push(alepha.events.on(name as any, hook as any));
39
+ }
40
+
41
+ return () => {
42
+ for (const clear of subs) {
43
+ clear();
44
+ }
45
+ };
46
+ }, deps);
47
+ };
48
+
49
+ type UseEvents = {
50
+ [T in keyof Hooks]?: Hook<T> | ((payload: Hooks[T]) => Async<void>);
51
+ };
@@ -1,4 +1,5 @@
1
1
  import { $module } from "@alepha/core";
2
+ import { AlephaDateTime } from "@alepha/datetime";
2
3
  import { AlephaServer } from "@alepha/server";
3
4
  import { AlephaServerLinks } from "@alepha/server-links";
4
5
  import { $page } from "./descriptors/$page.ts";
@@ -6,6 +7,7 @@ import { ReactBrowserProvider } from "./providers/ReactBrowserProvider.ts";
6
7
  import { ReactBrowserRendererProvider } from "./providers/ReactBrowserRendererProvider.ts";
7
8
  import { ReactBrowserRouterProvider } from "./providers/ReactBrowserRouterProvider.ts";
8
9
  import { ReactPageProvider } from "./providers/ReactPageProvider.ts";
10
+ import { ReactPageService } from "./services/ReactPageService.ts";
9
11
  import { ReactRouter } from "./services/ReactRouter.ts";
10
12
 
11
13
  // ---------------------------------------------------------------------------------------------------------------------
@@ -26,9 +28,11 @@ export const AlephaReact = $module({
26
28
  ReactBrowserProvider,
27
29
  ReactRouter,
28
30
  ReactBrowserRendererProvider,
31
+ ReactPageService,
29
32
  ],
30
33
  register: (alepha) =>
31
34
  alepha
35
+ .with(AlephaDateTime)
32
36
  .with(AlephaServer)
33
37
  .with(AlephaServerLinks)
34
38
  .with(ReactPageProvider)
@@ -8,13 +8,14 @@ export * from "./contexts/AlephaContext.ts";
8
8
  export * from "./contexts/RouterLayerContext.ts";
9
9
  export * from "./descriptors/$page.ts";
10
10
  export * from "./errors/Redirection.ts";
11
+ export * from "./hooks/useAction.ts";
11
12
  export * from "./hooks/useActive.ts";
12
13
  export * from "./hooks/useAlepha.ts";
13
14
  export * from "./hooks/useClient.ts";
15
+ export * from "./hooks/useEvents.ts";
14
16
  export * from "./hooks/useInject.ts";
15
17
  export * from "./hooks/useQueryParams.ts";
16
18
  export * from "./hooks/useRouter.ts";
17
- export * from "./hooks/useRouterEvents.ts";
18
19
  export * from "./hooks/useRouterState.ts";
19
20
  export * from "./hooks/useSchema.ts";
20
21
  export * from "./hooks/useStore.ts";
package/src/index.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { $module } from "@alepha/core";
2
+ import { AlephaDateTime } from "@alepha/datetime";
2
3
  import { AlephaServer, type ServerRequest } from "@alepha/server";
3
4
  import { AlephaServerCache } from "@alepha/server-cache";
4
5
  import { AlephaServerLinks } from "@alepha/server-links";
@@ -10,6 +11,8 @@ import {
10
11
  type ReactRouterState,
11
12
  } from "./providers/ReactPageProvider.ts";
12
13
  import { ReactServerProvider } from "./providers/ReactServerProvider.ts";
14
+ import { ReactPageServerService } from "./services/ReactPageServerService.ts";
15
+ import { ReactPageService } from "./services/ReactPageService.ts";
13
16
  import { ReactRouter } from "./services/ReactRouter.ts";
14
17
 
15
18
  // ---------------------------------------------------------------------------------------------------------------------
@@ -27,34 +30,92 @@ declare module "@alepha/core" {
27
30
  }
28
31
 
29
32
  interface Hooks {
33
+ /**
34
+ * Fires when the React application is starting to be rendered on the server.
35
+ */
30
36
  "react:server:render:begin": {
31
37
  request?: ServerRequest;
32
38
  state: ReactRouterState;
33
39
  };
40
+ /**
41
+ * Fires when the React application has been rendered on the server.
42
+ */
34
43
  "react:server:render:end": {
35
44
  request?: ServerRequest;
36
45
  state: ReactRouterState;
37
46
  html: string;
38
47
  };
39
48
  // -----------------------------------------------------------------------------------------------------------------
49
+ /**
50
+ * Fires when the React application is being rendered on the browser.
51
+ */
40
52
  "react:browser:render": {
41
53
  root: HTMLElement;
42
54
  element: ReactNode;
43
55
  state: ReactRouterState;
44
56
  hydration?: ReactHydrationState;
45
57
  };
58
+ // -----------------------------------------------------------------------------------------------------------------
59
+ // TOP LEVEL: All user actions (forms, transitions, custom actions)
60
+ /**
61
+ * Fires when a user action is starting.
62
+ * Action can be a form submission, a route transition, or a custom action.
63
+ */
64
+ "react:action:begin": {
65
+ type: string;
66
+ id?: string;
67
+ };
68
+ /**
69
+ * Fires when a user action has succeeded.
70
+ * Action can be a form submission, a route transition, or a custom action.
71
+ */
72
+ "react:action:success": {
73
+ type: string;
74
+ id?: string;
75
+ };
76
+ /**
77
+ * Fires when a user action has failed.
78
+ * Action can be a form submission, a route transition, or a custom action.
79
+ */
80
+ "react:action:error": {
81
+ type: string;
82
+ id?: string;
83
+ error: Error;
84
+ };
85
+ /**
86
+ * Fires when a user action has completed, regardless of success or failure.
87
+ * Action can be a form submission, a route transition, or a custom action.
88
+ */
89
+ "react:action:end": {
90
+ type: string;
91
+ id?: string;
92
+ };
93
+ // -----------------------------------------------------------------------------------------------------------------
94
+ // SPECIFIC: Route transitions
95
+ /**
96
+ * Fires when a route transition is starting.
97
+ */
46
98
  "react:transition:begin": {
47
99
  previous: ReactRouterState;
48
100
  state: ReactRouterState;
49
101
  animation?: PageAnimation;
50
102
  };
103
+ /**
104
+ * Fires when a route transition has succeeded.
105
+ */
51
106
  "react:transition:success": {
52
107
  state: ReactRouterState;
53
108
  };
109
+ /**
110
+ * Fires when a route transition has failed.
111
+ */
54
112
  "react:transition:error": {
55
113
  state: ReactRouterState;
56
114
  error: Error;
57
115
  };
116
+ /**
117
+ * Fires when a route transition has completed, regardless of success or failure.
118
+ */
58
119
  "react:transition:end": {
59
120
  state: ReactRouterState;
60
121
  };
@@ -76,12 +137,23 @@ declare module "@alepha/core" {
76
137
  export const AlephaReact = $module({
77
138
  name: "alepha.react",
78
139
  descriptors: [$page],
79
- services: [ReactServerProvider, ReactPageProvider, ReactRouter],
140
+ services: [
141
+ ReactServerProvider,
142
+ ReactPageProvider,
143
+ ReactRouter,
144
+ ReactPageService,
145
+ ReactPageServerService,
146
+ ],
80
147
  register: (alepha) =>
81
148
  alepha
149
+ .with(AlephaDateTime)
82
150
  .with(AlephaServer)
83
151
  .with(AlephaServerCache)
84
152
  .with(AlephaServerLinks)
153
+ .with({
154
+ provide: ReactPageService,
155
+ use: ReactPageServerService,
156
+ })
85
157
  .with(ReactServerProvider)
86
158
  .with(ReactPageProvider)
87
159
  .with(ReactRouter),
@@ -58,6 +58,10 @@ export class ReactBrowserRouterProvider extends RouterProvider<BrowserRoute> {
58
58
 
59
59
  const state = entry as ReactRouterState;
60
60
 
61
+ // Emit both action and transition events
62
+ await this.alepha.events.emit("react:action:begin", {
63
+ type: "transition",
64
+ });
61
65
  await this.alepha.events.emit("react:transition:begin", {
62
66
  previous: this.alepha.state.get("react.router.state")!,
63
67
  state,
@@ -96,6 +100,9 @@ export class ReactBrowserRouterProvider extends RouterProvider<BrowserRoute> {
96
100
  });
97
101
  }
98
102
 
103
+ await this.alepha.events.emit("react:action:success", {
104
+ type: "transition",
105
+ });
99
106
  await this.alepha.events.emit("react:transition:success", { state });
100
107
  } catch (e) {
101
108
  this.log.error("Transition has failed", e);
@@ -108,6 +115,10 @@ export class ReactBrowserRouterProvider extends RouterProvider<BrowserRoute> {
108
115
  },
109
116
  ];
110
117
 
118
+ await this.alepha.events.emit("react:action:error", {
119
+ type: "transition",
120
+ error: e as Error,
121
+ });
111
122
  await this.alepha.events.emit("react:transition:error", {
112
123
  error: e as Error,
113
124
  state,
@@ -126,6 +137,9 @@ export class ReactBrowserRouterProvider extends RouterProvider<BrowserRoute> {
126
137
 
127
138
  this.alepha.state.set("react.router.state", state);
128
139
 
140
+ await this.alepha.events.emit("react:action:end", {
141
+ type: "transition",
142
+ });
129
143
  await this.alepha.events.emit("react:transition:end", {
130
144
  state,
131
145
  });
@@ -42,6 +42,39 @@ export class ReactPageProvider {
42
42
  return this.pages;
43
43
  }
44
44
 
45
+ public getConcretePages(): PageRoute[] {
46
+ const pages: PageRoute[] = [];
47
+ for (const page of this.pages) {
48
+ if (page.children && page.children.length > 0) {
49
+ continue;
50
+ }
51
+ const fullPath = this.pathname(page.name);
52
+ if (fullPath.includes(":") || fullPath.includes("*")) {
53
+ if (typeof page.static === "object") {
54
+ const entries = page.static.entries;
55
+ if (entries && entries.length > 0) {
56
+ for (const entry of entries) {
57
+ const params = entry.params as Record<string, string>;
58
+ const path = this.compile(page.path ?? "", params);
59
+ if (!path.includes(":") && !path.includes("*")) {
60
+ pages.push({
61
+ ...page,
62
+ name: params[Object.keys(params)[0]],
63
+ path,
64
+ ...entry,
65
+ });
66
+ }
67
+ }
68
+ }
69
+ }
70
+
71
+ continue;
72
+ }
73
+ pages.push(page);
74
+ }
75
+ return pages;
76
+ }
77
+
45
78
  public page(name: string): PageRoute {
46
79
  for (const page of this.pages) {
47
80
  if (page.name === name) {
@@ -49,7 +82,7 @@ export class ReactPageProvider {
49
82
  }
50
83
  }
51
84
 
52
- throw new Error(`Page ${name} not found`);
85
+ throw new AlephaError(`Page '${name}' not found`);
53
86
  }
54
87
 
55
88
  public pathname(
@@ -73,7 +73,7 @@ export class ReactServerProvider implements Configurable {
73
73
  protected readonly serverRouterProvider = $inject(ServerRouterProvider);
74
74
  protected readonly serverTimingProvider = $inject(ServerTimingProvider);
75
75
 
76
- protected readonly ROOT_DIV_REGEX = new RegExp(
76
+ public readonly ROOT_DIV_REGEX = new RegExp(
77
77
  `<div([^>]*)\\s+id=["']${this.env.REACT_ROOT_ID}["']([^>]*)>(.*?)<\\/div>`,
78
78
  "is",
79
79
  );
@@ -94,23 +94,6 @@ export class ReactServerProvider implements Configurable {
94
94
 
95
95
  this.alepha.state.set("react.server.ssr", ssrEnabled);
96
96
 
97
- for (const page of pages) {
98
- page.render = this.createRenderFunction(page.name);
99
- page.fetch = async (options) => {
100
- const response = await fetch(
101
- `${this.serverProvider.hostname}/${page.pathname(options)}`,
102
- );
103
- const html = await response.text();
104
- if (options?.html) return { html, response };
105
- // take only text inside the root div
106
- const match = html.match(this.ROOT_DIV_REGEX);
107
- if (match) {
108
- return { html: match[3], response };
109
- }
110
- throw new AlephaError("Invalid HTML response");
111
- };
112
- }
113
-
114
97
  // development mode
115
98
  if (this.alepha.isViteDev()) {
116
99
  await this.configureVite(ssrEnabled);
@@ -248,66 +231,63 @@ export class ReactServerProvider implements Configurable {
248
231
  /**
249
232
  * For testing purposes, creates a render function that can be used.
250
233
  */
251
- protected createRenderFunction(name: string, withIndex = false) {
252
- return async (
253
- options: PageDescriptorRenderOptions = {},
254
- ): Promise<PageDescriptorRenderResult> => {
255
- const page = this.pageApi.page(name);
256
- const url = new URL(this.pageApi.url(name, options));
257
-
258
- const entry: Partial<ReactRouterState> = {
259
- url,
260
- params: options.params ?? {},
261
- query: options.query ?? {},
262
- onError: () => null,
263
- layers: [],
264
- meta: {},
265
- };
266
-
267
- const state = entry as ReactRouterState;
268
-
269
- this.log.trace("Rendering", {
270
- url,
271
- });
272
-
273
- await this.alepha.events.emit("react:server:render:begin", {
274
- state,
275
- });
276
-
277
- const { redirect } = await this.pageApi.createLayers(
278
- page,
279
- state as ReactRouterState,
280
- );
234
+ public async render(
235
+ name: string,
236
+ options: PageDescriptorRenderOptions = {},
237
+ ): Promise<PageDescriptorRenderResult> {
238
+ const page = this.pageApi.page(name);
239
+ const url = new URL(this.pageApi.url(name, options));
240
+ const entry: Partial<ReactRouterState> = {
241
+ url,
242
+ params: options.params ?? {},
243
+ query: options.query ?? {},
244
+ onError: () => null,
245
+ layers: [],
246
+ meta: {},
247
+ };
248
+ const state = entry as ReactRouterState;
281
249
 
282
- if (redirect) {
283
- return { state, html: "", redirect };
284
- }
250
+ this.log.trace("Rendering", {
251
+ url,
252
+ });
285
253
 
286
- if (!withIndex && !options.html) {
287
- this.alepha.state.set("react.router.state", state);
254
+ await this.alepha.events.emit("react:server:render:begin", {
255
+ state,
256
+ });
288
257
 
289
- return {
290
- state,
291
- html: renderToString(this.pageApi.root(state)),
292
- };
293
- }
258
+ const { redirect } = await this.pageApi.createLayers(
259
+ page,
260
+ state as ReactRouterState,
261
+ );
294
262
 
295
- const template = this.template ?? "";
296
- const html = this.renderToHtml(template, state, options.hydration);
263
+ if (redirect) {
264
+ return { state, html: "", redirect };
265
+ }
297
266
 
298
- if (html instanceof Redirection) {
299
- return { state, html: "", redirect };
300
- }
267
+ if (!options.html) {
268
+ this.alepha.state.set("react.router.state", state);
301
269
 
302
- const result = {
270
+ return {
303
271
  state,
304
- html,
272
+ html: renderToString(this.pageApi.root(state)),
305
273
  };
274
+ }
306
275
 
307
- await this.alepha.events.emit("react:server:render:end", result);
276
+ const template = this.template ?? "";
277
+ const html = this.renderToHtml(template, state, options.hydration);
308
278
 
309
- return result;
279
+ if (html instanceof Redirection) {
280
+ return { state, html: "", redirect };
281
+ }
282
+
283
+ const result = {
284
+ state,
285
+ html,
310
286
  };
287
+
288
+ await this.alepha.events.emit("react:server:render:end", result);
289
+
290
+ return result;
311
291
  }
312
292
 
313
293
  protected createHandler(
@@ -318,7 +298,7 @@ export class ReactServerProvider implements Configurable {
318
298
  const { url, reply, query, params } = serverRequest;
319
299
  const template = await templateLoader();
320
300
  if (!template) {
321
- throw new AlephaError("Template not found");
301
+ throw new AlephaError("Missing template for SSR rendering");
322
302
  }
323
303
 
324
304
  this.log.trace("Rendering page", {
@@ -0,0 +1,43 @@
1
+ import { $inject, AlephaError } from "@alepha/core";
2
+ import { ServerProvider } from "@alepha/server";
3
+ import type {
4
+ PageDescriptorRenderOptions,
5
+ PageDescriptorRenderResult,
6
+ } from "../descriptors/$page.ts";
7
+ import { ReactServerProvider } from "../providers/ReactServerProvider.ts";
8
+ import { ReactPageService } from "./ReactPageService.ts";
9
+
10
+ export class ReactPageServerService extends ReactPageService {
11
+ protected readonly reactServerProvider = $inject(ReactServerProvider);
12
+ protected readonly serverProvider = $inject(ServerProvider);
13
+
14
+ public async render(
15
+ name: string,
16
+ options: PageDescriptorRenderOptions = {},
17
+ ): Promise<PageDescriptorRenderResult> {
18
+ return this.reactServerProvider.render(name, options);
19
+ }
20
+
21
+ public async fetch(
22
+ pathname: string,
23
+ options: PageDescriptorRenderOptions = {},
24
+ ): Promise<{
25
+ html: string;
26
+ response: Response;
27
+ }> {
28
+ const response = await fetch(`${this.serverProvider.hostname}/${pathname}`);
29
+
30
+ const html = await response.text();
31
+ if (options?.html) {
32
+ return { html, response };
33
+ }
34
+
35
+ // take only text inside the root div
36
+ const match = html.match(this.reactServerProvider.ROOT_DIV_REGEX);
37
+ if (match) {
38
+ return { html: match[3], response };
39
+ }
40
+
41
+ throw new AlephaError("Invalid HTML response");
42
+ }
43
+ }
@@ -0,0 +1,24 @@
1
+ import { AlephaError } from "@alepha/core";
2
+ import type {
3
+ PageDescriptorRenderOptions,
4
+ PageDescriptorRenderResult,
5
+ } from "../descriptors/$page.ts";
6
+
7
+ export class ReactPageService {
8
+ public fetch(
9
+ pathname: string,
10
+ options: PageDescriptorRenderOptions = {},
11
+ ): Promise<{
12
+ html: string;
13
+ response: Response;
14
+ }> {
15
+ throw new AlephaError("Fetch is not available for this environment.");
16
+ }
17
+
18
+ public render(
19
+ name: string,
20
+ options: PageDescriptorRenderOptions = {},
21
+ ): Promise<PageDescriptorRenderResult> {
22
+ throw new AlephaError("Render is not available for this environment.");
23
+ }
24
+ }
@@ -22,6 +22,10 @@ export class ReactRouter<T extends object> {
22
22
  return this.pageApi.getPages();
23
23
  }
24
24
 
25
+ public get concretePages() {
26
+ return this.pageApi.getConcretePages();
27
+ }
28
+
25
29
  public get browser(): ReactBrowserProvider | undefined {
26
30
  if (this.alepha.isBrowser()) {
27
31
  return this.alepha.inject(ReactBrowserProvider);
@@ -30,6 +34,23 @@ export class ReactRouter<T extends object> {
30
34
  return undefined;
31
35
  }
32
36
 
37
+ public isActive(
38
+ href: string,
39
+ options: {
40
+ startWith?: boolean;
41
+ } = {},
42
+ ): boolean {
43
+ const current = this.state.url.pathname;
44
+ let isActive =
45
+ current === href || current === `${href}/` || `${current}/` === href;
46
+
47
+ if (options.startWith && !isActive) {
48
+ isActive = current.startsWith(href);
49
+ }
50
+
51
+ return isActive;
52
+ }
53
+
33
54
  public path(
34
55
  name: keyof VirtualRouter<T>,
35
56
  config: {
@@ -1,66 +0,0 @@
1
- import type { Hooks } from "@alepha/core";
2
- import { useEffect } from "react";
3
- import { useAlepha } from "./useAlepha.ts";
4
-
5
- type Hook<T extends keyof Hooks> =
6
- | ((ev: Hooks[T]) => void)
7
- | {
8
- priority?: "first" | "last";
9
- callback: (ev: Hooks[T]) => void;
10
- };
11
-
12
- /**
13
- * Subscribe to various router events.
14
- */
15
- export const useRouterEvents = (
16
- opts: {
17
- onBegin?: Hook<"react:transition:begin">;
18
- onError?: Hook<"react:transition:error">;
19
- onEnd?: Hook<"react:transition:end">;
20
- onSuccess?: Hook<"react:transition:success">;
21
- } = {},
22
- deps: any[] = [],
23
- ) => {
24
- const alepha = useAlepha();
25
-
26
- useEffect(() => {
27
- if (!alepha.isBrowser()) {
28
- return;
29
- }
30
-
31
- const cb = <T extends keyof Hooks>(callback: Hook<T>) => {
32
- if (typeof callback === "function") {
33
- return { callback };
34
- }
35
- return callback;
36
- };
37
-
38
- const subs: Function[] = [];
39
- const onBegin = opts.onBegin;
40
- const onEnd = opts.onEnd;
41
- const onError = opts.onError;
42
- const onSuccess = opts.onSuccess;
43
-
44
- if (onBegin) {
45
- subs.push(alepha.events.on("react:transition:begin", cb(onBegin)));
46
- }
47
-
48
- if (onEnd) {
49
- subs.push(alepha.events.on("react:transition:end", cb(onEnd)));
50
- }
51
-
52
- if (onError) {
53
- subs.push(alepha.events.on("react:transition:error", cb(onError)));
54
- }
55
-
56
- if (onSuccess) {
57
- subs.push(alepha.events.on("react:transition:success", cb(onSuccess)));
58
- }
59
-
60
- return () => {
61
- for (const sub of subs) {
62
- sub();
63
- }
64
- };
65
- }, deps);
66
- };