@alepha/react 0.14.3 → 0.15.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.
Files changed (57) hide show
  1. package/README.md +10 -0
  2. package/dist/auth/index.browser.js +29 -14
  3. package/dist/auth/index.browser.js.map +1 -1
  4. package/dist/auth/index.d.ts +4 -4
  5. package/dist/auth/index.d.ts.map +1 -1
  6. package/dist/auth/index.js +950 -194
  7. package/dist/auth/index.js.map +1 -1
  8. package/dist/core/index.d.ts +118 -118
  9. package/dist/core/index.d.ts.map +1 -1
  10. package/dist/form/index.d.ts +27 -28
  11. package/dist/form/index.d.ts.map +1 -1
  12. package/dist/head/index.browser.js +59 -19
  13. package/dist/head/index.browser.js.map +1 -1
  14. package/dist/head/index.d.ts +105 -576
  15. package/dist/head/index.d.ts.map +1 -1
  16. package/dist/head/index.js +91 -87
  17. package/dist/head/index.js.map +1 -1
  18. package/dist/i18n/index.d.ts +33 -33
  19. package/dist/i18n/index.d.ts.map +1 -1
  20. package/dist/router/index.browser.js +30 -15
  21. package/dist/router/index.browser.js.map +1 -1
  22. package/dist/router/index.d.ts +827 -403
  23. package/dist/router/index.d.ts.map +1 -1
  24. package/dist/router/index.js +951 -195
  25. package/dist/router/index.js.map +1 -1
  26. package/dist/websocket/index.d.ts +38 -39
  27. package/dist/websocket/index.d.ts.map +1 -1
  28. package/package.json +5 -5
  29. package/src/auth/__tests__/$auth.spec.ts +10 -11
  30. package/src/core/__tests__/Router.spec.tsx +4 -4
  31. package/src/head/{__tests__/expandSeo.spec.ts → helpers/SeoExpander.spec.ts} +1 -1
  32. package/src/head/index.ts +10 -28
  33. package/src/head/providers/BrowserHeadProvider.browser.spec.ts +1 -76
  34. package/src/head/providers/BrowserHeadProvider.ts +25 -19
  35. package/src/head/providers/HeadProvider.ts +76 -10
  36. package/src/head/providers/ServerHeadProvider.ts +22 -138
  37. package/src/router/__tests__/page-head-browser.browser.spec.ts +91 -0
  38. package/src/router/__tests__/page-head.spec.ts +44 -0
  39. package/src/{head → router}/__tests__/seo-head.spec.ts +2 -2
  40. package/src/router/atoms/ssrManifestAtom.ts +60 -0
  41. package/src/router/constants/PAGE_PRELOAD_KEY.ts +6 -0
  42. package/src/router/errors/Redirection.ts +1 -1
  43. package/src/router/index.shared.ts +1 -0
  44. package/src/router/index.ts +16 -2
  45. package/src/router/primitives/$page.browser.spec.tsx +15 -15
  46. package/src/router/primitives/$page.spec.tsx +18 -18
  47. package/src/router/primitives/$page.ts +46 -10
  48. package/src/router/providers/ReactBrowserProvider.ts +14 -29
  49. package/src/router/providers/ReactBrowserRouterProvider.ts +5 -0
  50. package/src/router/providers/ReactPageProvider.ts +11 -4
  51. package/src/router/providers/ReactServerProvider.ts +321 -316
  52. package/src/router/providers/ReactServerTemplateProvider.ts +793 -0
  53. package/src/router/providers/SSRManifestProvider.ts +365 -0
  54. package/src/router/services/ReactPageServerService.ts +5 -3
  55. package/src/router/services/ReactRouter.ts +3 -3
  56. package/src/head/__tests__/page-head.spec.ts +0 -39
  57. package/src/head/providers/ServerHeadProvider.spec.ts +0 -163
@@ -61,7 +61,7 @@ describe("$page primitive tests", () => {
61
61
  sort: t.optional(t.text()),
62
62
  }),
