@mandujs/core 0.54.13 → 0.54.15

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.
@@ -93,7 +93,7 @@ import {
93
93
  } from "./static-files";
94
94
  export { __clearStaticEtagCacheForTests } from "./static-files";
95
95
  import { extractShellHtml, createPPRResponse } from "./ppr";
96
- import { renderPageResponse } from "./page-render-response";
96
+ import { renderPageResponse, type InlineClientHydrationTarget } from "./page-render-response";
97
97
  import { isRedirectResponse } from "./redirect";
98
98
  import { isNotFoundResponse } from "./not-found";
99
99
  import { newId } from "../id";
@@ -479,8 +479,12 @@ export type ErrorLoader = () => Promise<{ default: ErrorComponent }>;
479
479
  * - component: React 컴포넌트
480
480
  * - filling: Slot의 ManduFilling 인스턴스 (loader 포함)
481
481
  */
482
- export interface PageRegistration {
483
- component: React.ComponentType<{ params: Record<string, string>; loaderData?: unknown }>;
482
+ export interface PageRegistration {
483
+ component: React.ComponentType<{
484
+ params: Record<string, string>;
485
+ loaderData?: unknown;
486
+ __manduHydration?: InlineClientHydrationTarget;
487
+ }>;
484
488
  filling?: ManduFilling<unknown>;
485
489
  /** #186: page 모듈의 static `metadata` export (선택) */
486
490
  metadata?: Metadata;
@@ -502,15 +506,20 @@ export type PageHandler = () => Promise<PageRegistration>;
502
506
  */
503
507
  export type MetadataHandler = () => Promise<unknown>;
504
508
 
505
- export interface AppContext {
506
- routeId: string;
507
- url: string;
508
- params: Record<string, string>;
509
- /** SSR loader에서 로드한 데이터 */
510
- loaderData?: unknown;
511
- }
512
-
513
- type RouteComponent = (props: { params: Record<string, string>; loaderData?: unknown }) => React.ReactElement;
509
+ export interface AppContext {
510
+ routeId: string;
511
+ url: string;
512
+ params: Record<string, string>;
513
+ /** SSR loader에서 로드한 데이터 */
514
+ loaderData?: unknown;
515
+ __manduHydration?: InlineClientHydrationTarget;
516
+ }
517
+
518
+ type RouteComponent = (props: {
519
+ params: Record<string, string>;
520
+ loaderData?: unknown;
521
+ __manduHydration?: InlineClientHydrationTarget;
522
+ }) => React.ReactElement;
514
523
  type CreateAppFn = (context: AppContext) => React.ReactElement;
515
524
 
516
525
  // ========== Server Registry (인스턴스별 분리) ==========
@@ -1086,8 +1095,8 @@ async function wrapWithLayouts(
1086
1095
  }
1087
1096
 
1088
1097
  // Default createApp implementation (registry 기반)
1089
- function createDefaultAppFactory(registry: ServerRegistry) {
1090
- return function defaultCreateApp(context: AppContext): React.ReactElement {
1098
+ function createDefaultAppFactory(registry: ServerRegistry) {
1099
+ return function defaultCreateApp(context: AppContext): React.ReactElement {
1091
1100
  const Component = registry.routeComponents.get(context.routeId);
1092
1101
 
1093
1102
  if (!Component) {
@@ -1097,14 +1106,53 @@ function createDefaultAppFactory(registry: ServerRegistry) {
1097
1106
  );
1098
1107
  }
1099
1108
 
1100
- return React.createElement(Component, {
1101
- params: context.params,
1102
- loaderData: context.loaderData,
1103
- });
1104
- };
1105
- }
1106
-
1107
- const INTERNAL_CACHE_ENDPOINT = "/_mandu/cache";
1109
+ return React.createElement(Component, {
1110
+ params: context.params,
1111
+ loaderData: context.loaderData,
1112
+ __manduHydration: context.__manduHydration,
1113
+ });
1114
+ };
1115
+ }
1116
+
1117
+ async function resolveInlineClientHydrationTarget(
1118
+ route: {
1119
+ id: string;
1120
+ clientModule?: string;
1121
+ clientExportName?: string;
1122
+ hydration?: HydrationConfig;
1123
+ },
1124
+ rootDir: string,
1125
+ src: string,
1126
+ ): Promise<InlineClientHydrationTarget | undefined> {
1127
+ if (!route.clientModule || !route.clientExportName || !src) {
1128
+ return undefined;
1129
+ }
1130
+
1131
+ try {
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;
1137
+
1138
+ if (!component) return undefined;
1139
+
1140
+ return {
1141
+ routeId: route.id,
1142
+ src,
1143
+ priority: route.hydration?.priority ?? "visible",
1144
+ component,
1145
+ };
1146
+ } catch (error) {
1147
+ console.warn(
1148
+ `[Mandu] Failed to resolve inline client hydration target for "${route.id}":`,
1149
+ error,
1150
+ );
1151
+ return undefined;
1152
+ }
1153
+ }
1154
+
1155
+ const INTERNAL_CACHE_ENDPOINT = "/_mandu/cache";
1108
1156
 
1109
1157
  // ========== Request Handler ==========
1110
1158
 
@@ -2017,8 +2065,19 @@ function extractTitleText(titleHtml: string): string | null {
2017
2065
  /**
2018
2066
  * SSR 렌더링 (Streaming/Non-streaming)
2019
2067
  */
2020
- async function renderPageSSR(
2021
- route: { id: string; pattern: string; layoutChain?: string[]; streaming?: boolean; hydration?: HydrationConfig; errorModule?: string; loadingModule?: string; notFoundModule?: string },
2068
+ async function renderPageSSR(
2069
+ route: {
2070
+ id: string;
2071
+ pattern: string;
2072
+ layoutChain?: string[];
2073
+ streaming?: boolean;
2074
+ hydration?: HydrationConfig;
2075
+ errorModule?: string;
2076
+ loadingModule?: string;
2077
+ notFoundModule?: string;
2078
+ clientModule?: string;
2079
+ clientExportName?: string;
2080
+ },
2022
2081
  params: Record<string, string>,
2023
2082
  loaderData: unknown,
2024
2083
  url: string,
@@ -2028,15 +2087,30 @@ async function renderPageSSR(
2028
2087
  ): Promise<Result<Response>> {
2029
2088
  const settings = registry.settings;
2030
2089
  const defaultAppCreator = createDefaultAppFactory(registry);
2031
- const appCreator = registry.createAppFn || defaultAppCreator;
2032
-
2033
- try {
2034
- let app = appCreator({
2035
- routeId: route.id,
2036
- url,
2037
- params,
2038
- loaderData,
2039
- });
2090
+ const appCreator = registry.createAppFn || defaultAppCreator;
2091
+
2092
+ try {
2093
+ const useStreaming = route.streaming !== undefined
2094
+ ? route.streaming
2095
+ : settings.streaming;
2096
+ const needsIslandHydration = !!(
2097
+ route.hydration &&
2098
+ route.hydration.strategy !== "none" &&
2099
+ settings.bundleManifest
2100
+ );
2101
+ const routeBundle = settings.bundleManifest?.bundles[route.id];
2102
+ const bundleSrc = routeBundle?.js ? `${routeBundle.js}?t=${Date.now()}` : "";
2103
+ const inlineClientHydration = needsIslandHydration && !useStreaming
2104
+ ? await resolveInlineClientHydrationTarget(route, settings.rootDir, bundleSrc)
2105
+ : undefined;
2106
+
2107
+ let app = appCreator({
2108
+ routeId: route.id,
2109
+ url,
2110
+ params,
2111
+ loaderData,
2112
+ __manduHydration: inlineClientHydration,
2113
+ });
2040
2114
 
2041
2115
  // Phase 18.β — per-route Suspense wrapper (Next.js `loading.tsx` parity).
2042
2116
  // If the route declared a `loading.tsx`, wrap the page element in a
@@ -2064,20 +2138,13 @@ async function renderPageSSR(
2064
2138
 
2065
2139
  // Island 래핑: 레이아웃 적용 전에 페이지 콘텐츠만 island div로 감쌈
2066
2140
  // 이렇게 하면 레이아웃은 island 바깥에 위치하여 하이드레이션 시 레이아웃이 유지됨
2067
- const needsIslandHydration = !!(
2068
- route.hydration &&
2069
- route.hydration.strategy !== "none" &&
2070
- settings.bundleManifest
2071
- );
2072
- const routeBundle = settings.bundleManifest?.bundles[route.id];
2073
- const bundleSrc = routeBundle?.js ? `${routeBundle.js}?t=${Date.now()}` : "";
2074
- const needsIslandWrap = needsIslandHydration && bundleSrc.length > 0;
2075
-
2076
- if (needsIslandHydration && !needsIslandWrap && settings.isDev) {
2077
- console.warn(
2078
- `[Mandu] Hydration requested for route "${route.id}" but no client bundle was found. ` +
2079
- `Run mandu build/generate and ensure the route has a clientModule.`,
2080
- );
2141
+ const needsIslandWrap = needsIslandHydration && bundleSrc.length > 0 && !inlineClientHydration;
2142
+
2143
+ if (needsIslandHydration && bundleSrc.length === 0 && settings.isDev) {
2144
+ console.warn(
2145
+ `[Mandu] Hydration requested for route "${route.id}" but no client bundle was found. ` +
2146
+ `Run mandu build/generate and ensure the route has a clientModule.`,
2147
+ );
2081
2148
  }
2082
2149
 
2083
2150
  if (needsIslandWrap) {
@@ -2098,12 +2165,7 @@ async function renderPageSSR(
2098
2165
  // #186: layout chain + page metadata 병합
2099
2166
  const builtMeta = await buildSSRMetadata(route, params, url, registry);
2100
2167
 
2101
- // Streaming SSR 모드 결정
2102
- const useStreaming = route.streaming !== undefined
2103
- ? route.streaming
2104
- : settings.streaming;
2105
-
2106
- const pageResponse = await renderPageResponse({
2168
+ const pageResponse = await renderPageResponse({
2107
2169
  app,
2108
2170
  useStreaming,
2109
2171
  title: builtMeta.title,
@@ -2114,11 +2176,12 @@ async function renderPageSSR(
2114
2176
  routePattern: route.pattern,
2115
2177
  layoutChain: route.layoutChain,
2116
2178
  hydration: route.hydration,
2117
- bundleManifest: settings.bundleManifest,
2118
- loaderData,
2119
- cssPath: settings.cssPath,
2120
- islandPreWrapped: needsIslandWrap,
2121
- transitions: settings.transitions,
2179
+ bundleManifest: settings.bundleManifest,
2180
+ loaderData,
2181
+ cssPath: settings.cssPath,
2182
+ islandPreWrapped: needsIslandWrap,
2183
+ inlineClientHydration,
2184
+ transitions: settings.transitions,
2122
2185
  prefetch: settings.prefetch,
2123
2186
  spa: settings.spa,
2124
2187
  devtools: settings.devtools,
@@ -390,7 +390,7 @@ function generateErrorScript(error: Error, routeId: string): string {
390
390
  * Island를 Suspense로 감싸는 래퍼
391
391
  * Streaming SSR에서 Island별 점진적 렌더링 지원
392
392
  */
393
- export function SuspenseIsland({
393
+ export function SuspenseIsland({
394
394
  children,
395
395
  fallback,
396
396
  routeId,
@@ -401,14 +401,16 @@ export function SuspenseIsland({
401
401
  fallback?: ReactNode;
402
402
  routeId: string;
403
403
  priority?: HydrationPriority;
404
- bundleSrc?: string;
405
- }): ReactElement {
406
- const defaultFallback = React.createElement("div", {
407
- "data-mandu-island": routeId,
408
- "data-mandu-priority": priority,
409
- "data-mandu-src": bundleSrc ? `${bundleSrc}${bundleSrc.includes('?') ? '&' : '?'}t=${Date.now()}` : bundleSrc,
410
- "data-mandu-loading": "true",
411
- style: { display: "contents", minHeight: "50px" },
404
+ bundleSrc?: string;
405
+ }): ReactElement {
406
+ const hydrate = priorityToHydrateStrategy(priority);
407
+ const defaultFallback = React.createElement("div", {
408
+ "data-mandu-island": routeId,
409
+ "data-mandu-priority": priority,
410
+ "data-hydrate": hydrate,
411
+ "data-mandu-src": bundleSrc ? `${bundleSrc}${bundleSrc.includes('?') ? '&' : '?'}t=${Date.now()}` : bundleSrc,
412
+ "data-mandu-loading": "true",
413
+ style: { display: "contents", minHeight: "50px" },
412
414
  }, React.createElement("div", {
413
415
  className: "mandu-loading-skeleton",
414
416
  style: {
@@ -424,14 +426,19 @@ export function SuspenseIsland({
424
426
  return React.createElement(
425
427
  Suspense,
426
428
  { fallback: fallback || defaultFallback },
427
- React.createElement("div", {
428
- "data-mandu-island": routeId,
429
- "data-mandu-priority": priority,
430
- "data-mandu-src": bundleSrc ? `${bundleSrc}${bundleSrc.includes('?') ? '&' : '?'}t=${Date.now()}` : bundleSrc,
431
- style: { display: "contents" },
432
- }, children)
433
- );
434
- }
429
+ React.createElement("div", {
430
+ "data-mandu-island": routeId,
431
+ "data-mandu-priority": priority,
432
+ "data-hydrate": hydrate,
433
+ "data-mandu-src": bundleSrc ? `${bundleSrc}${bundleSrc.includes('?') ? '&' : '?'}t=${Date.now()}` : bundleSrc,
434
+ style: { display: "contents" },
435
+ }, children)
436
+ );
437
+ }
438
+
439
+ function priorityToHydrateStrategy(priority: HydrationPriority): string {
440
+ return priority === "immediate" ? "load" : priority;
441
+ }
435
442
 
436
443
  /**
437
444
  * Deferred 데이터를 위한 Suspense 컴포넌트
@@ -619,8 +626,9 @@ function generateHTMLShell(options: StreamingSSROptions): string {
619
626
  const bundle = bundleManifest.bundles[routeId];
620
627
  const bundleSrc = bundle?.js ? `${bundle.js}?t=${Date.now()}` : "";
621
628
  const priority = hydration.priority || "visible";
629
+ const hydrate = priorityToHydrateStrategy(priority);
622
630
  if (hasRouteBundle) {
623
- islandOpenTag = `<div data-mandu-island="${escapeHtmlAttr(routeId)}" data-mandu-src="${escapeHtmlAttr(bundleSrc)}" data-mandu-priority="${escapeHtmlAttr(priority)}" style="display:contents">`;
631
+ islandOpenTag = `<div data-mandu-island="${escapeHtmlAttr(routeId)}" data-mandu-src="${escapeHtmlAttr(bundleSrc)}" data-mandu-priority="${escapeHtmlAttr(priority)}" data-hydrate="${escapeHtmlAttr(hydrate)}" style="display:contents">`;
624
632
  }
625
633
  }
626
634