@mandujs/core 0.54.2 → 0.54.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.
@@ -18,14 +18,38 @@ import fs from "fs/promises";
18
18
  import { watch as fsWatch, type FSWatcher } from "fs";
19
19
  import { withPerf } from "../perf";
20
20
 
21
- /**
22
- * Tailwind CLI 실행 명령어를 결정한다.
23
- * Windows에서 Bun.spawn은 PATH 기반 명령어 해석이 불안정하므로 (#152)
24
- * process.execPath (절대 경로)를 사용해 안정적으로 실행한다.
25
- */
26
- function getTailwindCommand(args: string[]): string[] {
27
- return [process.execPath, "x", ...args];
28
- }
21
+ /**
22
+ * Tailwind CLI 실행 명령어를 결정한다.
23
+ * Windows에서 Bun.spawn은 PATH 기반 명령어 해석이 불안정하므로 (#152)
24
+ * `bun run mandu` 환경에서는 process.execPath (bun 절대 경로)를 사용한다.
25
+ * Standalone Mandu binary에서는 process.execPath가 mandu.exe를 가리키므로
26
+ * `mandu x @tailwindcss/cli`로 오해석된다. 이 경우 PATH에서 Bun을 찾는다.
27
+ */
28
+ function isBunExecutable(executablePath: string | undefined): boolean {
29
+ if (!executablePath) return false;
30
+ const base = path.basename(executablePath).toLowerCase();
31
+ return base === "bun" || base === "bun.exe";
32
+ }
33
+
34
+ type WhichExecutable = (command: string) => string | null | undefined;
35
+
36
+ function resolveBunExecutable(
37
+ execPath = process.execPath,
38
+ which: WhichExecutable = (command) => Bun.which(command),
39
+ ): string {
40
+ if (isBunExecutable(execPath)) return execPath;
41
+ const fromPath = which("bun");
42
+ if (fromPath) return fromPath;
43
+ return process.platform === "win32" ? "bun.exe" : "bun";
44
+ }
45
+
46
+ function getTailwindCommand(
47
+ args: string[],
48
+ execPath = process.execPath,
49
+ which?: WhichExecutable,
50
+ ): string[] {
51
+ return [resolveBunExecutable(execPath, which), "x", ...args];
52
+ }
29
53
 
30
54
  // ========== Types ==========
31
55
 
@@ -320,7 +344,13 @@ export function getCSSServerPath(): string {
320
344
  /**
321
345
  * CSS 링크 태그 생성
322
346
  */
323
- export function generateCSSLinkTag(isDev: boolean = false): string {
324
- const cacheBust = isDev ? `?t=${Date.now()}` : "";
325
- return `<link rel="stylesheet" href="${SERVER_CSS_PATH}${cacheBust}">`;
326
- }
347
+ export function generateCSSLinkTag(isDev: boolean = false): string {
348
+ const cacheBust = isDev ? `?t=${Date.now()}` : "";
349
+ return `<link rel="stylesheet" href="${SERVER_CSS_PATH}${cacheBust}">`;
350
+ }
351
+
352
+ export const __private = {
353
+ getTailwindCommand,
354
+ isBunExecutable,
355
+ resolveBunExecutable,
356
+ };
@@ -28,7 +28,8 @@
28
28
  *
29
29
  * URL safety model (applies to `shared.runtime`, `shared.vendor`,
30
30
  * `shared.router`, `shared.fastRefresh.glue`, `shared.fastRefresh.runtime`,
31
- * `bundles[].js`, `bundles[].css`, `islands[].js`, `importMap.imports[*]`):
31
+ * `bundles[].js`, `bundles[].css`, `islands[].js`, `partials[].js`,
32
+ * `importMap.imports[*]`):
32
33
  *
33
34
  * ALLOW: absolute paths rooted at `/.mandu/client/` ending in `.js` or `.css`.
34
35
  * The bundler itself only ever emits this shape.
@@ -161,15 +162,20 @@ const BundleEntrySchema = z.object({
161
162
  priority: PrioritySchema,
162
163
  });
