@mandujs/core 0.54.16 → 0.54.18

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 (37) hide show
  1. package/package.json +3 -1
  2. package/src/agent/__tests__/context.test.ts +54 -16
  3. package/src/agent/context.ts +17 -0
  4. package/src/agent/types.ts +32 -12
  5. package/src/bundler/__snapshots__/build.test.ts.snap +5 -0
  6. package/src/bundler/__tests__/build-runner.ts +130 -17
  7. package/src/bundler/__tests__/client-boundary-transform.test.ts +524 -0
  8. package/src/bundler/__tests__/reverse-import-graph.test.ts +42 -33
  9. package/src/bundler/build.test.ts +440 -8
  10. package/src/bundler/build.ts +455 -132
  11. package/src/bundler/client-boundary-transform.ts +977 -0
  12. package/src/bundler/dev.ts +39 -112
  13. package/src/bundler/fast-refresh-preamble.ts +47 -0
  14. package/src/bundler/index.ts +3 -2
  15. package/src/bundler/manifest-schema.ts +10 -0
  16. package/src/bundler/types.ts +20 -2
  17. package/src/diagnose/__tests__/checks.test.ts +117 -17
  18. package/src/diagnose/checks.ts +184 -3
  19. package/src/diagnose/run.ts +10 -8
  20. package/src/generator/templates.test.ts +48 -5
  21. package/src/generator/templates.ts +10 -1
  22. package/src/internal/client-boundary.ts +266 -0
  23. package/src/internal/index.ts +2 -1
  24. package/src/router/client-entry.test.ts +43 -6
  25. package/src/router/client-entry.ts +33 -12
  26. package/src/router/fs-routes.test.ts +388 -1
  27. package/src/router/fs-routes.ts +16 -3
  28. package/src/router/fs-scanner.ts +166 -18
  29. package/src/router/fs-types.ts +4 -1
  30. package/src/runtime/__tests__/inline-client-hydration.test.ts +134 -0
  31. package/src/runtime/__tests__/page-render-response.test.ts +212 -0
  32. package/src/runtime/handlers.ts +50 -26
  33. package/src/runtime/page-render-response.ts +43 -3
  34. package/src/runtime/server.ts +42 -5
  35. package/src/runtime/ssr.ts +16 -5
  36. package/src/runtime/streaming-ssr.ts +119 -76
  37. package/src/spec/schema.ts +31 -5
@@ -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",
@@ -125,11 +125,14 @@ async function resolveAndWrapInlineClientHydration(
125
125
  };
126
126
  }
127
127
 
