@mandujs/core 0.54.17 → 0.54.19

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 (46) hide show
  1. package/package.json +3 -1
  2. package/src/agent/__tests__/context.test.ts +94 -25
  3. package/src/agent/context.ts +17 -0
  4. package/src/agent/types.ts +32 -12
  5. package/src/agent/verify.ts +55 -24
  6. package/src/bundler/__snapshots__/build.test.ts.snap +5 -0
  7. package/src/bundler/__tests__/build-runner.ts +130 -17
  8. package/src/bundler/__tests__/client-boundary-transform.test.ts +524 -0
  9. package/src/bundler/__tests__/reverse-import-graph.test.ts +42 -33
  10. package/src/bundler/build.test.ts +478 -9
  11. package/src/bundler/build.ts +424 -746
  12. package/src/bundler/client-boundary-transform.ts +977 -0
  13. package/src/bundler/dev.ts +39 -112
  14. package/src/bundler/fast-refresh-preamble.ts +47 -0
  15. package/src/bundler/index.ts +3 -2
  16. package/src/bundler/manifest-schema.ts +10 -0
  17. package/src/bundler/types.ts +20 -2
  18. package/src/client/__tests__/props-serialization.test.ts +37 -0
  19. package/src/client/hydrate.ts +2 -2
  20. package/src/client/index.ts +1 -1
  21. package/src/client/props-serialization.ts +233 -0
  22. package/src/client/runtime-entry.ts +567 -0
  23. package/src/client/runtime.ts +1 -1
  24. package/src/client/serialize.ts +50 -404
  25. package/src/diagnose/__tests__/checks.test.ts +132 -17
  26. package/src/diagnose/checks.ts +184 -3
  27. package/src/diagnose/run.ts +10 -8
  28. package/src/generator/templates.test.ts +48 -5
  29. package/src/generator/templates.ts +10 -1
  30. package/src/internal/client-boundary.ts +266 -0
  31. package/src/internal/index.ts +2 -1
  32. package/src/router/client-entry.test.ts +154 -29
  33. package/src/router/client-entry.ts +111 -313
  34. package/src/router/fs-routes.test.ts +443 -1
  35. package/src/router/fs-routes.ts +16 -3
  36. package/src/router/fs-scanner.ts +176 -57
  37. package/src/router/fs-types.ts +11 -2
  38. package/src/router/route-source-analyzer.ts +521 -0
  39. package/src/runtime/__tests__/inline-client-hydration.test.ts +104 -1
  40. package/src/runtime/__tests__/page-render-response.test.ts +218 -0
  41. package/src/runtime/handlers.ts +50 -26
  42. package/src/runtime/page-render-response.ts +24 -1
  43. package/src/runtime/server.ts +14 -0
  44. package/src/runtime/ssr.ts +16 -5
  45. package/src/runtime/streaming-ssr.ts +119 -76
  46. package/src/spec/schema.ts +31 -5
@@ -2,6 +2,7 @@ import { describe, expect, it } from "bun:test";
2
2
  import React from "react";
3
3
  import { renderPageResponse } from "../page-render-response";
4
4
  import type { BundleManifest } from "../../bundler/types";
5
+ import { __ManduClientBoundary } from "../../internal/client-boundary";
5
6
 
