@alepha/react 0.7.7 → 0.8.1

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.
@@ -15,6 +15,46 @@ import type { PageReactContext } from "../providers/PageDescriptorProvider.ts";
15
15
 
16
16
  const KEY = "PAGE";
17
17
 
18
+ /**
19
+ * Main descriptor for defining a React route in the application.
20
+ */
21
+ export const $page = <
22
+ TConfig extends PageConfigSchema = PageConfigSchema,
23
+ TProps extends object = TPropsDefault,
24
+ TPropsParent extends object = TPropsParentDefault,
25
+ >(
26
+ options: PageDescriptorOptions<TConfig, TProps, TPropsParent>,
27
+ ): PageDescriptor<TConfig, TProps, TPropsParent> => {
28
+ __descriptor(KEY);
29
+
30
+ // if (options.children) {
31
+ // for (const child of options.children) {
32
+ // child[OPTIONS].parent = {
33
+ // [OPTIONS]: options as PageDescriptorOptions<any, any, any>,
34
+ // };
35
+ // }
36
+ // }
37
+
38
+ // if (options.parent) {
39
+ // options.parent[OPTIONS].children ??= [];
40
+ // options.parent[OPTIONS].children.push({
41
+ // [OPTIONS]: options as PageDescriptorOptions<any, any, any>,
42
+ // });
43
+ // }
44
+
45
+ return {
46
+ [KIND]: KEY,
47
+ [OPTIONS]: options,
48
+ render: () => {
49
+ throw new NotImplementedError(KEY);
50
+ },
51
+ };
52
+ };
53
+
54
+ $page[KIND] = KEY;
55
+
56
+ // ---------------------------------------------------------------------------------------------------------------------
57
+
18
58
  export interface PageConfigSchema {
19
59
  query?: TSchema;
20
60
  params?: TSchema;
@@ -96,7 +136,9 @@ export interface PageDescriptorOptions<
96
136
  *
97
137
  * If you still want to render at this pathname, add a child page with an empty path.
98
138
  */
99
- children?: Array<{ [OPTIONS]: PageDescriptorOptions }>;
139
+ children?:
140
+ | Array<{ [OPTIONS]: PageDescriptorOptions }>
141
+ | (() => Array<{ [OPTIONS]: PageDescriptorOptions }>);
100
142
 
101
143
  parent?: { [OPTIONS]: PageDescriptorOptions<PageConfigSchema, TPropsParent> };
102
144
 
@@ -104,7 +146,13 @@ export interface PageDescriptorOptions<
104
146
 
105
147
  errorHandler?: (error: Error) => ReactNode;
106
148
 
107
- prerender?:
149
+ /**
150
+ * If true, the page will be rendered on the build time.
151
+ * Works only with viteAlepha plugin.
152
+ *
153
+ * Replace boolean by an object to define static entries. (e.g. list of params/query)
154
+ */
155
+ static?:
108
156
  | boolean
109
157
  | {
110
158
  entries?: Array<Partial<PageRequestConfig<TConfig>>>;
@@ -137,46 +185,6 @@ export interface PageDescriptor<
137
185
  ) => Promise<PageDescriptorRenderResult>;
138
186
  }
139
187
 
