@mandujs/core 0.54.12 → 0.54.13

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 (76) hide show
  1. package/package.json +9 -1
  2. package/scripts/postinstall-lock.ts +153 -153
  3. package/src/a11y/run-audit.ts +15 -15
  4. package/src/agent/__tests__/context.test.ts +49 -1
  5. package/src/agent/context.ts +535 -535
  6. package/src/agent/index.ts +6 -6
  7. package/src/agent/plan.ts +282 -282
  8. package/src/agent/repair.ts +171 -171
  9. package/src/agent/sync.ts +200 -200
  10. package/src/agent/types.ts +8 -0
  11. package/src/agent/verify.ts +100 -2
  12. package/src/brain/doctor/analyzer.ts +7 -7
  13. package/src/bundler/__tests__/build-runner.ts +5 -4
  14. package/src/bundler/__tests__/cold-start.test.ts +60 -60
  15. package/src/bundler/__tests__/css.test.ts +20 -20
  16. package/src/bundler/analyzer.ts +15 -15
  17. package/src/bundler/build.test.ts +66 -15
  18. package/src/bundler/build.ts +165 -103
  19. package/src/bundler/css.ts +42 -42
  20. package/src/bundler/manifest-schema.ts +21 -21
  21. package/src/bundler/plugins/__tests__/block-generated-imports.test.ts +13 -13
  22. package/src/bundler/plugins/block-generated-imports.ts +13 -13
  23. package/src/bundler/types.ts +31 -31
  24. package/src/client/island.ts +79 -79
  25. package/src/config/validate.ts +1 -1
  26. package/src/contract/schema.ts +7 -0
  27. package/src/deploy/inference/context.ts +82 -82
  28. package/src/devtools/client/components/panel/islands-panel.tsx +16 -16
  29. package/src/devtools/client/components/panel/panel-container.tsx +1 -1
  30. package/src/error/formatter.ts +10 -1
  31. package/src/experimental/index.ts +10 -0
  32. package/src/filling/context.ts +17 -17
  33. package/src/filling/filling.ts +22 -1
  34. package/src/filling/index.ts +15 -1
  35. package/src/generator/generate.ts +30 -30
  36. package/src/generator/index.ts +3 -3
  37. package/src/generator/templates.ts +210 -210
  38. package/src/guard/check.ts +9 -9
  39. package/src/guard/config-guard.ts +13 -13
  40. package/src/guard/fs-routes-policy.ts +51 -51
  41. package/src/guard/index.ts +11 -11
  42. package/src/index.ts +0 -10
  43. package/src/internal/index.ts +25 -0
  44. package/src/kitchen/api/file-api.ts +11 -11
  45. package/src/report/index.ts +1 -1
  46. package/src/resource/__tests__/generator.test.ts +6 -6
  47. package/src/resource/__tests__/schema.test.ts +14 -14
  48. package/src/resource/ddl/__tests__/emit.test.ts +165 -165
  49. package/src/resource/ddl/emit.ts +146 -146
  50. package/src/resource/generator-schema.ts +11 -11
  51. package/src/resource/generators/slot.ts +72 -72
  52. package/src/resource/schema.ts +21 -21
  53. package/src/router/client-entry.test.ts +69 -33
  54. package/src/router/client-entry.ts +134 -74
  55. package/src/router/fs-routes.ts +24 -22
  56. package/src/router/fs-scanner.ts +21 -17
  57. package/src/router/fs-types.ts +8 -5
  58. package/src/runtime/__tests__/devtools-adapter.test.ts +68 -68
  59. package/src/runtime/__tests__/observability-lifecycle.test.ts +103 -103
  60. package/src/runtime/__tests__/page-render-response.test.ts +103 -103
  61. package/src/runtime/__tests__/request-middleware.test.ts +70 -70
  62. package/src/runtime/devtools-adapter.ts +68 -68
  63. package/src/runtime/escape.ts +34 -34
  64. package/src/runtime/image-feature.ts +15 -0
  65. package/src/runtime/observability-lifecycle.ts +290 -290
  66. package/src/runtime/page-render-response.ts +106 -106
  67. package/src/runtime/rate-limit.ts +231 -0
  68. package/src/runtime/request-middleware.ts +31 -31
  69. package/src/runtime/scheduler-lifecycle.ts +64 -0
  70. package/src/runtime/server.ts +27 -295
  71. package/src/runtime/ssr.ts +59 -59
  72. package/src/runtime/static-files.ts +289 -289
  73. package/src/runtime/streaming-ssr.ts +22 -22
  74. package/src/spec/schema.ts +4 -3
  75. package/src/watcher/__tests__/watcher.test.ts +59 -59
  76. package/src/watcher/watcher.ts +61 -61