163
164
 
164
- const IslandEntrySchema = z.object({
165
- js: safeManduUrl("islands[].js"),
166
- route: z.string().min(1),
167
- priority: PrioritySchema,
168
- });
169
-
170
- const FastRefreshSchema = z.object({
171
- runtime: safeManduUrl("shared.fastRefresh.runtime"),
172
- glue: safeManduUrl("shared.fastRefresh.glue"),
165
+ const IslandEntrySchema = z.object({
166
+ js: safeManduUrl("islands[].js"),
167
+ route: z.string().min(1),
168
+ priority: PrioritySchema,
169
+ });
170
+
171
+ const PartialEntrySchema = z.object({
172
+ js: safeManduUrl("partials[].js"),
173
+ priority: PrioritySchema,
174
+ });
175
+
176
+ const FastRefreshSchema = z.object({
177
+ runtime: safeManduUrl("shared.fastRefresh.runtime"),
178
+ glue: safeManduUrl("shared.fastRefresh.glue"),
173
179
  });
174
180
 
175
181
  const SharedSchema = z.object({
@@ -219,10 +225,11 @@ export const BundleManifestSchema = z
219
225
  .object({
220
226
  version: z.number().int().min(1),
221
227
  buildTime: z.string().min(1),
222
- env: z.enum(["development", "production"]),
223
- bundles: z.record(z.string(), BundleEntrySchema),
224
- islands: z.record(z.string(), IslandEntrySchema).optional(),
225
- shared: SharedSchema,
228
+ env: z.enum(["development", "production"]),
229
+ bundles: z.record(z.string(), BundleEntrySchema),
230
+ islands: z.record(z.string(), IslandEntrySchema).optional(),
231
+ partials: z.record(z.string(), PartialEntrySchema).optional(),
232
+ shared: SharedSchema,
226
233
  importMap: ImportMapSchema.optional(),
227
234
  })
228
235
  .strict();
@@ -55,18 +55,28 @@ export interface BundleManifest {
55
55
  priority: "immediate" | "visible" | "idle" | "interaction";
56
56
  }
57
57
  >;
58
- /** Per-island bundles (code splitting: each island file gets its own JS bundle) */
59
- islands?: Record<
60
- string,
61
- {
62
- /** JavaScript bundle path */
58
+ /** Per-island bundles (code splitting: each island file gets its own JS bundle) */
59
+ islands?: Record<
60
+ string,
61
+ {
62
+ /** JavaScript bundle path */
63
63
  js: string;
64
64
  /** Route that owns this island */
65
65
  route: string;
66
66
  /** Hydration priority */
67
- priority: "immediate" | "visible" | "idle" | "interaction";
68
- }
69
- >;
67
+ priority: "immediate" | "visible" | "idle" | "interaction";
68
+ }
69
+ >;
70
+ /** Inline partial bundles (from *.partial.tsx / *.partial.ts files) */
71
+ partials?: Record<
72
+ string,
73
+ {
74
+ /** JavaScript bundle path */
75
+ js: string;
76
+ /** Hydration priority */
77
+ priority: "immediate" | "visible" | "idle" | "interaction";
78
+ }
79
+ >;
70
80
  /** 공유 청크 */
71
81
  shared: {
72
82
  /** Hydration 런타임 */
@@ -113,12 +123,19 @@ export interface BundleStats {
113
123
  }
114
124
 
115
125
  /** Per-island code splitting entry (used by scanIslandFiles) */
116
- export interface IslandFileEntry {
117
- name: string;
118
- filePath: string;
119
- routeId: string;
120
- priority: "immediate" | "visible" | "idle" | "interaction";
121
- }
126
+ export interface IslandFileEntry {
127
+ name: string;
128
+ filePath: string;
129
+ routeId: string;
130
+ priority: "immediate" | "visible" | "idle" | "interaction";
131
+ }
132
+
133
+ /** Inline partial bundle entry (used by scanPartialFiles) */
134
+ export interface PartialFileEntry {
135
+ name: string;
136
+ filePath: string;
137
+ priority: "immediate" | "visible" | "idle" | "interaction";
138
+ }
122
139
 
123
140
  /**
124
141
  * 번들러 옵션
@@ -3,8 +3,9 @@
3
3
  * Hydration을 위한 클라이언트 사이드 컴포넌트 정의
4
4
  */
5
5
 
6
- import type { ReactNode } from "react";
7
- import { getServerData as getGlobalServerData } from "./window-state";
6
+ import type { ReactNode } from "react";
7
+ import { serializeProps } from "./serialize";
8
+ import { getServerData as getGlobalServerData } from "./window-state";
8
9
 
9
10
  /**
10
11
  * Island 정의 타입
@@ -335,26 +336,45 @@ export interface PartialConfig {
335
336
  /**
336
337
  * Partial Island 정의 타입
337
338
  */
338
- export interface PartialDefinition<TProps> {
339
- /** Partial 컴포넌트 */
340
- component: React.ComponentType<TProps>;
341
- /** 초기 props (SSR에서 전달) */
342
- initialProps?: TProps;
343
- /** 하이드레이션 우선순위 */
344
- priority?: "immediate" | "visible" | "idle" | "interaction";
345
- }
339
+ export interface PartialDefinition<TProps> {
340
+ /** Partial 고유 ID. 기본값은 component displayName/name 입니다. */
341
+ id?: string;
342
+ /** Partial 컴포넌트 */
343
+ component: React.ComponentType<TProps>;
344
+ /** 초기 props (SSR에서 전달) */
345
+ initialProps?: TProps;
346
+ /** 하이드레이션 우선순위 */
347
+ priority?: "immediate" | "visible" | "idle" | "interaction";
348
+ /** 명시적 번들 URL. 기본값은 `/.mandu/client/{id}.partial.js` 입니다. */
349
+ src?: string;
350
+ /** 에러 시 표시할 UI */
351
+ errorBoundary?: (error: Error, reset: () => void) => ReactNode;
352
+ /** 로딩 중 표시할 UI */
353
+ loading?: () => ReactNode;
354
+ }
346
355
 
347
356
  /**
348
357
  * 컴파일된 Partial
349
358
  */
350
- export interface CompiledPartial<TProps> {
359
+ export interface CompiledPartial<TProps> {
351
360
  /** Partial 정의 */
352
361
  definition: PartialDefinition<TProps>;
353
362
  /** Mandu Partial 마커 */
354
363
  __mandu_partial: true;
355
364
  /** Partial ID */
356
- __mandu_partial_id?: string;
357
- }
365
+ __mandu_partial_id?: string;
366
+ }
367
+
368
+ function normalizePartialId(id: string): string {
369
+ return id
370
+ .trim()
371
+ .replace(/[^A-Za-z0-9_-]/g, "-")
372
+ .replace(/^-+|-+$/g, "") || "partial";
373
+ }
374
+
375
+ function priorityToHydrate(priority: NonNullable<PartialDefinition<unknown>["priority"]>): string {
376
+ return priority === "immediate" ? "load" : priority;
377
+ }
358
378
 
359
379
  /**
360
380
  * Partial Island 생성
@@ -379,26 +399,56 @@ export interface CompiledPartial<TProps> {
379
399
  * }
380
400
  * ```
381
401
  */
382
- export function partial<TProps extends Record<string, unknown>>(
383
- definition: PartialDefinition<TProps>
384
- ): CompiledPartial<TProps> & {
385
- Render: React.ComponentType<TProps>;
386
- } {
402
+ export function partial<TProps extends Record<string, unknown>>(
403
+ definition: PartialDefinition<TProps>
404
+ ): CompiledPartial<TProps> & {
405
+ Render: React.ComponentType<TProps>;
406
+ } {
387
407
  if (!definition.component) {
388
408
  throw new Error("[Mandu Partial] component is required");
389
409
  }
390
410
 
391
- const compiled: CompiledPartial<TProps> = {
392
- definition,
393
- __mandu_partial: true,
394
- };
395
-
396
- // Render 컴포넌트 생성
397
- const React = require("react");
398
-
399
- const RenderComponent: React.FC<TProps> = (props) => {
400
- return React.createElement(definition.component, props);
401
- };
411
+ const partialId = normalizePartialId(
412
+ definition.id ||
413
+ definition.component.displayName ||
414
+ definition.component.name ||
415
+ "partial",
416
+ );
417
+ const normalizedDefinition: PartialDefinition<TProps> = {
418
+ ...definition,
419
+ id: partialId,
420
+ };
421
+
422
+ const compiled: CompiledPartial<TProps> = {
423
+ definition: normalizedDefinition,
424
+ __mandu_partial: true,
425
+ __mandu_partial_id: partialId,
426
+ };
427
+
428
+ // Render 컴포넌트 생성
429
+ const React = require("react");
430
+
431
+ const RenderComponent: React.FC<TProps> = (props) => {
432
+ const renderProps = Object.keys(props).length > 0
433
+ ? props
434
+ : (normalizedDefinition.initialProps ?? props);
435
+ const priority = normalizedDefinition.priority ?? "visible";
436
+ const bundleSrc = normalizedDefinition.src ?? `/.mandu/client/${partialId}.partial.js`;
437
+
438
+ return React.createElement(
439
+ "div",
440
+ {
441
+ "data-mandu-island": partialId,
442
+ "data-mandu-partial": partialId,
443
+ "data-mandu-src": bundleSrc,
444
+ "data-mandu-priority": priority,
445
+ "data-hydrate": priorityToHydrate(priority),
446
+ "data-props": serializeProps(renderProps),
447
+ style: { display: "contents" },
448
+ },
449
+ React.createElement(normalizedDefinition.component, renderProps),
450
+ );
451
+ };
402
452
 
403
453
  return Object.assign(compiled, { Render: RenderComponent });
404
454
  }
@@ -187,13 +187,19 @@ export function formatConfigGuardResult(result: ConfigGuardResult): string {
187
187
  } else if (!result.lockfileExists) {
188
188
  lines.push("💡 Lockfile 없음");
189
189
  lines.push(" 'mandu lock'으로 생성 권장");
190
- } else {
191
- lines.push("❌ 설정 무결성 검증 실패");
192
-
193
- for (const error of result.errors) {
194
- lines.push(` 🔴 ${error.message}`);
195
- }
196
- }
190
+ } else {
191
+ lines.push("❌ 설정 무결성 검증 실패");
192
+
193
+ for (const error of result.errors) {
194
+ lines.push(` 🔴 ${error.message}`);
195
+ }
196
+
197
+ lines.push("");
198
+ lines.push(" 다음 단계:");
199
+ lines.push(" ↳ 변경 확인: mandu lock --diff");
200
+ lines.push(" ↳ 의도한 변경이면: mandu lock");
201
+ lines.push(" ↳ 패키지 업데이트 직후라면 새 Mandu 기본값으로 lockfile을 갱신하세요.");
202
+ }
197
203
 
198
204
  if (result.warnings.length > 0 && result.lockfileExists) {
199
205
  lines.push("");
@@ -0,0 +1,51 @@
1
+ import type { FSRoutesGuardConfig } from "./types";
2
+
3
+ export const DEFAULT_FS_ROUTES_GUARD_POLICY: FSRoutesGuardConfig = {
4
+ noPageToPage: true,
5
+ pageCanImport: [
6
+ "client/pages",
7
+ "client/widgets",
8
+ "client/features",
9
+ "client/entities",
10
+ "client/shared",
11
+ "shared/contracts",
12
+ "shared/types",
13
+ "shared/utils/client",
14
+ ],
15
+ layoutCanImport: [
16
+ "client/app",
17
+ "client/widgets",
18
+ "client/shared",
19
+ "shared/contracts",
20
+ "shared/types",
21
+ "shared/utils/client",
22
+ ],
23
+ routeCanImport: [
24
+ "server/api",
25
+ "server/application",
26
+ "server/domain",
27
+ "server/infra",
28
+ "server/core",
29
+ "shared/contracts",
30
+ "shared/schema",
31
+ "shared/types",
32
+ "shared/utils/client",
33
+ "shared/utils/server",
34
+ "shared/env",
35
+ ],
36
+ };
37
+
38
+ function clonePolicy(policy: FSRoutesGuardConfig): FSRoutesGuardConfig {
39
+ return {
40
+ noPageToPage: policy.noPageToPage,
41
+ pageCanImport: policy.pageCanImport ? [...policy.pageCanImport] : undefined,
42
+ layoutCanImport: policy.layoutCanImport ? [...policy.layoutCanImport] : undefined,
43
+ routeCanImport: policy.routeCanImport ? [...policy.routeCanImport] : undefined,
44
+ };
45
+ }
46
+
47
+ export function getDefaultFsRoutesGuardPolicy(
48
+ enabled: boolean
49
+ ): FSRoutesGuardConfig | undefined {
50
+ return enabled ? clonePolicy(DEFAULT_FS_ROUTES_GUARD_POLICY) : undefined;
51
+ }
@@ -120,12 +120,17 @@ export {
120
120
  // Architecture Guard - Watcher
121
121
  // ═══════════════════════════════════════════════════════════════════════════
122
122
 
123
- export {
124
- createGuardWatcher,
125
- checkFile,
126
- checkDirectory,
127
- clearAnalysisCache,
128
- } from "./watcher";
123
+ export {
124
+ createGuardWatcher,
125
+ checkFile,
126
+ checkDirectory,
127
+ clearAnalysisCache,
128
+ } from "./watcher";
129
+
130
+ export {
131
+ DEFAULT_FS_ROUTES_GUARD_POLICY,
132
+ getDefaultFsRoutesGuardPolicy,
133
+ } from "./fs-routes-policy";
129
134
 
130
135
  // ═══════════════════════════════════════════════════════════════════════════
131
136
  // Architecture Guard - Presets
package/src/index.ts CHANGED
@@ -21,8 +21,9 @@ export const __MANDU_CORE_VERSION__: string = (() => {
21
21
  }
22
22
  })();
23
23
 
24
- export * from "./spec";
25
- export * from "./runtime";
24
+ export * from "./spec";
25
+ export * from "./agent";
26
+ export * from "./runtime";
26
27
  export * from "./generator";
27
28
  export * from "./guard";
28
29
  export * from "./report";
@@ -0,0 +1,71 @@
1
+ import { readFile } from "fs/promises";
2
+ import path from "path";
3
+ import type { RouteSpec } from "../spec/schema";
4
+
5
+ export function normalizeRouteModulePath(value: string | undefined): string {
6
+ return (value ?? "").replace(/\\/g, "/").replace(/^\.\//, "");
7
+ }
8
+
9
+ export function hasUseClientDirective(source: string): boolean {
10
+ return /^(?:\uFEFF)?\s*(?:(?:\/\/[^\r\n]*(?:\r?\n|$))|(?:\/\*[\s\S]*?\*\/\s*))*["']use client["']\s*;?/.test(source);
11
+ }
12
+
13
+ export function hasUseServerDirective(source: string): boolean {
14
+ return /^(?:\uFEFF)?\s*(?:(?:\/\/[^\r\n]*(?:\r?\n|$))|(?:\/\*[\s\S]*?\*\/\s*))*["']use server["']\s*;?/.test(source);
15
+ }
16
+
17
+ export function clientModuleIsRouteComponent(route: RouteSpec, clientModule = route.clientModule): boolean {
18
+ if (route.kind !== "page" || !clientModule) return false;
19
+
20
+ const client = normalizeRouteModulePath(clientModule);
21
+ return (
22
+ client === normalizeRouteModulePath(route.componentModule) ||
23
+ client === normalizeRouteModulePath(route.module)
24
+ );
25
+ }
26
+
27
+ async function readRouteModule(rootDir: string, modulePath: string): Promise<string | null> {
28
+ try {
29
+ return await readFile(path.resolve(rootDir, modulePath), "utf-8");
30
+ } catch {
31
+ return null;
32
+ }
33
+ }
34
+
35
+ export async function shouldPreserveExistingClientModule(
36
+ route: RouteSpec,
37
+ clientModule: string,
38
+ rootDir: string,
39
+ ): Promise<boolean> {
40
+ const source = await readRouteModule(rootDir, clientModule);
41
+ if (source === null) return false;
42
+ if (hasUseServerDirective(source)) return false;
43
+ if (clientModuleIsRouteComponent(route, clientModule)) {
44
+ return hasUseClientDirective(source);
45
+ }
46
+ return true;
47
+ }
48
+
49
+ export async function validateClientModuleForBrowserBundle(
50
+ route: RouteSpec,
51
+ rootDir: string,
52
+ ): Promise<string | null> {
53
+ if (!route.clientModule) return null;
54
+
55
+ const source = await readRouteModule(rootDir, route.clientModule);
56
+ if (source === null) return null;
57
+
58
+ if (hasUseServerDirective(source)) {
59
+ return `[${route.id}] Client module "${route.clientModule}" has a "use server" directive and cannot be bundled for the browser.`;
60
+ }
61
+
62
+ if (clientModuleIsRouteComponent(route) && !hasUseClientDirective(source)) {
63
+ return (
64
+ `[${route.id}] Route component "${route.clientModule}" is configured as clientModule, ` +
65
+ `but it is a server page (missing "use client"). Mandu will not bundle server pages into client islands. ` +
66
+ `Remove the stale clientModule from .mandu/routes.manifest.json or use a *.partial.tsx / spec/slots/${route.id}.client.tsx client entry.`
67
+ );
68
+ }
69
+
70
+ return null;
71
+ }
@@ -14,10 +14,11 @@ import { DEFAULT_SCANNER_CONFIG } from "./fs-types";
14
14
  import { scanRoutes } from "./fs-scanner";
15
15
  import { loadManduConfig } from "../config";
16
16
  import type { ManduPlugin, ManduHooks } from "../plugins/hooks";
17
- import {
18
- runOnRouteRegistered,
19
- runOnManifestBuilt,
20
- } from "../plugins/runner";
17
+ import {
18
+ runOnRouteRegistered,
19
+ runOnManifestBuilt,
20
+ } from "../plugins/runner";
21
+ import { shouldPreserveExistingClientModule } from "./client-entry";
21
22
 
22
23
  // ═══════════════════════════════════════════════════════════════════════════
23
24
  // Types
@@ -308,10 +309,17 @@ export async function generateManifest(
308
309
  for (const route of manifest.routes) {
309
310
  const prev = existingMap.get(route.id);
310
311
  if (!prev) continue;
311
- // 사용자가 설정한 clientModule/hydration 보존
312
- if (prev.clientModule && !route.clientModule) {
313
- route.clientModule = prev.clientModule;
314
- }
312
+ // 사용자가 설정한 clientModule/hydration 보존.
313
+ // If app/page.tsx used to be a client page and later becomes a server
314
+ // page, preserving the old clientModule would leak the server graph into
315
+ // the browser bundle.
316
+ if (
317
+ prev.clientModule &&
318
+ !route.clientModule &&
319
+ await shouldPreserveExistingClientModule(route, prev.clientModule, rootDir)
320
+ ) {
321
+ route.clientModule = prev.clientModule;
322
+ }
315
323
  if (prev.hydration && !route.hydration) {
316
324
  route.hydration = prev.hydration;
317
325
  }
@@ -28,8 +28,9 @@ import {
28
28
  sortRoutesByPriority,
29
29
  getPatternShape,
30
30
  } from "./fs-patterns";
31
- import { mark, measure } from "../perf";
32
- import { METADATA_ROUTES } from "../routes/types";
31
+ import { mark, measure } from "../perf";
32
+ import { METADATA_ROUTES } from "../routes/types";
33
+ import { hasUseClientDirective } from "./client-entry";
33
34
 
34
35
  // ═══════════════════════════════════════════════════════════════════════════
35
36
  // Scanner Class
@@ -390,7 +391,7 @@ export class FSScanner {
390
391
  }
391
392
  } else if (file.type === "page" && pageFileContent) {
392
393
  // page 파일 자체에서 "use client" 확인
393
- const hasUseClient = /^\s*["']use client["']/m.test(pageFileContent);
394
+ const hasUseClient = hasUseClientDirective(pageFileContent);
394
395
  if (hasUseClient) {
395
396
  clientModule = modulePath;
396
397
  }
@@ -1,6 +1,32 @@
1
1
  import { describe, expect, it } from "bun:test";
2
2
  import React from "react";
3
3
  import { renderPageResponse } from "../page-render-response";
4
+ import type { BundleManifest } from "../../bundler/types";
5
+
6
+ const HYDRATED_MANIFEST: BundleManifest = {
7
+ version: 1,
8
+ buildTime: "2026-05-19T00:00:00.000Z",
9
+ env: "production",
10
+ bundles: {
11
+ home: {
12
+ js: "/.mandu/client/home.island.js",
13
+ dependencies: ["_runtime", "_react"],
14
+ priority: "visible",
15
+ },
16
+ },
17
+ shared: {
18
+ runtime: "/.mandu/client/_runtime.js",
19
+ vendor: "/.mandu/client/_react.js",
20
+ router: "/.mandu/client/_router.js",
21
+ },
22
+ importMap: {
23
+ imports: {
24
+ react: "/.mandu/client/_react.js",
25
+ "react-dom": "/.mandu/client/_react-dom.js",
26
+ "react-dom/client": "/.mandu/client/_react-dom-client.js",
27
+ },
28
+ },
29
+ };
4
30
 
5
31
  describe("runtime page render response orchestration", () => {
6
32
  it("pre-resolves async components on the non-streaming path", async () => {
@@ -51,4 +77,27 @@ describe("runtime page render response orchestration", () => {
51
77
  expect(html).toContain("stream-page");
52
78
  expect(html).toContain("Stream Page");
53
79
  });
80
+
81
+ it("serializes non-streaming loaderData as the route server data exactly once", async () => {
82
+ const response = await renderPageResponse({
83
+ app: React.createElement("main", null, "hydrated-page"),
84
+ useStreaming: false,
85
+ title: "Hydrated Page",
86
+ headTags: "",
87
+ isDev: false,
88
+ routeId: "home",
89
+ routePattern: "/",
90
+ loaderData: { items: ["a", "b"] },
91
+ hydration: { strategy: "island", priority: "visible", preload: false },
92
+ bundleManifest: HYDRATED_MANIFEST,
93
+ transitions: false,
94
+ prefetch: false,
95
+ spa: false,
96
+ devtools: false,
97
+ });
98
+
99
+ const html = await response.text();
100
+ expect(html).toContain('"home":{"serverData":{"items":["a","b"]}');
101
+ expect(html).not.toContain('"serverData":{"home"');
102
+ });
54
103
  });
@@ -84,10 +84,6 @@ function renderNonStreamingPageResponse(
84
84
  app: React.ReactElement,
85
85
  options: PageRenderResponseOptions
86
86
  ): Response {
87
- const serverData = options.loaderData
88
- ? { [options.routeId]: { serverData: options.loaderData } }
89
- : undefined;
90
-
91
87
  return renderSSR(app, {
92
88
  title: options.title,
93
89
  headTags: options.headTags,
@@ -96,7 +92,7 @@ function renderNonStreamingPageResponse(
96
92
  routeId: options.routeId,
97
93
  hydration: options.hydration,
98
94
  bundleManifest: options.bundleManifest,
99
- serverData,
95
+ serverData: options.loaderData,
100
96
  enableClientRouter: true,
101
97
  routePattern: options.routePattern,
102
98
  cssPath: options.cssPath,