140
- /**
141
- * Main descriptor for defining a React route in the application.
142
- */
143
- export const $page = <
144
- TConfig extends PageConfigSchema = PageConfigSchema,
145
- TProps extends object = TPropsDefault,
146
- TPropsParent extends object = TPropsParentDefault,
147
- >(
148
- options: PageDescriptorOptions<TConfig, TProps, TPropsParent>,
149
- ): PageDescriptor<TConfig, TProps, TPropsParent> => {
150
- __descriptor(KEY);
151
-
152
- if (options.children) {
153
- for (const child of options.children) {
154
- child[OPTIONS].parent = {
155
- [OPTIONS]: options as PageDescriptorOptions<any, any, any>,
156
- };
157
- }
158
- }
159
-
160
- if (options.parent) {
161
- options.parent[OPTIONS].children ??= [];
162
- options.parent[OPTIONS].children.push({
163
- [OPTIONS]: options as PageDescriptorOptions<any, any, any>,
164
- });
165
- }
166
-
167
- return {
168
- [KIND]: KEY,
169
- [OPTIONS]: options,
170
- render: () => {
171
- throw new NotImplementedError(KEY);
172
- },
173
- };
174
- };
175
-
176
- $page[KIND] = KEY;
177
-
178
- // ---------------------------------------------------------------------------------------------------------------------
179
-
180
188
  export interface PageDescriptorRenderOptions {
181
189
  params?: Record<string, string>;
182
190
  query?: Record<string, string>;
@@ -1,6 +1,7 @@
1
1
  import type { PageDescriptor } from "../descriptors/$page.ts";
2
2
  import type {
3
3
  AnchorProps,
4
+ PageReactContext,
4
5
  PageRoute,
5
6
  RouterState,
6
7
  } from "../providers/PageDescriptorProvider.ts";
@@ -12,6 +13,7 @@ import type {
12
13
  export class RouterHookApi {
13
14
  constructor(
14
15
  private readonly pages: PageRoute[],
16
+ private readonly context: PageReactContext,
15
17
  private readonly state: RouterState,
16
18
  private readonly layer: {
17
19
  path: string;
@@ -19,6 +21,21 @@ export class RouterHookApi {
19
21
  private readonly browser?: ReactBrowserProvider,
20
22
  ) {}
21
23
 
24
+ public getURL(): URL {
25
+ if (!this.browser) {
26
+ return this.context.url;
27
+ }
28
+ return new URL(this.location.href);
29
+ }
30
+
31
+ public get location(): Location {
32
+ if (!this.browser) {
33
+ throw new Error("Browser is required");
34
+ }
35
+
36
+ return this.browser.location;
37
+ }
38
+
22
39
  public get current(): RouterState {
23
40
  return this.state;
24
41
  }
@@ -20,6 +20,7 @@ export const useRouter = (): RouterHookApi => {
20
20
  () =>
21
21
  new RouterHookApi(
22
22
  pages,
23
+ ctx.context,
23
24
  ctx.state,
24
25
  layer,
25
26
  ctx.alepha.isBrowser()
@@ -1,4 +1,6 @@
1
1
  import { __bind, type Alepha, type Module } from "@alepha/core";
2
+ import { AlephaServer } from "@alepha/server";
3
+ import { AlephaServerLinks } from "@alepha/server-links";
2
4
  import { $page } from "./descriptors/$page.ts";
3
5
  import { BrowserRouterProvider } from "./providers/BrowserRouterProvider.ts";
4
6
  import { PageDescriptorProvider } from "./providers/PageDescriptorProvider.ts";
@@ -18,6 +20,8 @@ export class AlephaReact implements Module {
18
20
  public readonly name = "alepha.react";
19
21
  public readonly $services = (alepha: Alepha) =>
20
22
  alepha
23
+ .with(AlephaServer)
24
+ .with(AlephaServerLinks)
21
25
  .with(PageDescriptorProvider)
22
26
  .with(ReactBrowserProvider)
23
27
  .with(BrowserRouterProvider)
@@ -3,6 +3,7 @@ export { default as ErrorBoundary } from "./components/ErrorBoundary.tsx";
3
3
  export * from "./components/ErrorViewer.tsx";
4
4
  export { default as Link } from "./components/Link.tsx";
5
5
  export { default as NestedView } from "./components/NestedView.tsx";
6
+ export { default as NotFound } from "./components/NotFound.tsx";
6
7
  export * from "./contexts/RouterContext.ts";
7
8
  export * from "./contexts/RouterLayerContext.ts";
8
9
  export * from "./descriptors/$page.ts";
package/src/index.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { __bind, type Alepha, type Module } from "@alepha/core";
2
2
  import { AlephaServer, type ServerRequest } from "@alepha/server";
3
3
  import { AlephaServerCache } from "@alepha/server-cache";
4
+ import { AlephaServerLinks } from "@alepha/server-links";
4
5
  import { $page } from "./descriptors/$page.ts";
5
6
  import {
6
7
  PageDescriptorProvider,
@@ -65,10 +66,132 @@ declare module "@alepha/core" {
65
66
  // ---------------------------------------------------------------------------------------------------------------------
66
67
 
67
68
  /**
68
- * Alepha React Module
69
+ * Provides full-stack React development with declarative routing, server-side rendering, and client-side hydration.
69
70
  *
70
- * Alepha React Module contains a router for client-side navigation and server-side rendering.
71
- * Routes can be defined using the `$page` descriptor.
71
+ * The React module enables building modern React applications using the `$page` descriptor on class properties.
72
+ * It delivers seamless server-side rendering, automatic code splitting, and client-side navigation with full
73
+ * type safety and schema validation for route parameters and data.
74
+ *
75
+ * **Key Features:**
76
+ * - Declarative page definition with `$page` descriptor
77
+ * - Server-side rendering (SSR) with automatic hydration
78
+ * - Type-safe routing with parameter validation
79
+ * - Schema-based data resolution and validation
80
+ * - SEO-friendly meta tag management
81
+ * - Automatic code splitting and lazy loading
82
+ * - Client-side navigation with browser history
83
+ *
84
+ * **Basic Usage:**
85
+ * ```ts
86
+ * import { Alepha, run, t } from "alepha";
87
+ * import { AlephaReact, $page } from "alepha/react";
88
+ *
89
+ * class AppRoutes {
90
+ * // Home page
91
+ * home = $page({
92
+ * path: "/",
93
+ * component: () => (
94
+ * <div>
95
+ * <h1>Welcome to Alepha</h1>
96
+ * <p>Build amazing React applications!</p>
97
+ * </div>
98
+ * ),
99
+ * });
100
+ *
101
+ * // About page with meta tags
102
+ * about = $page({
103
+ * path: "/about",
104
+ * head: {
105
+ * title: "About Us",
106
+ * description: "Learn more about our mission",
107
+ * },
108
+ * component: () => (
109
+ * <div>
110
+ * <h1>About Us</h1>
111
+ * <p>Learn more about our mission.</p>
112
+ * </div>
113
+ * ),
114
+ * });
115
+ * }
116
+ *
117
+ * const alepha = Alepha.create()
118
+ * .with(AlephaReact)
119
+ * .with(AppRoutes);
120
+ *
121
+ * run(alepha);
122
+ * ```
123
+ *
124
+ * **Dynamic Routes with Parameters:**
125
+ * ```tsx
126
+ * class UserRoutes {
127
+ * userProfile = $page({
128
+ * path: "/users/:id",
129
+ * schema: {
130
+ * params: t.object({
131
+ * id: t.string(),
132
+ * }),
133
+ * },
134
+ * resolve: async ({ params }) => {
135
+ * // Fetch user data server-side
136
+ * const user = await getUserById(params.id);
137
+ * return { user };
138
+ * },
139
+ * head: ({ user }) => ({
140
+ * title: `${user.name} - Profile`,
141
+ * description: `View ${user.name}'s profile`,
142
+ * }),
143
+ * component: ({ user }) => (
144
+ * <div>
145
+ * <h1>{user.name}</h1>
146
+ * <p>Email: {user.email}</p>
147
+ * </div>
148
+ * ),
149
+ * });
150
+ *
151
+ * userSettings = $page({
152
+ * path: "/users/:id/settings",
153
+ * schema: {
154
+ * params: t.object({
155
+ * id: t.string(),
156
+ * }),
157
+ * },
158
+ * component: ({ params }) => (
159
+ * <UserSettings userId={params.id} />
160
+ * ),
161
+ * });
162
+ * }
163
+ * ```
164
+ *
165
+ * **Static Generation:**
166
+ * ```tsx
167
+ * class BlogRoutes {
168
+ * blogPost = $page({
169
+ * path: "/blog/:slug",
170
+ * schema: {
171
+ * params: t.object({
172
+ * slug: t.string(),
173
+ * }),
174
+ * },
175
+ * static: {
176
+ * entries: [
177
+ * { params: { slug: "getting-started" } },
178
+ * { params: { slug: "advanced-features" } },
179
+ * { params: { slug: "deployment" } },
180
+ * ],
181
+ * },
182
+ * resolve: ({ params }) => {
183
+ * const post = getBlogPost(params.slug);
184
+ * return { post };
185
+ * },
186
+ * component: ({ post }) => (
187
+ * <article>
188
+ * <h1>{post.title}</h1>
189
+ * <div>{post.content}</div>
190
+ * </article>
191
+ * ),
192
+ * });
193
+ * }
194
+ * ```
72
195
  *
73
196
  * @see {@link $page}
74
197
  * @module alepha.react
@@ -79,6 +202,7 @@ export class AlephaReact implements Module {
79
202
  alepha
80
203
  .with(AlephaServer)
81
204
  .with(AlephaServerCache)
205
+ .with(AlephaServerLinks)
82
206
  .with(ReactServerProvider)
83
207
  .with(PageDescriptorProvider);
84
208
  }
@@ -28,7 +28,7 @@ export class BrowserRouterProvider extends RouterProvider<BrowserRoute> {
28
28
  }
29
29
 
30
30
  protected readonly configure = $hook({
31
- name: "configure",
31
+ on: "configure",
32
32
  handler: async () => {
33
33
  for (const page of this.pageDescriptorProvider.getPages()) {
34
34
  // mount only if a view is provided
@@ -109,7 +109,7 @@ export class PageDescriptorProvider {
109
109
  try {
110
110
  config.query = route.schema?.query
111
111
  ? this.alepha.parse(route.schema.query, request.query)
112
- : request.query;
112
+ : {};
113
113
  } catch (e) {
114
114
  it.error = e as Error;
115
115
  break;
@@ -118,7 +118,7 @@ export class PageDescriptorProvider {
118
118
  try {
119
119
  config.params = route.schema?.params
120
120
  ? this.alepha.parse(route.schema.params, request.params)
121
- : request.params;
121
+ : {};
122
122
  } catch (e) {
123
123
  it.error = e as Error;
124
124
  break;
@@ -129,11 +129,6 @@ export class PageDescriptorProvider {
129
129
  ...config,
130
130
  };
131
131
 
132
- // no resolve, render a basic view by default
133
- if (!route.resolve) {
134
- continue;
135
- }
136
-
137
132
  // check if previous layer is the same, reuse if possible
138
133
  const previous = request.previous;
139
134
  if (previous?.[i] && !forceRefresh && previous[i].name === route.name) {
@@ -153,16 +148,23 @@ export class PageDescriptorProvider {
153
148
  // part is the same, reuse previous layer
154
149
  it.props = previous[i].props;
155
150
  it.error = previous[i].error;
151
+ it.cache = true;
156
152
  context = {
157
153
  ...context,
158
154
  ...it.props,
159
155
  };
160
156
  continue;
161
157
  }
158
+
162
159
  // part is different, force refresh of next layers
163
160
  forceRefresh = true;
164
161
  }
165
162
 
163
+ // no resolve, render a basic view by default
164
+ if (!route.resolve) {
165
+ continue;
166
+ }
167
+
166
168
  try {
167
169
  const props =
168
170
  (await route.resolve?.({
@@ -209,13 +211,6 @@ export class PageDescriptorProvider {
209
211
  params[key] = String(params[key]);
210
212
  }
211
213
 
212
- // if (it.route.head && !it.error) {
213
- // this.fillHead(it.route, request, {
214
- // ...props,
215
- // ...context,
216
- // });
217
- // }
218
-
219
214
  acc += "/";
220
215
  acc += it.route.path ? this.compile(it.route.path, params) : "";
221
216
  const path = acc.replace(/\/+/, "/");
@@ -240,7 +235,7 @@ export class PageDescriptorProvider {
240
235
  element: this.renderView(i + 1, path, element, it.route),
241
236
  index: i + 1,
242
237
  path,
243
- route,
238
+ route: it.route,
244
239
  });
245
240
  break;
246
241
  }
@@ -260,7 +255,8 @@ export class PageDescriptorProvider {
260
255
  element: this.renderView(i + 1, path, element, it.route),
261
256
  index: i + 1,
262
257
  path,
263
- route,
258
+ route: it.route,
259
+ cache: it.cache,
264
260
  });
265
261
  }
266
262
 
@@ -357,24 +353,38 @@ export class PageDescriptorProvider {
357
353
  }
358
354
 
359
355
  protected readonly configure = $hook({
360
- name: "configure",
356
+ on: "configure",
361
357
  handler: () => {
362
358
  let hasNotFoundHandler = false;
363
359
  const pages = this.alepha.getDescriptorValues($page);
360
+
361
+ const hasParent = (it: { [OPTIONS]: PageDescriptorOptions }) => {
362
+ for (const page of pages) {
363
+ const children = page.value[OPTIONS].children
364
+ ? Array.isArray(page.value[OPTIONS].children)
365
+ ? page.value[OPTIONS].children
366
+ : page.value[OPTIONS].children()
367
+ : [];
368
+ if (children.includes(it)) {
369
+ return true;
370
+ }
371
+ }
372
+ };
373
+
364
374
  for (const { value, key } of pages) {
365
375
  value[OPTIONS].name ??= key;
366
376
  }
367
377
 
368
378
  for (const { value } of pages) {
369
- // skip children, we only want root pages
370
- if (value[OPTIONS].parent) {
371
- continue;
372
- }
373
-
374
379
  if (value[OPTIONS].path === "/*") {
375
380
  hasNotFoundHandler = true;
376
381
  }
377
382
 
383
+ // skip children, we only want root pages
384
+ if (hasParent(value)) {
385
+ continue;
386
+ }
387
+
378
388
  this.add(this.map(pages, value));
379
389
  }
380
390
 
@@ -397,7 +407,11 @@ export class PageDescriptorProvider {
397
407
  pages: Array<{ value: { [OPTIONS]: PageDescriptorOptions } }>,
398
408
  target: { [OPTIONS]: PageDescriptorOptions },
399
409
  ): PageRouteEntry {
400
- const children = target[OPTIONS].children ?? [];
410
+ const children = target[OPTIONS].children
411
+ ? Array.isArray(target[OPTIONS].children)
412
+ ? target[OPTIONS].children
413
+ : target[OPTIONS].children()
414
+ : [];
401
415
 
402
416
  return {
403
417
  ...target[OPTIONS],
@@ -488,6 +502,7 @@ export interface Layer {
488
502
  index: number;
489
503
  path: string;
490
504
  route?: PageRoute;
505
+ cache?: boolean;
491
506
  }
492
507
 
493
508
  export type PreviousLayerData = Omit<Layer, "element" | "index" | "path">;
@@ -514,6 +529,7 @@ export interface RouterStackItem {
514
529
  config?: Record<string, any>;
515
530
  props?: Record<string, any>;
516
531
  error?: Error;
532
+ cache?: boolean;
517
533
  }
518
534
 
519
535
  export interface RouterRenderResult {
@@ -35,8 +35,35 @@ export class ReactBrowserProvider {
35
35
  return window.history;
36
36
  }
37
37
 
38
+ public get location() {
39
+ return window.location;
40
+ }
41
+
38
42
  public get url(): string {
39
- return window.location.pathname + window.location.search;
43
+ let url = this.location.pathname + this.location.search;
44
+
45
+ if (import.meta?.env?.BASE_URL) {
46
+ url = url.replace(import.meta.env?.BASE_URL, "");
47
+ if (!url.startsWith("/")) {
48
+ url = `/${url}`;
49
+ }
50
+ }
51
+
52
+ return url;
53
+ }
54
+
55
+ public pushState(url: string, replace?: boolean) {
56
+ let path = url;
57
+
58
+ if (import.meta?.env?.BASE_URL) {
59
+ path = (import.meta.env?.BASE_URL + path).replaceAll("//", "/");
60
+ }
61
+
62
+ if (replace) {
63
+ this.history.replaceState({}, "", path);
64
+ } else {
65
+ this.history.pushState({}, "", path);
66
+ }
40
67
  }
41
68
 
42
69
  public async invalidate(props?: Record<string, any>) {
@@ -72,16 +99,16 @@ export class ReactBrowserProvider {
72
99
  // when redirecting in browser
73
100
  if (result.context.url.pathname !== url) {
74
101
  // TODO: check if losing search params is acceptable?
75
- this.history.replaceState({}, "", result.context.url.pathname);
102
+ this.pushState(result.context.url.pathname);
76
103
  return;
77
104
  }
78
105
 
79
106
  if (options.replace) {
80
- this.history.replaceState({}, "", url);
107
+ this.pushState(url);
81
108
  return;
82
109
  }
83
110
 
84
- this.history.pushState({}, "", url);
111
+ this.pushState(url);
85
112
  }
86
113
 
87
114
  protected async render(
@@ -125,7 +152,7 @@ export class ReactBrowserProvider {
125
152
  // -------------------------------------------------------------------------------------------------------------------
126
153
 
127
154
  public readonly ready = $hook({
128
- name: "ready",
155
+ on: "ready",
129
156
  handler: async () => {
130
157
  const hydration = this.getHydrationState();
131
158
  const previous = hydration?.layers ?? [];
@@ -145,6 +172,12 @@ export class ReactBrowserProvider {
145
172
  });
146
173
 
147
174
  window.addEventListener("popstate", () => {
175
+ // when you update silently queryparams or hash, skip rendering
176
+ // if you want to force a rendering, use #go()
177
+ if (this.state.pathname === location.pathname) {
178
+ return;
179
+ }
180
+
148
181
  this.render();
149
182
  });
150
183
  },
@@ -17,6 +17,10 @@ declare module "@alepha/core" {
17
17
  interface Env extends Partial<Static<typeof envSchema>> {}
18
18
  }
19
19
 
20
+ export interface ReactBrowserRendererOptions {
21
+ scrollRestoration?: "top" | "manual";
22
+ }
23
+
20
24
  // TODO: move to ReactBrowserProvider when it will be removed from server-side imports
21
25
  export class ReactBrowserRenderer {
22
26
  protected readonly browserProvider = $inject(ReactBrowserProvider);
@@ -26,6 +30,10 @@ export class ReactBrowserRenderer {
26
30
 
27
31
  protected root!: Root;
28
32
 
33
+ public options: ReactBrowserRendererOptions = {
34
+ scrollRestoration: "top",
35
+ };
36
+
29
37
  protected getRootElement() {
30
38
  const root = this.browserProvider.document.getElementById(
31
39
  this.env.REACT_ROOT_ID,
@@ -43,7 +51,7 @@ export class ReactBrowserRenderer {
43
51
  }
44
52
 
45
53
  public readonly ready = $hook({
46
- name: "react:browser:render",
54
+ on: "react:browser:render",
47
55
  handler: async ({ state, context, hydration }) => {
48
56
  const element = this.browserRouterProvider.root(state, context);
49
57
 
@@ -57,6 +65,18 @@ export class ReactBrowserRenderer {
57
65
  }
58
66
  },
59
67
  });
68
+
69
+ protected readonly onTransitionEnd = $hook({
70
+ on: "react:transition:end",
71
+ handler: () => {
72
+ if (
73
+ this.options.scrollRestoration === "top" &&
74
+ typeof window !== "undefined"
75
+ ) {
76
+ window.scrollTo(0, 0);
77
+ }
78
+ },
79
+ });
60
80
  }
61
81
 
62
82
  // ---------------------------------------------------------------------------------------------------------------------
@@ -41,8 +41,8 @@ const envSchema = t.object({
41
41
  declare module "@alepha/core" {
42
42
  interface Env extends Partial<Static<typeof envSchema>> {}
43
43
  interface State {
44
- "ReactServerProvider.template"?: string;
45
- "ReactServerProvider.ssr"?: boolean;
44
+ "react.server.template"?: string;
45
+ "react.server.ssr"?: boolean;
46
46
  }
47
47
  }
48
48
 
@@ -60,14 +60,14 @@ export class ReactServerProvider {
60
60
  );
61
61
 
62
62
  public readonly onConfigure = $hook({
63
- name: "configure",
63
+ on: "configure",
64
64
  handler: async () => {
65
65
  const pages = this.alepha.getDescriptorValues($page);
66
66
 
67
67
  const ssrEnabled =
68
68
  pages.length > 0 && this.env.REACT_SSR_ENABLED !== false;
69
69
 
70
- this.alepha.state("ReactServerProvider.ssr", ssrEnabled);
70
+ this.alepha.state("react.server.ssr", ssrEnabled);
71
71
 
72
72
  for (const { key, instance, value } of pages) {
73
73
  const name = value[OPTIONS].name ?? key;
@@ -127,7 +127,7 @@ export class ReactServerProvider {
127
127
 
128
128
  public get template() {
129
129
  return (
130
- this.alepha.state("ReactServerProvider.template") ??
130
+ this.alepha.state("react.server.template") ??
131
131
  "<!DOCTYPE html><html lang='en'><head></head><body></body></html>"
132
132
  );
133
133
  }