@alepha/react 0.14.3 → 0.14.4
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.
- package/dist/auth/index.browser.js +29 -14
- package/dist/auth/index.browser.js.map +1 -1
- package/dist/auth/index.js +959 -194
- package/dist/auth/index.js.map +1 -1
- package/dist/head/index.browser.js +59 -19
- package/dist/head/index.browser.js.map +1 -1
- package/dist/head/index.d.ts +99 -560
- package/dist/head/index.d.ts.map +1 -1
- package/dist/head/index.js +91 -87
- package/dist/head/index.js.map +1 -1
- package/dist/router/index.browser.js +30 -15
- package/dist/router/index.browser.js.map +1 -1
- package/dist/router/index.d.ts +616 -192
- package/dist/router/index.d.ts.map +1 -1
- package/dist/router/index.js +960 -195
- package/dist/router/index.js.map +1 -1
- package/package.json +4 -4
- package/src/core/__tests__/Router.spec.tsx +4 -4
- package/src/head/{__tests__/expandSeo.spec.ts → helpers/SeoExpander.spec.ts} +1 -1
- package/src/head/index.ts +10 -28
- package/src/head/providers/BrowserHeadProvider.browser.spec.ts +1 -76
- package/src/head/providers/BrowserHeadProvider.ts +25 -19
- package/src/head/providers/HeadProvider.ts +76 -10
- package/src/head/providers/ServerHeadProvider.ts +22 -138
- package/src/router/__tests__/page-head-browser.browser.spec.ts +91 -0
- package/src/router/__tests__/page-head.spec.ts +44 -0
- package/src/{head → router}/__tests__/seo-head.spec.ts +2 -2
- package/src/router/atoms/ssrManifestAtom.ts +60 -0
- package/src/router/constants/PAGE_PRELOAD_KEY.ts +6 -0
- package/src/router/errors/Redirection.ts +1 -1
- package/src/router/index.shared.ts +1 -0
- package/src/router/index.ts +16 -2
- package/src/router/primitives/$page.browser.spec.tsx +15 -15
- package/src/router/primitives/$page.spec.tsx +18 -18
- package/src/router/primitives/$page.ts +46 -10
- package/src/router/providers/ReactBrowserProvider.ts +14 -29
- package/src/router/providers/ReactBrowserRouterProvider.ts +5 -0
- package/src/router/providers/ReactPageProvider.ts +11 -4
- package/src/router/providers/ReactServerProvider.ts +331 -316
- package/src/router/providers/ReactServerTemplateProvider.ts +775 -0
- package/src/router/providers/SSRManifestProvider.ts +365 -0
- package/src/router/services/ReactPageServerService.ts +5 -3
- package/src/router/services/ReactRouter.ts +3 -3
- package/src/head/__tests__/page-head.spec.ts +0 -39
- package/src/head/providers/ServerHeadProvider.spec.ts +0 -163
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { Alepha } from "alepha";
|
|
2
|
+
import { describe, it } from "vitest";
|
|
3
|
+
import { $head, AlephaReactHead } from "@alepha/react/head";
|
|
4
|
+
import { $page } from "../index.ts";
|
|
5
|
+
|
|
6
|
+
class App {
|
|
7
|
+
head = $head({
|
|
8
|
+
htmlAttributes: { lang: "fr", "x-data-custom": "ok" },
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
hello = $page({
|
|
12
|
+
head: {
|
|
13
|
+
title: "Hello World",
|
|
14
|
+
bodyAttributes: { class: "hello-world" },
|
|
15
|
+
meta: [
|
|
16
|
+
{ name: "description", content: "This is a test page." },
|
|
17
|
+
{ name: "keywords", content: "test, alepha, react" },
|
|
18
|
+
],
|
|
19
|
+
},
|
|
20
|
+
component: () => "",
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const alepha = Alepha.create().with(AlephaReactHead);
|
|
25
|
+
const a = alepha.inject(App);
|
|
26
|
+
|
|
27
|
+
describe("PageHead", () => {
|
|
28
|
+
it("should render page with custom head and body attributes", async ({
|
|
29
|
+
expect,
|
|
30
|
+
}) => {
|
|
31
|
+
const result = await a.hello.render({ html: true, hydration: false });
|
|
32
|
+
|
|
33
|
+
// Check key parts of the HTML output (streaming adds newlines between sections)
|
|
34
|
+
expect(result.html).toContain('<!DOCTYPE html>');
|
|
35
|
+
expect(result.html).toContain('<html lang="fr" x-data-custom="ok">');
|
|
36
|
+
expect(result.html).toContain('<title>Hello World</title>');
|
|
37
|
+
expect(result.html).toContain('<meta name="description" content="This is a test page.">');
|
|
38
|
+
expect(result.html).toContain('<meta name="keywords" content="test, alepha, react">');
|
|
39
|
+
expect(result.html).toContain('<body class="hello-world">');
|
|
40
|
+
expect(result.html).toContain('<div id="root">');
|
|
41
|
+
expect(result.html).toContain('</body>');
|
|
42
|
+
expect(result.html).toContain('</html>');
|
|
43
|
+
});
|
|
44
|
+
});
|
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import { $page } from "@alepha/react/router";
|
|
2
1
|
import { Alepha } from "alepha";
|
|
3
2
|
import { describe, it } from "vitest";
|
|
4
|
-
import { $head, AlephaReactHead } from "
|
|
3
|
+
import { $head, AlephaReactHead } from "@alepha/react/head";
|
|
4
|
+
import { $page } from "../index.ts";
|
|
5
5
|
|
|
6
6
|
class App {
|
|
7
7
|
head = $head({
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { $atom, t } from "alepha";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Schema for the SSR manifest atom.
|
|
5
|
+
*/
|
|
6
|
+
export const ssrManifestAtomSchema = t.object({
|
|
7
|
+
/**
|
|
8
|
+
* Preload manifest mapping short keys to source paths.
|
|
9
|
+
* Generated by viteAlephaSsrPreload plugin at build time.
|
|
10
|
+
*/
|
|
11
|
+
preload: t.optional(t.record(t.string(), t.string())),
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* SSR manifest mapping source files to their required chunks.
|
|
15
|
+
*/
|
|
16
|
+
ssr: t.optional(t.record(t.string(), t.array(t.string()))),
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Client manifest mapping source files to their output information.
|
|
20
|
+
*/
|
|
21
|
+
client: t.optional(
|
|
22
|
+
t.record(
|
|
23
|
+
t.string(),
|
|
24
|
+
t.object({
|
|
25
|
+
file: t.string(),
|
|
26
|
+
src: t.optional(t.string()),
|
|
27
|
+
isEntry: t.optional(t.boolean()),
|
|
28
|
+
isDynamicEntry: t.optional(t.boolean()),
|
|
29
|
+
imports: t.optional(t.array(t.string())),
|
|
30
|
+
dynamicImports: t.optional(t.array(t.string())),
|
|
31
|
+
css: t.optional(t.array(t.string())),
|
|
32
|
+
assets: t.optional(t.array(t.string())),
|
|
33
|
+
}),
|
|
34
|
+
),
|
|
35
|
+
),
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Type for the SSR manifest schema.
|
|
40
|
+
*/
|
|
41
|
+
export type SsrManifestAtomSchema = typeof ssrManifestAtomSchema;
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* SSR Manifest atom containing all manifest data for SSR module preloading.
|
|
45
|
+
*
|
|
46
|
+
* This atom is populated at build time by embedding manifest data into the
|
|
47
|
+
* generated index.js. This approach is optimal for serverless deployments
|
|
48
|
+
* as it eliminates filesystem reads at runtime.
|
|
49
|
+
*
|
|
50
|
+
* The manifest includes:
|
|
51
|
+
* - preload: Maps short hash keys to source paths (from viteAlephaSsrPreload)
|
|
52
|
+
* - ssr: Maps source files to their required chunks
|
|
53
|
+
* - client: Maps source files to their output info including imports/css
|
|
54
|
+
*/
|
|
55
|
+
export const ssrManifestAtom = $atom({
|
|
56
|
+
name: "alepha.react.ssr.manifest",
|
|
57
|
+
description: "SSR manifest for module preloading",
|
|
58
|
+
schema: ssrManifestAtomSchema,
|
|
59
|
+
default: {},
|
|
60
|
+
});
|
|
@@ -6,6 +6,7 @@ export { default as NestedView } from "./components/NestedView.tsx";
|
|
|
6
6
|
export type * from "./components/NestedView.tsx";
|
|
7
7
|
export { default as NotFound } from "./components/NotFound.tsx";
|
|
8
8
|
export type * from "./components/NotFound.tsx";
|
|
9
|
+
export * from "./constants/PAGE_PRELOAD_KEY.ts";
|
|
9
10
|
export * from "./contexts/RouterLayerContext.ts";
|
|
10
11
|
export * from "./primitives/$page.ts";
|
|
11
12
|
export * from "./errors/Redirection.ts";
|
package/src/router/index.ts
CHANGED
|
@@ -7,6 +7,8 @@ import { AlephaServer, type ServerRequest } from "alepha/server";
|
|
|
7
7
|
import type { ReactNode } from "react";
|
|
8
8
|
import type { ReactHydrationState } from "./providers/ReactBrowserProvider.ts";
|
|
9
9
|
import { ReactServerProvider } from "./providers/ReactServerProvider.ts";
|
|
10
|
+
import { ReactServerTemplateProvider } from "./providers/ReactServerTemplateProvider.ts";
|
|
11
|
+
import { SSRManifestProvider } from "./providers/SSRManifestProvider.ts";
|
|
10
12
|
import { ReactPageServerService } from "./services/ReactPageServerService.ts";
|
|
11
13
|
import { AlephaServerCache } from "alepha/server/cache";
|
|
12
14
|
import { AlephaServerLinks } from "alepha/server/links";
|
|
@@ -19,6 +21,8 @@ export * from "./index.shared.ts";
|
|
|
19
21
|
export * from "./providers/ReactPageProvider.ts";
|
|
20
22
|
export * from "./providers/ReactBrowserProvider.ts";
|
|
21
23
|
export * from "./providers/ReactServerProvider.ts";
|
|
24
|
+
export * from "./providers/ReactServerTemplateProvider.ts";
|
|
25
|
+
export * from "./providers/SSRManifestProvider.ts";
|
|
22
26
|
|
|
23
27
|
// ---------------------------------------------------------------------------------------------------------------------
|
|
24
28
|
|
|
@@ -46,6 +50,9 @@ declare module "alepha" {
|
|
|
46
50
|
// -----------------------------------------------------------------------------------------------------------------
|
|
47
51
|
/**
|
|
48
52
|
* Fires when the React application is being rendered on the browser.
|
|
53
|
+
*
|
|
54
|
+
* Note: this one is not really necessary, it's a hack because we need to isolate renderer from server code in order
|
|
55
|
+
* to avoid including react-dom/client in server bundles.
|
|
49
56
|
*/
|
|
50
57
|
"react:browser:render": {
|
|
51
58
|
root: HTMLElement;
|
|
@@ -94,7 +101,7 @@ declare module "alepha" {
|
|
|
94
101
|
* - URL pattern matching with parameters (e.g., `/users/:id`)
|
|
95
102
|
* - Nested routing with parent-child relationships
|
|
96
103
|
* - Type-safe URL parameter and query string validation
|
|
97
|
-
* - Server-side data fetching with the `
|
|
104
|
+
* - Server-side data fetching with the `loader` function
|
|
98
105
|
* - Lazy loading and code splitting
|
|
99
106
|
* - Page animations and error handling
|
|
100
107
|
*
|
|
@@ -107,7 +114,12 @@ export const AlephaReactRouter = $module({
|
|
|
107
114
|
services: [
|
|
108
115
|
ReactPageProvider,
|
|
109
116
|
ReactPageService,
|
|
110
|
-
ReactRouter,
|
|
117
|
+
ReactRouter,
|
|
118
|
+
ReactServerProvider,
|
|
119
|
+
ReactServerTemplateProvider,
|
|
120
|
+
SSRManifestProvider,
|
|
121
|
+
ReactPageServerService,
|
|
122
|
+
],
|
|
111
123
|
register: (alepha) =>
|
|
112
124
|
alepha
|
|
113
125
|
.with(AlephaReact)
|
|
@@ -119,6 +131,8 @@ export const AlephaReactRouter = $module({
|
|
|
119
131
|
provide: ReactPageService,
|
|
120
132
|
use: ReactPageServerService,
|
|
121
133
|
})
|
|
134
|
+
.with(SSRManifestProvider)
|
|
135
|
+
.with(ReactServerTemplateProvider)
|
|
122
136
|
.with(ReactServerProvider)
|
|
123
137
|
.with(ReactPageProvider)
|
|
124
138
|
.with(ReactRouter),
|
|
@@ -89,7 +89,7 @@ describe("$page browser tests", () => {
|
|
|
89
89
|
id: t.text(),
|
|
90
90
|
}),
|
|
91
91
|
},
|
|
92
|
-
|
|
92
|
+
loader: ({ params }) => ({
|
|
93
93
|
userId: params.id,
|
|
94
94
|
userName: `User ${params.id}`,
|
|
95
95
|
}),
|
|
@@ -132,7 +132,7 @@ describe("$page browser tests", () => {
|
|
|
132
132
|
class App {
|
|
133
133
|
async = $page({
|
|
134
134
|
path: "/async",
|
|
135
|
-
|
|
135
|
+
loader: async () => {
|
|
136
136
|
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
137
137
|
return { message: "Loaded async data" };
|
|
138
138
|
},
|
|
@@ -174,7 +174,7 @@ describe("$page browser tests", () => {
|
|
|
174
174
|
|
|
175
175
|
protected = $page({
|
|
176
176
|
path: "/protected",
|
|
177
|
-
|
|
177
|
+
loader: () => {
|
|
178
178
|
if (!isAuthenticated) {
|
|
179
179
|
throw new Error("Unauthorized");
|
|
180
180
|
}
|
|
@@ -227,7 +227,7 @@ describe("$page browser tests", () => {
|
|
|
227
227
|
class App {
|
|
228
228
|
errorPage = $page({
|
|
229
229
|
path: "/error",
|
|
230
|
-
|
|
230
|
+
loader: () => {
|
|
231
231
|
throw new Error("Something went wrong");
|
|
232
232
|
},
|
|
233
233
|
errorHandler: (error) => (
|
|
@@ -259,7 +259,7 @@ describe("$page browser tests", () => {
|
|
|
259
259
|
class App {
|
|
260
260
|
layout = $page({
|
|
261
261
|
path: "/",
|
|
262
|
-
|
|
262
|
+
loader: () => ({ appName: "My App" }),
|
|
263
263
|
component: ({ appName }: { appName: string }) => (
|
|
264
264
|
<div data-testid="layout">
|
|
265
265
|
<header data-testid="header">{appName}</header>
|
|
@@ -316,7 +316,7 @@ describe("$page browser tests", () => {
|
|
|
316
316
|
class App {
|
|
317
317
|
layout = $page({
|
|
318
318
|
path: "/",
|
|
319
|
-
|
|
319
|
+
loader: () => ({ theme: "dark" }),
|
|
320
320
|
component: ({ theme }: { theme: string }) => (
|
|
321
321
|
<div data-testid="layout" data-theme={theme}>
|
|
322
322
|
<NestedView />
|
|
@@ -328,7 +328,7 @@ describe("$page browser tests", () => {
|
|
|
328
328
|
page = $page({
|
|
329
329
|
path: "/page",
|
|
330
330
|
parent: this.layout,
|
|
331
|
-
|
|
331
|
+
loader: ({ theme }) => ({
|
|
332
332
|
message: `Theme is ${theme}`,
|
|
333
333
|
}),
|
|
334
334
|
component: ({ message }: { message: string }) => (
|
|
@@ -362,7 +362,7 @@ describe("$page browser tests", () => {
|
|
|
362
362
|
class App {
|
|
363
363
|
root = $page({
|
|
364
364
|
path: "/",
|
|
365
|
-
|
|
365
|
+
loader: () => ({ level: "root" }),
|
|
366
366
|
component: ({ level }: { level: string }) => (
|
|
367
367
|
<div data-testid="root">
|
|
368
368
|
{level}
|
|
@@ -374,7 +374,7 @@ describe("$page browser tests", () => {
|
|
|
374
374
|
section = $page({
|
|
375
375
|
path: "/section",
|
|
376
376
|
parent: this.root,
|
|
377
|
-
|
|
377
|
+
loader: ({ level }) => ({ level: `${level} > section` }),
|
|
378
378
|
component: ({ level }: { level: string }) => (
|
|
379
379
|
<div data-testid="section">
|
|
380
380
|
{level}
|
|
@@ -386,7 +386,7 @@ describe("$page browser tests", () => {
|
|
|
386
386
|
page = $page({
|
|
387
387
|
path: "/page",
|
|
388
388
|
parent: this.section,
|
|
389
|
-
|
|
389
|
+
loader: ({ level }) => ({ level: `${level} > page` }),
|
|
390
390
|
component: ({ level }: { level: string }) => (
|
|
391
391
|
<div data-testid="page">{level}</div>
|
|
392
392
|
),
|
|
@@ -469,7 +469,7 @@ describe("$page browser tests", () => {
|
|
|
469
469
|
id: t.text(),
|
|
470
470
|
}),
|
|
471
471
|
},
|
|
472
|
-
|
|
472
|
+
loader: ({ params }) => ({
|
|
473
473
|
userId: params.id,
|
|
474
474
|
}),
|
|
475
475
|
component: ({ userId }: { userId: string }) => (
|
|
@@ -504,7 +504,7 @@ describe("$page browser tests", () => {
|
|
|
504
504
|
page: t.number({ default: 1 }),
|
|
505
505
|
}),
|
|
506
506
|
},
|
|
507
|
-
|
|
507
|
+
loader: ({ query }) => ({
|
|
508
508
|
searchQuery: query.q,
|
|
509
509
|
currentPage: query.page,
|
|
510
510
|
}),
|
|
@@ -553,7 +553,7 @@ describe("$page browser tests", () => {
|
|
|
553
553
|
page: t.number({ default: 1 }),
|
|
554
554
|
}),
|
|
555
555
|
},
|
|
556
|
-
|
|
556
|
+
loader: ({ query }) => ({
|
|
557
557
|
searchQuery: query.q,
|
|
558
558
|
currentPage: query.page,
|
|
559
559
|
}),
|
|
@@ -605,7 +605,7 @@ describe("$page browser tests", () => {
|
|
|
605
605
|
limit: t.number({ default: 10 }),
|
|
606
606
|
}),
|
|
607
607
|
},
|
|
608
|
-
|
|
608
|
+
loader: ({ params, query }) => ({
|
|
609
609
|
userId: params.userId,
|
|
610
610
|
sortBy: query.sort,
|
|
611
611
|
limit: query.limit,
|
|
@@ -660,7 +660,7 @@ describe("$page browser tests", () => {
|
|
|
660
660
|
page: t.number({ default: 1 }),
|
|
661
661
|
}),
|
|
662
662
|
},
|
|
663
|
-
|
|
663
|
+
loader: ({ query }) => ({
|
|
664
664
|
searchQuery: query.q,
|
|
665
665
|
currentPage: query.page,
|
|
666
666
|
}),
|
|
@@ -61,7 +61,7 @@ describe("$page primitive tests", () => {
|
|
|
61
61
|
sort: t.optional(t.text()),
|
|
62
62
|
}),
|
|
63
63
|
},
|
|
64
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
156
|
-
expect(typeof app.async.options.
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
651
|
+
loader: () => ({ parentValue: "from parent" }),
|
|
652
652
|
});
|
|
653
653
|
|
|
654
654
|
child = $page({
|
|
655
655
|
path: "/child",
|
|
656
656
|
parent: this.parent,
|
|
657
|
-
|
|
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.
|
|
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 `
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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
|
-
|
|
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 `
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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
|
|
461
|
+
export type PageLoader<
|
|
426
462
|
TConfig extends PageConfigSchema = PageConfigSchema,
|
|
427
463
|
TPropsParent extends object = TPropsParentDefault,
|
|
428
464
|
> = PageRequestConfig<TConfig> &
|