@mandujs/core 0.54.17 → 0.54.19

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 (46) hide show
  1. package/package.json +3 -1
  2. package/src/agent/__tests__/context.test.ts +94 -25
  3. package/src/agent/context.ts +17 -0
  4. package/src/agent/types.ts +32 -12
  5. package/src/agent/verify.ts +55 -24
  6. package/src/bundler/__snapshots__/build.test.ts.snap +5 -0
  7. package/src/bundler/__tests__/build-runner.ts +130 -17
  8. package/src/bundler/__tests__/client-boundary-transform.test.ts +524 -0
  9. package/src/bundler/__tests__/reverse-import-graph.test.ts +42 -33
  10. package/src/bundler/build.test.ts +478 -9
  11. package/src/bundler/build.ts +424 -746
  12. package/src/bundler/client-boundary-transform.ts +977 -0
  13. package/src/bundler/dev.ts +39 -112
  14. package/src/bundler/fast-refresh-preamble.ts +47 -0
  15. package/src/bundler/index.ts +3 -2
  16. package/src/bundler/manifest-schema.ts +10 -0
  17. package/src/bundler/types.ts +20 -2
  18. package/src/client/__tests__/props-serialization.test.ts +37 -0
  19. package/src/client/hydrate.ts +2 -2
  20. package/src/client/index.ts +1 -1
  21. package/src/client/props-serialization.ts +233 -0
  22. package/src/client/runtime-entry.ts +567 -0
  23. package/src/client/runtime.ts +1 -1
  24. package/src/client/serialize.ts +50 -404
  25. package/src/diagnose/__tests__/checks.test.ts +132 -17
  26. package/src/diagnose/checks.ts +184 -3
  27. package/src/diagnose/run.ts +10 -8
  28. package/src/generator/templates.test.ts +48 -5
  29. package/src/generator/templates.ts +10 -1
  30. package/src/internal/client-boundary.ts +266 -0
  31. package/src/internal/index.ts +2 -1
  32. package/src/router/client-entry.test.ts +154 -29
  33. package/src/router/client-entry.ts +111 -313
  34. package/src/router/fs-routes.test.ts +443 -1
  35. package/src/router/fs-routes.ts +16 -3
  36. package/src/router/fs-scanner.ts +176 -57
  37. package/src/router/fs-types.ts +11 -2
  38. package/src/router/route-source-analyzer.ts +521 -0
  39. package/src/runtime/__tests__/inline-client-hydration.test.ts +104 -1
  40. package/src/runtime/__tests__/page-render-response.test.ts +218 -0
  41. package/src/runtime/handlers.ts +50 -26
  42. package/src/runtime/page-render-response.ts +24 -1
  43. package/src/runtime/server.ts +14 -0
  44. package/src/runtime/ssr.ts +16 -5
  45. package/src/runtime/streaming-ssr.ts +119 -76
  46. package/src/spec/schema.ts +31 -5
@@ -1,12 +1,14 @@
1
1
  import { describe, expect, it } from "bun:test";
2
2
  import { mkdir, mkdtemp, rm, writeFile } from "fs/promises";
3
3
  import path from "path";
