@alepha/react 0.8.0 → 0.9.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.
@@ -1,9 +1,9 @@
1
1
  import {
2
- __descriptor,
3
2
  type Async,
3
+ createDescriptor,
4
+ Descriptor,
4
5
  KIND,
5
6
  NotImplementedError,
6
- OPTIONS,
7
7
  type Static,
8
8
  type TSchema,
9
9
  } from "@alepha/core";
@@ -13,16 +13,23 @@ import type { FC, ReactNode } from "react";
13
13
  import type { ClientOnlyProps } from "../components/ClientOnly.tsx";
14
14
  import type { PageReactContext } from "../providers/PageDescriptorProvider.ts";
15
15
 
16
- const KEY = "PAGE";
17
-
18
- export interface PageConfigSchema {
19
- query?: TSchema;
20
- params?: TSchema;
21
- }
22
-
23
- export type TPropsDefault = any;
16
+ /**
17
+ * Main descriptor for defining a React route in the application.
18
+ */
19
+ export const $page = <
20
+ TConfig extends PageConfigSchema = PageConfigSchema,
21
+ TProps extends object = TPropsDefault,
22
+ TPropsParent extends object = TPropsParentDefault,
23
+ >(
24
+ options: PageDescriptorOptions<TConfig, TProps, TPropsParent>,
25
+ ): PageDescriptor<TConfig, TProps, TPropsParent> => {
26
+ return createDescriptor(
27
+ PageDescriptor<TConfig, TProps, TPropsParent>,
28
+ options,
29
+ );
30
+ };
24
31
 
25
- export type TPropsParentDefault = {};
32
+ // ---------------------------------------------------------------------------------------------------------------------
26
33
 
27
34
  export interface PageDescriptorOptions<
28
35
  TConfig extends PageConfigSchema = PageConfigSchema,
@@ -96,11 +103,9 @@ export interface PageDescriptorOptions<
96
103
  *
97
104
  * If you still want to render at this pathname, add a child page with an empty path.
98
105
  */
99
- children?:
100
- | Array<{ [OPTIONS]: PageDescriptorOptions }>
101
- | (() => Array<{ [OPTIONS]: PageDescriptorOptions }>);
106
+ children?: Array<PageDescriptor> | (() => Array<PageDescriptor>);
102
107
 
103
- parent?: { [OPTIONS]: PageDescriptorOptions<PageConfigSchema, TPropsParent> };
108
+ parent?: PageDescriptor<PageConfigSchema, TPropsParent>;
104
109
 
105
110
  can?: () => boolean;
106
111
 
@@ -128,63 +133,39 @@ export interface PageDescriptorOptions<
128
133
  cache?: ServerRouteCache;
129
134
  }
130
135
 
131
- export interface PageDescriptor<
136
+ export class PageDescriptor<
132
137
  TConfig extends PageConfigSchema = PageConfigSchema,
133
138
  TProps extends object = TPropsDefault,
134
139
  TPropsParent extends object = TPropsParentDefault,
135
- > {
136
- [KIND]: typeof KEY;
137
- [OPTIONS]: PageDescriptorOptions<TConfig, TProps, TPropsParent>;
140
+ > extends Descriptor<PageDescriptorOptions<TConfig, TProps, TPropsParent>> {
141
+ public get name(): string {
142
+ return this.options.name ?? this.config.propertyKey;
143
+ }
138
144
 
139
145
  /**
140
146
  * For testing or build purposes, this will render the page (with or without the HTML layout) and return the HTML and context.
141
147
  * Only valid for server-side rendering, it will throw an error if called on the client-side.
142
148
  */
143
- render: (
149
+ public async render(
144
150
  options?: PageDescriptorRenderOptions,
145
- ) => Promise<PageDescriptorRenderResult>;
151
+ ): Promise<PageDescriptorRenderResult> {
152
+ throw new NotImplementedError("");
153
+ }
146
154
  }
147
155
 
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;
156
+ $page[KIND] = PageDescriptor;
185
157
 
186
158
  // ---------------------------------------------------------------------------------------------------------------------
187
159
 
160
+ export interface PageConfigSchema {
161
+ query?: TSchema;
162
+ params?: TSchema;
163
+ }
164
+
165
+ export type TPropsDefault = any;
166
+
167
+ export type TPropsParentDefault = {};
168
+
188
169
  export interface PageDescriptorRenderOptions {
189
170
  params?: Record<string, string>;
190
171
  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
  }
@@ -8,5 +8,5 @@ export const useInject = <T extends object>(clazz: Service<T>): T => {
8
8
  throw new Error("useRouter must be used within a <RouterProvider>");
9
9
  }
10
10
 
11
- return useMemo(() => ctx.alepha.get(clazz), []);
11
+ return useMemo(() => ctx.alepha.inject(clazz), []);
12
12
  };
