@mandujs/core 0.54.17 → 0.54.18

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/package.json +3 -1
  2. package/src/agent/__tests__/context.test.ts +54 -16
  3. package/src/agent/context.ts +17 -0
  4. package/src/agent/types.ts +32 -12
  5. package/src/bundler/__snapshots__/build.test.ts.snap +5 -0
  6. package/src/bundler/__tests__/build-runner.ts +130 -17
  7. package/src/bundler/__tests__/client-boundary-transform.test.ts +524 -0
  8. package/src/bundler/__tests__/reverse-import-graph.test.ts +42 -33
  9. package/src/bundler/build.test.ts +440 -8
  10. package/src/bundler/build.ts +455 -132
  11. package/src/bundler/client-boundary-transform.ts +977 -0
  12. package/src/bundler/dev.ts +39 -112
  13. package/src/bundler/fast-refresh-preamble.ts +47 -0
  14. package/src/bundler/index.ts +3 -2
  15. package/src/bundler/manifest-schema.ts +10 -0
  16. package/src/bundler/types.ts +20 -2
  17. package/src/diagnose/__tests__/checks.test.ts +117 -17
  18. package/src/diagnose/checks.ts +184 -3
  19. package/src/diagnose/run.ts +10 -8
  20. package/src/generator/templates.test.ts +48 -5
  21. package/src/generator/templates.ts +10 -1
  22. package/src/internal/client-boundary.ts +266 -0
  23. package/src/internal/index.ts +2 -1
  24. package/src/router/client-entry.test.ts +43 -6
  25. package/src/router/client-entry.ts +33 -12
  26. package/src/router/fs-routes.test.ts +388 -1
  27. package/src/router/fs-routes.ts +16 -3
  28. package/src/router/fs-scanner.ts +166 -18
  29. package/src/router/fs-types.ts +4 -1
  30. package/src/runtime/__tests__/page-render-response.test.ts +212 -0
  31. package/src/runtime/handlers.ts +50 -26
  32. package/src/runtime/page-render-response.ts +1 -0
  33. package/src/runtime/ssr.ts +16 -5
  34. package/src/runtime/streaming-ssr.ts +119 -76
  35. package/src/spec/schema.ts +31 -5
@@ -11,6 +11,391 @@ async function mkRepoTempDir(prefix: string): Promise<string> {
11
11
  }
12
12
 