@@ -40,10 +40,11 @@ describe("findClientComponentImports", () => {
40
40
  }
41
41
  `);
42
42
 
43
- expect(routeClient).toEqual({
44
- module: "@/client/pages/login/LoginPage.client",
45
- localName: "LoginPage",
46
- });
43
+ expect(routeClient).toEqual({
44
+ module: "@/client/pages/login/LoginPage.client",
45
+ localName: "LoginPage",
46
+ exportName: "default",
47
+ });
47
48
  });
48
49
 
49
50
  it("promotes a fragment wrapper with head-only elements and a bare client component", () => {
@@ -58,10 +59,11 @@ describe("findClientComponentImports", () => {
58
59
  }
59
60
  `);
60
61
 
61
- expect(routeClient).toEqual({
62
- module: "@/client/pages/home/HomeApp.client",
63
- localName: "HomeApp",
64
- });
62
+ expect(routeClient).toEqual({
63
+ module: "@/client/pages/home/HomeApp.client",
64
+ localName: "HomeApp",
65
+ exportName: "default",
66
+ });
65
67
  });
66
68
 
67
69
  it("detects a named-imported client component when the page returns only that component", () => {
@@ -73,11 +75,28 @@ describe("findClientComponentImports", () => {
73
75
  }
74
76
  `);
75
77
 
76
- expect(routeClient).toEqual({
77
- module: "@/client/pages/notifications/NotificationsPage.client",
78
- localName: "NotificationsPage",
79
- });
80
- });
78
+ expect(routeClient).toEqual({
79
+ module: "@/client/pages/notifications/NotificationsPage.client",
80
+ localName: "NotificationsPage",
81
+ exportName: "NotificationsPage",
82
+ });
83
+ });
84
+
85
+ it("preserves the source export name for aliased named imports", () => {
86
+ const routeClient = findRouteLevelClientComponentImport(`
87
+ import { NotificationsPage as PageIsland } from "@/client/pages/notifications/NotificationsPage.client";
88
+
89
+ export default function Page() {
90
+ return <PageIsland />;
91
+ }
92
+ `);
93
+
94
+ expect(routeClient).toEqual({
95
+ module: "@/client/pages/notifications/NotificationsPage.client",
96
+ localName: "PageIsland",
97
+ exportName: "NotificationsPage",
98
+ });
99
+ });
81
100
 
82
101
  it("promotes embedded client imports inside a larger server page", () => {
83
102
  const routeClient = findRouteLevelClientComponentImport(`
@@ -91,10 +110,11 @@ describe("findClientComponentImports", () => {
91
110
  }
92
111
  `);
93
112
 
94
- expect(routeClient).toEqual({
95
- module: "@/client/pages/home/HomeApp.client",
96
- localName: "HomeApp",
97
- });
113
+ expect(routeClient).toEqual({
114
+ module: "@/client/pages/home/HomeApp.client",
115
+ localName: "HomeApp",
116
+ exportName: "default",
117
+ });
98
118
  });
99
119
 
100
120
  it("promotes client imports when the page wrapper passes props", () => {
@@ -106,10 +126,11 @@ describe("findClientComponentImports", () => {
106
126
  }
107
127
  `);
108
128
 
109
- expect(routeClient).toEqual({
110
- module: "@/client/pages/pledges/PledgePage.client",
111
- localName: "PledgePage",
112
- });
129
+ expect(routeClient).toEqual({
130
+ module: "@/client/pages/pledges/PledgePage.client",
131
+ localName: "PledgePage",
132
+ exportName: "default",
133
+ });
113
134
  });
114
135
 
115
136
  it("detects multiple rendered client imports in a server shell", () => {
@@ -130,14 +151,16 @@ describe("findClientComponentImports", () => {
130
151
  `);
131
152
 
132
153
  expect(routeClients).toEqual([
133
- {
134
- module: "@/client/widgets/comments-section/CommentsSection.client",
135
- localName: "CommentsSection",
136
- },
137
- {
138
- module: "@/client/widgets/pledge-actions/PledgeActions.client",
139
- localName: "PledgeActions",
140
- },
154
+ {
155
+ module: "@/client/widgets/comments-section/CommentsSection.client",
156
+ localName: "CommentsSection",
157
+ exportName: "CommentsSection",
158
+ },
159
+ {
160
+ module: "@/client/widgets/pledge-actions/PledgeActions.client",
161
+ localName: "PledgeActions",
162
+ exportName: "PledgeActions",
163
+ },
141
164
  ]);
142
165
  });
143
166
 
@@ -150,10 +173,23 @@ describe("findClientComponentImports", () => {
150
173
  }
151
174
  `);
152
175
 
153
- expect(routeClient).toEqual({
154
- module: "@/client/widgets/pledge-form/PledgeForm",
155
- localName: "PledgeForm",
156
- });
176
+ expect(routeClient).toEqual({
177
+ module: "@/client/widgets/pledge-form/PledgeForm",
178
+ localName: "PledgeForm",
179
+ exportName: "PledgeForm",
180
+ });
181
+ });
182
+
183
+ it("does not promote shared UI primitives to route-level client entries", () => {
184
+ const routeClient = findRouteLevelClientComponentImport(`
185
+ import { Button } from "@/client/shared/ui/button";
186
+
187
+ export default function Page() {
188
+ return <main><Button>Open</Button></main>;
189
+ }
190
+ `);
191
+
192
+ expect(routeClient).toBeNull();
157
193
  });
158
194
 
159
195
  it("resolves a route-level client entry by reading a use client target without .client in the path", async () => {
@@ -8,12 +8,23 @@ export interface ClientComponentImport {
8
8
  names: string[];
9
9
  }
10
10
 
11
- export interface RouteLevelClientComponentImport {
12
- module: string;
13
- localName: string;
14
- }
15
-
16
- const CLIENT_ENTRY_SPECIFIER_PATTERN = /\.(?:client|island)(?:\.[tj]sx?)?$/;
11
+ export interface RouteLevelClientComponentImport {
12
+ module: string;
13
+ localName: string;
14
+ exportName: string;
15
+ }
16
+
17
+ const CLIENT_ENTRY_SPECIFIER_PATTERN = /\.(?:client|island)(?:\.[tj]sx?)?$/;
18
+ const DEFAULT_EXPORT_NAME = "default";
19
+
20
+ interface ComponentImportSpecifier {
21
+ importedName: string;
22
+ localName: string;
23
+ }
24
+
25
+ interface ComponentImportRecord extends ClientComponentImport {
26
+ specifiers: ComponentImportSpecifier[];
27
+ }
17
28
 
18
29
  export function normalizeRouteModulePath(value: string | undefined): string {
19
30
  return (value ?? "").replace(/\\/g, "/").replace(/^\.\//, "");
@@ -45,45 +56,64 @@ async function readRouteModule(rootDir: string, modulePath: string): Promise<str
45
56
  }
46
57
  }
47
58
 
48
- export function findClientComponentImports(source: string): ClientComponentImport[] {
49
- return findComponentImports(source).filter((entry) => clientSpecifierLooksBrowserOnly(entry.module));
50
- }
51
-
52
- function findComponentImports(source: string): ClientComponentImport[] {
53
- const imports: ClientComponentImport[] = [];
54
- const importFromPattern = /import\s+(?!type\b)([\s\S]*?)\s+from\s+["']([^"']+)["']/g;
55
- const sideEffectPattern = /import\s+["']([^"']+)["']/g;
59
+ export function findClientComponentImports(source: string): ClientComponentImport[] {
60
+ return findComponentImportRecords(source)
61
+ .filter((entry) => clientSpecifierLooksBrowserOnly(entry.module))
62
+ .map(toPublicClientComponentImport);
63
+ }
64
+
65
+ function toPublicClientComponentImport(entry: ComponentImportRecord): ClientComponentImport {
66
+ return {
67
+ module: entry.module,
68
+ kind: entry.kind,
69
+ names: entry.names,
70
+ };
71
+ }
72
+
73
+ function findComponentImportRecords(source: string): ComponentImportRecord[] {
74
+ const imports: ComponentImportRecord[] = [];
75
+ const importFromPattern = /import\s+(?!type\b)([\s\S]*?)\s+from\s+["']([^"']+)["']/g;
76
+ const sideEffectPattern = /import\s+["']([^"']+)["']/g;
56
77
 
57
78
  for (const match of source.matchAll(importFromPattern)) {
58
79
  const clause = (match[1] ?? "").trim();
59
- const module = match[2] ?? "";
60
- const names: string[] = [];
61
- let hasDefault = false;
62
- let hasNamed = false;
63
- let hasNamespace = false;
80
+ const module = match[2] ?? "";
81
+ const names: string[] = [];
82
+ const specifiers: ComponentImportSpecifier[] = [];
83
+ let hasDefault = false;
84
+ let hasNamed = false;
85
+ let hasNamespace = false;
64
86
 
65
87
  const namedMatch = clause.match(/\{([^}]*)\}/);
66
88
  if (namedMatch) {
67
89
  hasNamed = true;
68
90
  for (const rawName of namedMatch[1].split(",")) {
69
- const name = rawName.trim();
70
- if (!name) continue;
71
- const parts = name.split(/\s+as\s+/i).map((part) => part.trim()).filter(Boolean);
72
- names.push(parts[1] ?? parts[0]);
73
- }
74
- }
75
-
76
- if (/\*\s+as\s+/.test(clause)) {
77
- hasNamespace = true;
78
- const namespaceMatch = clause.match(/\*\s+as\s+([A-Za-z_$][A-Za-z0-9_$]*)/);
79
- if (namespaceMatch?.[1]) names.push(namespaceMatch[1]);
80
- }
81
-
82
- const beforeNamed = clause.split("{")[0]?.replace(/,\s*$/, "").trim() ?? "";
83
- if (beforeNamed && !beforeNamed.startsWith("*")) {
84
- hasDefault = true;
85
- names.push(beforeNamed.split(",")[0].trim());
86
- }
91
+ const name = rawName.trim();
92
+ if (!name) continue;
93
+ const parts = name.split(/\s+as\s+/i).map((part) => part.trim()).filter(Boolean);
94
+ const importedName = parts[0] ?? "";
95
+ const localName = parts[1] ?? importedName;
96
+ names.push(localName);
97
+ specifiers.push({ importedName, localName });
98
+ }
99
+ }
100
+
101
+ if (/\*\s+as\s+/.test(clause)) {
102
+ hasNamespace = true;
103
+ const namespaceMatch = clause.match(/\*\s+as\s+([A-Za-z_$][A-Za-z0-9_$]*)/);
104
+ if (namespaceMatch?.[1]) {
105
+ names.push(namespaceMatch[1]);
106
+ specifiers.push({ importedName: "*", localName: namespaceMatch[1] });
107
+ }
108
+ }
109
+
110
+ const beforeNamed = clause.split("{")[0]?.replace(/,\s*$/, "").trim() ?? "";
111
+ if (beforeNamed && !beforeNamed.startsWith("*")) {
112
+ hasDefault = true;
113
+ const localName = beforeNamed.split(",")[0].trim();
114
+ names.push(localName);
115
+ specifiers.push({ importedName: DEFAULT_EXPORT_NAME, localName });
116
+ }
87
117
 
88
118
  const kind =
89
119
  (hasDefault && (hasNamed || hasNamespace))
@@ -94,33 +124,51 @@ function findComponentImports(source: string): ClientComponentImport[] {
94
124
  ? "namespace"
95
125
  : "default";
96
126
 
97
- imports.push({ module, kind, names });
98
- }
127
+ imports.push({ module, kind, names, specifiers });
128
+ }
99
129
 
100
130
  for (const match of source.matchAll(sideEffectPattern)) {
101
131
  const module = match[1] ?? "";
102
132
  if (imports.some((entry) => entry.module === module)) continue;
103
- imports.push({ module, kind: "side-effect", names: [] });
104
- }
105
-
106
- return imports;
133
+ imports.push({ module, kind: "side-effect", names: [], specifiers: [] });
134
+ }
135
+
136
+ return imports;
107
137
  }
108
138
 
109
139
  export function findRouteLevelClientComponentImport(source: string): RouteLevelClientComponentImport | null {
110
140
  return findRouteLevelClientComponentImports(source)[0] ?? null;
111
141
  }
112
142
 
113
- export function findRouteLevelClientComponentImports(source: string): RouteLevelClientComponentImport[] {
114
- const candidates = findComponentImports(source).flatMap((entry) =>
115
- entry.names
116
- .filter((localName) => /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(localName))
117
- .map((localName) => ({ module: entry.module, localName }))
118
- );
143
+ export function findRouteLevelClientComponentImports(source: string): RouteLevelClientComponentImport[] {
144
+ const candidates = findComponentImportRecords(source).flatMap((entry) =>
145
+ isRouteLevelClientEntrySpecifier(entry.module)
146
+ ? entry.specifiers
147
+ .filter((specifier) => /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(specifier.localName))
148
+ .map((specifier) => ({
149
+ module: entry.module,
150
+ localName: specifier.localName,
151
+ exportName: specifier.importedName,
152
+ }))
153
+ : []
154
+ );
119
155
 
120
156
  if (candidates.length === 0) return [];
121
157
  return defaultExportRendersClientComponents(source, candidates);
122
158
  }
123
159
 
160
+ function isRouteLevelClientEntrySpecifier(specifier: string): boolean {
161
+ const normalized = specifier.replace(/\\/g, "/");
162
+ if (
163
+ normalized.includes("/client/shared/") ||
164
+ normalized.startsWith("@/client/shared/") ||
165
+ normalized.startsWith("src/client/shared/")
166
+ ) {
167
+ return false;
168
+ }
169
+ return true;
170
+ }
171
+
124
172
  export async function resolveClientImportModulePath(
125
173
  rootDir: string,
126
174
  importerModule: string,
@@ -138,31 +186,42 @@ export async function resolveClientImportModulePath(
138
186
  return null;
139
187
  }
140
188
 
141
- export async function resolveRouteLevelClientEntryPath(
142
- rootDir: string,
143
- routeModule: string,
144
- source: string,
145
- ): Promise<string | null> {
146
- const routeLevelClientImports = findRouteLevelClientComponentImports(source);
147
- for (const routeLevelClientImport of routeLevelClientImports) {
148
- const resolved = await resolveClientImportModulePath(rootDir, routeModule, routeLevelClientImport.module);
149
- if (!resolved) continue;
150
- if (clientSpecifierLooksBrowserOnly(routeLevelClientImport.module)) return resolved;
151
-
152
- const importedSource = await readRouteModule(rootDir, resolved);
153
- if (importedSource !== null && hasUseClientDirective(importedSource)) {
154
- return resolved;
155
- }
156
- }
157
-
158
- return null;
159
- }
189
+ export async function resolveRouteLevelClientEntryPath(
190
+ rootDir: string,
191
+ routeModule: string,
192
+ source: string,
193
+ ): Promise<string | null> {
194
+ return (await resolveRouteLevelClientEntry(rootDir, routeModule, source))?.modulePath ?? null;
195
+ }
196
+
197
+ export async function resolveRouteLevelClientEntry(
198
+ rootDir: string,
199
+ routeModule: string,
200
+ source: string,
201
+ ): Promise<{ modulePath: string; exportName: string } | null> {
202
+ const routeLevelClientImports = findRouteLevelClientComponentImports(source);
203
+ for (const routeLevelClientImport of routeLevelClientImports) {
204
+ const resolved = await resolveClientImportModulePath(rootDir, routeModule, routeLevelClientImport.module);
205
+ if (!resolved) continue;
206
+ if (clientSpecifierLooksBrowserOnly(routeLevelClientImport.module)) {
207
+ return { modulePath: resolved, exportName: routeLevelClientImport.exportName };
208
+ }
209
+
210
+ const importedSource = await readRouteModule(rootDir, resolved);
211
+ if (importedSource !== null && hasUseClientDirective(importedSource)) {
212
+ return { modulePath: resolved, exportName: routeLevelClientImport.exportName };
213
+ }
214
+ }
215
+
216
+ return null;
217
+ }
160
218
 
161
219
  export async function shouldPreserveExistingClientModule(
162
220
  route: RouteSpec,
163
221
  clientModule: string,
164
222
  rootDir: string,
165
223
  ): Promise<boolean> {
224
+ if (!isRouteLevelClientEntrySpecifier(clientModule)) return false;
166
225
  const source = await readRouteModule(rootDir, clientModule);
167
226
  if (source === null) return false;
168
227
  if (hasUseServerDirective(source)) return false;
@@ -446,12 +505,13 @@ export async function validateClientModuleForBrowserBundle(
446
505
  return `[${route.id}] Client module "${route.clientModule}" has a "use server" directive and cannot be bundled for the browser.`;
447
506
  }
448
507
 
449
- if (clientModuleIsRouteComponent(route) && !hasUseClientDirective(source)) {
450
- const realClientEntry = await resolveRouteLevelClientEntryPath(rootDir, route.clientModule, source);
451
- if (realClientEntry) {
452
- route.clientModule = realClientEntry;
453
- return null;
454
- }
508
+ if (clientModuleIsRouteComponent(route) && !hasUseClientDirective(source)) {
509
+ const realClientEntry = await resolveRouteLevelClientEntry(rootDir, route.clientModule, source);
510
+ if (realClientEntry) {
511
+ route.clientModule = realClientEntry.modulePath;
512
+ route.clientExportName = realClientEntry.exportName;
513
+ return null;
514
+ }
455
515
  return (
456
516
  `[${route.id}] Route component "${route.clientModule}" is configured as clientModule, ` +
457
517
  `but it is a server page (missing "use client"). Mandu will not bundle server pages into client islands. ` +
@@ -14,11 +14,11 @@ import { DEFAULT_SCANNER_CONFIG } from "./fs-types";
14
14
  import { scanRoutes } from "./fs-scanner";
15
15
  import { loadManduConfig } from "../config";
16
16
  import type { ManduPlugin, ManduHooks } from "../plugins/hooks";
17
- import {
18
- runOnRouteRegistered,
19
- runOnManifestBuilt,
20
- } from "../plugins/runner";
21
- import { shouldPreserveExistingClientModule } from "./client-entry";
17
+ import {
18
+ runOnRouteRegistered,
19
+ runOnManifestBuilt,
20
+ } from "../plugins/runner";
21
+ import { shouldPreserveExistingClientModule } from "./client-entry";
22
22
 
23
23
  // ═══════════════════════════════════════════════════════════════════════════
24
24
  // Types
@@ -87,12 +87,13 @@ export function fsRouteToRouteSpec(fsRoute: FSRouteConfig): RouteSpec {
87
87
  ...base,
88
88
  kind: "page" as const,
89
89
  componentModule: normalizePath(fsRoute.componentModule ?? ""),
90
- ...(fsRoute.clientModule
91
- ? {
92
- clientModule: normalizePath(fsRoute.clientModule),
93
- hydration: fsRoute.hydration ?? {
94
- strategy: "island" as const,
95
- priority: "immediate" as const,
90
+ ...(fsRoute.clientModule
91
+ ? {
92
+ clientModule: normalizePath(fsRoute.clientModule),
93
+ ...(fsRoute.clientExportName ? { clientExportName: fsRoute.clientExportName } : {}),
94
+ hydration: fsRoute.hydration ?? {
95
+ strategy: "island" as const,
96
+ priority: "immediate" as const,
96
97
  preload: false,
97
98
  },
98
99
  }
@@ -309,17 +310,18 @@ export async function generateManifest(
309
310
  for (const route of manifest.routes) {
310
311
  const prev = existingMap.get(route.id);
311
312
  if (!prev) continue;
312
- // 사용자가 설정한 clientModule/hydration 보존.
313
- // If app/page.tsx used to be a client page and later becomes a server
314
- // page, preserving the old clientModule would leak the server graph into
315
- // the browser bundle.
316
- if (
317
- prev.clientModule &&
318
- !route.clientModule &&
319
- await shouldPreserveExistingClientModule(route, prev.clientModule, rootDir)
320
- ) {
321
- route.clientModule = prev.clientModule;
322
- }
313
+ // 사용자가 설정한 clientModule/hydration 보존.
314
+ // If app/page.tsx used to be a client page and later becomes a server
315
+ // page, preserving the old clientModule would leak the server graph into
316
+ // the browser bundle.
317
+ if (
318
+ prev.clientModule &&
319
+ !route.clientModule &&
320
+ await shouldPreserveExistingClientModule(route, prev.clientModule, rootDir)
321
+ ) {
322
+ route.clientModule = prev.clientModule;
323
+ route.clientExportName = prev.clientExportName;
324
+ }
323
325
  if (prev.hydration && !route.hydration) {
324
326
  route.hydration = prev.hydration;
325
327
  }
@@ -30,10 +30,10 @@ import {
30
30
  } from "./fs-patterns";
31
31
  import { mark, measure } from "../perf";
32
32
  import { METADATA_ROUTES } from "../routes/types";
33
- import {
34
- hasUseClientDirective,
35
- resolveRouteLevelClientEntryPath,
36
- } from "./client-entry";
33
+ import {
34
+ hasUseClientDirective,
35
+ resolveRouteLevelClientEntry,
36
+ } from "./client-entry";
37
37
 
38
38
  // ═══════════════════════════════════════════════════════════════════════════
39
39
  // Scanner Class
@@ -364,9 +364,10 @@ export class FSScanner {
364
364
  const dirPath = this.getDirPath(file.relativePath);
365
365
  const islands = islandMap.get(dirPath);
366
366
 
367
- // clientModule 결정: island 파일 또는 "use client"가 있는 page 자체
368
- let clientModule: string | undefined;
369
- let pageFileContent: string | null = null;
367
+ // clientModule 결정: island 파일 또는 "use client"가 있는 page 자체
368
+ let clientModule: string | undefined;
369
+ let clientExportName: string | undefined;
370
+ let pageFileContent: string | null = null;
370
371
 
371
372
  if (file.type === "page") {
372
373
  try {
@@ -395,12 +396,14 @@ export class FSScanner {
395
396
  } else if (file.type === "page" && pageFileContent) {
396
397
  // page 파일 자체에서 "use client" 확인
397
398
  const hasUseClient = hasUseClientDirective(pageFileContent);
398
- if (hasUseClient) {
399
- clientModule = modulePath;
400
- } else {
401
- clientModule = await resolveRouteLevelClientEntryPath(rootDir, modulePath, pageFileContent) ?? undefined;
402
- }
403
- }
399
+ if (hasUseClient) {
400
+ clientModule = modulePath;
401
+ } else {
402
+ const routeClientEntry = await resolveRouteLevelClientEntry(rootDir, modulePath, pageFileContent);
403
+ clientModule = routeClientEntry?.modulePath;
404
+ clientExportName = routeClientEntry?.exportName;
405
+ }
406
+ }
404
407
 
405
408
  // 로딩/에러/404 모듈 찾기 — nearest-ancestor resolution.
406
409
  // Phase 18.β: `not-found.tsx` joins `loading.tsx`/`error.tsx` in
@@ -415,10 +418,11 @@ export class FSScanner {
415
418
  segments: file.segments,
416
419
  pattern,
417
420
  kind: file.type === "page" ? "page" : "api",
418
- module: modulePath,
419
- componentModule: file.type === "page" ? modulePath : undefined,
420
- clientModule,
421
- layoutChain,
421
+ module: modulePath,
422
+ componentModule: file.type === "page" ? modulePath : undefined,
423
+ clientModule,
424
+ clientExportName,
425
+ layoutChain,
422
426
  loadingModule,
423
427
  errorModule,
424
428
  notFoundModule,
@@ -117,11 +117,14 @@ export interface FSRouteConfig {
117
117
  /** API 핸들러 모듈 경로 */
118
118
  module: string;
119
119
 
120
- /** Island 컴포넌트 모듈 경로 */
121
- clientModule?: string;
122
-
123
- /** 적용할 레이아웃 체인 */
124
- layoutChain: string[];
120
+ /** Island 컴포넌트 모듈 경로 */
121
+ clientModule?: string;
122
+
123
+ /** Named export used by the route-level client module, if not default. */
124
+ clientExportName?: string;
125
+
126
+ /** 적용할 레이아웃 체인 */
127
+ layoutChain: string[];
125
128
 
126
129
  /** 로딩 UI 모듈 경로 */
127
130
  loadingModule?: string;