@mandujs/core 0.54.12 → 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.
Files changed (80) 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 +36 -16
  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/__tests__/fast-refresh.test.ts +24 -17
  17. package/src/bundler/analyzer.ts +15 -15
  18. package/src/bundler/build.test.ts +136 -48
  19. package/src/bundler/build.ts +193 -122
  20. package/src/bundler/css.ts +42 -42
  21. package/src/bundler/manifest-schema.ts +21 -21
  22. package/src/bundler/plugins/__tests__/block-generated-imports.test.ts +13 -13
  23. package/src/bundler/plugins/block-generated-imports.ts +13 -13
  24. package/src/bundler/types.ts +31 -31
  25. package/src/client/island.ts +79 -79
  26. package/src/config/validate.ts +1 -1
  27. package/src/contract/schema.ts +7 -0
  28. package/src/deploy/inference/context.ts +82 -82
  29. package/src/devtools/client/components/panel/islands-panel.tsx +16 -16
  30. package/src/devtools/client/components/panel/panel-container.tsx +1 -1
  31. package/src/error/formatter.ts +10 -1
  32. package/src/experimental/index.ts +10 -0
  33. package/src/filling/context.ts +17 -17
  34. package/src/filling/filling.ts +22 -1
  35. package/src/filling/index.ts +15 -1
  36. package/src/generator/generate.ts +30 -30
  37. package/src/generator/index.ts +3 -3
  38. package/src/generator/templates.ts +210 -210
  39. package/src/guard/check.ts +9 -9
  40. package/src/guard/config-guard.ts +13 -13
  41. package/src/guard/fs-routes-policy.ts +51 -51
  42. package/src/guard/index.ts +11 -11
  43. package/src/index.ts +0 -10
  44. package/src/internal/index.ts +25 -0
  45. package/src/kitchen/api/file-api.ts +11 -11
  46. package/src/report/index.ts +1 -1
  47. package/src/resource/__tests__/generator.test.ts +6 -6
  48. package/src/resource/__tests__/schema.test.ts +14 -14
  49. package/src/resource/ddl/__tests__/emit.test.ts +165 -165
  50. package/src/resource/ddl/emit.ts +146 -146
  51. package/src/resource/generator-schema.ts +11 -11
  52. package/src/resource/generators/slot.ts +72 -72
  53. package/src/resource/schema.ts +21 -21
  54. package/src/router/client-entry.test.ts +84 -41
  55. package/src/router/client-entry.ts +156 -89
  56. package/src/router/fs-routes.test.ts +90 -0
  57. package/src/router/fs-routes.ts +37 -29
  58. package/src/router/fs-scanner.ts +81 -33
  59. package/src/router/fs-types.ts +8 -5
  60. package/src/runtime/__tests__/devtools-adapter.test.ts +68 -68
  61. package/src/runtime/__tests__/observability-lifecycle.test.ts +103 -103
  62. package/src/runtime/__tests__/page-render-response.test.ts +164 -103
  63. package/src/runtime/__tests__/request-middleware.test.ts +70 -70
  64. package/src/runtime/devtools-adapter.ts +68 -68
  65. package/src/runtime/escape.ts +34 -34
  66. package/src/runtime/image-feature.ts +15 -0
  67. package/src/runtime/observability-lifecycle.ts +290 -290
  68. package/src/runtime/page-render-response.ts +208 -106
  69. package/src/runtime/rate-limit.ts +231 -0
  70. package/src/runtime/request-middleware.ts +31 -31
  71. package/src/runtime/router.test.ts +4 -4
  72. package/src/runtime/router.ts +10 -12
  73. package/src/runtime/scheduler-lifecycle.ts +64 -0
  74. package/src/runtime/server.ts +148 -353
  75. package/src/runtime/ssr.ts +59 -59
  76. package/src/runtime/static-files.ts +289 -289
  77. package/src/runtime/streaming-ssr.ts +22 -22
  78. package/src/spec/schema.ts +4 -3
  79. package/src/watcher/__tests__/watcher.test.ts +59 -59
  80. package/src/watcher/watcher.ts +61 -61