128
- if (typeof type === "function" && isAsyncFunctionComponent(type)) {
129
- const rendered = await (type as (props: Record<string, unknown>) => React.ReactNode | Promise<React.ReactNode>)(
128
+ if (typeof type === "function" && !isClassComponent(type)) {
129
+ const rendered = await renderFunctionComponentForInlineHydration(
130
+ type,
130
131
  element.props ?? {},
131
132
  );
132
- return resolveAndWrapInlineClientHydration(rendered, target, counter);
133
+ if (rendered.ok) {
134
+ return resolveAndWrapInlineClientHydration(rendered.node, target, counter);
135
+ }
133
136
  }
134
137
 
135
138
  const props = element.props;
@@ -154,6 +157,42 @@ function isAsyncFunctionComponent(type: Function): boolean {
154
157
  (type as { constructor?: { name?: string } }).constructor?.name === "AsyncFunction";
155
158
  }
156
159
 
160
+ function isClassComponent(type: Function): boolean {
161
+ return !!type.prototype?.isReactComponent;
162
+ }
163
+
164
+ async function renderFunctionComponentForInlineHydration(
165
+ type: Function,
166
+ props: Record<string, unknown>,
167
+ ): Promise<{ ok: true; node: React.ReactNode } | { ok: false }> {
168
+ const render = type as (props: Record<string, unknown>) => React.ReactNode | Promise<React.ReactNode>;
169
+ if (isAsyncFunctionComponent(type)) {
170
+ return { ok: true, node: await render(props) };
171
+ }
172
+
173
+ if (functionComponentLooksHookDependent(type)) {
174
+ return { ok: false };
175
+ }
176
+
177
+ try {
178
+ return { ok: true, node: await render(props) };
179
+ } catch {
180
+ return { ok: false };
181
+ }
182
+ }
183
+
184
+ function functionComponentLooksHookDependent(type: Function): boolean {
185
+ let source = "";
186
+ try {
187
+ source = Function.prototype.toString.call(type);
188
+ } catch {
189
+ return true;
190
+ }
191
+
192
+ return /\bReact\.use[A-Za-z0-9_$]*\s*\(/.test(source) ||
193
+ /\buse[A-Z][A-Za-z0-9_$]*\s*\(/.test(source);
194
+ }
195
+
157
196
  function priorityToHydrate(priority: InlineClientHydrationTarget["priority"]): string {
158
197
  return priority === "immediate" ? "load" : priority;
159
198
  }
@@ -175,6 +214,7 @@ async function renderStreamingPageResponse(
175
214
  criticalData: options.loaderData as Record<string, unknown> | undefined,
176
215
  enableClientRouter: true,
177
216
  cssPath: options.cssPath,
217
+ islandPreWrapped: !!options.islandPreWrapped,
178
218
  transitions: options.transitions,
179
219
  prefetch: options.prefetch,
180
220
  spa: options.spa,
@@ -1124,16 +1124,13 @@ async function resolveInlineClientHydrationTarget(
1124
1124
  rootDir: string,
1125
1125
  src: string,
1126
1126
  ): Promise<InlineClientHydrationTarget | undefined> {
1127
- if (!route.clientModule || !route.clientExportName || !src) {
1127
+ if (!route.clientModule || !src) {
1128
1128
  return undefined;
1129
1129
  }
1130
1130
 
1131
1131
  try {
1132
1132
  const module = await import(path.join(rootDir, route.clientModule));
1133
- const exportName = route.clientExportName;
1134
- const component = exportName === "default"
1135
- ? module.default
1136
- : module[exportName] ?? module.default;
1133
+ const component = resolveInlineClientHydrationComponent(module, route);
1137
1134
 
1138
1135
  if (!component) return undefined;
1139
1136
 
@@ -1152,6 +1149,46 @@ async function resolveInlineClientHydrationTarget(
1152
1149
  }
1153
1150
  }
1154
1151
 
1152
+ function resolveInlineClientHydrationComponent(
1153
+ module: Record<string, unknown>,
1154
+ route: { id: string; clientModule?: string; clientExportName?: string },
1155
+ ): unknown {
1156
+ if (module.default) return module.default;
1157
+
1158
+ const candidates = [
1159
+ route.clientExportName && route.clientExportName !== "default" ? route.clientExportName : undefined,
1160
+ inferInlineClientExportNameFromPath(route.clientModule),
1161
+ inferInlineClientExportNameFromRouteId(route.id),
1162
+ ].filter((candidate, index, values): candidate is string =>
1163
+ !!candidate && values.indexOf(candidate) === index
1164
+ );
1165
+
1166
+ for (const candidate of candidates) {
1167
+ if (module[candidate]) return module[candidate];
1168
+ }
1169
+
1170
+ const runtimeExports = Object.keys(module).filter((name) => name !== "__esModule");
1171
+ return runtimeExports.length === 1 ? module[runtimeExports[0]] : undefined;
1172
+ }
1173
+
1174
+ function inferInlineClientExportNameFromPath(clientModulePath: string | undefined): string | undefined {
1175
+ if (!clientModulePath) return undefined;
1176
+ const basename = path.basename(clientModulePath).replace(/\.[cm]?[jt]sx?$/, "");
1177
+ const withoutClientSuffix = basename.replace(/\.(client|island)$/, "");
1178
+ return /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(withoutClientSuffix)
1179
+ ? withoutClientSuffix
1180
+ : undefined;
1181
+ }
1182
+
1183
+ function inferInlineClientExportNameFromRouteId(routeId: string): string | undefined {
1184
+ const pascal = routeId
1185
+ .split(/[^A-Za-z0-9]+/)
1186
+ .filter(Boolean)
1187
+ .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
1188
+ .join("");
1189
+ return /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(pascal) ? pascal : undefined;
1190
+ }
1191
+
1155
1192
  const INTERNAL_CACHE_ENDPOINT = "/_mandu/cache";
1156
1193
 
1157
1194
  // ========== Request Handler ==========
@@ -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?.() ?? "";
@@ -23,9 +23,13 @@ import { escapeHtmlAttr, escapeHtmlText, escapeJsonForInlineScript, escapeJsStri
23
23
  import { REACT_INTERNALS_SHIM_SCRIPT } from "./shims";
24
24
  import { getRenderToString } from "./react-renderer";
25
25
  import { mark, measure } from "../perf";
26
- import { generateFastRefreshPreamble } from "../bundler/dev";
27
- import { PREFETCH_HELPER_SCRIPT } from "../client/prefetch-helper";
28
- import { SPA_NAV_HELPER_SCRIPT } from "../client/spa-nav-helper";
26
+ import { generateFastRefreshPreamble } from "../bundler/fast-refresh-preamble";
27
+ import { PREFETCH_HELPER_SCRIPT } from "../client/prefetch-helper";
28
+ import { SPA_NAV_HELPER_SCRIPT } from "../client/spa-nav-helper";
29
+ import {
30
+ createManduClientBoundaryRenderScope,
31
+ renderWithManduClientBoundaryManifest,
32
+ } from "../internal/client-boundary";
29
33
 
30
34
  /**
31
35
  * Issue #192 — `@view-transition` at-rule, mirror of the constant in
@@ -110,10 +114,12 @@ export interface StreamingSSROptions {
110
114
  // Note: deferredData는 renderWithDeferredData의 deferredPromises로 대체됨
111
115
  /** Hydration 설정 */
112
116
  hydration?: HydrationConfig;
113
- /** 번들 매니페스트 */
114
- bundleManifest?: BundleManifest;
115
- /** 추가 head 태그 (SEO metadata와 병합됨) */
116
- headTags?: string;
117
+ /** 번들 매니페스트 */
118
+ bundleManifest?: BundleManifest;
119
+ /** React element already contains its own island wrapper. */
120
+ islandPreWrapped?: boolean;
121
+ /** 추가 head 태그 (SEO metadata와 병합됨) */
122
+ headTags?: string;
117
123
  /**
118
124
  * SEO 메타데이터 (Layout 체인 또는 단일 객체)
119
125
  * - 배열: [rootLayout, ...nestedLayouts, page] 순서로 병합
@@ -550,9 +556,10 @@ function generateHTMLShell(options: StreamingSSROptions): string {
550
556
  isDev = false,
551
557
  transitions = true,
552
558
  prefetch = true,
553
- spa = true,
554
- layoutChain,
555
- } = options;
559
+ spa = true,
560
+ layoutChain,
561
+ islandPreWrapped = false,
562
+ } = options;
556
563
 
557
564
  // Issue #233 — layout-key for SPA cross-layout detection. Mirror of the
558
565
  // block in `ssr.ts::renderToHTML`; see that call-site for the full
@@ -627,7 +634,7 @@ function generateHTMLShell(options: StreamingSSROptions): string {
627
634
  const bundleSrc = bundle?.js ? `${bundle.js}?t=${Date.now()}` : "";
628
635
  const priority = hydration.priority || "visible";
629
636
  const hydrate = priorityToHydrateStrategy(priority);
630
- if (hasRouteBundle) {
637
+ if (hasRouteBundle && !islandPreWrapped) {
631
638
  islandOpenTag = `<div data-mandu-island="${escapeHtmlAttr(routeId)}" data-mandu-src="${escapeHtmlAttr(bundleSrc)}" data-mandu-priority="${escapeHtmlAttr(priority)}" data-hydrate="${escapeHtmlAttr(hydrate)}" style="display:contents">`;
632
639
  }
633
640
  }
@@ -695,10 +702,11 @@ function generateHTMLTailContent(options: StreamingSSROptions): string {
695
702
  bundleManifest,
696
703
  isDev = false,
697
704
  hmrPort,
698
- enableClientRouter = false,
699
- hydration,
700
- devtools,
701
- } = options;
705
+ enableClientRouter = false,
706
+ hydration,
707
+ devtools,
708
+ islandPreWrapped = false,
709
+ } = options;
702
710
 
703
711
  const scripts: string[] = [];
704
712
 
@@ -755,11 +763,21 @@ function generateHTMLTailContent(options: StreamingSSROptions): string {
755
763
  scripts.push(`<link rel="modulepreload" href="${escapeHtmlAttr(bundleManifest.shared.runtime)}">`);
756
764
  }
757
765
 
758
- // 6. Island modulepreload
759
- const bundle = bundleManifest.bundles[routeId];
760
- if (bundle) {
761
- const cacheBust = `${bundle.js}${bundle.js.includes('?') ? '&' : '?'}v=${Date.now()}`;
762
- scripts.push(`<link rel="modulepreload" href="${escapeHtmlAttr(cacheBust)}">`);
766
+ // 6. Island modulepreload
767
+ const routeIslands = bundleManifest.islands
768
+ ? Object.values(bundleManifest.islands).filter((island) => island.route === routeId)
769
+ : [];
770
+ if (routeIslands.length > 0) {
771
+ for (const island of routeIslands) {
772
+ const cacheBust = `${island.js}${island.js.includes('?') ? '&' : '?'}v=${Date.now()}`;
773
+ scripts.push(`<link rel="modulepreload" href="${escapeHtmlAttr(cacheBust)}">`);
774
+ }
775
+ } else {
776
+ const bundle = bundleManifest.bundles[routeId];
777
+ if (bundle) {
778
+ const cacheBust = `${bundle.js}${bundle.js.includes('?') ? '&' : '?'}v=${Date.now()}`;
779
+ scripts.push(`<link rel="modulepreload" href="${escapeHtmlAttr(cacheBust)}">`);
780
+ }
763
781
  }
764
782
  if (bundleManifest.partials) {
765
783
  for (const partial of Object.values(bundleManifest.partials)) {
@@ -767,10 +785,18 @@ function generateHTMLTailContent(options: StreamingSSROptions): string {
767
785
  scripts.push(`<link rel="modulepreload" href="${escapeHtmlAttr(cacheBust)}">`);
768
786
  }
769
787
  }
770
-
771
- // 7. Runtime 로드
772
- if (bundleManifest.shared.runtime) {
773
- scripts.push(`<script type="module" src="${escapeHtmlAttr(bundleManifest.shared.runtime)}"></script>`);
788
+
789
+ if (bundleManifest.boundaries) {
790
+ for (const boundary of Object.values(bundleManifest.boundaries)) {
791
+ if (boundary.route !== routeId) continue;
792
+ const cacheBust = `${boundary.js}${boundary.js.includes('?') ? '&' : '?'}v=${Date.now()}`;
793
+ scripts.push(`<link rel="modulepreload" href="${escapeHtmlAttr(cacheBust)}">`);
794
+ }
795
+ }
796
+
797
+ // 7. Runtime 로드
798
+ if (bundleManifest.shared.runtime) {
799
+ scripts.push(`<script type="module" src="${escapeHtmlAttr(bundleManifest.shared.runtime)}"></script>`);
774
800
  }
775
801
 
776
802
  // 7.5 React internals shim (must run before react-dom/client runs)
@@ -798,9 +824,9 @@ function generateHTMLTailContent(options: StreamingSSROptions): string {
798
824
  if (isDev && shouldInjectDevtoolsStreaming(devtools, bundleManifest)) {
799
825
  scripts.push(generateStreamingDevtoolsScript(bundleManifest));
800
826
  }
801
-
802
- // Island wrapper 닫기 (hydration이 필요한 경우)
803
- const islandCloseTag = needsHydration && bundleManifest.bundles[routeId]?.js ? "</div>" : "";
827
+
828
+ // Island wrapper 닫기 (hydration이 필요한 경우)
829
+ const islandCloseTag = needsHydration && !islandPreWrapped && bundleManifest.bundles[routeId]?.js ? "</div>" : "";
804
830
 
805
831
  return `${islandCloseTag}</div>
806
832
  ${scripts.join("\n ")}`;
@@ -1028,9 +1054,13 @@ export async function renderToStream(
1028
1054
  warnStreamingCaveats(isDev);
1029
1055
  streamingWarnings.markWarned();
1030
1056
  }
1031
-
1032
- const encoder = new TextEncoder();
1033
- const collectedHeadTags = collectStreamingHeadTags(element);
1057
+
1058
+ const encoder = new TextEncoder();
1059
+ const collectedHeadTags = renderWithManduClientBoundaryManifest(
1060
+ options.routeId,
1061
+ options.bundleManifest,
1062
+ () => collectStreamingHeadTags(element),
1063
+ );
1034
1064
  const resolvedOptions = collectedHeadTags
1035
1065
  ? { ...options, headTags: [options.headTags, collectedHeadTags].filter(Boolean).join("\n") }
1036
1066
  : options;
@@ -1072,44 +1102,53 @@ export async function renderToStream(
1072
1102
  // try/catch safely returns an empty string in that case, and any
1073
1103
  // `useHead`-pushed tags from async components are instead picked
1074
1104
  // up by `buildHtmlTail` on the way out. No additional wiring is
1075
- // needed on this code path.
1076
- // 실패 시 throw → renderStreamingResponse에서 500 처리
1077
- const renderToReadableStream = getRenderToReadableStream();
1078
- const reactStream = await renderToReadableStream(element, {
1079
- onError: (error: Error) => {
1080
- if (timedOut) return;
1081
-
1082
- metrics.hasError = true;
1083
- const streamingError: StreamingError = {
1084
- error,
1085
- isShellError: !shellSent,
1086
- recoverable: shellSent,
1087
- timestamp: Date.now(),
1088
- };
1089
-
1090
- console.error("[Mandu Streaming] React render error:", error);
1091
-
1092
- if (!shellSent) {
1093
- // Shell 전 에러 - 콜백만 호출 (throw는 하지 않음, 이미 스트림 시작됨)
1094
- onShellError?.(streamingError);
1095
- } else {
1096
- // Shell 에러 - 스트림에 에러 스크립트 삽입됨
1097
- onStreamError?.(streamingError);
1098
- }
1099
-
1100
- onError?.(error);
1101
- },
1102
- });
1103
-
1104
- // allReady는 백그라운드에서 메트릭용으로만 사용 (대기 안 함!)
1105
- reactStream.allReady.then(() => {
1106
- metrics.allReadyTime = Date.now() - metrics.startTime;
1107
- if (isDev) {
1108
- console.log(`[Mandu Streaming] All ready: ${routeId} (${metrics.allReadyTime}ms)`);
1109
- }
1110
- }).catch(() => {
1111
- // 에러는 onError에서 이미 처리됨
1112
- });
1105
+ // needed on this code path.
1106
+ // 실패 시 throw → renderStreamingResponse에서 500 처리
1107
+ const renderToReadableStream = getRenderToReadableStream();
1108
+ const renderBoundaryScope = createManduClientBoundaryRenderScope(
1109
+ routeId,
1110
+ resolvedOptions.bundleManifest,
1111
+ );
1112
+ const scopedElement = renderBoundaryScope.wrapElement(element);
1113
+ const reactStream = await renderBoundaryScope(() =>
1114
+ renderToReadableStream(scopedElement, {
1115
+ onError: (error: Error) => {
1116
+ if (timedOut) return;
1117
+
1118
+ metrics.hasError = true;
1119
+ const streamingError: StreamingError = {
1120
+ error,
1121
+ isShellError: !shellSent,
1122
+ recoverable: shellSent,
1123
+ timestamp: Date.now(),
1124
+ };
1125
+
1126
+ console.error("[Mandu Streaming] React render error:", error);
1127
+
1128
+ if (!shellSent) {
1129
+ // Shell 전 에러 - 콜백만 호출 (throw는 하지 않음, 이미 스트림 시작됨)
1130
+ onShellError?.(streamingError);
1131
+ } else {
1132
+ // Shell 후 에러 - 스트림에 에러 스크립트 삽입됨
1133
+ onStreamError?.(streamingError);
1134
+ }
1135
+
1136
+ onError?.(error);
1137
+ },
1138
+ }),
1139
+ );
1140
+
1141
+ // allReady는 백그라운드에서 메트릭용으로만 사용 (대기 안 함!)
1142
+ renderBoundaryScope(() => {
1143
+ reactStream.allReady.then(() => {
1144
+ metrics.allReadyTime = Date.now() - metrics.startTime;
1145
+ if (isDev) {
1146
+ console.log(`[Mandu Streaming] All ready: ${routeId} (${metrics.allReadyTime}ms)`);
1147
+ }
1148
+ }).catch(() => {
1149
+ // 에러는 onError에서 이미 처리됨
1150
+ });
1151
+ });
1113
1152
 
1114
1153
  // Custom stream으로 래핑 (Shell + React Content + Tail)
1115
1154
  let tailSent = false;
@@ -1118,10 +1157,12 @@ export async function renderToStream(
1118
1157
  ? metrics.startTime + streamTimeout
1119
1158
  : null;
1120
1159
 
1121
- async function readWithTimeout(): Promise<ReadableStreamReadResult<Uint8Array> | null> {
1122
- if (!deadline) {
1123
- return reader.read() as Promise<ReadableStreamReadResult<Uint8Array>>;
1124
- }
1160
+ async function readWithTimeout(): Promise<ReadableStreamReadResult<Uint8Array> | null> {
1161
+ if (!deadline) {
1162
+ return renderBoundaryScope(() =>
1163
+ reader.read() as Promise<ReadableStreamReadResult<Uint8Array>>
1164
+ );
1165
+ }
1125
1166
 
1126
1167
  const remaining = deadline - Date.now();
1127
1168
  if (remaining <= 0) {
@@ -1133,10 +1174,12 @@ export async function renderToStream(
1133
1174
  timeoutId = setTimeout(() => resolve({ kind: "timeout" }), remaining);
1134
1175
  });
1135
1176
 
1136
- const readPromise = reader
1137
- .read()
1138
- .then((result) => ({ kind: "read" as const, result: result as ReadableStreamReadResult<Uint8Array> }))
1139
- .catch((error: unknown) => ({ kind: "error" as const, error }));
1177
+ const readPromise = renderBoundaryScope(() =>
1178
+ reader
1179
+ .read()
1180
+ .then((result) => ({ kind: "read" as const, result: result as ReadableStreamReadResult<Uint8Array> }))
1181
+ .catch((error: unknown) => ({ kind: "error" as const, error }))
1182
+ );
1140
1183
 
1141
1184
  const result = await Promise.race([readPromise, timeoutPromise]);
1142
1185
 
@@ -8,7 +8,7 @@ export type SpecHydrationStrategy = z.infer<typeof SpecHydrationStrategy>;
8
8
  export const HydrationPriority = z.enum(["immediate", "visible", "idle", "interaction"]);
9
9
  export type HydrationPriority = z.infer<typeof HydrationPriority>;
10
10
 
11
- export const HydrationConfig = z.object({
11
+ export const HydrationConfig = z.object({
12
12
  /**
13
13
  * Hydration 전략
14
14
  * - none: 순수 Static HTML (JS 없음)
@@ -32,10 +32,35 @@ export const HydrationConfig = z.object({
32
32
  */
33
33
  preload: z.boolean().default(false),
34
34
  });
35
-
36
- export type HydrationConfig = z.infer<typeof HydrationConfig>;
37
-
38
- // ========== Loader 설정 ==========
35
+
36
+ export type HydrationConfig = z.infer<typeof HydrationConfig>;
37
+
38
+ // ========== Client Boundary Metadata ==========
39
+
40
+ export const RouteClientBoundarySource = z.object({
41
+ file: z.string().min(1),
42
+ line: z.number().int().positive(),
43
+ column: z.number().int().positive(),
44
+ });
45
+ export type RouteClientBoundarySource = z.infer<typeof RouteClientBoundarySource>;
46
+
47
+ export const RouteClientBoundary = z.object({
48
+ id: z.string().min(1),
49
+ routeId: z.string().min(1),
50
+ module: z.string().min(1),
51
+ importSpecifier: z.string().min(1).optional(),
52
+ exportName: z.string().min(1),
53
+ localName: z.string().min(1),
54
+ hydrate: z.string().min(1).default("visible"),
55
+ ordinal: z.number().int().nonnegative(),
56
+ propsSource: z.enum(["inline", "route-data", "data-props", "none", "unknown"]).default("inline"),
57
+ propsKeys: z.array(z.string()).optional(),
58
+ hasSpreadProps: z.boolean().optional(),
59
+ source: RouteClientBoundarySource,
60
+ });
61
+ export type RouteClientBoundary = z.infer<typeof RouteClientBoundary>;
62
+
63
+ // ========== Loader 설정 ==========
39
64
 
40
65
  export const LoaderConfig = z.object({
41
66
  /**
@@ -75,6 +100,7 @@ const RouteSpecBase = {
75
100
  slotModule: z.string().optional(),
76
101
  clientModule: z.string().optional(),
77
102
  clientExportName: z.string().optional(),
103
+ boundaries: z.array(RouteClientBoundary).optional(),
78
104
  contractModule: z.string().optional(),
79
105
  hydration: HydrationConfig.optional(),
80
106
  loader: LoaderConfig.optional(),