@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.
Files changed (57) hide show
  1. package/dist/auth/index.browser.js +29 -14
  2. package/dist/auth/index.browser.js.map +1 -1
  3. package/dist/auth/index.js +960 -195
  4. package/dist/auth/index.js.map +1 -1
  5. package/dist/core/index.d.ts +4 -0
  6. package/dist/core/index.d.ts.map +1 -1
  7. package/dist/core/index.js +7 -4
  8. package/dist/core/index.js.map +1 -1
  9. package/dist/head/index.browser.js +59 -19
  10. package/dist/head/index.browser.js.map +1 -1
  11. package/dist/head/index.d.ts +99 -560
  12. package/dist/head/index.d.ts.map +1 -1
  13. package/dist/head/index.js +92 -87
  14. package/dist/head/index.js.map +1 -1
  15. package/dist/router/index.browser.js +30 -15
  16. package/dist/router/index.browser.js.map +1 -1
  17. package/dist/router/index.d.ts +616 -192
  18. package/dist/router/index.d.ts.map +1 -1
  19. package/dist/router/index.js +961 -196
  20. package/dist/router/index.js.map +1 -1
  21. package/package.json +4 -4
  22. package/src/auth/__tests__/$auth.spec.ts +188 -0
  23. package/src/core/__tests__/Router.spec.tsx +169 -0
  24. package/src/core/hooks/useAction.browser.spec.tsx +569 -0
  25. package/src/core/hooks/useAction.ts +11 -0
  26. package/src/form/hooks/useForm.browser.spec.tsx +366 -0
  27. package/src/head/helpers/SeoExpander.spec.ts +203 -0
  28. package/src/head/hooks/useHead.spec.tsx +288 -0
  29. package/src/head/index.ts +11 -28
  30. package/src/head/providers/BrowserHeadProvider.browser.spec.ts +196 -0
  31. package/src/head/providers/BrowserHeadProvider.ts +25 -19
  32. package/src/head/providers/HeadProvider.ts +76 -10
  33. package/src/head/providers/ServerHeadProvider.ts +22 -138
  34. package/src/i18n/__tests__/integration.spec.tsx +239 -0
  35. package/src/i18n/components/Localize.spec.tsx +357 -0
  36. package/src/i18n/hooks/useI18n.browser.spec.tsx +438 -0
  37. package/src/i18n/providers/I18nProvider.spec.ts +389 -0
  38. package/src/router/__tests__/page-head-browser.browser.spec.ts +91 -0
  39. package/src/router/__tests__/page-head.spec.ts +44 -0
  40. package/src/router/__tests__/seo-head.spec.ts +121 -0
  41. package/src/router/atoms/ssrManifestAtom.ts +60 -0
  42. package/src/router/constants/PAGE_PRELOAD_KEY.ts +6 -0
  43. package/src/router/errors/Redirection.ts +1 -1
  44. package/src/router/index.shared.ts +1 -0
  45. package/src/router/index.ts +16 -2
  46. package/src/router/primitives/$page.browser.spec.tsx +702 -0
  47. package/src/router/primitives/$page.spec.tsx +702 -0
  48. package/src/router/primitives/$page.ts +46 -10
  49. package/src/router/providers/ReactBrowserProvider.ts +14 -29
  50. package/src/router/providers/ReactBrowserRouterProvider.ts +5 -0
  51. package/src/router/providers/ReactPageProvider.ts +11 -4
  52. package/src/router/providers/ReactServerProvider.spec.tsx +316 -0
  53. package/src/router/providers/ReactServerProvider.ts +331 -315
  54. package/src/router/providers/ReactServerTemplateProvider.ts +775 -0
  55. package/src/router/providers/SSRManifestProvider.ts +365 -0
  56. package/src/router/services/ReactPageServerService.ts +5 -3
  57. 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 `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
  }
@@ -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
+ });