@@ -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";
@@ -40,10 +47,11 @@ describe("findClientComponentImports", () => {
40
47
  }
41
48
  `);
42
49
 
43
- expect(routeClient).toEqual({
44
- module: "@/client/pages/login/LoginPage.client",
45
- localName: "LoginPage",
46
- });
50
+ expect(routeClient).toEqual({
51
+ module: "@/client/pages/login/LoginPage.client",
52
+ localName: "LoginPage",
53
+ exportName: "default",
54
+ });
47
55
  });
48
56
 
49
57
  it("promotes a fragment wrapper with head-only elements and a bare client component", () => {
@@ -58,10 +66,11 @@ describe("findClientComponentImports", () => {
58
66
  }
59
67
  `);
60
68
 
61
- expect(routeClient).toEqual({
62
- module: "@/client/pages/home/HomeApp.client",
63
- localName: "HomeApp",
64
- });
69
+ expect(routeClient).toEqual({
70
+ module: "@/client/pages/home/HomeApp.client",
71
+ localName: "HomeApp",
72
+ exportName: "default",
73
+ });
65
74
  });
66
75
 
67
76
  it("detects a named-imported client component when the page returns only that component", () => {
@@ -73,11 +82,28 @@ describe("findClientComponentImports", () => {
73
82
  }
74
83
  `);
75
84
 
76
- expect(routeClient).toEqual({
77
- module: "@/client/pages/notifications/NotificationsPage.client",
78
- localName: "NotificationsPage",
79
- });
80
- });
85
+ expect(routeClient).toEqual({
86
+ module: "@/client/pages/notifications/NotificationsPage.client",
87
+ localName: "NotificationsPage",
88
+ exportName: "NotificationsPage",
89
+ });
90
+ });
91
+
92
+ it("preserves the source export name for aliased named imports", () => {
93
+ const routeClient = findRouteLevelClientComponentImport(`
94
+ import { NotificationsPage as PageIsland } from "@/client/pages/notifications/NotificationsPage.client";
95
+
96
+ export default function Page() {
97
+ return <PageIsland />;
98
+ }
99
+ `);
100
+
101
+ expect(routeClient).toEqual({
102
+ module: "@/client/pages/notifications/NotificationsPage.client",
103
+ localName: "PageIsland",
104
+ exportName: "NotificationsPage",
105
+ });
106
+ });
81
107
 
82
108
  it("promotes embedded client imports inside a larger server page", () => {
83
109
  const routeClient = findRouteLevelClientComponentImport(`
@@ -91,10 +117,11 @@ describe("findClientComponentImports", () => {
91
117
  }
92
118
  `);
93
119
 
94
- expect(routeClient).toEqual({
95
- module: "@/client/pages/home/HomeApp.client",
96
- localName: "HomeApp",
97
- });
120
+ expect(routeClient).toEqual({
121
+ module: "@/client/pages/home/HomeApp.client",
122
+ localName: "HomeApp",
123
+ exportName: "default",
124
+ });
98
125
  });
99
126
 
100
127
  it("promotes client imports when the page wrapper passes props", () => {
@@ -106,10 +133,11 @@ describe("findClientComponentImports", () => {
106
133
  }
107
134
  `);
108
135
 
109
- expect(routeClient).toEqual({
110
- module: "@/client/pages/pledges/PledgePage.client",
111
- localName: "PledgePage",
112
- });
136
+ expect(routeClient).toEqual({
137
+ module: "@/client/pages/pledges/PledgePage.client",
138
+ localName: "PledgePage",
139
+ exportName: "default",
140
+ });
113
141
  });
114
142
 
115
143
  it("detects multiple rendered client imports in a server shell", () => {
@@ -130,14 +158,16 @@ describe("findClientComponentImports", () => {
130
158
  `);
131
159
 
132
160
  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
- },
161
+ {
162
+ module: "@/client/widgets/comments-section/CommentsSection.client",
163
+ localName: "CommentsSection",
164
+ exportName: "CommentsSection",
165
+ },
166
+ {
167
+ module: "@/client/widgets/pledge-actions/PledgeActions.client",
168
+ localName: "PledgeActions",
169
+ exportName: "PledgeActions",
170
+ },
141
171
  ]);
142
172
  });
143
173
 
@@ -150,14 +180,27 @@ describe("findClientComponentImports", () => {
150
180
  }
151
181
  `);
152
182
 
153
- expect(routeClient).toEqual({
154
- module: "@/client/widgets/pledge-form/PledgeForm",
155
- localName: "PledgeForm",
156
- });
183
+ expect(routeClient).toEqual({
184
+ module: "@/client/widgets/pledge-form/PledgeForm",
185
+ localName: "PledgeForm",
186
+ exportName: "PledgeForm",
187
+ });
188
+ });
189
+
190
+ it("does not promote shared UI primitives to route-level client entries", () => {
191
+ const routeClient = findRouteLevelClientComponentImport(`
192
+ import { Button } from "@/client/shared/ui/button";
193
+
194
+ export default function Page() {
195
+ return <main><Button>Open</Button></main>;
196
+ }
197
+ `);
198
+
199
+ expect(routeClient).toBeNull();
157
200
  });
158
201
 
159
- it("resolves a route-level client entry by reading a use client target without .client in the path", async () => {
160
- 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-");
161
204
  try {
162
205
  await mkdir(path.join(rootDir, "app", "pledges", "new"), { recursive: true });
163
206
  await mkdir(path.join(rootDir, "src", "client", "widgets", "pledge-form"), { recursive: true });
@@ -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,40 +186,58 @@ 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
- }
160
-
161
- export async function shouldPreserveExistingClientModule(
162
- route: RouteSpec,
163
- clientModule: string,
164
- rootDir: string,
165
- ): Promise<boolean> {
166
- const source = await readRouteModule(rootDir, clientModule);
167
- if (source === null) return false;
168
- if (hasUseServerDirective(source)) return false;
169
- if (clientModuleIsRouteComponent(route, clientModule)) {
170
- if (hasUseClientDirective(source)) return true;
171
- return await routeComponentHasResolvableClientEntry(rootDir, clientModule, source);
172
- }
173
- return true;
174
- }
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
+ }
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
+
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
+ }
175
241
 
176
242
  function resolveImportBasePath(rootDir: string, importerModule: string, specifier: string): string | null {
177
243
  const normalized = specifier.replace(/\\/g, "/");
@@ -446,12 +512,13 @@ export async function validateClientModuleForBrowserBundle(
446
512
  return `[${route.id}] Client module "${route.clientModule}" has a "use server" directive and cannot be bundled for the browser.`;
447
513
  }
448
514
 
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
- }
515
+ if (clientModuleIsRouteComponent(route) && !hasUseClientDirective(source)) {
516
+ const realClientEntry = await resolveRouteLevelClientEntry(rootDir, route.clientModule, source);
517
+ if (realClientEntry) {
518
+ route.clientModule = realClientEntry.modulePath;
519
+ route.clientExportName = realClientEntry.exportName;
520
+ return null;
521
+ }
455
522
  return (
456
523
  `[${route.id}] Route component "${route.clientModule}" is configured as clientModule, ` +
457
524
  `but it is a server page (missing "use client"). Mandu will not bundle server pages into client islands. ` +
@@ -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
+ });
@@ -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,16 +87,19 @@ 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,
96
- preload: false,
97
- },
98
- }
99
- : {}),
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,
97
+ preload: false,
98
+ },
99
+ }
100
+ : fsRoute.hydration
101
+ ? { hydration: fsRoute.hydration }
102
+ : {}),
100
103
  ...(fsRoute.layoutChain && fsRoute.layoutChain.length > 0
101
104
  ? { layoutChain: fsRoute.layoutChain.map(normalizePath) }
102
105
  : {}),
@@ -309,20 +312,25 @@ export async function generateManifest(
309
312
  for (const route of manifest.routes) {
310
313
  const prev = existingMap.get(route.id);
311
314
  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
- }
323
- if (prev.hydration && !route.hydration) {
324
- route.hydration = prev.hydration;
325
- }
315
+ // 사용자가 설정한 clientModule/hydration 보존.
316
+ // If app/page.tsx used to be a client page and later becomes a server
317
+ // page, preserving the old clientModule would leak the server graph into
318
+ // the browser bundle.
319
+ if (
320
+ prev.clientModule &&
321
+ !route.clientModule &&
322
+ await shouldPreserveExistingClientModule(route, prev.clientModule, rootDir)
323
+ ) {
324
+ route.clientModule = prev.clientModule;
325
+ route.clientExportName = prev.clientExportName;
326
+ }
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
+ }
326
334
  // Issue #214 — preserve prerender-time fields (`dynamicParams`,
327
335
  // `staticParams`) across manifest rescans. `mandu build`'s prerender
328
336
  // phase stamps these onto the manifest; a subsequent `mandu dev`