13
13
  describe("generateManifest hydration config", () => {
14
+ it("records compiler-discovered client boundaries on page routes", async () => {
15
+ const rootDir = await mkRepoTempDir("routes-boundaries-");
16
+ try {
17
+ await mkdir(path.join(rootDir, "app", "pledges", "[id]"), { recursive: true });
18
+ await mkdir(path.join(rootDir, "src", "client", "widgets", "comments-section"), { recursive: true });
19
+ await mkdir(path.join(rootDir, "src", "client", "widgets", "activity"), { recursive: true });
20
+ await writeFile(
21
+ path.join(rootDir, "app", "pledges", "[id]", "page.tsx"),
22
+ `
23
+ import { CommentsSection } from "@/client/widgets/comments-section/CommentsSection.client";
24
+ import ActivityFeed from "@/client/widgets/activity/ActivityFeed.client";
25
+
26
+ export default function Page({ params }) {
27
+ return (
28
+ <main>
29
+ <CommentsSection pledgeId={params.id} initialComments={[]} />
30
+ <ActivityFeed pledgeId={params.id} />
31
+ </main>
32
+ );
33
+ }
34
+ `,
35
+ "utf-8",
36
+ );
37
+ await writeFile(
38
+ path.join(rootDir, "src", "client", "widgets", "comments-section", "CommentsSection.client.tsx"),
39
+ `"use client";
40
+ export function CommentsSection() {
41
+ return <section />;
42
+ }`,
43
+ "utf-8",
44
+ );
45
+ await writeFile(
46
+ path.join(rootDir, "src", "client", "widgets", "activity", "ActivityFeed.client.tsx"),
47
+ `"use client";
48
+ export default function ActivityFeed() {
49
+ return <aside />;
50
+ }`,
51
+ "utf-8",
52
+ );
53
+
54
+ const result = await generateManifest(rootDir);
55
+ const route = result.manifest.routes.find((entry) => entry.id === "pledges-$id");
56
+
57
+ expect(route?.hydration?.strategy).toBe("island");
58
+ expect(route?.clientModule).toBeUndefined();
59
+ expect(route?.boundaries?.map(({ id, module, importSpecifier, exportName, localName, hydrate, propsSource, propsKeys, hasSpreadProps }) => ({
60
+ id,
61
+ module,
62
+ importSpecifier,
63
+ exportName,
64
+ localName,
65
+ hydrate,
66
+ propsSource,
67
+ propsKeys,
68
+ hasSpreadProps,
69
+ }))).toEqual([
70
+ {
71
+ id: "pledges-$id--0",
72
+ module: "src/client/widgets/comments-section/CommentsSection.client.tsx",
73
+ importSpecifier: "@/client/widgets/comments-section/CommentsSection.client",
74
+ exportName: "CommentsSection",
75
+ localName: "CommentsSection",
76
+ hydrate: "visible",
77
+ propsSource: "inline",
78
+ propsKeys: ["pledgeId", "initialComments"],
79
+ hasSpreadProps: false,
80
+ },
81
+ {
82
+ id: "pledges-$id--1",
83
+ module: "src/client/widgets/activity/ActivityFeed.client.tsx",
84
+ importSpecifier: "@/client/widgets/activity/ActivityFeed.client",
85
+ exportName: "default",
86
+ localName: "ActivityFeed",
87
+ hydrate: "visible",
88
+ propsSource: "inline",
89
+ propsKeys: ["pledgeId"],
90
+ hasSpreadProps: false,
91
+ },
92
+ ]);
93
+ expect(route?.boundaries?.[0]?.source).toEqual({
94
+ file: "app/pledges/[id]/page.tsx",
95
+ line: expect.any(Number),
96
+ column: expect.any(Number),
97
+ });
98
+ } finally {
99
+ await rm(rootDir, { recursive: true, force: true });
100
+ }
101
+ });
102
+
103
+ it("does not preserve stale route-level clientModule when compiler boundaries own the client imports", async () => {
104
+ const rootDir = await mkRepoTempDir("routes-boundaries-stale-client-");
105
+ try {
106
+ await mkdir(path.join(rootDir, ".mandu"), { recursive: true });
107
+ await mkdir(path.join(rootDir, "app", "pledges", "[id]"), { recursive: true });
108
+ await mkdir(path.join(rootDir, "src", "client", "widgets", "comments-section"), { recursive: true });
109
+ await writeFile(
110
+ path.join(rootDir, "app", "pledges", "[id]", "page.tsx"),
111
+ `
112
+ import { CommentsSection } from "@/client/widgets/comments-section/CommentsSection.client";
113
+
114
+ export default function Page({ params }) {
115
+ return <main><CommentsSection pledgeId={params.id} initialComments={[]} /></main>;
116
+ }
117
+ `,
118
+ "utf-8",
119
+ );
120
+ await writeFile(
121
+ path.join(rootDir, "src", "client", "widgets", "comments-section", "CommentsSection.client.tsx"),
122
+ `"use client";
123
+ export function CommentsSection() {
124
+ return <section />;
125
+ }`,
126
+ "utf-8",
127
+ );
128
+ await writeFile(
129
+ path.join(rootDir, ".mandu", "routes.manifest.json"),
130
+ JSON.stringify({
131
+ version: 1,
132
+ routes: [
133
+ {
134
+ id: "pledges-$id",
135
+ pattern: "/pledges/:id",
136
+ module: "app/pledges/[id]/page.tsx",
137
+ kind: "page",
138
+ componentModule: "app/pledges/[id]/page.tsx",
139
+ clientModule: "src/client/widgets/comments-section/CommentsSection.client.tsx",
140
+ clientExportName: "CommentsSection",
141
+ hydration: { strategy: "island", priority: "visible", preload: false },
142
+ },
143
+ ],
144
+ }, null, 2),
145
+ "utf-8",
146
+ );
147
+
148
+ const result = await generateManifest(rootDir);
149
+ const route = result.manifest.routes.find((entry) => entry.id === "pledges-$id");
150
+
151
+ expect(route?.clientModule).toBeUndefined();
152
+ expect(route?.clientExportName).toBeUndefined();
153
+ expect(route?.hydration?.strategy).toBe("island");
154
+ expect(route?.boundaries?.map(({ id, module, exportName }) => ({ id, module, exportName }))).toEqual([
155
+ {
156
+ id: "pledges-$id--0",
157
+ module: "src/client/widgets/comments-section/CommentsSection.client.tsx",
158
+ exportName: "CommentsSection",
159
+ },
160
+ ]);
161
+ } finally {
162
+ await rm(rootDir, { recursive: true, force: true });
163
+ }
164
+ });
165
+
166
+ it("records client boundaries hidden behind route-owned server wrappers", async () => {
167
+ const rootDir = await mkRepoTempDir("routes-boundary-wrapper-");
168
+ try {
169
+ await mkdir(path.join(rootDir, "app", "pledges", "[id]"), { recursive: true });
170
+ await mkdir(path.join(rootDir, "src", "client", "widgets"), { recursive: true });
171
+ await writeFile(
172
+ path.join(rootDir, "app", "pledges", "[id]", "page.tsx"),
173
+ `
174
+ import { CommentsPanel } from "./CommentsPanel";
175
+
176
+ export default function Page({ params }) {
177
+ return <main><CommentsPanel pledgeId={params.id} /></main>;
178
+ }
179
+ `,
180
+ "utf-8",
181
+ );
182
+ await writeFile(
183
+ path.join(rootDir, "app", "pledges", "[id]", "CommentsPanel.tsx"),
184
+ `
185
+ import { CommentsSection } from "@/client/widgets/CommentsSection.client";
186
+
187
+ export function CommentsPanel({ pledgeId }) {
188
+ return <CommentsSection pledgeId={pledgeId} />;
189
+ }
190
+ `,
191
+ "utf-8",
192
+ );
193
+ await writeFile(
194
+ path.join(rootDir, "src", "client", "widgets", "CommentsSection.client.tsx"),
195
+ `"use client";
196
+ export function CommentsSection() {
197
+ return <section />;
198
+ }`,
199
+ "utf-8",
200
+ );
201
+
202
+ const result = await generateManifest(rootDir);
203
+ const route = result.manifest.routes.find((entry) => entry.id === "pledges-$id");
204
+
205
+ expect(route?.clientModule).toBeUndefined();
206
+ expect(route?.hydration?.strategy).toBe("island");
207
+ expect(route?.boundaries?.map(({ id, module, importSpecifier, exportName, localName, source }) => ({
208
+ id,
209
+ module,
210
+ importSpecifier,
211
+ exportName,
212
+ localName,
213
+ sourceFile: source.file,
214
+ }))).toEqual([
215
+ {
216
+ id: "pledges-$id--0",
217
+ module: "src/client/widgets/CommentsSection.client.tsx",
218
+ importSpecifier: "@/client/widgets/CommentsSection.client",
219
+ exportName: "CommentsSection",
220
+ localName: "CommentsSection",
221
+ sourceFile: "app/pledges/[id]/CommentsPanel.tsx",
222
+ },
223
+ ]);
224
+
225
+ await writeFile(
226
+ path.join(rootDir, "app", "pledges", "[id]", "CommentsPanel.tsx"),
227
+ `
228
+ export function CommentsPanel({ pledgeId }) {
229
+ return <section>{pledgeId}</section>;
230
+ }
231
+ `,
232
+ "utf-8",
233
+ );
234
+
235
+ const second = await generateManifest(rootDir);
236
+ const secondRoute = second.manifest.routes.find((entry) => entry.id === "pledges-$id");
237
+ expect(secondRoute?.clientModule).toBeUndefined();
238
+ expect(secondRoute?.boundaries).toBeUndefined();
239
+ expect(secondRoute?.hydration?.strategy).not.toBe("island");
240
+ } finally {
241
+ await rm(rootDir, { recursive: true, force: true });
242
+ }
243
+ });
244
+
245
+ it("fails route manifest generation for unsupported client boundary children", async () => {
246
+ const rootDir = await mkRepoTempDir("routes-boundary-children-");
247
+ try {
248
+ await mkdir(path.join(rootDir, "app", "cards"), { recursive: true });
249
+ await mkdir(path.join(rootDir, "src", "client"), { recursive: true });
250
+ await writeFile(
251
+ path.join(rootDir, "app", "cards", "page.tsx"),
252
+ `
253
+ import Card from "@/client/Card.client";
254
+
255
+ export default function Page() {
256
+ return <main><Card><span>server child</span></Card></main>;
257
+ }
258
+ `,
259
+ "utf-8",
260
+ );
261
+ await writeFile(
262
+ path.join(rootDir, "src", "client", "Card.client.tsx"),
263
+ `"use client";
264
+ export default function Card() {
265
+ return <section />;
266
+ }`,
267
+ "utf-8",
268
+ );
269
+
270
+ await expect(generateManifest(rootDir)).rejects.toThrow(/MANDU_BOUNDARY_UNSUPPORTED_CHILDREN/);
271
+ await expect(generateManifest(rootDir)).rejects.toThrow(/app\/cards\/page\.tsx/);
272
+ } finally {
273
+ await rm(rootDir, { recursive: true, force: true });
274
+ }
275
+ });
276
+
277
+ it("fails route manifest generation for statically visible non-serializable boundary props", async () => {
278
+ const rootDir = await mkRepoTempDir("routes-boundary-nonserializable-");
279
+ try {
280
+ await mkdir(path.join(rootDir, "app", "settings"), { recursive: true });
281
+ await mkdir(path.join(rootDir, "src", "client"), { recursive: true });
282
+ await writeFile(
283
+ path.join(rootDir, "app", "settings", "page.tsx"),
284
+ `
285
+ import SettingsPanel from "@/client/SettingsPanel.client";
286
+
287
+ export default function Page() {
288
+ return <main><SettingsPanel config={{ onSave: () => "server function" }} /></main>;
289
+ }
290
+ `,
291
+ "utf-8",
292
+ );
293
+ await writeFile(
294
+ path.join(rootDir, "src", "client", "SettingsPanel.client.tsx"),
295
+ `"use client";
296
+ export default function SettingsPanel() {
297
+ return <section />;
298
+ }`,
299
+ "utf-8",
300
+ );
301
+
302
+ let errorMessage = "";
303
+ try {
304
+ await generateManifest(rootDir);
305
+ } catch (error) {
306
+ errorMessage = String(error);
307
+ }
308
+ expect(errorMessage).toContain("MANDU_BOUNDARY_UNSUPPORTED_PROP_VALUE");
309
+ expect(errorMessage).toContain("Suggestion:");
310
+ expect(errorMessage).toContain("route=settings");
311
+ } finally {
312
+ await rm(rootDir, { recursive: true, force: true });
313
+ }
314
+ });
315
+
316
+ it("fails route manifest generation for unresolved client boundary named exports", async () => {
317
+ const rootDir = await mkRepoTempDir("routes-boundary-missing-export-");
318
+ try {
319
+ await mkdir(path.join(rootDir, "app", "profile"), { recursive: true });
320
+ await mkdir(path.join(rootDir, "src", "client"), { recursive: true });
321
+ await writeFile(
322
+ path.join(rootDir, "app", "profile", "page.tsx"),
323
+ `
324
+ import { MissingProfile } from "@/client/Profile.client";
325
+
326
+ export default function Page() {
327
+ return <main><MissingProfile label="profile" /></main>;
328
+ }
329
+ `,
330
+ "utf-8",
331
+ );
332
+ await writeFile(
333
+ path.join(rootDir, "src", "client", "Profile.client.tsx"),
334
+ `"use client";
335
+ export function ExistingProfile() {
336
+ return <section />;
337
+ }`,
338
+ "utf-8",
339
+ );
340
+
341
+ let errorMessage = "";
342
+ try {
343
+ await generateManifest(rootDir);
344
+ } catch (error) {
345
+ errorMessage = String(error);
346
+ }
347
+ expect(errorMessage).toContain("MANDU_BOUNDARY_UNRESOLVED_EXPORT");
348
+ expect(errorMessage).toContain("MissingProfile");
349
+ expect(errorMessage).toContain("route=profile");
350
+ expect(errorMessage).toContain("Suggestion:");
351
+ } finally {
352
+ await rm(rootDir, { recursive: true, force: true });
353
+ }
354
+ });
355
+
356
+ it("fails route manifest generation for server-only imports inside client boundaries", async () => {
357
+ const rootDir = await mkRepoTempDir("routes-boundary-server-only-");
358
+ try {
359
+ await mkdir(path.join(rootDir, "app", "files"), { recursive: true });
360
+ await mkdir(path.join(rootDir, "src", "client"), { recursive: true });
361
+ await writeFile(
362
+ path.join(rootDir, "app", "files", "page.tsx"),
363
+ `
364
+ import { FilePanel } from "@/client/FilePanel.client";
365
+
366
+ export default function Page() {
367
+ return <main><FilePanel label="files" /></main>;
368
+ }
369
+ `,
370
+ "utf-8",
371
+ );
372
+ await writeFile(
373
+ path.join(rootDir, "src", "client", "FilePanel.client.tsx"),
374
+ `"use client";
375
+ import { readFile } from "node:fs/promises";
376
+ import { Database } from "bun:sqlite";
377
+ export function FilePanel() {
378
+ return <section>{String(readFile)}{String(Database)}</section>;
379
+ }`,
380
+ "utf-8",
381
+ );
382
+
383
+ let errorMessage = "";
384
+ try {
385
+ await generateManifest(rootDir);
386
+ } catch (error) {
387
+ errorMessage = String(error);
388
+ }
389
+ expect(errorMessage).toContain("MANDU_BOUNDARY_SERVER_ONLY_IMPORT");
390
+ expect(errorMessage).toContain("node:fs/promises");
391
+ expect(errorMessage).toContain("bun:sqlite");
392
+ expect(errorMessage).toContain("src/client/FilePanel.client.tsx");
393
+ expect(errorMessage).toContain("Suggestion:");
394
+ } finally {
395
+ await rm(rootDir, { recursive: true, force: true });
396
+ }
397
+ });
398
+
14
399
  it("does not preserve stale island hydration after the client entry disappears", async () => {
15
400
  const rootDir = await mkRepoTempDir("routes-hydration-stale-");
16
401
  try {
@@ -39,7 +424,8 @@ describe("generateManifest hydration config", () => {
39
424
 
40
425
  const first = await generateManifest(rootDir);
41
426
  const firstRoute = first.manifest.routes.find((route) => route.id === "candidates-$id");
42
- expect(firstRoute?.clientModule).toContain("PledgeAccordion.client.tsx");
427
+ expect(firstRoute?.clientModule).toBeUndefined();
428
+ expect(firstRoute?.boundaries?.[0]?.module).toContain("PledgeAccordion.client.tsx");
43
429
  expect(firstRoute?.hydration?.strategy).toBe("island");
44
430
 
45
431
  await writeFile(
@@ -55,6 +441,7 @@ describe("generateManifest hydration config", () => {
55
441
  const second = await generateManifest(rootDir);
56
442
  const secondRoute = second.manifest.routes.find((route) => route.id === "candidates-$id");
57
443
  expect(secondRoute?.clientModule).toBeUndefined();
444
+ expect(secondRoute?.boundaries).toBeUndefined();
58
445
  expect(secondRoute?.hydration?.strategy).not.toBe("island");
59
446
  } finally {
60
447
  await rm(rootDir, { recursive: true, force: true });
@@ -100,9 +100,21 @@ export function fsRouteToRouteSpec(fsRoute: FSRouteConfig): RouteSpec {
100
100
  : fsRoute.hydration
101
101
  ? { hydration: fsRoute.hydration }
102
102
  : {}),
103
- ...(fsRoute.layoutChain && fsRoute.layoutChain.length > 0
104
- ? { layoutChain: fsRoute.layoutChain.map(normalizePath) }
105
- : {}),
103
+ ...(fsRoute.boundaries && fsRoute.boundaries.length > 0
104
+ ? {
105
+ boundaries: fsRoute.boundaries.map((boundary) => ({
106
+ ...boundary,
107
+ module: normalizePath(boundary.module),
108
+ source: {
109
+ ...boundary.source,
110
+ file: normalizePath(boundary.source.file),
111
+ },
112
+ })),
113
+ }
114
+ : {}),
115
+ ...(fsRoute.layoutChain && fsRoute.layoutChain.length > 0
116
+ ? { layoutChain: fsRoute.layoutChain.map(normalizePath) }
117
+ : {}),
106
118
  ...(fsRoute.loadingModule ? { loadingModule: normalizePath(fsRoute.loadingModule) } : {}),
107
119
  ...(fsRoute.errorModule ? { errorModule: normalizePath(fsRoute.errorModule) } : {}),
108
120
  ...(fsRoute.notFoundModule ? { notFoundModule: normalizePath(fsRoute.notFoundModule) } : {}),
@@ -319,6 +331,7 @@ export async function generateManifest(
319
331
  if (
320
332
  prev.clientModule &&
321
333
  !route.clientModule &&
334
+ !(route.kind === "page" && route.boundaries && route.boundaries.length > 0) &&
322
335
  await shouldPreserveExistingClientModule(route, prev.clientModule, rootDir)
323
336
  ) {
324
337
  route.clientModule = prev.clientModule;