@mandujs/core 0.54.17 → 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.
- package/package.json +3 -1
- package/src/agent/__tests__/context.test.ts +54 -16
- package/src/agent/context.ts +17 -0
- package/src/agent/types.ts +32 -12
- package/src/bundler/__snapshots__/build.test.ts.snap +5 -0
- package/src/bundler/__tests__/build-runner.ts +130 -17
- package/src/bundler/__tests__/client-boundary-transform.test.ts +524 -0
- package/src/bundler/__tests__/reverse-import-graph.test.ts +42 -33
- package/src/bundler/build.test.ts +440 -8
- package/src/bundler/build.ts +455 -132
- package/src/bundler/client-boundary-transform.ts +977 -0
- package/src/bundler/dev.ts +39 -112
- package/src/bundler/fast-refresh-preamble.ts +47 -0
- package/src/bundler/index.ts +3 -2
- package/src/bundler/manifest-schema.ts +10 -0
- package/src/bundler/types.ts +20 -2
- package/src/diagnose/__tests__/checks.test.ts +117 -17
- package/src/diagnose/checks.ts +184 -3
- package/src/diagnose/run.ts +10 -8
- package/src/generator/templates.test.ts +48 -5
- package/src/generator/templates.ts +10 -1
- package/src/internal/client-boundary.ts +266 -0
- package/src/internal/index.ts +2 -1
- package/src/router/client-entry.test.ts +43 -6
- package/src/router/client-entry.ts +33 -12
- package/src/router/fs-routes.test.ts +388 -1
- package/src/router/fs-routes.ts +16 -3
- package/src/router/fs-scanner.ts +166 -18
- package/src/router/fs-types.ts +4 -1
- package/src/runtime/__tests__/page-render-response.test.ts +212 -0
- package/src/runtime/handlers.ts +50 -26
- package/src/runtime/page-render-response.ts +1 -0
- package/src/runtime/ssr.ts +16 -5
- package/src/runtime/streaming-ssr.ts +119 -76
- package/src/spec/schema.ts +31 -5
package/src/runtime/ssr.ts
CHANGED
|
@@ -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/
|
|
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
|
-
|
|
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 =
|
|
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/
|
|
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
|
-
/**
|
|
116
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
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
|
-
|
|
772
|
-
|
|
773
|
-
|
|
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 =
|
|
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
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
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
|
|
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 =
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
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
|
|
package/src/spec/schema.ts
CHANGED
|
@@ -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
|
-
// ==========
|
|
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(),
|