@alepha/react 0.14.2 → 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 +960 -195
- package/dist/auth/index.js.map +1 -1
- package/dist/core/index.d.ts +4 -0
- package/dist/core/index.d.ts.map +1 -1
- package/dist/core/index.js +7 -4
- package/dist/core/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 +92 -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 +961 -196
- package/dist/router/index.js.map +1 -1
- package/package.json +4 -4
- package/src/auth/__tests__/$auth.spec.ts +188 -0
- package/src/core/__tests__/Router.spec.tsx +169 -0
- package/src/core/hooks/useAction.browser.spec.tsx +569 -0
- package/src/core/hooks/useAction.ts +11 -0
- package/src/form/hooks/useForm.browser.spec.tsx +366 -0
- package/src/head/helpers/SeoExpander.spec.ts +203 -0
- package/src/head/hooks/useHead.spec.tsx +288 -0
- package/src/head/index.ts +11 -28
- package/src/head/providers/BrowserHeadProvider.browser.spec.ts +196 -0
- 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/i18n/__tests__/integration.spec.tsx +239 -0
- package/src/i18n/components/Localize.spec.tsx +357 -0
- package/src/i18n/hooks/useI18n.browser.spec.tsx +438 -0
- package/src/i18n/providers/I18nProvider.spec.ts +389 -0
- package/src/router/__tests__/page-head-browser.browser.spec.ts +91 -0
- package/src/router/__tests__/page-head.spec.ts +44 -0
- package/src/router/__tests__/seo-head.spec.ts +121 -0
- 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 +702 -0
- package/src/router/primitives/$page.spec.tsx +702 -0
- 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.spec.tsx +316 -0
- package/src/router/providers/ReactServerProvider.ts +331 -315
- 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
|
@@ -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> &
|
|
@@ -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.
|
|
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.
|
|
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
|
|
255
|
-
if (!route.
|
|
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.
|
|
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
|
|
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
|
}
|
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
import { test } from "vitest";
|
|
2
|
+
|
|
3
|
+
// Simple mock for testing template functionality without full Alepha setup
|
|
4
|
+
class MockReactServerProvider {
|
|
5
|
+
protected readonly env = { REACT_ROOT_ID: "root" };
|
|
6
|
+
protected readonly ROOT_DIV_REGEX = new RegExp(
|
|
7
|
+
`<div([^>]*)\\s+id=["']${this.env.REACT_ROOT_ID}["']([^>]*)>(.*?)<\\/div>`,
|
|
8
|
+
"is",
|
|
9
|
+
);
|
|
10
|
+
protected preprocessedTemplate: any = null;
|
|
11
|
+
|
|
12
|
+
public preprocessTemplate(template: string) {
|
|
13
|
+
// Find the body close tag for script injection
|
|
14
|
+
const bodyCloseMatch = template.match(/<\/body>/i);
|
|
15
|
+
const bodyCloseIndex = bodyCloseMatch?.index ?? template.length;
|
|
16
|
+
|
|
17
|
+
const beforeScript = template.substring(0, bodyCloseIndex);
|
|
18
|
+
const afterScript = template.substring(bodyCloseIndex);
|
|
19
|
+
|
|
20
|
+
// Check if there's an existing root div
|
|
21
|
+
const rootDivMatch = beforeScript.match(this.ROOT_DIV_REGEX);
|
|
22
|
+
|
|
23
|
+
if (rootDivMatch) {
|
|
24
|
+
// Split around the existing root div content
|
|
25
|
+
const beforeDiv = beforeScript.substring(0, rootDivMatch.index!);
|
|
26
|
+
const afterDivStart = rootDivMatch.index! + rootDivMatch[0].length;
|
|
27
|
+
const afterDiv = beforeScript.substring(afterDivStart);
|
|
28
|
+
|
|
29
|
+
const beforeApp = `${beforeDiv}<div${rootDivMatch[1]} id="${this.env.REACT_ROOT_ID}"${rootDivMatch[2]}>`;
|
|
30
|
+
const afterApp = `</div>${afterDiv}`;
|
|
31
|
+
|
|
32
|
+
return { beforeApp, afterApp, beforeScript: "", afterScript };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// No existing root div, find body tag to inject new div
|
|
36
|
+
const bodyMatch = beforeScript.match(/<body([^>]*)>/i);
|
|
37
|
+
if (bodyMatch) {
|
|
38
|
+
const beforeBody = beforeScript.substring(
|
|
39
|
+
0,
|
|
40
|
+
bodyMatch.index! + bodyMatch[0].length,
|
|
41
|
+
);
|
|
42
|
+
const afterBody = beforeScript.substring(
|
|
43
|
+
bodyMatch.index! + bodyMatch[0].length,
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
const beforeApp = `${beforeBody}<div id="${this.env.REACT_ROOT_ID}">`;
|
|
47
|
+
const afterApp = `</div>${afterBody}`;
|
|
48
|
+
|
|
49
|
+
return { beforeApp, afterApp, beforeScript: "", afterScript };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Fallback: no body tag found, just wrap everything
|
|
53
|
+
return {
|
|
54
|
+
beforeApp: `<div id="${this.env.REACT_ROOT_ID}">`,
|
|
55
|
+
afterApp: `</div>`,
|
|
56
|
+
beforeScript,
|
|
57
|
+
afterScript,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
public fillTemplate(response: { html: string }, app: string, script: string) {
|
|
62
|
+
if (!this.preprocessedTemplate) {
|
|
63
|
+
// Fallback to old logic if preprocessing failed
|
|
64
|
+
this.preprocessedTemplate = this.preprocessTemplate(response.html);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Pure concatenation - no regex replacements needed
|
|
68
|
+
response.html =
|
|
69
|
+
this.preprocessedTemplate.beforeApp +
|
|
70
|
+
app +
|
|
71
|
+
this.preprocessedTemplate.afterApp +
|
|
72
|
+
script +
|
|
73
|
+
this.preprocessedTemplate.afterScript;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const setup = (env?: any) => {
|
|
78
|
+
const provider = new MockReactServerProvider();
|
|
79
|
+
if (env?.REACT_ROOT_ID) {
|
|
80
|
+
(provider as any).env.REACT_ROOT_ID = env.REACT_ROOT_ID;
|
|
81
|
+
(provider as any).ROOT_DIV_REGEX = new RegExp(
|
|
82
|
+
`<div([^>]*)\\s+id=["']${env.REACT_ROOT_ID}["']([^>]*)>(.*?)<\\/div>`,
|
|
83
|
+
"is",
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
return { provider };
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
test("ReactServerProvider - preprocessTemplate with existing root div", async ({
|
|
90
|
+
expect,
|
|
91
|
+
}) => {
|
|
92
|
+
const { provider } = setup();
|
|
93
|
+
|
|
94
|
+
const template = `<!DOCTYPE html>
|
|
95
|
+
<html>
|
|
96
|
+
<head><title>Test</title></head>
|
|
97
|
+
<body>
|
|
98
|
+
<div id="root">existing content</div>
|
|
99
|
+
</body>
|
|
100
|
+
</html>`;
|
|
101
|
+
|
|
102
|
+
const preprocessed = provider.preprocessTemplate(template);
|
|
103
|
+
|
|
104
|
+
expect(preprocessed.beforeApp).toBe(
|
|
105
|
+
`<!DOCTYPE html>
|
|
106
|
+
<html>
|
|
107
|
+
<head><title>Test</title></head>
|
|
108
|
+
<body>
|
|
109
|
+
<div id="root">`,
|
|
110
|
+
);
|
|
111
|
+
expect(preprocessed.afterApp).toBe(`</div>
|
|
112
|
+
`);
|
|
113
|
+
expect(preprocessed.beforeScript).toBe("");
|
|
114
|
+
expect(preprocessed.afterScript).toBe(`</body>
|
|
115
|
+
</html>`);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test("ReactServerProvider - preprocessTemplate without root div", async ({
|
|
119
|
+
expect,
|
|
120
|
+
}) => {
|
|
121
|
+
const { provider } = setup();
|
|
122
|
+
|
|
123
|
+
const template = `<!DOCTYPE html>
|
|
124
|
+
<html>
|
|
125
|
+
<head><title>Test</title></head>
|
|
126
|
+
<body>
|
|
127
|
+
<h1>Welcome</h1>
|
|
128
|
+
</body>
|
|
129
|
+
</html>`;
|
|
130
|
+
|
|
131
|
+
const preprocessed = provider.preprocessTemplate(template);
|
|
132
|
+
|
|
133
|
+
expect(preprocessed.beforeApp).toBe(
|
|
134
|
+
`<!DOCTYPE html>
|
|
135
|
+
<html>
|
|
136
|
+
<head><title>Test</title></head>
|
|
137
|
+
<body><div id="root">`,
|
|
138
|
+
);
|
|
139
|
+
expect(preprocessed.afterApp).toBe(`</div>
|
|
140
|
+
<h1>Welcome</h1>
|
|
141
|
+
`);
|
|
142
|
+
expect(preprocessed.beforeScript).toBe("");
|
|
143
|
+
expect(preprocessed.afterScript).toBe(`</body>
|
|
144
|
+
</html>`);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test("ReactServerProvider - preprocessTemplate with custom root ID", async ({
|
|
148
|
+
expect,
|
|
149
|
+
}) => {
|
|
150
|
+
const { provider } = setup({ REACT_ROOT_ID: "app" });
|
|
151
|
+
|
|
152
|
+
const template = `<!DOCTYPE html>
|
|
153
|
+
<html>
|
|
154
|
+
<head><title>Test</title></head>
|
|
155
|
+
<body>
|
|
156
|
+
<div id="app">existing content</div>
|
|
157
|
+
</body>
|
|
158
|
+
</html>`;
|
|
159
|
+
|
|
160
|
+
const preprocessed = provider.preprocessTemplate(template);
|
|
161
|
+
|
|
162
|
+
expect(preprocessed.beforeApp).toBe(
|
|
163
|
+
`<!DOCTYPE html>
|
|
164
|
+
<html>
|
|
165
|
+
<head><title>Test</title></head>
|
|
166
|
+
<body>
|
|
167
|
+
<div id="app">`,
|
|
168
|
+
);
|
|
169
|
+
expect(preprocessed.afterApp).toBe(`</div>
|
|
170
|
+
`);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
test("ReactServerProvider - preprocessTemplate with root div with attributes", async ({
|
|
174
|
+
expect,
|
|
175
|
+
}) => {
|
|
176
|
+
const { provider } = setup();
|
|
177
|
+
|
|
178
|
+
const template = `<!DOCTYPE html>
|
|
179
|
+
<html>
|
|
180
|
+
<body>
|
|
181
|
+
<div class="container" id="root" data-test="true">existing content</div>
|
|
182
|
+
</body>
|
|
183
|
+
</html>`;
|
|
184
|
+
|
|
185
|
+
const preprocessed = provider.preprocessTemplate(template);
|
|
186
|
+
|
|
187
|
+
expect(preprocessed.beforeApp).toBe(
|
|
188
|
+
`<!DOCTYPE html>
|
|
189
|
+
<html>
|
|
190
|
+
<body>
|
|
191
|
+
<div class="container" id="root" data-test="true">`,
|
|
192
|
+
);
|
|
193
|
+
expect(preprocessed.afterApp).toBe(`</div>
|
|
194
|
+
`);
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
test("ReactServerProvider - preprocessTemplate fallback (no body tag)", async ({
|
|
198
|
+
expect,
|
|
199
|
+
}) => {
|
|
200
|
+
const { provider } = setup();
|
|
201
|
+
|
|
202
|
+
const template = `<html><div>content</div></html>`;
|
|
203
|
+
|
|
204
|
+
const preprocessed = provider.preprocessTemplate(template);
|
|
205
|
+
|
|
206
|
+
expect(preprocessed.beforeApp).toBe(`<div id="root">`);
|
|
207
|
+
expect(preprocessed.afterApp).toBe(`</div>`);
|
|
208
|
+
expect(preprocessed.beforeScript).toBe(`<html><div>content</div></html>`);
|
|
209
|
+
expect(preprocessed.afterScript).toBe(``);
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
test("ReactServerProvider - fillTemplate concatenation", async ({ expect }) => {
|
|
213
|
+
const { provider } = setup();
|
|
214
|
+
|
|
215
|
+
const template = `<!DOCTYPE html>
|
|
216
|
+
<html>
|
|
217
|
+
<head><title>Test</title></head>
|
|
218
|
+
<body>
|
|
219
|
+
<div id="root">existing</div>
|
|
220
|
+
</body>
|
|
221
|
+
</html>`;
|
|
222
|
+
|
|
223
|
+
// Preprocess the template
|
|
224
|
+
const preprocessed = provider.preprocessTemplate(template);
|
|
225
|
+
(provider as any).preprocessedTemplate = preprocessed;
|
|
226
|
+
|
|
227
|
+
const response = { html: "" };
|
|
228
|
+
const app = "<h1>Hello World</h1>";
|
|
229
|
+
const script = '<script>window.__ssr={"test":true}</script>';
|
|
230
|
+
|
|
231
|
+
provider.fillTemplate(response, app, script);
|
|
232
|
+
|
|
233
|
+
const expected = `<!DOCTYPE html>
|
|
234
|
+
<html>
|
|
235
|
+
<head><title>Test</title></head>
|
|
236
|
+
<body>
|
|
237
|
+
<div id="root"><h1>Hello World</h1></div>
|
|
238
|
+
<script>window.__ssr={"test":true}</script></body>
|
|
239
|
+
</html>`;
|
|
240
|
+
|
|
241
|
+
expect(response.html).toBe(expected);
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
test("ReactServerProvider - fillTemplate without preprocessed template (fallback)", async ({
|
|
245
|
+
expect,
|
|
246
|
+
}) => {
|
|
247
|
+
const { provider } = setup();
|
|
248
|
+
|
|
249
|
+
const template = `<!DOCTYPE html>
|
|
250
|
+
<html>
|
|
251
|
+
<body>
|
|
252
|
+
<div id="root">existing</div>
|
|
253
|
+
</body>
|
|
254
|
+
</html>`;
|
|
255
|
+
|
|
256
|
+
const response = { html: template };
|
|
257
|
+
const app = "<h1>Hello World</h1>";
|
|
258
|
+
const script = '<script>window.__ssr={"test":true}</script>';
|
|
259
|
+
|
|
260
|
+
// Don't set preprocessedTemplate to test fallback
|
|
261
|
+
(provider as any).preprocessedTemplate = null;
|
|
262
|
+
|
|
263
|
+
provider.fillTemplate(response, app, script);
|
|
264
|
+
|
|
265
|
+
const expected = `<!DOCTYPE html>
|
|
266
|
+
<html>
|
|
267
|
+
<body>
|
|
268
|
+
<div id="root"><h1>Hello World</h1></div>
|
|
269
|
+
<script>window.__ssr={"test":true}</script></body>
|
|
270
|
+
</html>`;
|
|
271
|
+
|
|
272
|
+
expect(response.html).toBe(expected);
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
test("ReactServerProvider - fillTemplate performance comparison", async ({
|
|
276
|
+
expect,
|
|
277
|
+
}) => {
|
|
278
|
+
const { provider } = setup();
|
|
279
|
+
|
|
280
|
+
const template = `<!DOCTYPE html>
|
|
281
|
+
<html>
|
|
282
|
+
<head><title>Performance Test</title></head>
|
|
283
|
+
<body>
|
|
284
|
+
<div id="root">existing content</div>
|
|
285
|
+
<script>console.log('existing');</script>
|
|
286
|
+
</body>
|
|
287
|
+
</html>`;
|
|
288
|
+
|
|
289
|
+
const app = "<div><h1>Hello World</h1><p>This is a test</p></div>";
|
|
290
|
+
const script = '<script>window.__ssr={"data":"value","count":42}</script>';
|
|
291
|
+
|
|
292
|
+
// Test with preprocessing (should be faster)
|
|
293
|
+
const preprocessed = provider.preprocessTemplate(template);
|
|
294
|
+
(provider as any).preprocessedTemplate = preprocessed;
|
|
295
|
+
|
|
296
|
+
const response1 = { html: "" };
|
|
297
|
+
const start1 = performance.now();
|
|
298
|
+
provider.fillTemplate(response1, app, script);
|
|
299
|
+
const time1 = performance.now() - start1;
|
|
300
|
+
|
|
301
|
+
// Test fallback without preprocessing (should work but potentially slower)
|
|
302
|
+
(provider as any).preprocessedTemplate = null;
|
|
303
|
+
const response2 = { html: template };
|
|
304
|
+
const start2 = performance.now();
|
|
305
|
+
provider.fillTemplate(response2, app, script);
|
|
306
|
+
const time2 = performance.now() - start2;
|
|
307
|
+
|
|
308
|
+
// Both should produce the same result
|
|
309
|
+
expect(response1.html).toBe(response2.html);
|
|
310
|
+
|
|
311
|
+
// The result should contain the app content
|
|
312
|
+
expect(response1.html).toContain("<h1>Hello World</h1>");
|
|
313
|
+
expect(response1.html).toContain(
|
|
314
|
+
'<script>window.__ssr={"data":"value","count":42}</script>',
|
|
315
|
+
);
|
|
316
|
+
});
|