@mandujs/core 0.54.13 → 0.54.14

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mandujs/core",
3
- "version": "0.54.13",
3
+ "version": "0.54.14",
4
4
  "description": "Mandu Framework Core - Spec, Generator, Guard, Runtime",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
@@ -100,11 +100,11 @@ const manifest: RoutesManifest = mode === "server-page-client-module"
100
100
  },
101
101
  ],
102
102
  }
103
- : mode === "hydration-no-client-module"
104
- ? {
105
- version: 1,
106
- routes: [
107
- {
103
+ : mode === "hydration-no-client-module"
104
+ ? {
105
+ version: 1,
106
+ routes: [
107
+ {
108
108
  id: "login",
109
109
  kind: "page",
110
110
  pattern: "/login",
@@ -115,13 +115,32 @@ const manifest: RoutesManifest = mode === "server-page-client-module"
115
115
  priority: "immediate",
116
116
  preload: false,
117
117
  },
118
- },
119
- ],
120
- }
121
- : {
122
- version: 1,
123
- routes: [
124
- {
118
+ },
119
+ ],
120
+ }
121
+ : mode === "i18n-locale-route-id"
122
+ ? {
123
+ version: 1,
124
+ routes: [
125
+ {
126
+ id: "ko::demo",
127
+ kind: "page",
128
+ pattern: "/ko",
129
+ module: "app/page.tsx",
130
+ componentModule: "app/page.tsx",
131
+ clientModule: "app/demo.client.tsx",
132
+ hydration: {
133
+ strategy: "island",
134
+ priority: "visible",
135
+ preload: false,
136
+ },
137
+ },
138
+ ],
139
+ }
140
+ : {
141
+ version: 1,
142
+ routes: [
143
+ {
125
144
  id: "demo",
126
145
  kind: "page",
127
146
  pattern: "/",
@@ -19,9 +19,9 @@
19
19
  * packages/core/src/runtime/fast-refresh-runtime.ts
20
20
  */
21
21
 
22
- import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
23
- import { mkdtemp, mkdir, rm, writeFile, readFile } from "fs/promises";
24
- import path from "path";
22
+ import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
23
+ import { mkdtemp, mkdir, rm, writeFile, readFile } from "fs/promises";
24
+ import path from "path";
25
25
 
26
26
  import {
27
27
  appendBoundary,
@@ -40,12 +40,19 @@ import {
40
40
  _isRefreshScheduledForTests,
41
41
  type ReactRefreshRuntime,
42
42
  } from "../../runtime/fast-refresh-runtime";
43
- import {
44
- createManduHot,
45
- dispatchReplacement,
46
- _resetRegistryForTests,
47
- } from "../../runtime/hmr-client";
48
- import { generateFastRefreshPreamble } from "../dev";
43
+ import {
44
+ createManduHot,
45
+ dispatchReplacement,
46
+ _resetRegistryForTests,
47
+ } from "../../runtime/hmr-client";
48
+ import { generateFastRefreshPreamble } from "../dev";
49
+
50
+ const repoTempRoot = path.resolve(import.meta.dir, "../../../../..", ".tmp-test-artifacts");
51
+
52
+ async function mkRepoTempDir(prefix: string): Promise<string> {
53
+ await mkdir(repoTempRoot, { recursive: true });
54
+ return mkdtemp(path.join(repoTempRoot, prefix));
55
+ }
49
56
 
50
57
  // ═══════════════════════════════════════════════════════════════════
51
58
  // Section A — plugin pure unit tests
@@ -263,10 +270,10 @@ describe("manduHMR — __MANDU_HMR__ global behavior", () => {
263
270
  describe.skipIf(process.env.MANDU_SKIP_BUNDLER_TESTS === "1")(
264
271
  "Bun.build({ reactFastRefresh: true }) + fastRefreshPlugin()",
265
272
  () => {
266
- let rootDir: string;
267
-
268
- beforeAll(async () => {
269
- rootDir = await mkdtemp(path.join(import.meta.dir, ".tmp-fr-build-"));
273
+ let rootDir: string;
274
+
275
+ beforeAll(async () => {
276
+ rootDir = await mkRepoTempDir("fr-build-");
270
277
  // Three source files — one boundary, one plain, one .island.tsx —
271
278
  // give us the minimal matrix for the plugin's include filter.
272
279
  await writeFile(
@@ -512,10 +519,10 @@ describe("dispatchReplacement + __MANDU_HMR__ integration", () => {
512
519
  describe.skipIf(process.env.MANDU_SKIP_BUNDLER_TESTS === "1")(
513
520
  "buildVendorShims emits fast-refresh shims in dev mode",
514
521
  () => {
515
- let rootDir: string;
516
-
517
- beforeAll(async () => {
518
- rootDir = await mkdtemp(path.join(import.meta.dir, ".tmp-fr-vendor-"));
522
+ let rootDir: string;
523
+
524
+ beforeAll(async () => {
525
+ rootDir = await mkRepoTempDir("fr-vendor-");
519
526
  await mkdir(path.join(rootDir, "app"), { recursive: true });
520
527
  await writeFile(
521
528
  path.join(rootDir, "package.json"),
@@ -1,11 +1,17 @@
1
- import { afterAll, beforeAll, describe, expect, test } from "bun:test";
2
- import { mkdtemp, mkdir, readFile, rm, writeFile } from "fs/promises";
3
- import path from "path";
4
- import { pathToFileURL } from "url";
5
-
6
- // 모든 테스트가 하나의 빌드 결과를 공유 — 병렬 Bun.build 충돌 방지
7
- let rootDir: string;
8
- let result: { success: boolean; errors: string[] };
1
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
2
+ import { mkdtemp, mkdir, readFile, rm, writeFile } from "fs/promises";
3
+ import path from "path";
4
+ import { pathToFileURL } from "url";
5
+
6
+ // 모든 테스트가 하나의 빌드 결과를 공유 — 병렬 Bun.build 충돌 방지
7
+ let rootDir: string;
8
+ let result: { success: boolean; errors: string[] };
9
+ const repoTempRoot = path.resolve(import.meta.dir, "../../../..", ".tmp-test-artifacts");
10
+
11
+ async function mkRepoTempDir(prefix: string): Promise<string> {
12
+ await mkdir(repoTempRoot, { recursive: true });
13
+ return mkdtemp(path.join(repoTempRoot, prefix));
14
+ }
9
15
 
10
16
  async function importBuiltModule(relativePath: string): Promise<Record<string, unknown>> {
11
17
  const fileUrl = pathToFileURL(path.join(rootDir, relativePath)).href;
@@ -24,10 +30,13 @@ async function importBuiltModule(relativePath: string): Promise<Record<string, u
24
30
  * See `__tests__/build-runner.ts` for the subprocess entrypoint and more
25
31
  * background.
26
32
  */
27
- async function runBuildInSubprocess(root: string, mode?: string): Promise<{
28
- success: boolean;
29
- errors: string[];
30
- }> {
33
+ async function runBuildInSubprocess(root: string, mode?: string): Promise<{
34
+ success: boolean;
35
+ errors: string[];
36
+ manifest?: {
37
+ bundles?: Record<string, { js?: string }>;
38
+ } | null;
39
+ }> {
31
40
  const runner = path.join(
32
41
  import.meta.dir,
33
42
  "__tests__",
@@ -52,25 +61,27 @@ async function runBuildInSubprocess(root: string, mode?: string): Promise<{
52
61
  const last = lines[lines.length - 1] ?? "";
53
62
  try {
54
63
  const parsed = JSON.parse(last);
55
- return {
56
- success: parsed.success === true,
57
- errors: Array.isArray(parsed.errors) ? parsed.errors : [],
58
- };
64
+ return {
65
+ success: parsed.success === true,
66
+ errors: Array.isArray(parsed.errors) ? parsed.errors : [],
67
+ manifest: parsed.manifest ?? null,
68
+ };
59
69
  } catch (e) {
60
- return {
61
- success: false,
62
- errors: [
63
- `build-runner output could not be parsed as JSON: ${String(e)}\nLast stdout line: ${last}`,
64
- ],
65
- };
66
- }
67
- } catch (err) {
68
- return { success: false, errors: [`spawn failed: ${String(err)}`] };
69
- }
70
- }
70
+ return {
71
+ success: false,
72
+ errors: [
73
+ `build-runner output could not be parsed as JSON: ${String(e)}\nLast stdout line: ${last}`,
74
+ ],
75
+ manifest: null,
76
+ };
77
+ }
78
+ } catch (err) {
79
+ return { success: false, errors: [`spawn failed: ${String(err)}`], manifest: null };
80
+ }
81
+ }
71
82
 
72
- beforeAll(async () => {
73
- rootDir = await mkdtemp(path.join(import.meta.dir, ".tmp-bundler-"));
83
+ beforeAll(async () => {
84
+ rootDir = await mkRepoTempDir("bundler-");
74
85
 
75
86
  await mkdir(path.join(rootDir, "app"), { recursive: true });
76
87
  await writeFile(
@@ -186,8 +197,8 @@ describe("buildClientBundles vendor shims", () => {
186
197
  expect(runtimeSource).toContain("JSON.parse");
187
198
  });
188
199
 
189
- test("does not bundle a server page when stale manifest marks page.tsx as clientModule", async () => {
190
- const staleRoot = await mkdtemp(path.join(import.meta.dir, ".tmp-stale-client-module-"));
200
+ test("does not bundle a server page when stale manifest marks page.tsx as clientModule", async () => {
201
+ const staleRoot = await mkRepoTempDir("stale-client-module-");
191
202
  try {
192
203
  await mkdir(path.join(staleRoot, "app"), { recursive: true });
193
204
  await mkdir(path.join(staleRoot, "src", "shared", "contracts"), { recursive: true });
@@ -220,7 +231,7 @@ describe("buildClientBundles vendor shims", () => {
220
231
  });
221
232
 
222
233
  test("rewrites route-component clientModule to the real client import before bundling", async () => {
223
- const routeClientRoot = await mkdtemp(path.join(import.meta.dir, ".tmp-route-client-import-"));
234
+ const routeClientRoot = await mkRepoTempDir("route-client-import-");
224
235
  try {
225
236
  await mkdir(path.join(routeClientRoot, "app", "login"), { recursive: true });
226
237
  await mkdir(path.join(routeClientRoot, "src", "client", "pages", "login"), { recursive: true });
@@ -267,7 +278,7 @@ describe("buildClientBundles vendor shims", () => {
267
278
  });
268
279
 
269
280
  test("bundles route-level named client exports without requiring a default export", async () => {
270
- const routeClientRoot = await mkdtemp(path.join(import.meta.dir, ".tmp-route-named-client-import-"));
281
+ const routeClientRoot = await mkRepoTempDir("route-named-client-import-");
271
282
  try {
272
283
  await mkdir(path.join(routeClientRoot, "app", "login"), { recursive: true });
273
284
  await mkdir(path.join(routeClientRoot, "src", "client", "pages", "login"), { recursive: true });
@@ -317,8 +328,34 @@ describe("buildClientBundles vendor shims", () => {
317
328
  }
318
329
  });
319
330
 
331
+ test("uses Windows-safe asset filenames for locale-prefixed route ids", async () => {
332
+ const localeRoot = await mkRepoTempDir("i18n-locale-route-id-");
333
+ try {
334
+ await mkdir(path.join(localeRoot, "app"), { recursive: true });
335
+ await writeFile(
336
+ path.join(localeRoot, "package.json"),
337
+ JSON.stringify({ name: "mandu-i18n-route-id-test", type: "module" }, null, 2),
338
+ "utf-8",
339
+ );
340
+ await writeFile(
341
+ path.join(localeRoot, "app", "demo.client.tsx"),
342
+ "export default function DemoIsland() { return null; }\n",
343
+ "utf-8",
344
+ );
345
+
346
+ const localeResult = await runBuildInSubprocess(localeRoot, "i18n-locale-route-id");
347
+ expect(localeResult.success).toBe(true);
348
+ const bundle = localeResult.manifest?.bundles?.["ko::demo"];
349
+ expect(bundle?.js).toBe("/.mandu/client/ko_3a__3a_demo.island.js");
350
+ expect(bundle?.js).not.toContain(":");
351
+ expect(await Bun.file(path.join(localeRoot, ".mandu", "client", "ko_3a__3a_demo.island.js")).exists()).toBe(true);
352
+ } finally {
353
+ await rm(localeRoot, { recursive: true, force: true });
354
+ }
355
+ });
356
+
320
357
  test("fails when hydration is enabled but no clientModule can be resolved", async () => {
321
- const missingRoot = await mkdtemp(path.join(import.meta.dir, ".tmp-hydration-no-client-"));
358
+ const missingRoot = await mkRepoTempDir("hydration-no-client-");
322
359
  try {
323
360
  await mkdir(path.join(missingRoot, "app", "login"), { recursive: true });
324
361
  await mkdir(path.join(missingRoot, "src", "client", "widgets", "login-form"), { recursive: true });
@@ -1929,25 +1929,34 @@ async function buildVendorShims(
1929
1929
  };
1930
1930
  }
1931
1931
 
1932
- function vendorShimFailureHint(shimName: string): string {
1933
- if (shimName.includes("react-refresh")) {
1934
- return "Hint: install the optional dev peer dependency with `bun add -d react-refresh`.";
1935
- }
1936
- return "Hint: check the import paths and ensure the vendor package is installed.";
1937
- }
1938
-
1939
- /**
1940
- * 단일 Island 번들 빌드
1941
- */
1932
+ function vendorShimFailureHint(shimName: string): string {
1933
+ if (shimName.includes("react-refresh")) {
1934
+ return "Hint: install the optional dev peer dependency with `bun add -d react-refresh`.";
1935
+ }
1936
+ return "Hint: check the import paths and ensure the vendor package is installed.";
1937
+ }
1938
+
1939
+ function routeIdToAssetStem(routeId: string): string {
1940
+ const safe = routeId.replace(/[<>:"/\\|?*\x00-\x1F]/g, (ch) =>
1941
+ `_${ch.codePointAt(0)!.toString(16)}_`
1942
+ );
1943
+ return safe.replace(/[. ]+$/g, "") || "route";
1944
+ }
1945
+
1946
+ /**
1947
+ * 단일 Island 번들 빌드
1948
+ */
1942
1949
  async function buildIsland(
1943
1950
  route: RouteSpec,
1944
1951
  rootDir: string,
1945
1952
  outDir: string,
1946
1953
  options: BundlerOptions
1947
- ): Promise<BundleOutput> {
1948
- const clientModulePath = path.join(rootDir, route.clientModule!);
1949
- const entryPath = path.join(outDir, `_entry_${route.id}.js`);
1950
- const outputName = `${route.id}.island.js`;
1954
+ ): Promise<BundleOutput> {
1955
+ const clientModulePath = path.join(rootDir, route.clientModule!);
1956
+ const assetStem = routeIdToAssetStem(route.id);
1957
+ const entryStem = `_entry_${assetStem}`;
1958
+ const entryPath = path.join(outDir, `${entryStem}.js`);
1959
+ const outputName = `${assetStem}.island.js`;
1951
1960
 
1952
1961
  // Phase 7.1 B-1/B-4: wire native Fast Refresh transform + Mandu's
1953
1962
  // boundary injection plugin. Dev-only; prod bundles remain clean.
@@ -1988,11 +1997,11 @@ async function buildIsland(
1988
1997
  let actualOutputPath: string;
1989
1998
  let actualOutputName: string;
1990
1999
 
1991
- if (options.splitting && result.outputs.length > 0) {
1992
- // splitting 모드: 결과에서 엔트리 파일 찾기
1993
- const entryOutput = result.outputs.find(
1994
- (o) => o.kind === "entry-point" || o.path.includes(route.id)
1995
- );
2000
+ if (options.splitting && result.outputs.length > 0) {
2001
+ // splitting 모드: 결과에서 엔트리 파일 찾기
2002
+ const entryOutput = result.outputs.find(
2003
+ (o) => o.kind === "entry-point" || o.path.includes(entryStem) || o.path.includes(assetStem)
2004
+ );
1996
2005
  if (entryOutput) {
1997
2006
  actualOutputPath = entryOutput.path;
1998
2007
  actualOutputName = path.basename(entryOutput.path);
@@ -1,14 +1,21 @@
1
- import { describe, expect, it } from "bun:test";
2
- import { mkdir, mkdtemp, rm, writeFile } from "fs/promises";
3
- import path from "path";
1
+ import { describe, expect, it } from "bun:test";
2
+ import { mkdir, mkdtemp, rm, writeFile } from "fs/promises";
3
+ import path from "path";
4
4
  import {
5
5
  findClientComponentImports,
6
6
  findRouteLevelClientComponentImport,
7
7
  findRouteLevelClientComponentImports,
8
8
  resolveRouteLevelClientEntryPath,
9
- } from "./client-entry";
10
-
11
- describe("findClientComponentImports", () => {
9
+ } from "./client-entry";
10
+
11
+ const repoTempRoot = path.resolve(import.meta.dir, "../../../..", ".tmp-test-artifacts");
12
+
13
+ async function mkRepoTempDir(prefix: string): Promise<string> {
14
+ await mkdir(repoTempRoot, { recursive: true });
15
+ return mkdtemp(path.join(repoTempRoot, prefix));
16
+ }
17
+
18
+ describe("findClientComponentImports", () => {
12
19
  it("detects named .client imports for diagnostics", () => {
13
20
  const imports = findClientComponentImports(`
14
21
  import { LoginForm, SubmitButton as Button } from "@/client/widgets/login-form/LoginForm.client";
@@ -192,8 +199,8 @@ describe("findClientComponentImports", () => {
192
199
  expect(routeClient).toBeNull();
193
200
  });
194
201
 
195
- it("resolves a route-level client entry by reading a use client target without .client in the path", async () => {
196
- const rootDir = await mkdtemp(path.join(import.meta.dir, ".tmp-client-entry-"));
202
+ it("resolves a route-level client entry by reading a use client target without .client in the path", async () => {
203
+ const rootDir = await mkRepoTempDir("client-entry-");
197
204
  try {
198
205
  await mkdir(path.join(rootDir, "app", "pledges", "new"), { recursive: true });
199
206
  await mkdir(path.join(rootDir, "src", "client", "widgets", "pledge-form"), { recursive: true });
@@ -216,21 +216,28 @@ export async function resolveRouteLevelClientEntry(
216
216
  return null;
217
217
  }
218
218
 
219
- export async function shouldPreserveExistingClientModule(
220
- route: RouteSpec,
221
- clientModule: string,
222
- rootDir: string,
223
- ): Promise<boolean> {
224
- if (!isRouteLevelClientEntrySpecifier(clientModule)) return false;
225
- const source = await readRouteModule(rootDir, clientModule);
226
- if (source === null) return false;
227
- if (hasUseServerDirective(source)) return false;
228
- if (clientModuleIsRouteComponent(route, clientModule)) {
229
- if (hasUseClientDirective(source)) return true;
230
- return await routeComponentHasResolvableClientEntry(rootDir, clientModule, source);
231
- }
232
- return true;
233
- }
219
+ export async function shouldPreserveExistingClientModule(
220
+ route: RouteSpec,
221
+ clientModule: string,
222
+ rootDir: string,
223
+ ): Promise<boolean> {
224
+ if (!isRouteLevelClientEntrySpecifier(clientModule)) return false;
225
+ const source = await readRouteModule(rootDir, clientModule);
226
+ if (source === null) return false;
227
+ if (hasUseServerDirective(source)) return false;
228
+ if (clientModuleIsRouteComponent(route, clientModule)) {
229
+ if (hasUseClientDirective(source)) return true;
230
+ return await routeComponentHasResolvableClientEntry(rootDir, clientModule, source);
231
+ }
232
+
233
+ if (route.kind !== "page" || !route.componentModule) return false;
234
+
235
+ const routeSource = await readRouteModule(rootDir, route.componentModule);
236
+ if (routeSource === null) return false;
237
+
238
+ const currentEntry = await resolveRouteLevelClientEntry(rootDir, route.componentModule, routeSource);
239
+ return normalizeRouteModulePath(currentEntry?.modulePath) === normalizeRouteModulePath(clientModule);
240
+ }
234
241
 
235
242
  function resolveImportBasePath(rootDir: string, importerModule: string, specifier: string): string | null {
236
243
  const normalized = specifier.replace(/\\/g, "/");
@@ -0,0 +1,90 @@
1
+ import { describe, expect, it } from "bun:test";
2
+ import { mkdir, mkdtemp, rm, writeFile } from "fs/promises";
3
+ import path from "path";
4
+ import { generateManifest } from "./fs-routes";
5
+
6
+ const repoTempRoot = path.resolve(import.meta.dir, "../../../..", ".tmp-test-artifacts");
7
+
8
+ async function mkRepoTempDir(prefix: string): Promise<string> {
9
+ await mkdir(repoTempRoot, { recursive: true });
10
+ return mkdtemp(path.join(repoTempRoot, prefix));
11
+ }
12
+
13
+ describe("generateManifest hydration config", () => {
14
+ it("does not preserve stale island hydration after the client entry disappears", async () => {
15
+ const rootDir = await mkRepoTempDir("routes-hydration-stale-");
16
+ try {
17
+ await mkdir(path.join(rootDir, "app", "candidates", "[id]"), { recursive: true });
18
+ await mkdir(path.join(rootDir, "src", "client", "widgets"), { recursive: true });
19
+ await writeFile(
20
+ path.join(rootDir, "app", "candidates", "[id]", "page.tsx"),
21
+ `
22
+ import { PledgeAccordion } from "@/client/widgets/PledgeAccordion.client";
23
+ export default function Page() {
24
+ return <main><PledgeAccordion pledges={[]} /></main>;
25
+ }
26
+ `,
27
+ "utf-8",
28
+ );
29
+ await writeFile(
30
+ path.join(rootDir, "src", "client", "widgets", "PledgeAccordion.client.tsx"),
31
+ `
32
+ "use client";
33
+ export function PledgeAccordion() {
34
+ return <div />;
35
+ }
36
+ `,
37
+ "utf-8",
38
+ );
39
+
40
+ const first = await generateManifest(rootDir);
41
+ const firstRoute = first.manifest.routes.find((route) => route.id === "candidates-$id");
42
+ expect(firstRoute?.clientModule).toContain("PledgeAccordion.client.tsx");
43
+ expect(firstRoute?.hydration?.strategy).toBe("island");
44
+
45
+ await writeFile(
46
+ path.join(rootDir, "app", "candidates", "[id]", "page.tsx"),
47
+ `
48
+ export default function Page() {
49
+ return <main><details><summary>server only</summary></details></main>;
50
+ }
51
+ `,
52
+ "utf-8",
53
+ );
54
+
55
+ const second = await generateManifest(rootDir);
56
+ const secondRoute = second.manifest.routes.find((route) => route.id === "candidates-$id");
57
+ expect(secondRoute?.clientModule).toBeUndefined();
58
+ expect(secondRoute?.hydration?.strategy).not.toBe("island");
59
+ } finally {
60
+ await rm(rootDir, { recursive: true, force: true });
61
+ }
62
+ });
63
+
64
+ it("reads page-level hydration exports from the current page source", async () => {
65
+ const rootDir = await mkRepoTempDir("routes-hydration-export-");
66
+ try {
67
+ await mkdir(path.join(rootDir, "app", "about"), { recursive: true });
68
+ await writeFile(
69
+ path.join(rootDir, "app", "about", "page.tsx"),
70
+ `
71
+ export const hydration = { strategy: "none", priority: "idle", preload: true };
72
+ export default function Page() {
73
+ return <main>About</main>;
74
+ }
75
+ `,
76
+ "utf-8",
77
+ );
78
+
79
+ const result = await generateManifest(rootDir);
80
+ const route = result.manifest.routes.find((entry) => entry.id === "about");
81
+ expect(route?.hydration).toEqual({
82
+ strategy: "none",
83
+ priority: "idle",
84
+ preload: true,
85
+ });
86
+ } finally {
87
+ await rm(rootDir, { recursive: true, force: true });
88
+ }
89
+ });
90
+ });
@@ -94,10 +94,12 @@ export function fsRouteToRouteSpec(fsRoute: FSRouteConfig): RouteSpec {
94
94
  hydration: fsRoute.hydration ?? {
95
95
  strategy: "island" as const,
96
96
  priority: "immediate" as const,
97
- preload: false,
98
- },
99
- }
100
- : {}),
97
+ preload: false,
98
+ },
99
+ }
100
+ : fsRoute.hydration
101
+ ? { hydration: fsRoute.hydration }
102
+ : {}),
101
103
  ...(fsRoute.layoutChain && fsRoute.layoutChain.length > 0
102
104
  ? { layoutChain: fsRoute.layoutChain.map(normalizePath) }
103
105
  : {}),
@@ -322,9 +324,13 @@ export async function generateManifest(
322
324
  route.clientModule = prev.clientModule;
323
325
  route.clientExportName = prev.clientExportName;
324
326
  }
325
- if (prev.hydration && !route.hydration) {
326
- route.hydration = prev.hydration;
327
- }
327
+ if (prev.hydration && !route.hydration) {
328
+ const canPreserveHydration =
329
+ !!route.clientModule || prev.hydration.strategy === "none";
330
+ if (canPreserveHydration) {
331
+ route.hydration = prev.hydration;
332
+ }
333
+ }
328
334
  // Issue #214 — preserve prerender-time fields (`dynamicParams`,
329
335
  // `staticParams`) across manifest rescans. `mandu build`'s prerender
330
336
  // phase stamps these onto the manifest; a subsequent `mandu dev`
@@ -28,16 +28,55 @@ import {
28
28
  sortRoutesByPriority,
29
29
  getPatternShape,
30
30
  } from "./fs-patterns";
31
- import { mark, measure } from "../perf";
32
- import { METADATA_ROUTES } from "../routes/types";
31
+ import { mark, measure } from "../perf";
32
+ import { METADATA_ROUTES } from "../routes/types";
33
+ import type { HydrationConfig } from "../spec/schema";
33
34
  import {
34
35
  hasUseClientDirective,
35
36
  resolveRouteLevelClientEntry,
36
37
  } from "./client-entry";
37
-
38
- // ═══════════════════════════════════════════════════════════════════════════
39
- // Scanner Class
40
- // ═══════════════════════════════════════════════════════════════════════════
38
+
39
+ const HYDRATION_STRATEGIES = new Set(["none", "island", "full", "progressive"]);
40
+ const HYDRATION_PRIORITIES = new Set(["immediate", "visible", "idle", "interaction"]);
41
+
42
+ function parsePageHydrationConfig(source: string): HydrationConfig | undefined {
43
+ const stringMatch = source.match(
44
+ /export\s+const\s+hydration\s*(?::[^=]+)?=\s*["'](none|island|full|progressive)["']/m,
45
+ );
46
+ if (stringMatch?.[1]) {
47
+ return {
48
+ strategy: stringMatch[1] as HydrationConfig["strategy"],
49
+ priority: "visible",
50
+ preload: false,
51
+ };
52
+ }
53
+
54
+ const objectMatch = source.match(
55
+ /export\s+const\s+hydration\s*(?::[^=]+)?=\s*\{([\s\S]*?)\}\s*;?/m,
56
+ );
57
+ const body = objectMatch?.[1];
58
+ if (!body) return undefined;
59
+
60
+ const strategyMatch = body.match(/\bstrategy\s*:\s*["']([^"']+)["']/);
61
+ const strategy = strategyMatch?.[1];
62
+ if (!strategy || !HYDRATION_STRATEGIES.has(strategy)) return undefined;
63
+
64
+ const priorityMatch = body.match(/\bpriority\s*:\s*["']([^"']+)["']/);
65
+ const priority = priorityMatch?.[1];
66
+ const preloadMatch = body.match(/\bpreload\s*:\s*(true|false)\b/);
67
+
68
+ return {
69
+ strategy: strategy as HydrationConfig["strategy"],
70
+ priority: HYDRATION_PRIORITIES.has(priority ?? "")
71
+ ? (priority as HydrationConfig["priority"])
72
+ : "visible",
73
+ preload: preloadMatch?.[1] === "true",
74
+ };
75
+ }
76
+
77
+ // ═══════════════════════════════════════════════════════════════════════════
78
+ // Scanner Class
79
+ // ═══════════════════════════════════════════════════════════════════════════
41
80
 
42
81
  /**
43
82
  * FS Routes 스캐너
@@ -367,15 +406,19 @@ export class FSScanner {
367
406
  // clientModule 결정: island 파일 또는 "use client"가 있는 page 자체
368
407
  let clientModule: string | undefined;
369
408
  let clientExportName: string | undefined;
409
+ let hydration: HydrationConfig | undefined;
370
410
  let pageFileContent: string | null = null;
371
-
372
- if (file.type === "page") {
373
- try {
374
- pageFileContent = await Bun.file(file.absolutePath).text();
375
- } catch {
376
- pageFileContent = null;
377
- }
378
- }
411
+
412
+ if (file.type === "page") {
413
+ try {
414
+ pageFileContent = await Bun.file(file.absolutePath).text();
415
+ } catch {
416
+ pageFileContent = null;
417
+ }
418
+ if (pageFileContent) {
419
+ hydration = parsePageHydrationConfig(pageFileContent);
420
+ }
421
+ }
379
422
 
380
423
  if (islands?.[0]) {
381
424
  // 우선순위: 명시적 island 파일
@@ -422,9 +465,10 @@ export class FSScanner {
422
465
  componentModule: file.type === "page" ? modulePath : undefined,
423
466
  clientModule,
424
467
  clientExportName,
468
+ hydration,
425
469
  layoutChain,
426
- loadingModule,
427
- errorModule,
470
+ loadingModule,
471
+ errorModule,
428
472
  notFoundModule,
429
473
  sourceFile: file.absolutePath,
430
474
  };
@@ -100,4 +100,65 @@ describe("runtime page render response orchestration", () => {
100
100
  expect(html).toContain('"home":{"serverData":{"items":["a","b"]}');
101
101
  expect(html).not.toContain('"serverData":{"home"');
102
102
  });
103
+
104
+ it("serializes props for inline client components rendered by async server pages", async () => {
105
+ function PledgeAccordion({ pledges }: { pledges: Array<{ id: string; title: string }> }) {
106
+ return React.createElement(
107
+ "ul",
108
+ null,
109
+ pledges.map((pledge) => React.createElement("li", { key: pledge.id }, pledge.title)),
110
+ );
111
+ }
112
+
113
+ async function CandidatePage() {
114
+ const pledges = [
115
+ { id: "p1", title: "Public transit" },
116
+ { id: "p2", title: "Housing" },
117
+ ];
118
+ return React.createElement(
119
+ "main",
120
+ null,
121
+ React.createElement(PledgeAccordion, { pledges }),
122
+ );
123
+ }
124
+
125
+ const response = await renderPageResponse({
126
+ app: React.createElement(CandidatePage),
127
+ useStreaming: false,
128
+ title: "Candidate",
129
+ headTags: "",
130
+ isDev: false,
131
+ routeId: "candidates-$id",
132
+ routePattern: "/candidates/:id",
133
+ hydration: { strategy: "island", priority: "immediate", preload: false },
134
+ bundleManifest: {
135
+ ...HYDRATED_MANIFEST,
136
+ bundles: {
137
+ "candidates-$id": {
138
+ js: "/.mandu/client/candidates-$id.island.js",
139
+ dependencies: ["_runtime", "_react"],
140
+ priority: "immediate",
141
+ },
142
+ },
143
+ },
144
+ loaderData: undefined,
145
+ transitions: false,
146
+ prefetch: false,
147
+ spa: false,
148
+ devtools: false,
149
+ inlineClientHydration: {
150
+ routeId: "candidates-$id",
151
+ src: "/.mandu/client/candidates-$id.island.js",
152
+ priority: "immediate",
153
+ component: PledgeAccordion,
154
+ },
155
+ });
156
+
157
+ const html = await response.text();
158
+ expect(html).toContain('data-mandu-island="candidates-$id--0"');
159
+ expect(html).toContain('data-mandu-src="/.mandu/client/candidates-$id.island.js"');
160
+ expect(html).toContain("&quot;pledges&quot;");
161
+ expect(html).toContain("Public transit");
162
+ expect(html).not.toContain('data-mandu-island="candidates-$id"');
163
+ });
103
164
  });
@@ -1,8 +1,16 @@
1
- import type React from "react";
1
+ import React from "react";
2
2
  import type { BundleManifest } from "../bundler/types";
3
3
  import type { HydrationConfig } from "../spec/schema";
4
4
  import type { CookieManager } from "../filling/context";
5
5
  import { renderSSR, renderStreamingResponse, resolveAsyncElement } from "./ssr";
6
+ import { serializeProps } from "../client/serialize";
7
+
8
+ export interface InlineClientHydrationTarget {
9
+ routeId: string;
10
+ src: string;
11
+ priority: NonNullable<HydrationConfig["priority"]>;
12
+ component: unknown;
13
+ }
6
14
 
7
15
  export interface PageRenderResponseOptions {
8
16
  app: React.ReactElement;
@@ -23,6 +31,7 @@ export interface PageRenderResponseOptions {
23
31
  spa?: boolean;
24
32
  devtools?: boolean;
25
33
  islandPreWrapped?: boolean;
34
+ inlineClientHydration?: InlineClientHydrationTarget;
26
35
  cookies?: CookieManager;
27
36
  }
28
37
 
@@ -30,18 +39,111 @@ export async function renderPageResponse(
30
39
  options: PageRenderResponseOptions
31
40
  ): Promise<Response> {
32
41
  let app = options.app;
42
+ let islandPreWrapped = !!options.islandPreWrapped;
33
43
 
34
44
  if (!options.useStreaming) {
35
- app = (await resolveAsyncElement(app)) as React.ReactElement;
45
+ if (options.inlineClientHydration) {
46
+ const resolved = await resolveAndWrapInlineClientHydration(
47
+ app,
48
+ options.inlineClientHydration,
49
+ );
50
+ app = resolved.node as React.ReactElement;
51
+ islandPreWrapped = islandPreWrapped || resolved.didWrap;
52
+ } else {
53
+ app = (await resolveAsyncElement(app)) as React.ReactElement;
54
+ }
36
55
  }
37
56
 
57
+ const effectiveOptions = islandPreWrapped === !!options.islandPreWrapped
58
+ ? options
59
+ : { ...options, islandPreWrapped };
60
+
38
61
  const response = options.useStreaming
39
- ? await renderStreamingPageResponse(app, options)
40
- : renderNonStreamingPageResponse(app, options);
62
+ ? await renderStreamingPageResponse(app, effectiveOptions)
63
+ : renderNonStreamingPageResponse(app, effectiveOptions);
41
64
 
42
65
  return options.cookies ? options.cookies.applyToResponse(response) : response;
43
66
  }
44
67
 
68
+ async function resolveAndWrapInlineClientHydration(
69
+ node: React.ReactNode,
70
+ target: InlineClientHydrationTarget,
71
+ counter = { value: 0 },
72
+ ): Promise<{ node: React.ReactNode; didWrap: boolean }> {
73
+ if (node == null || typeof node !== "object") {
74
+ return { node, didWrap: false };
75
+ }
76
+
77
+ if (Array.isArray(node)) {
78
+ let didWrap = false;
79
+ const children = await Promise.all(
80
+ node.map(async (child) => {
81
+ const result = await resolveAndWrapInlineClientHydration(child, target, counter);
82
+ didWrap = didWrap || result.didWrap;
83
+ return result.node;
84
+ }),
85
+ );
86
+ return { node: children, didWrap };
87
+ }
88
+
89
+ if (!React.isValidElement(node)) {
90
+ return { node, didWrap: false };
91
+ }
92
+
93
+ const element = node as React.ReactElement<Record<string, unknown>>;
94
+ const type = element.type;
95
+
96
+ if (type === target.component) {
97
+ const id = `${target.routeId}--${counter.value++}`;
98
+ return {
99
+ node: React.createElement(
100
+ "div",
101
+ {
102
+ "data-mandu-island": id,
103
+ "data-mandu-src": target.src,
104
+ "data-mandu-priority": target.priority,
105
+ "data-hydrate": priorityToHydrate(target.priority),
106
+ "data-props": serializeProps(element.props ?? {}),
107
+ style: { display: "contents" },
108
+ },
109
+ element,
110
+ ),
111
+ didWrap: true,
112
+ };
113
+ }
114
+
115
+ if (typeof type === "function" && !isClassComponent(type)) {
116
+ const rendered = await (type as (props: Record<string, unknown>) => React.ReactNode | Promise<React.ReactNode>)(
117
+ element.props ?? {},
118
+ );
119
+ return resolveAndWrapInlineClientHydration(rendered, target, counter);
120
+ }
121
+
122
+ const props = element.props;
123
+ const rawChildren = props?.children as React.ReactNode | undefined;
124
+ if (rawChildren === undefined) {
125
+ return { node: element, didWrap: false };
126
+ }
127
+
128
+ const resolvedChildren = await resolveAndWrapInlineClientHydration(rawChildren, target, counter);
129
+ if (!resolvedChildren.didWrap && resolvedChildren.node === rawChildren) {
130
+ return { node: element, didWrap: false };
131
+ }
132
+
133
+ const cloned = Array.isArray(resolvedChildren.node)
134
+ ? React.cloneElement(element, undefined, ...resolvedChildren.node)
135
+ : React.cloneElement(element, undefined, resolvedChildren.node);
136
+ return { node: cloned, didWrap: resolvedChildren.didWrap };
137
+ }
138
+
139
+ function isClassComponent(type: Function): boolean {
140
+ return !!(type.prototype && type.prototype.isReactComponent);
141
+ }
142
+
143
+ function priorityToHydrate(priority: InlineClientHydrationTarget["priority"]): string {
144
+ return priority === "immediate" ? "load" : priority;
145
+ }
146
+
45
147
  async function renderStreamingPageResponse(
46
148
  app: React.ReactElement,
47
149
  options: PageRenderResponseOptions
@@ -157,10 +157,10 @@ describe("Wildcard Matching", () => {
157
157
 
158
158
  const result = router.match("/docs");
159
159
 
160
- expect(result).not.toBeNull();
161
- expect(result!.route.id).toBe("docs");
162
- expect(result!.params).toEqual({});
163
- });
160
+ expect(result).not.toBeNull();
161
+ expect(result!.route.id).toBe("docs");
162
+ expect(result!.params).toEqual({ path: "" });
163
+ });
164
164
 
165
165
  test("optional wildcard matches with remaining path", () => {
166
166
  const router = createRouter([
@@ -415,12 +415,10 @@ export class Router {
415
415
  route,
416
416
  };
417
417
 
418
- // Optional wildcard: 현재 노드도 매칭 가능하게 route 설정
419
- if (isOptional && !node.route) {
420
- node.route = route;
421
- }
422
- return;
423
- }
418
+ // Optional wildcard base paths are matched from wildcardConfig so
419
+ // the named wildcard param is still materialized as an empty string.
420
+ return;
421
+ }
424
422
 
425
423
  // Regular parameter: :param
426
424
  const paramName = seg.slice(1);
@@ -528,12 +526,12 @@ export class Router {
528
526
  if (node.wildcardConfig.optional) {
529
527
  if (this.debug) {
530
528
  console.log(`[Router] Optional wildcard match: ${node.wildcardConfig.route.id} with empty path`);
531
- }
532
- return {
533
- route: node.wildcardConfig.route,
534
- params,
535
- };
536
- }
529
+ }
530
+ return {
531
+ route: node.wildcardConfig.route,
532
+ params: { ...params, [node.wildcardConfig.name]: "" },
533
+ };
534
+ }
537
535
  // Non-optional wildcard: /files/:path* does NOT match /files
538
536
  if (this.debug) {
539
537
  console.log(`[Router] Wildcard policy: ${pathname} does not match non-optional wildcard`);
@@ -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,