@@ -13,17 +13,18 @@ export const useRouter = (): RouterHookApi => {
13
13
  }
14
14
 
15
15
  const pages = useMemo(() => {
16
- return ctx.alepha.get(PageDescriptorProvider).getPages();
16
+ return ctx.alepha.inject(PageDescriptorProvider).getPages();
17
17
  }, []);
18
18
 
19
19
  return useMemo(
20
20
  () =>
21
21
  new RouterHookApi(
22
22
  pages,
23
+ ctx.context,
23
24
  ctx.state,
24
25
  layer,
25
26
  ctx.alepha.isBrowser()
26
- ? ctx.alepha.get(ReactBrowserProvider)
27
+ ? ctx.alepha.inject(ReactBrowserProvider)
27
28
  : undefined,
28
29
  ),
29
30
  [layer],
@@ -1,4 +1,4 @@
1
- import { __bind, type Alepha, type Module } from "@alepha/core";
1
+ import { $module } from "@alepha/core";
2
2
  import { AlephaServer } from "@alepha/server";
3
3
  import { AlephaServerLinks } from "@alepha/server-links";
4
4
  import { $page } from "./descriptors/$page.ts";
@@ -16,16 +16,21 @@ export * from "./providers/ReactBrowserProvider.ts";
16
16
 
17
17
  // ---------------------------------------------------------------------------------------------------------------------
18
18
 
19
- export class AlephaReact implements Module {
20
- public readonly name = "alepha.react";
21
- public readonly $services = (alepha: Alepha) =>
19
+ export const AlephaReact = $module({
20
+ name: "alepha.react",
21
+ descriptors: [$page],
22
+ services: [
23
+ PageDescriptorProvider,
24
+ ReactBrowserRenderer,
25
+ BrowserRouterProvider,
26
+ ReactBrowserProvider,
27
+ ],
28
+ register: (alepha) =>
22
29
  alepha
23
30
  .with(AlephaServer)
24
31
  .with(AlephaServerLinks)
25
32
  .with(PageDescriptorProvider)
26
33
  .with(ReactBrowserProvider)
27
34
  .with(BrowserRouterProvider)
28
- .with(ReactBrowserRenderer);
29
- }
30
-
31
- __bind($page, AlephaReact);
35
+ .with(ReactBrowserRenderer),
36
+ });
@@ -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,4 +1,4 @@
1
- import { __bind, type Alepha, type Module } from "@alepha/core";
1
+ 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";
@@ -9,7 +9,10 @@ import {
9
9
  type PageRequest,
10
10
  type RouterState,
11
11
  } from "./providers/PageDescriptorProvider.ts";
12
- import type { ReactHydrationState } from "./providers/ReactBrowserProvider.ts";
12
+ import {
13
+ ReactBrowserProvider,
14
+ type ReactHydrationState,
15
+ } from "./providers/ReactBrowserProvider.ts";
13
16
  import { ReactServerProvider } from "./providers/ReactServerProvider.ts";
14
17
 
15
18
  // ---------------------------------------------------------------------------------------------------------------------
@@ -66,23 +69,24 @@ declare module "@alepha/core" {
66
69
  // ---------------------------------------------------------------------------------------------------------------------
67
70
 
68
71
  /**
69
- * Alepha React Module
72
+ * Provides full-stack React development with declarative routing, server-side rendering, and client-side hydration.
70
73
  *
71
- * Alepha React Module contains a router for client-side navigation and server-side rendering.
72
- * Routes can be defined using the `$page` descriptor.
74
+ * The React module enables building modern React applications using the `$page` descriptor on class properties.
75
+ * It delivers seamless server-side rendering, automatic code splitting, and client-side navigation with full
76
+ * type safety and schema validation for route parameters and data.
73
77
  *
74
78
  * @see {@link $page}
75
79
  * @module alepha.react
76
80
  */
77
- export class AlephaReact implements Module {
78
- public readonly name = "alepha.react";
79
- public readonly $services = (alepha: Alepha) =>
81
+ export const AlephaReact = $module({
82
+ name: "alepha.react",
83
+ descriptors: [$page],
84
+ services: [ReactServerProvider, PageDescriptorProvider, ReactBrowserProvider],
85
+ register: (alepha) =>
80
86
  alepha
81
87
  .with(AlephaServer)
82
88
  .with(AlephaServerCache)
83
89
  .with(AlephaServerLinks)
84
90
  .with(ReactServerProvider)
85
- .with(PageDescriptorProvider);
86
- }
87
-
88
- __bind($page, AlephaReact);
91
+ .with(PageDescriptorProvider),
92
+ });
@@ -1,5 +1,12 @@
1
- import type { Static } from "@alepha/core";
2
- import { $hook, $inject, $logger, Alepha, OPTIONS, t } from "@alepha/core";
1
+ import {
2
+ $env,
3
+ $hook,
4
+ $inject,
5
+ $logger,
6
+ Alepha,
7
+ type Static,
8
+ t,
9
+ } from "@alepha/core";
3
10
  import type { ApiLinksResponse } from "@alepha/server";
4
11
  import { createElement, type ReactNode, StrictMode } from "react";
5
12
  import ClientOnly from "../components/ClientOnly.tsx";
@@ -8,7 +15,11 @@ import NestedView from "../components/NestedView.tsx";
8
15
  import NotFoundPage from "../components/NotFound.tsx";
9
16
  import { RouterContext } from "../contexts/RouterContext.ts";
10
17
  import { RouterLayerContext } from "../contexts/RouterLayerContext.ts";
11
- import { $page, type PageDescriptorOptions } from "../descriptors/$page.ts";
18
+ import {
19
+ $page,
20
+ type PageDescriptor,
21
+ type PageDescriptorOptions,
22
+ } from "../descriptors/$page.ts";
12
23
  import { RedirectionError } from "../errors/RedirectionError.ts";
13
24
 
14
25
  const envSchema = t.object({
@@ -21,7 +32,7 @@ declare module "@alepha/core" {
21
32
 
22
33
  export class PageDescriptorProvider {
23
34
  protected readonly log = $logger();
24
- protected readonly env = $inject(envSchema);
35
+ protected readonly env = $env(envSchema);
25
36
  protected readonly alepha = $inject(Alepha);
26
37
  protected readonly pages: PageRoute[] = [];
27
38
 
@@ -109,7 +120,7 @@ export class PageDescriptorProvider {
109
120
  try {
110
121
  config.query = route.schema?.query
111
122
  ? this.alepha.parse(route.schema.query, request.query)
112
- : request.query;
123
+ : {};
113
124
  } catch (e) {
114
125
  it.error = e as Error;
115
126
  break;
@@ -118,7 +129,7 @@ export class PageDescriptorProvider {
118
129
  try {
119
130
  config.params = route.schema?.params
120
131
  ? this.alepha.parse(route.schema.params, request.params)
121
- : request.params;
132
+ : {};
122
133
  } catch (e) {
123
134
  it.error = e as Error;
124
135
  break;
@@ -129,11 +140,6 @@ export class PageDescriptorProvider {
129
140
  ...config,
130
141
  };
131
142
 
132
- // no resolve, render a basic view by default
133
- if (!route.resolve) {
134
- continue;
135
- }
136
-
137
143
  // check if previous layer is the same, reuse if possible
138
144
  const previous = request.previous;
139
145
  if (previous?.[i] && !forceRefresh && previous[i].name === route.name) {
@@ -153,16 +159,23 @@ export class PageDescriptorProvider {
153
159
  // part is the same, reuse previous layer
154
160
  it.props = previous[i].props;
155
161
  it.error = previous[i].error;
162
+ it.cache = true;
156
163
  context = {
157
164
  ...context,
158
165
  ...it.props,
159
166
  };
160
167
  continue;
161
168
  }
169
+
162
170
  // part is different, force refresh of next layers
163
171
  forceRefresh = true;
164
172
  }
165
173
 
174
+ // no resolve, render a basic view by default
175
+ if (!route.resolve) {
176
+ continue;
177
+ }
178
+
166
179
  try {
167
180
  const props =
168
181
  (await route.resolve?.({
@@ -233,7 +246,7 @@ export class PageDescriptorProvider {
233
246
  element: this.renderView(i + 1, path, element, it.route),
234
247
  index: i + 1,
235
248
  path,
236
- route,
249
+ route: it.route,
237
250
  });
238
251
  break;
239
252
  }
@@ -253,7 +266,8 @@ export class PageDescriptorProvider {
253
266
  element: this.renderView(i + 1, path, element, it.route),
254
267
  index: i + 1,
255
268
  path,
256
- route,
269
+ route: it.route,
270
+ cache: it.cache,
257
271
  });
258
272
  }
259
273
 
@@ -353,14 +367,14 @@ export class PageDescriptorProvider {
353
367
  on: "configure",
354
368
  handler: () => {
355
369
  let hasNotFoundHandler = false;
356
- const pages = this.alepha.getDescriptorValues($page);
370
+ const pages = this.alepha.descriptors($page);
357
371
 
358
- const hasParent = (it: { [OPTIONS]: PageDescriptorOptions }) => {
372
+ const hasParent = (it: PageDescriptor) => {
359
373
  for (const page of pages) {
360
- const children = page.value[OPTIONS].children
361
- ? Array.isArray(page.value[OPTIONS].children)
362
- ? page.value[OPTIONS].children
363
- : page.value[OPTIONS].children()
374
+ const children = page.options.children
375
+ ? Array.isArray(page.options.children)
376
+ ? page.options.children
377
+ : page.options.children()
364
378
  : [];
365
379
  if (children.includes(it)) {
366
380
  return true;
@@ -368,21 +382,17 @@ export class PageDescriptorProvider {
368
382
  }
369
383
  };
370
384
 
371
- for (const { value, key } of pages) {
372
- value[OPTIONS].name ??= key;
373
- }
385
+ for (const page of pages) {
386
+ if (page.options.path === "/*") {
387
+ hasNotFoundHandler = true;
388
+ }
374
389
 
375
- for (const { value } of pages) {
376
390
  // skip children, we only want root pages
377
- if (hasParent(value)) {
391
+ if (hasParent(page)) {
378
392
  continue;
379
393
  }
380
394
 
381
- if (value[OPTIONS].path === "/*") {
382
- hasNotFoundHandler = true;
383
- }
384
-
385
- this.add(this.map(pages, value));
395
+ this.add(this.map(pages, page));
386
396
  }
387
397
 
388
398
  if (!hasNotFoundHandler && pages.length > 0) {
@@ -401,17 +411,18 @@ export class PageDescriptorProvider {
401
411
  });
402
412
 
403
413
  protected map(
404
- pages: Array<{ value: { [OPTIONS]: PageDescriptorOptions } }>,
405
- target: { [OPTIONS]: PageDescriptorOptions },
414
+ pages: Array<PageDescriptor>,
415
+ target: PageDescriptor,
406
416
  ): PageRouteEntry {
407
- const children = target[OPTIONS].children
408
- ? Array.isArray(target[OPTIONS].children)
409
- ? target[OPTIONS].children
410
- : target[OPTIONS].children()
417
+ const children = target.options.children
418
+ ? Array.isArray(target.options.children)
419
+ ? target.options.children
420
+ : target.options.children()
411
421
  : [];
412
422
 
413
423
  return {
414
- ...target[OPTIONS],
424
+ ...target.options,
425
+ name: target.name,
415
426
  parent: undefined,
416
427
  children: children.map((it) => this.map(pages, it)),
417
428
  } as PageRoute;
@@ -499,6 +510,7 @@ export interface Layer {
499
510
  index: number;
500
511
  path: string;
501
512
  route?: PageRoute;
513
+ cache?: boolean;
502
514
  }
503
515
 
504
516
  export type PreviousLayerData = Omit<Layer, "element" | "index" | "path">;
@@ -525,6 +537,7 @@ export interface RouterStackItem {
525
537
  config?: Record<string, any>;
526
538
  props?: Record<string, any>;
527
539
  error?: Error;
540
+ cache?: boolean;
528
541
  }
529
542
 
530
543
  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
  },
@@ -1,4 +1,4 @@
1
- import { $hook, $inject, $logger, type Static, t } from "@alepha/core";
1
+ import { $env, $hook, $inject, $logger, type Static, t } from "@alepha/core";
2
2
  import type { ApiLinksResponse } from "@alepha/server";
3
3
  import type { Root } from "react-dom/client";
4
4
  import { createRoot, hydrateRoot } from "react-dom/client";
@@ -17,15 +17,23 @@ 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);
23
27
  protected readonly browserRouterProvider = $inject(BrowserRouterProvider);
24
- protected readonly env = $inject(envSchema);
28
+ protected readonly env = $env(envSchema);
25
29
  protected readonly log = $logger();
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
  // ---------------------------------------------------------------------------------------------------------------------