@mandujs/core 0.54.2 → 0.54.3

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.
@@ -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
@@ -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,
@@ -46,7 +46,7 @@ export interface SSROptions {
46
46
  title?: string;
47
47
  lang?: string;
48
48
  /** 서버에서 로드한 데이터 (클라이언트로 전달) */
49
- serverData?: Record<string, unknown>;
49
+ serverData?: unknown;
50
50
  /** Hydration 설정 */
51
51
  hydration?: HydrationConfig;
52
52
  /** 번들 매니페스트 */
@@ -255,19 +255,26 @@ function generateHydrationScripts(
255
255
  ? Object.values(manifest.islands).filter((ib) => ib.route === routeId)
256
256
  : [];
257
257
 
258
- if (routeIslands.length > 0) {
259
- for (const ib of routeIslands) {
260
- const cacheBust = `${ib.js}${ib.js.includes('?') ? '&' : '?'}v=${Date.now()}`;
261
- scripts.push(`<link rel="modulepreload" href="${escapeHtmlAttr(cacheBust)}">`);
262
- }
258
+ if (routeIslands.length > 0) {
259
+ for (const ib of routeIslands) {
260
+ const cacheBust = `${ib.js}${ib.js.includes('?') ? '&' : '?'}v=${Date.now()}`;
261
+ scripts.push(`<link rel="modulepreload" href="${escapeHtmlAttr(cacheBust)}">`);
262
+ }
263
263
  } else {
264
264
  // Fallback: route-level bundle (backward compat)
265
265
  const bundle = manifest.bundles[routeId];
266
266
  if (bundle) {
267
267
  const cacheBust = `${bundle.js}${bundle.js.includes('?') ? '&' : '?'}v=${Date.now()}`;
268
- scripts.push(`<link rel="modulepreload" href="${escapeHtmlAttr(cacheBust)}">`);
269
- }
270
- }
268
+ scripts.push(`<link rel="modulepreload" href="${escapeHtmlAttr(cacheBust)}">`);
269
+ }
270
+ }
271
+
272
+ if (manifest.partials) {
273
+ for (const partial of Object.values(manifest.partials)) {
274
+ const cacheBust = `${partial.js}${partial.js.includes('?') ? '&' : '?'}v=${Date.now()}`;
275
+ scripts.push(`<link rel="modulepreload" href="${escapeHtmlAttr(cacheBust)}">`);
276
+ }
277
+ }
271
278
 
272
279
  // Runtime 로드 (hydrateIslands 실행 - dynamic import 사용)
273
280
  if (manifest.shared.runtime) {
@@ -706,12 +713,14 @@ export function renderToHTML(element: ReactElement, options: SSROptions = {}): s
706
713
  const needsHydration =
707
714
  hydration && hydration.strategy !== "none" && routeId && bundleManifest;
708
715
 
709
- if (needsHydration && !islandPreWrapped) {
710
- // v0.8.0: bundleSrc를 data-mandu-src 속성으로 전달 (Runtime이 dynamic import로 로드)
711
- const bundle = bundleManifest.bundles[routeId];
712
- const bundleSrc = bundle?.js;
713
- content = wrapWithIsland(content, routeId, hydration.priority, bundleSrc);
714
- }
716
+ if (needsHydration && !islandPreWrapped) {
717
+ // v0.8.0: bundleSrc를 data-mandu-src 속성으로 전달 (Runtime이 dynamic import로 로드)
718
+ const bundle = bundleManifest.bundles[routeId];
719
+ const bundleSrc = bundle?.js;
720
+ if (bundleSrc) {
721
+ content = wrapWithIsland(content, routeId, hydration.priority, bundleSrc);
722
+ }
723
+ }
715
724
 
716
725
  // Zero-JS 모드: island이 없는 페이지에서는 클라이언트 JS 번들을 전송하지 않음
717
726
  // HMR/DevTools는 dev 환경에서만 유지 (CSS 핫리로드 등)
@@ -722,10 +731,10 @@ export function renderToHTML(element: ReactElement, options: SSROptions = {}): s
722
731
 
723
732
  if (needsHydration) {
724
733
  // 서버 데이터 스크립트 (클라이언트 hydration에서 사용)
725
- if (serverData && routeId) {
726
- const wrappedData = {
727
- [routeId]: {
728
- serverData,
734
+ if (serverData !== undefined && routeId) {
735
+ const wrappedData = {
736
+ [routeId]: {
737
+ serverData,
729
738
  timestamp: Date.now(),
730
739
  },
731
740
  };
@@ -862,11 +871,11 @@ export function renderToHTML(element: ReactElement, options: SSROptions = {}): s
862
871
  /**
863
872
  * Client-side Routing: 현재 라우트 정보 스크립트 생성
864
873
  */
865
- function generateRouteScript(
866
- routeId: string,
867
- pattern: string,
868
- _serverData?: Record<string, unknown>
869
- ): string {
874
+ function generateRouteScript(
875
+ routeId: string,
876
+ pattern: string,
877
+ _serverData?: unknown
878
+ ): string {
870
879
  const routeInfo = {
871
880
  id: routeId,
872
881
  pattern,
@@ -1167,12 +1176,12 @@ export function renderSSR(element: ReactElement, options: SSROptions = {}): Resp
1167
1176
  */
1168
1177
  export async function renderWithHydration(
1169
1178
  element: ReactElement,
1170
- options: SSROptions & {
1171
- routeId: string;
1172
- serverData: Record<string, unknown>;
1173
- hydration: HydrationConfig;
1174
- bundleManifest: BundleManifest;
1175
- }
1179
+ options: SSROptions & {
1180
+ routeId: string;
1181
+ serverData: unknown;
1182
+ hydration: HydrationConfig;
1183
+ bundleManifest: BundleManifest;
1184
+ }
1176
1185
  ): Promise<Response> {
1177
1186
  const html = renderToHTML(element, options);
1178
1187
  // Phase 7.2 R1 Agent C (H1) — same CSP header logic as renderSSR.
@@ -613,13 +613,16 @@ function generateHTMLShell(options: StreamingSSROptions): string {
613
613
  }
614
614
  </style>`;
615
615
 
616
- let islandOpenTag = "";
617
- if (needsHydration) {
618
- const bundle = bundleManifest.bundles[routeId];
619
- const bundleSrc = bundle?.js ? `${bundle.js}?t=${Date.now()}` : "";
620
- const priority = hydration.priority || "visible";
621
- islandOpenTag = `<div data-mandu-island="${escapeHtmlAttr(routeId)}" data-mandu-src="${escapeHtmlAttr(bundleSrc)}" data-mandu-priority="${escapeHtmlAttr(priority)}" style="display:contents">`;
622
- }
616
+ let islandOpenTag = "";
617
+ const hasRouteBundle = !!(needsHydration && bundleManifest.bundles[routeId]?.js);
618
+ if (needsHydration) {
619
+ const bundle = bundleManifest.bundles[routeId];
620
+ const bundleSrc = bundle?.js ? `${bundle.js}?t=${Date.now()}` : "";
621
+ const priority = hydration.priority || "visible";
622
+ if (hasRouteBundle) {
623
+ islandOpenTag = `<div data-mandu-island="${escapeHtmlAttr(routeId)}" data-mandu-src="${escapeHtmlAttr(bundleSrc)}" data-mandu-priority="${escapeHtmlAttr(priority)}" style="display:contents">`;
624
+ }
625
+ }
623
626
 
624
627
  // Phase 7.1 R2 Agent D: Fast Refresh preamble. Must land in <head>
625
628
  // BEFORE any island script evaluates — the stubs it installs for
@@ -697,7 +700,7 @@ function generateHTMLTailContent(options: StreamingSSROptions): string {
697
700
  // 1~8: hydration이 필요한 경우에만 클라이언트 JS 관련 스크립트 삽입
698
701
  if (needsHydration) {
699
702
  // 1. Critical 데이터 스크립트 (즉시 사용 가능)
700
- if (criticalData && routeId) {
703
+ if (criticalData !== undefined && routeId) {
701
704
  const wrappedData = {
702
705
  [routeId]: {
703
706
  serverData: criticalData,
@@ -746,10 +749,16 @@ function generateHTMLTailContent(options: StreamingSSROptions): string {
746
749
 
747
750
  // 6. Island modulepreload
748
751
  const bundle = bundleManifest.bundles[routeId];
749
- if (bundle) {
750
- const cacheBust = `${bundle.js}${bundle.js.includes('?') ? '&' : '?'}v=${Date.now()}`;
751
- scripts.push(`<link rel="modulepreload" href="${escapeHtmlAttr(cacheBust)}">`);
752
- }
752
+ if (bundle) {
753
+ const cacheBust = `${bundle.js}${bundle.js.includes('?') ? '&' : '?'}v=${Date.now()}`;
754
+ scripts.push(`<link rel="modulepreload" href="${escapeHtmlAttr(cacheBust)}">`);
755
+ }
756
+ if (bundleManifest.partials) {
757
+ for (const partial of Object.values(bundleManifest.partials)) {
758
+ const cacheBust = `${partial.js}${partial.js.includes('?') ? '&' : '?'}v=${Date.now()}`;
759
+ scripts.push(`<link rel="modulepreload" href="${escapeHtmlAttr(cacheBust)}">`);
760
+ }
761
+ }
753
762
 
754
763
  // 7. Runtime 로드
755
764
  if (bundleManifest.shared.runtime) {
@@ -783,7 +792,7 @@ function generateHTMLTailContent(options: StreamingSSROptions): string {
783
792
  }
784
793
 
785
794
  // Island wrapper 닫기 (hydration이 필요한 경우)
786
- const islandCloseTag = needsHydration ? "</div>" : "";
795
+ const islandCloseTag = needsHydration && bundleManifest.bundles[routeId]?.js ? "</div>" : "";
787
796
 
788
797
  return `${islandCloseTag}</div>
789
798
  ${scripts.join("\n ")}`;