@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.
- package/package.json +3 -1
- package/src/agent/__tests__/context.test.ts +94 -25
- package/src/agent/context.ts +17 -0
- package/src/agent/types.ts +32 -12
- package/src/agent/verify.ts +55 -24
- package/src/bundler/__snapshots__/build.test.ts.snap +5 -0
- package/src/bundler/__tests__/build-runner.ts +130 -17
- package/src/bundler/__tests__/client-boundary-transform.test.ts +524 -0
- package/src/bundler/__tests__/reverse-import-graph.test.ts +42 -33
- package/src/bundler/build.test.ts +478 -9
- package/src/bundler/build.ts +424 -746
- package/src/bundler/client-boundary-transform.ts +977 -0
- package/src/bundler/dev.ts +39 -112
- package/src/bundler/fast-refresh-preamble.ts +47 -0
- package/src/bundler/index.ts +3 -2
- package/src/bundler/manifest-schema.ts +10 -0
- package/src/bundler/types.ts +20 -2
- package/src/client/__tests__/props-serialization.test.ts +37 -0
- package/src/client/hydrate.ts +2 -2
- package/src/client/index.ts +1 -1
- package/src/client/props-serialization.ts +233 -0
- package/src/client/runtime-entry.ts +567 -0
- package/src/client/runtime.ts +1 -1
- package/src/client/serialize.ts +50 -404
- package/src/diagnose/__tests__/checks.test.ts +132 -17
- package/src/diagnose/checks.ts +184 -3
- package/src/diagnose/run.ts +10 -8
- package/src/generator/templates.test.ts +48 -5
- package/src/generator/templates.ts +10 -1
- package/src/internal/client-boundary.ts +266 -0
- package/src/internal/index.ts +2 -1
- package/src/router/client-entry.test.ts +154 -29
- package/src/router/client-entry.ts +111 -313
- package/src/router/fs-routes.test.ts +443 -1
- package/src/router/fs-routes.ts +16 -3
- package/src/router/fs-scanner.ts +176 -57
- package/src/router/fs-types.ts +11 -2
- package/src/router/route-source-analyzer.ts +521 -0
- package/src/runtime/__tests__/inline-client-hydration.test.ts +104 -1
- package/src/runtime/__tests__/page-render-response.test.ts +218 -0
- package/src/runtime/handlers.ts +50 -26
- package/src/runtime/page-render-response.ts +24 -1
- package/src/runtime/server.ts +14 -0
- package/src/runtime/ssr.ts +16 -5
- package/src/runtime/streaming-ssr.ts +119 -76
- 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
|
+
});
|