4
- import {
5
- findClientComponentImports,
6
- findRouteLevelClientComponentImport,
7
- findRouteLevelClientComponentImports,
8
- resolveRouteLevelClientEntryPath,
4
+ import {
5
+ findClientComponentImports,
6
+ findRouteLevelClientComponentImport,
7
+ findRouteLevelClientComponentImports,
8
+ resolveRouteLevelClientEntryPath,
9
+ validateClientModuleForBrowserBundle,
9
10
  } from "./client-entry";
11
+ import { analyzeRouteSource } from "./route-source-analyzer";
10
12
 
11
13
  const repoTempRoot = path.resolve(import.meta.dir, "../../../..", ".tmp-test-artifacts");
12
14
 
@@ -16,10 +18,10 @@ async function mkRepoTempDir(prefix: string): Promise<string> {
16
18
  }
17
19
 
18
20
  describe("findClientComponentImports", () => {
19
- it("detects named .client imports for diagnostics", () => {
20
- const imports = findClientComponentImports(`
21
- import { LoginForm, SubmitButton as Button } from "@/client/widgets/login-form/LoginForm.client";
22
- import Header from "./Header.client.tsx";
21
+ it("detects named .client imports for diagnostics", () => {
22
+ const imports = findClientComponentImports(`
23
+ import { LoginForm, SubmitButton as Button } from "@/client/widgets/login-form/LoginForm.client";
24
+ import Header from "./Header.client.tsx";
23
25
  `);
24
26
 
25
27
  expect(imports).toEqual([
@@ -32,9 +34,34 @@ describe("findClientComponentImports", () => {
32
34
  module: "./Header.client.tsx",
33
35
  kind: "default",
34
36
  names: ["Header"],
35
- },
36
- ]);
37
- });
37
+ },
38
+ ]);
39
+ });
40
+
41
+ it("ignores import syntax inside string literals", () => {
42
+ const imports = findClientComponentImports(`
43
+ const example = "import Fake from './Fake.client'";
44
+
45
+ export default function Page() {
46
+ return <main>{example}</main>;
47
+ }
48
+ `);
49
+
50
+ expect(imports).toEqual([]);
51
+ });
52
+
53
+ it("ignores type-only client imports", () => {
54
+ const imports = findClientComponentImports(`
55
+ import type { LoginForm } from "@/client/widgets/login-form/LoginForm.client";
56
+ import { type SubmitButton } from "@/client/widgets/login-form/LoginForm.client";
57
+
58
+ export default function Page() {
59
+ return <main />;
60
+ }
61
+ `);
62
+
63
+ expect(imports).toEqual([]);
64
+ });
38
65
 
39
66
  it("detects a default-imported client component when the page returns only that component", () => {
40
67
  const routeClient = findRouteLevelClientComponentImport(`
@@ -124,11 +151,11 @@ describe("findClientComponentImports", () => {
124
151
  });
125
152
  });
126
153
 
127
- it("promotes client imports when the page wrapper passes props", () => {
128
- const routeClient = findRouteLevelClientComponentImport(`
129
- import PledgePage from "@/client/pages/pledges/PledgePage.client";
130
-
131
- export default function Page({ params }) {
154
+ it("promotes client imports when the page wrapper passes props", () => {
155
+ const routeClient = findRouteLevelClientComponentImport(`
156
+ import PledgePage from "@/client/pages/pledges/PledgePage.client";
157
+
158
+ export default function Page({ params }) {
132
159
  return <PledgePage id={params.id} />;
133
160
  }
134
161
  `);
@@ -138,7 +165,23 @@ describe("findClientComponentImports", () => {
138
165
  localName: "PledgePage",
139
166
  exportName: "default",
140
167
  });
141
- });
168
+ });
169
+
170
+ it("detects client entries rendered by an identifier default export", () => {
171
+ const routeClient = findRouteLevelClientComponentImport(`
172
+ import PledgePage from "@/client/pages/pledges/PledgePage.client";
173
+
174
+ const Page = ({ params }) => <PledgePage id={params.id} />;
175
+
176
+ export default Page;
177
+ `);
178
+
179
+ expect(routeClient).toEqual({
180
+ module: "@/client/pages/pledges/PledgePage.client",
181
+ localName: "PledgePage",
182
+ exportName: "default",
183
+ });
184
+ });
142
185
 
143
186
  it("detects multiple rendered client imports in a server shell", () => {
144
187
  const routeClients = findRouteLevelClientComponentImports(`
@@ -187,17 +230,63 @@ describe("findClientComponentImports", () => {
187
230
  });
188
231
  });
189
232
 
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() {
233
+ it("does not promote shared UI primitives to route-level client entries", () => {
234
+ const routeClient = findRouteLevelClientComponentImport(`
235
+ import { Button } from "@/client/shared/ui/button";
236
+
237
+ export default function Page() {
195
238
  return <main><Button>Open</Button></main>;
196
239
  }
197
240
  `);
198
-
199
- expect(routeClient).toBeNull();
200
- });
241
+
242
+ expect(routeClient).toBeNull();
243
+ });
244
+
245
+ it("classifies default re-exports without treating them as rendered JSX", () => {
246
+ const analysis = analyzeRouteSource(`
247
+ export { default } from "./Page";
248
+ `);
249
+
250
+ expect(analysis.defaultExport).toMatchObject({
251
+ kind: "identifier",
252
+ localName: "default",
253
+ source: "./Page",
254
+ referencesJsx: false,
255
+ renderedJsxNames: [],
256
+ });
257
+ });
258
+
259
+ it("diagnoses default export wrapper calls conservatively", () => {
260
+ const analysis = analyzeRouteSource(`
261
+ import { memo } from "react";
262
+
263
+ const Page = () => <main />;
264
+
265
+ export default memo(Page);
266
+ `);
267
+
268
+ expect(analysis.defaultExport.kind).toBe("call");
269
+ expect(analysis.diagnostics.some((diagnostic) =>
270
+ diagnostic.code === "MANDU_ROUTE_DEFAULT_EXPORT_WRAPPER"
271
+ )).toBe(true);
272
+ });
273
+
274
+ it("records namespace imports without promoting member JSX as a route-level client entry", () => {
275
+ const source = `
276
+ import * as Widgets from "@/client/widgets/Widget.client";
277
+
278
+ export default function Page() {
279
+ return <Widgets.Panel />;
280
+ }
281
+ `;
282
+ const analysis = analyzeRouteSource(source);
283
+
284
+ expect(analysis.imports[0]).toMatchObject({
285
+ source: "@/client/widgets/Widget.client",
286
+ namespaceName: "Widgets",
287
+ });
288
+ expect(findRouteLevelClientComponentImport(source)).toBeNull();
289
+ });
201
290
 
202
291
  it("resolves a route-level client entry by reading a use client target without .client in the path", async () => {
203
292
  const rootDir = await mkRepoTempDir("client-entry-");
@@ -230,6 +319,42 @@ describe("findClientComponentImports", () => {
230
319
  expect(resolved).toBe("src/client/widgets/pledge-form/PledgeForm.tsx");
231
320
  } finally {
232
321
  await rm(rootDir, { recursive: true, force: true });
233
- }
234
- });
235
- });
322
+ }
323
+ });
324
+
325
+ it("validates explicit route-level client modules against server-only imports", async () => {
326
+ const rootDir = await mkRepoTempDir("client-entry-server-only-");
327
+ try {
328
+ await mkdir(path.join(rootDir, "app"), { recursive: true });
329
+ await writeFile(
330
+ path.join(rootDir, "app", "dashboard.island.tsx"),
331
+ `"use client";
332
+ import { Database } from "bun:sqlite";
333
+ export default function Dashboard() {
334
+ return String(Database);
335
+ }`,
336
+ );
337
+
338
+ const error = await validateClientModuleForBrowserBundle(
339
+ {
340
+ id: "dashboard",
341
+ kind: "page",
342
+ pattern: "/dashboard",
343
+ module: "app/dashboard.tsx",
344
+ componentModule: "app/dashboard.tsx",
345
+ clientModule: "app/dashboard.island.tsx",
346
+ clientExportName: "default",
347
+ hydration: { strategy: "island", priority: "visible", preload: false },
348
+ },
349
+ rootDir,
350
+ );
351
+
352
+ expect(error).toContain("MANDU_BOUNDARY_SERVER_ONLY_IMPORT");
353
+ expect(error).toContain("bun:sqlite");
354
+ expect(error).toContain("route=dashboard");
355
+ expect(error).toContain("Suggestion:");
356
+ } finally {
357
+ await rm(rootDir, { recursive: true, force: true });
358
+ }
359
+ });
360
+ });