@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.
- package/package.json +1 -1
- package/src/bundler/__tests__/build-runner.ts +31 -12
- package/src/bundler/__tests__/fast-refresh.test.ts +24 -17
- package/src/bundler/__tests__/hdr.test.ts +10 -3
- package/src/bundler/__tests__/hmr-client.test.ts +16 -11
- package/src/bundler/build.test.ts +101 -49
- package/src/bundler/build.ts +244 -115
- package/src/client/router.ts +47 -40
- package/src/router/client-entry.test.ts +15 -8
- package/src/router/client-entry.ts +22 -15
- package/src/router/fs-routes.test.ts +90 -0
- package/src/router/fs-routes.ts +13 -7
- package/src/router/fs-scanner.ts +60 -16
- package/src/runtime/__tests__/page-render-response.test.ts +194 -0
- package/src/runtime/page-render-response.ts +106 -4
- package/src/runtime/router.test.ts +4 -4
- package/src/runtime/router.ts +10 -12
- package/src/runtime/server.ts +121 -58
- package/src/runtime/streaming-ssr.ts +26 -18
package/src/runtime/server.ts
CHANGED
|
@@ -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<{
|
|
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
|
-
|
|
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
|
-
|
|
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: {
|
|
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
|
-
|
|
2035
|
-
|
|
2036
|
-
|
|
2037
|
-
|
|
2038
|
-
|
|
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
|
|
2068
|
-
|
|
2069
|
-
|
|
2070
|
-
|
|
2071
|
-
|
|
2072
|
-
|
|
2073
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
407
|
-
|
|
408
|
-
"data-mandu-
|
|
409
|
-
"data-mandu-
|
|
410
|
-
"data-
|
|
411
|
-
|
|
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-
|
|
431
|
-
|
|
432
|
-
|
|
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
|
|