@alepha/react 0.8.0 → 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;
@@ -145,46 +185,6 @@ export interface PageDescriptor<
145
185
  ) => Promise<PageDescriptorRenderResult>;
146
186
  }
147
187
 
148
- /**
149
- * Main descriptor for defining a React route in the application.
150
- */
151
- export const $page = <
152
- TConfig extends PageConfigSchema = PageConfigSchema,
153
- TProps extends object = TPropsDefault,
154
- TPropsParent extends object = TPropsParentDefault,
155
- >(
156
- options: PageDescriptorOptions<TConfig, TProps, TPropsParent>,
157
- ): PageDescriptor<TConfig, TProps, TPropsParent> => {
158
- __descriptor(KEY);
159
-
160
- // if (options.children) {
161
- // for (const child of options.children) {
162
- // child[OPTIONS].parent = {
163
- // [OPTIONS]: options as PageDescriptorOptions<any, any, any>,
164
- // };
165
- // }
166
- // }
167
-
168
- // if (options.parent) {
169
- // options.parent[OPTIONS].children ??= [];
170
- // options.parent[OPTIONS].children.push({
171
- // [OPTIONS]: options as PageDescriptorOptions<any, any, any>,
172
- // });
173
- // }
174
-
175
- return {
176
- [KIND]: KEY,
177
- [OPTIONS]: options,
178
- render: () => {
179
- throw new NotImplementedError(KEY);
180
- },
181
- };
182
- };
183
-
184
- $page[KIND] = KEY;
185
-
186
- // ---------------------------------------------------------------------------------------------------------------------
187
-
188
188
  export interface PageDescriptorRenderOptions {
189
189
  params?: Record<string, string>;
190
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()
@@ -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
@@ -66,10 +66,132 @@ declare module "@alepha/core" {
66
66
  // ---------------------------------------------------------------------------------------------------------------------
67
67
 
68
68
  /**
69
- * Alepha React Module
69
+ * Provides full-stack React development with declarative routing, server-side rendering, and client-side hydration.
70
70
  *
71
- * Alepha React Module contains a router for client-side navigation and server-side rendering.
72
- * 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
+ * ```
73
195
  *
74
196
  * @see {@link $page}
75
197
  * @module alepha.react
@@ -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?.({
@@ -233,7 +235,7 @@ export class PageDescriptorProvider {
233
235
  element: this.renderView(i + 1, path, element, it.route),
234
236
  index: i + 1,
235
237
  path,
236
- route,
238
+ route: it.route,
237
239
  });
238
240
  break;
239
241
  }
@@ -253,7 +255,8 @@ export class PageDescriptorProvider {
253
255
  element: this.renderView(i + 1, path, element, it.route),
254
256
  index: i + 1,
255
257
  path,
256
- route,
258
+ route: it.route,
259
+ cache: it.cache,
257
260
  });
258
261
  }
259
262
 
@@ -373,15 +376,15 @@ export class PageDescriptorProvider {
373
376
  }
374
377
 
375
378
  for (const { value } of pages) {
379
+ if (value[OPTIONS].path === "/*") {
380
+ hasNotFoundHandler = true;
381
+ }
382
+
376
383
  // skip children, we only want root pages
377
384
  if (hasParent(value)) {
378
385
  continue;
379
386
  }
380
387
 
381
- if (value[OPTIONS].path === "/*") {
382
- hasNotFoundHandler = true;
383
- }
384
-
385
388
  this.add(this.map(pages, value));
386
389
  }
387
390
 
@@ -499,6 +502,7 @@ export interface Layer {
499
502
  index: number;
500
503
  path: string;
501
504
  route?: PageRoute;
505
+ cache?: boolean;
502
506
  }
503
507
 
504
508
  export type PreviousLayerData = Omit<Layer, "element" | "index" | "path">;
@@ -525,6 +529,7 @@ export interface RouterStackItem {
525
529
  config?: Record<string, any>;
526
530
  props?: Record<string, any>;
527
531
  error?: Error;
532
+ cache?: boolean;
528
533
  }
529
534
 
530
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(
@@ -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,
@@ -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
 
@@ -67,7 +67,7 @@ export class ReactServerProvider {
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
  }