63
63
  },
64
- resolve: ({ params, query }) => ({ params, query }),
64
+ loader: ({ params, query }) => ({ params, query }),
65
65
  component: ({ params, query }) =>
66
66
  `User ${params.id} - Tab: ${query.tab}`,
67
67
  });
@@ -72,7 +72,7 @@ describe("$page primitive tests", () => {
72
72
 
73
73
  expect(app.user.options.schema?.params).toBeDefined();
74
74
  expect(app.user.options.schema?.query).toBeDefined();
75
- expect(app.user.options.resolve).toBeDefined();
75
+ expect(app.user.options.loader).toBeDefined();
76
76
  expect(app.user.options.component).toBeDefined();
77
77
 
78
78
  const rendered = await app.user.render({
@@ -89,7 +89,7 @@ describe("$page primitive tests", () => {
89
89
  class App {
90
90
  lazy = $page({
91
91
  path: "/lazy",
92
- resolve: () => ({ message: "loaded" }),
92
+ loader: () => ({ message: "loaded" }),
93
93
  lazy: async () => ({ default: LazyComponent }),
94
94
  });
95
95
  }
@@ -141,7 +141,7 @@ describe("$page primitive tests", () => {
141
141
  id: t.text(),
142
142
  }),
143
143
  },
144
- resolve: async ({ params }) => {
144
+ loader: async ({ params }) => {
145
145
  await new Promise((resolve) => setTimeout(resolve, 10));
146
146
  return { data: `Data for ${params.id}`, timestamp: Date.now() };
147
147
  },
@@ -152,8 +152,8 @@ describe("$page primitive tests", () => {
152
152
  const app = alepha.inject(App);
153
153
  await alepha.start();
154
154
 
155
- expect(app.async.options.resolve).toBeDefined();
156
- expect(typeof app.async.options.resolve).toBe("function");
155
+ expect(app.async.options.loader).toBeDefined();
156
+ expect(typeof app.async.options.loader).toBe("function");
157
157
 
158
158
  const mockContext = {
159
159
  params: { id: "test" },
@@ -161,7 +161,7 @@ describe("$page primitive tests", () => {
161
161
  pathname: "/async/test",
162
162
  search: "",
163
163
  };
164
- const result = await app.async.options.resolve!(mockContext as any);
164
+ const result = await app.async.options.loader!(mockContext as any);
165
165
  expect(result.data).toBe("Data for test");
166
166
  expect(typeof result.timestamp).toBe("number");
167
167
 
@@ -173,14 +173,14 @@ describe("$page primitive tests", () => {
173
173
  class App {
174
174
  parent = $page({
175
175
  path: "/parent",
176
- resolve: () => ({ parentData: "from parent" }),
176
+ loader: () => ({ parentData: "from parent" }),
177
177
  children: [],
178
178
  });
179
179
 
180
180
  child = $page({
181
181
  path: "/child",
182
182
  parent: this.parent,
183
- resolve: ({ parentData }) => ({
183
+ loader: ({ parentData }) => ({
184
184
  childData: `child with ${parentData}`,
185
185
  }),
186
186
  component: ({ childData }) => childData,
@@ -261,7 +261,7 @@ describe("$page primitive tests", () => {
261
261
  class App {
262
262
  errorPage = $page({
263
263
  path: "/error",
264
- resolve: () => {
264
+ loader: () => {
265
265
  throw new Error("Test error");
266
266
  },
267
267
  errorHandler: (error) => `Error: ${error.message}`,
@@ -289,7 +289,7 @@ describe("$page primitive tests", () => {
289
289
  class App {
290
290
  errorPage = $page({
291
291
  path: "/error",
292
- resolve: () => {
292
+ loader: () => {
293
293
  throw new Error("unauthorized");
294
294
  },
295
295
  errorHandler: (error) => {
@@ -342,7 +342,7 @@ describe("$page primitive tests", () => {
342
342
  static: {
343
343
  entries: [{ params: { id: "1" } }, { params: { id: "2" } }],
344
344
  },
345
- resolve: ({ params }) => ({ id: params.id }),
345
+ loader: ({ params }) => ({ id: params.id }),
346
346
  component: ({ id }) => `Static page ${id}`,
347
347
  });
348
348
  }
@@ -566,7 +566,7 @@ describe("$page primitive tests", () => {
566
566
  limit: t.number({ default: 10 }),
567
567
  }),
568
568
  },
569
- resolve: ({ params, query }) => ({
569
+ loader: ({ params, query }) => ({
570
570
  user: { id: params.userId },
571
571
  pagination: { page: query.page, limit: query.limit },
572
572
  filters: query.filters,
@@ -582,7 +582,7 @@ describe("$page primitive tests", () => {
582
582
 
583
583
  expect(app.complex.options.schema?.params).toBeDefined();
584
584
  expect(app.complex.options.schema?.query).toBeDefined();
585
- expect(app.complex.options.resolve).toBeDefined();
585
+ expect(app.complex.options.loader).toBeDefined();
586
586
 
587
587
  const rendered = await app.complex.render({
588
588
  params: { userId: "123" },
@@ -608,7 +608,7 @@ describe("$page primitive tests", () => {
608
608
 
609
609
  child = $page({
610
610
  path: "/child",
611
- resolve: () => {
611
+ loader: () => {
612
612
  throw new Error("Child error");
613
613
  },
614
614
  errorHandler: (error) => {
@@ -648,13 +648,13 @@ describe("$page primitive tests", () => {
648
648
  test("$page - resolve function receives parent props", async ({ expect }) => {
649
649
  class App {
650
650
  parent = $page({
651
- resolve: () => ({ parentValue: "from parent" }),
651
+ loader: () => ({ parentValue: "from parent" }),
652
652
  });
653
653
 
654
654
  child = $page({
655
655
  path: "/child",
656
656
  parent: this.parent,
657
- resolve: ({ parentValue }) => ({
657
+ loader: ({ parentValue }) => ({
658
658
  childData: `Child received: ${parentValue}`,
659
659
  }),
660
660
  component: ({ childData, parentValue }) =>
@@ -665,7 +665,7 @@ describe("$page primitive tests", () => {
665
665
  const app = alepha.inject(App);
666
666
  await alepha.start();
667
667
 
668
- expect(app.child.options.resolve).toBeDefined();
668
+ expect(app.child.options.loader).toBeDefined();
669
669
 
670
670
  const rendered = await app.child.fetch();
671
671
  expect(rendered.html).toBe("Child received: from parent and from parent");
@@ -14,6 +14,9 @@ import type { Redirection } from "../errors/Redirection.ts";
14
14
  import type { ReactRouterState } from "../providers/ReactPageProvider.ts";
15
15
  import { ReactPageService } from "../services/ReactPageService.ts";
16
16
  import type { ClientOnlyProps } from "@alepha/react";
17
+ import type { Head } from "@alepha/react/head";
18
+ import { PAGE_PRELOAD_KEY } from "../constants/PAGE_PRELOAD_KEY.ts";
19
+
17
20
 
18
21
  /**
19
22
  * Main primitive for defining a React route in the application.
@@ -27,7 +30,7 @@ import type { ClientOnlyProps } from "@alepha/react";
27
30
  * - Type-safe URL parameter and query string validation
28
31
  *
29
32
  * **Data Loading**
30
- * - Server-side data fetching with the `resolve` function
33
+ * - Server-side data fetching with the `loader` function
31
34
  * - Automatic serialization and hydration for SSR
32
35
  * - Access to request context, URL params, and parent data
33
36
  *
@@ -64,7 +67,7 @@ import type { ClientOnlyProps } from "@alepha/react";
64
67
  * params: t.object({ id: t.integer() }),
65
68
  * query: t.object({ tab: t.optional(t.text()) })
66
69
  * },
67
- * resolve: async ({ params }) => {
70
+ * loader: async ({ params }) => {
68
71
  * const user = await userApi.getUser(params.id);
69
72
  * return { user };
70
73
  * },
@@ -77,7 +80,7 @@ import type { ClientOnlyProps } from "@alepha/react";
77
80
  * const projectSection = $page({
78
81
  * path: "/projects/:id",
79
82
  * children: () => [projectBoard, projectSettings],
80
- * resolve: async ({ params }) => {
83
+ * loader: async ({ params }) => {
81
84
  * const project = await projectApi.get(params.id);
82
85
  * return { project };
83
86
  * },
@@ -96,7 +99,7 @@ import type { ClientOnlyProps } from "@alepha/react";
96
99
  * static: {
97
100
  * entries: posts.map(p => ({ params: { slug: p.slug } }))
98
101
  * },
99
- * resolve: async ({ params }) => {
102
+ * loader: async ({ params }) => {
100
103
  * const post = await loadPost(params.slug);
101
104
  * return { post };
102
105
  * }
@@ -155,12 +158,12 @@ export interface PagePrimitiveOptions<
155
158
  *
156
159
  * > In SSR, the returned data will be serialized and sent to the client, then reused during the client-side hydration.
157
160
  *
158
- * Resolve can be stopped by throwing an error, which will be handled by the `errorHandler` function.
161
+ * Loader can be stopped by throwing an error, which will be handled by the `errorHandler` function.
159
162
  * It's common to throw a `NotFoundError` to display a 404 page.
160
163
  *
161
164
  * RedirectError can be thrown to redirect the user to another page.
162
165
  */
163
- resolve?: (context: PageResolve<TConfig, TPropsParent>) => Async<TProps>;
166
+ loader?: (context: PageLoader<TConfig, TPropsParent>) => Async<TProps>;
164
167
 
165
168
  /**
166
169
  * Default props to pass to the component when rendering the page.
@@ -205,7 +208,7 @@ export interface PagePrimitiveOptions<
205
208
  can?: () => boolean;
206
209
 
207
210
  /**
208
- * Catch any error from the `resolve` function or during `rendering`.
211
+ * Catch any error from the `loader` function or during `rendering`.
209
212
  *
210
213
  * Expected to return one of the following:
211
214
  * - a ReactNode to render an error page
@@ -217,7 +220,7 @@ export interface PagePrimitiveOptions<
217
220
  *
218
221
  * @example Catch a 404 from API and render a custom not found component:
219
222
  * ```ts
220
- * resolve: async ({ params, query }) => {
223
+ * loader: async ({ params, query }) => {
221
224
  * api.fetch("/api/resource", { params, query });
222
225
  * },
223
226
  * errorHandler: (error, context) => {
@@ -229,7 +232,7 @@ export interface PagePrimitiveOptions<
229
232
  *
230
233
  * @example Catch an 401 error and redirect the user to the login page:
231
234
  * ```ts
232
- * resolve: async ({ params, query }) => {
235
+ * loader: async ({ params, query }) => {
233
236
  * // but the user is not authenticated
234
237
  * api.fetch("/api/resource", { params, query });
235
238
  * },
@@ -318,6 +321,39 @@ export interface PagePrimitiveOptions<
318
321
  * ```
319
322
  */
320
323
  animation?: PageAnimation;
324
+
325
+ /**
326
+ * Head configuration for the page (title, meta tags, etc.).
327
+ *
328
+ * Can be a static object or a function that receives resolved props.
329
+ *
330
+ * @example Static head
331
+ * ```ts
332
+ * head: {
333
+ * title: "My Page",
334
+ * description: "Page description",
335
+ * }
336
+ * ```
337
+ *
338
+ * @example Dynamic head based on props
339
+ * ```ts
340
+ * head: (props) => ({
341
+ * title: props.user.name,
342
+ * description: `Profile of ${props.user.name}`,
343
+ * })
344
+ * ```
345
+ */
346
+ head?: Head | ((props: TProps, previous?: Head) => Head);
347
+
348
+ /**
349
+ * Source path for SSR module preloading.
350
+ *
351
+ * This is automatically injected by the viteAlephaPreload plugin.
352
+ * It maps to the source file path used in Vite's SSR manifest.
353
+ *
354
+ * @internal
355
+ */
356
+ [PAGE_PRELOAD_KEY]?: string;
321
357
  }
322
358
 
323
359
  export type ErrorHandler = (
@@ -422,7 +458,7 @@ export interface PageRequestConfig<
422
458
  : Record<string, string>;
423
459
  }
424
460
 
425
- export type PageResolve<
461
+ export type PageLoader<
426
462
  TConfig extends PageConfigSchema = PageConfigSchema,
427
463
  TPropsParent extends object = TPropsParentDefault,
428
464
  > = PageRequestConfig<TConfig> &
@@ -1,36 +1,14 @@
1
- import {
2
- $atom,
3
- $env,
4
- $hook,
5
- $inject,
6
- $use,
7
- Alepha,
8
- type State,
9
- type Static,
10
- t,
11
- } from "alepha";
1
+ import { $atom, $env, $hook, $inject, $use, Alepha, type State, type Static, t, } from "alepha";
12
2
  import { DateTimeProvider } from "alepha/datetime";
13
3
  import { $logger } from "alepha/logger";
14
4
  import { LinkProvider } from "alepha/server/links";
5
+ import { BrowserHeadProvider } from "@alepha/react/head";
15
6
  import { ReactBrowserRouterProvider } from "./ReactBrowserRouterProvider.ts";
16
- import type {
17
- PreviousLayerData,
18
- ReactRouterState,
19
- TransitionOptions,
20
- } from "./ReactPageProvider.ts";
7
+ import type { PreviousLayerData, ReactRouterState, } from "./ReactPageProvider.ts";
21
8
  import type { RouterGoOptions } from "../services/ReactRouter.ts";
22
9
 
23
10
  export type { RouterGoOptions } from "../services/ReactRouter.ts";
24
11
 
25
- // ---------------------------------------------------------------------------------------------------------------------
26
-
27
- const envSchema = t.object({
28
- REACT_ROOT_ID: t.text({ default: "root" }),
29
- });
30
-
31
- declare module "alepha" {
32
- interface Env extends Partial<Static<typeof envSchema>> {}
33
- }
34
12
 
35
13
  /**
36
14
  * React browser renderer configuration atom
@@ -38,7 +16,7 @@ declare module "alepha" {
38
16
  export const reactBrowserOptions = $atom({
39
17
  name: "alepha.react.browser.options",
40
18
  schema: t.object({
41
- scrollRestoration: t.enum(["top", "manual"]),
19
+ scrollRestoration: t.enum(["top", "manual"]), // TODO: must be per page?
42
20
  }),
43
21
  default: {
44
22
  scrollRestoration: "top" as const,
@@ -58,23 +36,27 @@ declare module "alepha" {
58
36
  // ---------------------------------------------------------------------------------------------------------------------
59
37
 
60
38
  export class ReactBrowserProvider {
61
- protected readonly env = $env(envSchema);
62
39
  protected readonly log = $logger();
63
40
  protected readonly client = $inject(LinkProvider);
64
41
  protected readonly alepha = $inject(Alepha);
65
42
  protected readonly router = $inject(ReactBrowserRouterProvider);
66
43
  protected readonly dateTimeProvider = $inject(DateTimeProvider);
44
+ protected readonly browserHeadProvider = $inject(BrowserHeadProvider);
67
45
 
68
46
  protected readonly options = $use(reactBrowserOptions);
69
47
 
48
+ public get rootId() {
49
+ return "root";
50
+ }
51
+
70
52
  protected getRootElement() {
71
- const root = this.document.getElementById(this.env.REACT_ROOT_ID);
53
+ const root = this.document.getElementById(this.rootId);
72
54
  if (root) {
73
55
  return root;
74
56
  }
75
57
 
76
58
  const div = this.document.createElement("div");
77
- div.id = this.env.REACT_ROOT_ID;
59
+ div.id = this.rootId;
78
60
 
79
61
  this.document.body.prepend(div);
80
62
 
@@ -281,6 +263,9 @@ export class ReactBrowserProvider {
281
263
  state: this.state,
282
264
  });
283
265
 
266
+ // Fill and render head from route configurations
267
+ this.browserHeadProvider.fillAndRenderHead(this.state);
268
+
284
269
  window.addEventListener("popstate", () => {
285
270
  // when you update silently queryParams or hash, skip rendering
286
271
  // if you want to force a rendering, use #go()
@@ -2,6 +2,7 @@ import { $hook, $inject, Alepha } from "alepha";
2
2
  import { $logger } from "alepha/logger";
3
3
  import { type Route, RouterProvider } from "alepha/router";
4
4
  import { createElement, type ReactNode } from "react";
5
+ import { BrowserHeadProvider } from "@alepha/react/head";
5
6
  import NotFoundPage from "../components/NotFound.tsx";
6
7
  import {
7
8
  isPageRoute,
@@ -23,6 +24,7 @@ export class ReactBrowserRouterProvider extends RouterProvider<BrowserRoute> {
23
24
  protected readonly log = $logger();
24
25
  protected readonly alepha = $inject(Alepha);
25
26
  protected readonly pageApi = $inject(ReactPageProvider);
27
+ protected readonly browserHeadProvider = $inject(BrowserHeadProvider);
26
28
 
27
29
  public add(entry: PageRouteEntry) {
28
30
  this.pageApi.add(entry);
@@ -147,6 +149,9 @@ export class ReactBrowserRouterProvider extends RouterProvider<BrowserRoute> {
147
149
  await this.alepha.events.emit("react:transition:end", {
148
150
  state,
149
151
  });
152
+
153
+ // Fill and render head from route configurations
154
+ this.browserHeadProvider.fillAndRenderHead(state);
150
155
  }
151
156
 
152
157
  public root(state: ReactRouterState): ReactNode {
@@ -22,6 +22,7 @@ import {
22
22
  type PagePrimitive,
23
23
  type PagePrimitiveOptions,
24
24
  } from "../primitives/$page.ts";
25
+ import type { Head } from "@alepha/react/head";
25
26
 
26
27
  const envSchema = t.object({
27
28
  REACT_STRICT_MODE: t.boolean({ default: true }),
@@ -251,15 +252,15 @@ export class ReactPageProvider {
251
252
  forceRefresh = true;
252
253
  }
253
254
 
254
- // no resolve, render a basic view by default
255
- if (!route.resolve) {
255
+ // no loader, render a basic view by default
256
+ if (!route.loader) {
256
257
  continue;
257
258
  }
258
259
 
259
260
  try {
260
261
  const args = Object.create(state);
261
262
  Object.assign(args, config, context);
262
- const props = (await route.resolve?.(args)) ?? {};
263
+ const props = (await route.loader?.(args)) ?? {};
263
264
 
264
265
  // save props
265
266
  it.props = {
@@ -279,7 +280,7 @@ export class ReactPageProvider {
279
280
  };
280
281
  }
281
282
 
282
- this.log.error("Page resolver has failed", e);
283
+ this.log.error("Page loader has failed", e);
283
284
 
284
285
  it.error = e as Error;
285
286
  break;
@@ -696,6 +697,12 @@ export interface ReactRouterState {
696
697
  */
697
698
  meta: Record<string, any>;
698
699
 
700
+ /**
701
+ * Head configuration for the current page (title, meta tags, etc.).
702
+ * Populated by HeadProvider during SSR.
703
+ */
704
+ head: Head;
705
+
699
706
  //
700
707
  name?: string;
701
708
  }