6
7
  const HYDRATED_MANIFEST: BundleManifest = {
7
8
  version: 1,
@@ -102,6 +103,164 @@ describe("runtime page render response orchestration", () => {
102
103
  expect(html).toContain('data-hydrate="interaction"');
103
104
  });
104
105
 
106
+ it("does not duplicate pre-wrapped streaming islands and preloads split island chunks", async () => {
107
+ const response = await renderPageResponse({
108
+ app: React.createElement(
109
+ "div",
110
+ {
111
+ "data-mandu-island": "home",
112
+ "data-mandu-src": "/.mandu/client/home.island.js?t=prewrapped",
113
+ "data-mandu-priority": "visible",
114
+ "data-hydrate": "visible",
115
+ style: { display: "contents" },
116
+ },
117
+ React.createElement("main", null, "prewrapped-stream-page"),
118
+ ),
119
+ useStreaming: true,
120
+ title: "Prewrapped Stream",
121
+ headTags: "",
122
+ isDev: false,
123
+ routeId: "home",
124
+ routePattern: "/",
125
+ loaderData: undefined,
126
+ hydration: { strategy: "island", priority: "visible", preload: false },
127
+ bundleManifest: {
128
+ ...HYDRATED_MANIFEST,
129
+ islands: {
130
+ "home-widget": {
131
+ route: "home",
132
+ js: "/.mandu/client/home-widget.island.js",
133
+ priority: "visible",
134
+ },
135
+ },
136
+ },
137
+ islandPreWrapped: true,
138
+ transitions: false,
139
+ prefetch: false,
140
+ spa: false,
141
+ devtools: false,
142
+ });
143
+
144
+ const html = await response.text();
145
+ expect(html.match(/data-mandu-island="home"/g)?.length).toBe(1);
146
+ expect(html).toContain("prewrapped-stream-page");
147
+ expect(html).toContain('<link rel="modulepreload" href="/.mandu/client/home-widget.island.js?v=');
148
+ expect(html).not.toContain('<link rel="modulepreload" href="/.mandu/client/home.island.js?v=');
149
+ });
150
+
151
+ it("serializes compiler-owned client boundaries on the streaming path", async () => {
152
+ const routeId = "stream-boundary";
153
+ const manifest: BundleManifest = {
154
+ ...HYDRATED_MANIFEST,
155
+ bundles: {},
156
+ boundaries: {
157
+ "stream-boundary--0": {
158
+ route: routeId,
159
+ js: "/.mandu/client/stream-boundary--0.boundary.js",
160
+ module: "src/client/Counter.client.tsx",
161
+ exportName: "Counter",
162
+ priority: "visible",
163
+ hydrate: "visible",
164
+ },
165
+ },
166
+ };
167
+
168
+ const response = await renderPageResponse({
169
+ app: React.createElement(
170
+ "main",
171
+ null,
172
+ React.createElement(__ManduClientBoundary, {
173
+ routeId,
174
+ boundaryId: "stream-boundary--0",
175
+ module: "src/client/Counter.client.tsx",
176
+ exportName: "Counter",
177
+ hydrate: "visible",
178
+ props: { count: 7 },
179
+ }),
180
+ ),
181
+ useStreaming: true,
182
+ title: "Stream Boundary",
183
+ headTags: "",
184
+ isDev: false,
185
+ routeId,
186
+ routePattern: "/stream-boundary",
187
+ loaderData: undefined,
188
+ hydration: { strategy: "island", priority: "visible", preload: false },
189
+ bundleManifest: manifest,
190
+ transitions: false,
191
+ prefetch: false,
192
+ spa: false,
193
+ devtools: false,
194
+ });
195
+
196
+ const html = await response.text();
197
+ expect(html).toContain('data-mandu-island="stream-boundary--0"');
198
+ expect(html).toContain('data-mandu-boundary-id="stream-boundary--0"');
199
+ expect(html).toContain('data-mandu-client-export="Counter"');
200
+ expect(html).toContain('data-mandu-src="/.mandu/client/stream-boundary--0.boundary.js?t=');
201
+ expect(html).toContain('type="application/json" data-mandu-props="stream-boundary--0"');
202
+ expect(html).toContain('"count":7');
203
+ expect(html).toContain('<link rel="modulepreload" href="/.mandu/client/stream-boundary--0.boundary.js?v=');
204
+ expect(html).not.toContain('data-mandu-island="stream-boundary" data-mandu-src=');
205
+ });
206
+
207
+ it("keeps boundary context across async streaming server components", async () => {
208
+ const routeId = "async-stream-boundary";
209
+ const manifest: BundleManifest = {
210
+ ...HYDRATED_MANIFEST,
211
+ bundles: {},
212
+ boundaries: {
213
+ "async-stream-boundary--0": {
214
+ route: routeId,
215
+ js: "/.mandu/client/async-stream-boundary--0.boundary.js",
216
+ module: "src/client/AsyncCounter.client.tsx",
217
+ exportName: "AsyncCounter",
218
+ priority: "visible",
219
+ hydrate: "visible",
220
+ },
221
+ },
222
+ };
223
+
224
+ async function AsyncPage() {
225
+ await new Promise((resolve) => setTimeout(resolve, 1));
226
+ return React.createElement(
227
+ "main",
228
+ null,
229
+ React.createElement(__ManduClientBoundary, {
230
+ routeId,
231
+ boundaryId: "async-stream-boundary--0",
232
+ module: "src/client/AsyncCounter.client.tsx",
233
+ exportName: "AsyncCounter",
234
+ hydrate: "visible",
235
+ props: { count: 11 },
236
+ }),
237
+ );
238
+ }
239
+
240
+ const response = await renderPageResponse({
241
+ app: React.createElement(AsyncPage),
242
+ useStreaming: true,
243
+ title: "Async Stream Boundary",
244
+ headTags: "",
245
+ isDev: false,
246
+ routeId,
247
+ routePattern: "/async-stream-boundary",
248
+ loaderData: undefined,
249
+ hydration: { strategy: "island", priority: "visible", preload: false },
250
+ bundleManifest: manifest,
251
+ transitions: false,
252
+ prefetch: false,
253
+ spa: false,
254
+ devtools: false,
255
+ });
256
+
257
+ const html = await response.text();
258
+ expect(html).toContain('data-mandu-island="async-stream-boundary--0"');
259
+ expect(html).toContain('data-mandu-src="/.mandu/client/async-stream-boundary--0.boundary.js?t=');
260
+ expect(html).toContain('data-mandu-props="async-stream-boundary--0"');
261
+ expect(html).toContain('"count":11');
262
+ });
263
+
105
264
  it("serializes non-streaming loaderData as the route server data exactly once", async () => {
106
265
  const response = await renderPageResponse({
107
266
  app: React.createElement("main", null, "hydrated-page"),
@@ -175,6 +334,8 @@ describe("runtime page render response orchestration", () => {
175
334
  src: "/.mandu/client/candidates-$id.island.js",
176
335
  priority: "immediate",
177
336
  component: PledgeAccordion,
337
+ legacyRuntimeScan: true,
338
+ sourceFile: "app/candidates/[id]/page.tsx",
178
339
  },
179
340
  });
180
341
 
@@ -188,6 +349,61 @@ describe("runtime page render response orchestration", () => {
188
349
  expect(html).not.toContain('data-mandu-island="candidates-$id"');
189
350
  });
190
351
 
352
+ it("serializes inline client props through a sync server wrapper fallback", async () => {
353
+ function ClientWidget({ label }: { label: string }) {
354
+ return React.createElement("button", null, label);
355
+ }
356
+
357
+ function ServerWrapper({ label }: { label: string }) {
358
+ return React.createElement("section", null, React.createElement(ClientWidget, { label }));
359
+ }
360
+
361
+ function WrappedPage() {
362
+ return React.createElement("main", null, React.createElement(ServerWrapper, { label: "wrapped" }));
363
+ }
364
+
365
+ const response = await renderPageResponse({
366
+ app: React.createElement(WrappedPage),
367
+ useStreaming: false,
368
+ title: "Wrapped",
369
+ headTags: "",
370
+ isDev: false,
371
+ routeId: "wrapped-fallback",
372
+ routePattern: "/wrapped",
373
+ hydration: { strategy: "island", priority: "visible", preload: false },
374
+ bundleManifest: {
375
+ ...HYDRATED_MANIFEST,
376
+ bundles: {
377
+ "wrapped-fallback": {
378
+ js: "/.mandu/client/wrapped-fallback.island.js",
379
+ dependencies: ["_runtime", "_react"],
380
+ priority: "visible",
381
+ },
382
+ },
383
+ },
384
+ loaderData: undefined,
385
+ transitions: false,
386
+ prefetch: false,
387
+ spa: false,
388
+ devtools: false,
389
+ inlineClientHydration: {
390
+ routeId: "wrapped-fallback",
391
+ src: "/.mandu/client/wrapped-fallback.island.js",
392
+ priority: "visible",
393
+ component: ClientWidget,
394
+ legacyRuntimeScan: true,
395
+ sourceFile: "app/wrapped/page.tsx",
396
+ },
397
+ });
398
+
399
+ const html = await response.text();
400
+ expect(html).toContain('data-mandu-island="wrapped-fallback--0"');
401
+ expect(html).toContain('data-mandu-src="/.mandu/client/wrapped-fallback.island.js"');
402
+ expect(html).toContain('data-mandu-props="wrapped-fallback--0"');
403
+ expect(html).toContain('"label":"wrapped"');
404
+ expect(html).not.toContain('data-mandu-island="wrapped-fallback"');
405
+ });
406
+
191
407
  it("does not invoke sync function components while looking for inline client targets", async () => {
192
408
  function ClientWidget({ label }: { label: string }) {
193
409
  return React.createElement("button", null, label);
@@ -287,6 +503,8 @@ describe("runtime page render response orchestration", () => {
287
503
  src: "/.mandu/client/ordered.island.js",
288
504
  priority: "visible",
289
505
  component: ClientWidget,
506
+ legacyRuntimeScan: true,
507
+ sourceFile: "app/ordered/page.tsx",
290
508
  },
291
509
  });
292
510
 
@@ -23,7 +23,7 @@ import {
23
23
  type PageRegistration,
24
24
  } from "./server";
25
25
  import { registerManifest } from "./registry";
26
- import { needsHydration, type RoutesManifest } from "../spec/schema";
26
+ import { needsHydration, type RouteClientBoundary, type RoutesManifest } from "../spec/schema";
27
27
 
28
28
  type RouteModule = Record<string, unknown>;
29
29
 
@@ -84,7 +84,7 @@ function createMethodDispatcher(module: RouteModule, routeId: string) {
84
84
  };
85
85
  }
86
86
 
87
- export interface RegisterHandlersOptions {
87
+ export interface RegisterHandlersOptions {
88
88
  /**
89
89
  * Module import function (dev: importFresh, start: standard import).
90
90
  * The optional `opts.changedFile` is forwarded into Phase 7.0 B5's
@@ -92,7 +92,17 @@ export interface RegisterHandlersOptions {
92
92
  * module's import graph, `importFn` returns the cached bundle in ~0.1 ms
93
93
  * instead of re-running Bun.build.
94
94
  */
95
- importFn: (modulePath: string, opts?: { changedFile?: string }) => Promise<unknown>;
95
+ importFn: (
96
+ modulePath: string,
97
+ opts?: {
98
+ changedFile?: string;
99
+ clientBoundaryTransform?: {
100
+ routeId: string;
101
+ hydrate?: string;
102
+ boundaries?: RouteClientBoundary[];
103
+ };
104
+ },
105
+ ) => Promise<unknown>;
96
106
  /** Set for tracking already registered layout paths */
97
107
  registeredLayouts: Set<string>;
98
108
  /** Clear layout cache on reload */
@@ -115,9 +125,23 @@ export async function registerManifestHandlers(
115
125
  rootDir: string,
116
126
  options: RegisterHandlersOptions
117
127
  ): Promise<void> {
118
- const { importFn, registeredLayouts, isReload = false, changedFile } = options;
119
- const importOpts: { changedFile?: string } | undefined =
120
- changedFile !== undefined ? { changedFile } : undefined;
128
+ const { importFn, registeredLayouts, isReload = false, changedFile } = options;
129
+ const baseImportOpts: { changedFile?: string } | undefined =
130
+ changedFile !== undefined ? { changedFile } : undefined;
131
+ const importOptsForRoute = (route?: RoutesManifest["routes"][number]) => {
132
+ const boundaryTransform = route?.kind === "page" && route.boundaries?.length
133
+ ? {
134
+ routeId: route.id,
135
+ hydrate: route.hydration?.priority ?? "visible",
136
+ boundaries: route.boundaries,
137
+ }
138
+ : undefined;
139
+ if (!baseImportOpts && !boundaryTransform) return undefined;
140
+ return {
141
+ ...baseImportOpts,
142
+ ...(boundaryTransform ? { clientBoundaryTransform: boundaryTransform } : {}),
143
+ };
144
+ };
121
145
 
122
146
  if (isReload) {
123
147
  registeredLayouts.clear();
@@ -134,19 +158,19 @@ export async function registerManifestHandlers(
134
158
  // runtime dispatcher invokes the default export on each request
135
159
  // so HMR reloads pick up edits automatically (same pattern as
136
160
  // API routes below).
137
- if (route.kind === "metadata") {
138
- const modulePath = path.resolve(rootDir, route.module);
139
- registerMetadataHandler(route.id, async () => {
140
- return importFn(modulePath, importOpts);
141
- });
142
- console.log(` 🗺️ Metadata: ${route.pattern} -> ${route.id}`);
143
- continue;
161
+ if (route.kind === "metadata") {
162
+ const modulePath = path.resolve(rootDir, route.module);
163
+ registerMetadataHandler(route.id, async () => {
164
+ return importFn(modulePath, importOptsForRoute(route));
165
+ });
166
+ console.log(` 🗺️ Metadata: ${route.pattern} -> ${route.id}`);
167
+ continue;
144
168
  }
145
169
 
146
- if (route.kind === "api") {
147
- const modulePath = path.resolve(rootDir, route.module);
148
- try {
149
- const module = (await importFn(modulePath, importOpts)) as RouteModule;
170
+ if (route.kind === "api") {
171
+ const modulePath = path.resolve(rootDir, route.module);
172
+ try {
173
+ const module = (await importFn(modulePath, importOptsForRoute(route))) as RouteModule;
150
174
  let handler: unknown = module.default ?? module.handler ?? module;
151
175
 
152
176
  // 1) ManduFilling instance
@@ -193,7 +217,7 @@ export async function registerManifestHandlers(
193
217
  // Layout modules must export a default component. Runtime
194
218
  // validation in `renderToHTML` / page-loader asserts this —
195
219
  // so casting the unknown `importFn` result is safe here.
196
- return importFn(absLayoutPath, importOpts);
220
+ return importFn(absLayoutPath, baseImportOpts);
197
221
  }) as Parameters<typeof registerLayoutLoader>[1]);
198
222
  registeredLayouts.add(layoutPath);
199
223
  console.log(` 🎨 Layout: ${layoutPath}`);
@@ -204,7 +228,7 @@ export async function registerManifestHandlers(
204
228
  // Use PageHandler if slotModule exists (filling.loader support)
205
229
  if (route.slotModule) {
206
230
  registerPageHandler(route.id, async () => {
207
- const mod = (await importFn(componentPath, importOpts)) as Record<string, unknown>;
231
+ const mod = (await importFn(componentPath, importOptsForRoute(route))) as Record<string, unknown>;
208
232
  // Normalize the page module shape. Users write pages in two styles:
209
233
  // (a) `export default function Page() {…}` + `export const filling = …`
210
234
  // (b) `export default { component: …, filling: … }`
@@ -242,7 +266,7 @@ export async function registerManifestHandlers(
242
266
  ` 📄 Page: ${route.pattern} -> ${route.id} (with loader)${isIsland ? " 🏝️" : ""}${hasLayout ? " 🎨" : ""}`
243
267
  );
244
268
  } else {
245
- registerPageLoader(route.id, (() => importFn(componentPath, importOpts)) as Parameters<typeof registerPageLoader>[1]);
269
+ registerPageLoader(route.id, (() => importFn(componentPath, importOptsForRoute(route))) as Parameters<typeof registerPageLoader>[1]);
246
270
  console.log(
247
271
  ` 📄 Page: ${route.pattern} -> ${route.id}${isIsland ? " 🏝️" : ""}${hasLayout ? " 🎨" : ""}`
248
272
  );
@@ -252,7 +276,7 @@ export async function registerManifestHandlers(
252
276
 
253
277
  // Phase 6.3: register `app/not-found.tsx` if it exists. Global, one per
254
278
  // app — the server falls through to the built-in 404 if unregistered.
255
- await registerAppNotFound(rootDir, importFn, importOpts);
279
+ await registerAppNotFound(rootDir, importFn, baseImportOpts);
256
280
  }
257
281
 
258
282
  /**
@@ -260,11 +284,11 @@ export async function registerManifestHandlers(
260
284
  * project root and register it as the app-level 404 handler. Silent
261
285
  * no-op if no file exists — the server's built-in 404 covers that case.
262
286
  */
263
- async function registerAppNotFound(
264
- rootDir: string,
265
- importFn: (modulePath: string, opts?: { changedFile?: string }) => Promise<unknown>,
266
- importOpts?: { changedFile?: string },
267
- ): Promise<void> {
287
+ async function registerAppNotFound(
288
+ rootDir: string,
289
+ importFn: RegisterHandlersOptions["importFn"],
290
+ importOpts?: { changedFile?: string },
291
+ ): Promise<void> {
268
292
  const candidates = [
269
293
  "app/not-found.tsx",
270
294
  "app/not-found.ts",
@@ -11,6 +11,13 @@ export interface InlineClientHydrationTarget {
11
11
  src: string;
12
12
  priority: NonNullable<HydrationConfig["priority"]>;
13
13
  component: unknown;
14
+ /**
15
+ * Compatibility path for pre-F42 route-level client modules where the
16
+ * client component is hidden behind a server wrapper. Disabled by default
17
+ * because it invokes user function components outside React's renderer.
18
+ */
19
+ legacyRuntimeScan?: boolean;
20
+ sourceFile?: string;
14
21
  }
15
22
 
16
23
  export interface PageRenderResponseOptions {
@@ -125,7 +132,8 @@ async function resolveAndWrapInlineClientHydration(
125
132
  };
126
133
  }
127
134
 
128
- if (typeof type === "function" && !isClassComponent(type)) {
135
+ if (target.legacyRuntimeScan && typeof type === "function" && !isClassComponent(type)) {
136
+ warnLegacyRuntimePartialScan(target);
129
137
  const rendered = await renderFunctionComponentForInlineHydration(
130
138
  type,
131
139
  element.props ?? {},
@@ -152,6 +160,20 @@ async function resolveAndWrapInlineClientHydration(
152
160
  return { node: cloned, didWrap: resolvedChildren.didWrap };
153
161
  }
154
162
 
163
+ const warnedLegacyRuntimePartialScanRoutes = new Set<string>();
164
+
165
+ function warnLegacyRuntimePartialScan(target: InlineClientHydrationTarget): void {
166
+ if (warnedLegacyRuntimePartialScanRoutes.has(target.routeId)) return;
167
+ warnedLegacyRuntimePartialScanRoutes.add(target.routeId);
168
+
169
+ const file = target.sourceFile ? ` file="${target.sourceFile}"` : "";
170
+ console.warn(
171
+ `[MANDU_LEGACY_RUNTIME_PARTIAL_SCAN] route="${target.routeId}"${file}: ` +
172
+ "runtime client component discovery is running under the compatibility flag. " +
173
+ "Migrate this route to compiler-owned client boundaries so SSR does not invoke user components during discovery.",
174
+ );
175
+ }
176
+
155
177
  function isAsyncFunctionComponent(type: Function): boolean {
156
178
  return !type.prototype?.isReactComponent &&
157
179
  (type as { constructor?: { name?: string } }).constructor?.name === "AsyncFunction";
@@ -214,6 +236,7 @@ async function renderStreamingPageResponse(
214
236
  criticalData: options.loaderData as Record<string, unknown> | undefined,
215
237
  enableClientRouter: true,
216
238
  cssPath: options.cssPath,
239
+ islandPreWrapped: !!options.islandPreWrapped,
217
240
  transitions: options.transitions,
218
241
  prefetch: options.prefetch,
219
242
  spa: options.spa,
@@ -1117,9 +1117,11 @@ function createDefaultAppFactory(registry: ServerRegistry) {
1117
1117
  async function resolveInlineClientHydrationTarget(
1118
1118
  route: {
1119
1119
  id: string;
1120
+ componentModule?: string;
1120
1121
  clientModule?: string;
1121
1122
  clientExportName?: string;
1122
1123
  hydration?: HydrationConfig;
1124
+ boundaries?: unknown[];
1123
1125
  },
1124
1126
  rootDir: string,
1125
1127
  src: string,
@@ -1127,6 +1129,14 @@ async function resolveInlineClientHydrationTarget(
1127
1129
  if (!route.clientModule || !src) {
1128
1130
  return undefined;
1129
1131
  }
1132
+ if (route.boundaries && route.boundaries.length > 0) {
1133
+ return undefined;
1134
+ }
1135
+
1136
+ const legacyRuntimeScan = process.env.MANDU_LEGACY_RUNTIME_PARTIAL_SCAN === "1";
1137
+ if (!legacyRuntimeScan) {
1138
+ return undefined;
1139
+ }
1130
1140
 
1131
1141
  try {
1132
1142
  const module = await import(path.join(rootDir, route.clientModule));
@@ -1139,6 +1149,8 @@ async function resolveInlineClientHydrationTarget(
1139
1149
  src,
1140
1150
  priority: route.hydration?.priority ?? "visible",
1141
1151
  component,
1152
+ legacyRuntimeScan,
1153
+ sourceFile: route.componentModule ?? route.clientModule,
1142
1154
  };
1143
1155
  } catch (error) {
1144
1156
  console.warn(
@@ -2112,8 +2124,10 @@ async function renderPageSSR(
2112
2124
  errorModule?: string;
2113
2125
  loadingModule?: string;
2114
2126
  notFoundModule?: string;
2127
+ componentModule?: string;
2115
2128
  clientModule?: string;
2116
2129
  clientExportName?: string;
2130
+ boundaries?: unknown[];
2117
2131
  },
2118
2132
  params: Record<string, string>,
2119
2133
  loaderData: unknown,
@@ -8,10 +8,11 @@ import type { HydrationConfig, HydrationPriority } from "../spec/schema";
8
8
  import { PORTS, TIMEOUTS } from "../constants";
9
9
  import { decodeHtmlText, escapeHtmlAttr, escapeHtmlText, escapeJsonForInlineScript } from "./escape";
10
10
  import { REACT_INTERNALS_SHIM_SCRIPT } from "./shims";
11
- import { generateFastRefreshPreamble } from "../bundler/dev";
11
+ import { generateFastRefreshPreamble } from "../bundler/fast-refresh-preamble";
12
12
  import { PREFETCH_HELPER_SCRIPT } from "../client/prefetch-helper";
13
13
  import { SPA_NAV_HELPER_SCRIPT } from "../client/spa-nav-helper";
14
- import { maybeInjectDevOverlay } from "../dev-error-overlay";
14
+ import { maybeInjectDevOverlay } from "../dev-error-overlay";
15
+ import { renderWithManduClientBoundaryManifest } from "../internal/client-boundary";
15
16
 
16
17
  /**
17
18
  * Issue #192 — `@view-transition` at-rule block.
@@ -275,8 +276,16 @@ function generateHydrationScripts(
275
276
  scripts.push(`<link rel="modulepreload" href="${escapeHtmlAttr(cacheBust)}">`);
276
277
  }
277
278
  }
278
-
279
- // Runtime 로드 (hydrateIslands 실행 - dynamic import 사용)
279
+
280
+ if (manifest.boundaries) {
281
+ for (const boundary of Object.values(manifest.boundaries)) {
282
+ if (boundary.route !== routeId) continue;
283
+ const cacheBust = `${boundary.js}${boundary.js.includes('?') ? '&' : '?'}v=${Date.now()}`;
284
+ scripts.push(`<link rel="modulepreload" href="${escapeHtmlAttr(cacheBust)}">`);
285
+ }
286
+ }
287
+
288
+ // Runtime 로드 (hydrateIslands 실행 - dynamic import 사용)
280
289
  if (manifest.shared.runtime) {
281
290
  scripts.push(`<script type="module" src="${escapeHtmlAttr(manifest.shared.runtime)}"></script>`);
282
291
  }
@@ -703,7 +712,9 @@ export function renderToHTML(element: ReactElement, options: SSROptions = {}): s
703
712
  } catch { /* client 모듈 로드 실패 시 무시 */ }
704
713
 
705
714
  const renderToString = getRenderToString();
706
- let content = renderToString(element);
715
+ let content = renderWithManduClientBoundaryManifest(routeId, bundleManifest, () =>
716
+ renderToString(element),
717
+ );
707
718
 
708
719
  // 렌더링 중 수집된 head 태그
709
720
  collectedHeadTags = headGet?.() ?? "";