@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
|
@@ -0,0 +1,524 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test";
|
|
2
|
+
import React from "react";
|
|
3
|
+
import { renderToStaticMarkup } from "react-dom/server";
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
__ManduClientBoundary,
|
|
7
|
+
renderWithManduClientBoundaryManifest,
|
|
8
|
+
} from "../../internal/client-boundary";
|
|
9
|
+
import {
|
|
10
|
+
transformClientBoundaries,
|
|
11
|
+
validateClientBoundaryExport,
|
|
12
|
+
validateClientBoundaryServerOnlyImports,
|
|
13
|
+
} from "../client-boundary-transform";
|
|
14
|
+
|
|
15
|
+
describe("transformClientBoundaries", () => {
|
|
16
|
+
it("rewrites named client component JSX into an internal boundary", () => {
|
|
17
|
+
const result = transformClientBoundaries(
|
|
18
|
+
`
|
|
19
|
+
import { CommentsSection } from "./CommentsSection.client";
|
|
20
|
+
|
|
21
|
+
export default async function PledgePage({ comments }) {
|
|
22
|
+
return <main><CommentsSection initialComments={comments} /></main>;
|
|
23
|
+
}
|
|
24
|
+
`,
|
|
25
|
+
{
|
|
26
|
+
routeId: "pledges-$id",
|
|
27
|
+
fileName: "app/pledges/[id]/page.tsx",
|
|
28
|
+
},
|
|
29
|
+
);
|
|
30
|
+
|
|
31
|
+
expect(result.transformed).toBe(true);
|
|
32
|
+
expect(result.diagnostics).toEqual([]);
|
|
33
|
+
expect(result.boundaries).toMatchObject([
|
|
34
|
+
{
|
|
35
|
+
id: "pledges-$id--0",
|
|
36
|
+
routeId: "pledges-$id",
|
|
37
|
+
module: "./CommentsSection.client",
|
|
38
|
+
importSpecifier: "./CommentsSection.client",
|
|
39
|
+
exportName: "CommentsSection",
|
|
40
|
+
localName: "CommentsSection",
|
|
41
|
+
hydrate: "visible",
|
|
42
|
+
ordinal: 0,
|
|
43
|
+
propsSource: "inline",
|
|
44
|
+
propsKeys: ["initialComments"],
|
|
45
|
+
hasSpreadProps: false,
|
|
46
|
+
},
|
|
47
|
+
]);
|
|
48
|
+
expect(result.code).toContain('import { __ManduClientBoundary } from "@mandujs/core/internal/client-boundary";');
|
|
49
|
+
expect(result.code).not.toContain("import { CommentsSection }");
|
|
50
|
+
expect(result.code).toContain("<__ManduClientBoundary");
|
|
51
|
+
expect(result.code).toContain('boundaryId="pledges-$id--0"');
|
|
52
|
+
expect(result.code).toContain('module="./CommentsSection.client"');
|
|
53
|
+
expect(result.code).toContain('exportName="CommentsSection"');
|
|
54
|
+
expect(result.code).toContain("initialComments: comments");
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("tracks default and namespace client exports in JSX order", () => {
|
|
58
|
+
const result = transformClientBoundaries(
|
|
59
|
+
`
|
|
60
|
+
import Profile from "./Profile.client";
|
|
61
|
+
import * as Widgets from "./widgets.client";
|
|
62
|
+
|
|
63
|
+
export default function Dashboard({ user }) {
|
|
64
|
+
return (
|
|
65
|
+
<main>
|
|
66
|
+
<Profile user={user} />
|
|
67
|
+
<Widgets.ActivityFeed userId={user.id} />
|
|
68
|
+
</main>
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
`,
|
|
72
|
+
{
|
|
73
|
+
routeId: "dashboard",
|
|
74
|
+
fileName: "app/dashboard/page.tsx",
|
|
75
|
+
hydrate: "idle",
|
|
76
|
+
},
|
|
77
|
+
);
|
|
78
|
+
|
|
79
|
+
expect(result.diagnostics).toEqual([]);
|
|
80
|
+
expect(result.boundaries.map((boundary) => ({
|
|
81
|
+
id: boundary.id,
|
|
82
|
+
module: boundary.module,
|
|
83
|
+
importSpecifier: boundary.importSpecifier,
|
|
84
|
+
exportName: boundary.exportName,
|
|
85
|
+
localName: boundary.localName,
|
|
86
|
+
hydrate: boundary.hydrate,
|
|
87
|
+
propsKeys: boundary.propsKeys,
|
|
88
|
+
hasSpreadProps: boundary.hasSpreadProps,
|
|
89
|
+
}))).toEqual([
|
|
90
|
+
{
|
|
91
|
+
id: "dashboard--0",
|
|
92
|
+
module: "./Profile.client",
|
|
93
|
+
importSpecifier: "./Profile.client",
|
|
94
|
+
exportName: "default",
|
|
95
|
+
localName: "Profile",
|
|
96
|
+
hydrate: "idle",
|
|
97
|
+
propsKeys: ["user"],
|
|
98
|
+
hasSpreadProps: false,
|
|
99
|
+
},
|
|
100
|
+
{
|
|
101
|
+
id: "dashboard--1",
|
|
102
|
+
module: "./widgets.client",
|
|
103
|
+
importSpecifier: "./widgets.client",
|
|
104
|
+
exportName: "ActivityFeed",
|
|
105
|
+
localName: "Widgets.ActivityFeed",
|
|
106
|
+
hydrate: "idle",
|
|
107
|
+
propsKeys: ["userId"],
|
|
108
|
+
hasSpreadProps: false,
|
|
109
|
+
},
|
|
110
|
+
]);
|
|
111
|
+
expect(result.code).not.toContain("import Profile");
|
|
112
|
+
expect(result.code).not.toContain("import * as Widgets");
|
|
113
|
+
expect(result.code).toContain('boundaryId="dashboard--0"');
|
|
114
|
+
expect(result.code).toContain('boundaryId="dashboard--1"');
|
|
115
|
+
expect(result.code).toContain('exportName="default"');
|
|
116
|
+
expect(result.code).toContain('exportName="ActivityFeed"');
|
|
117
|
+
expect(result.code).toContain("userId: user.id");
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it("reports unsupported children while still making the boundary explicit", () => {
|
|
121
|
+
const result = transformClientBoundaries(
|
|
122
|
+
`
|
|
123
|
+
import Card from "./Card.client";
|
|
124
|
+
|
|
125
|
+
export default function Page() {
|
|
126
|
+
return <Card><span>server child</span></Card>;
|
|
127
|
+
}
|
|
128
|
+
`,
|
|
129
|
+
{
|
|
130
|
+
routeId: "card",
|
|
131
|
+
fileName: "app/card/page.tsx",
|
|
132
|
+
},
|
|
133
|
+
);
|
|
134
|
+
|
|
135
|
+
expect(result.transformed).toBe(true);
|
|
136
|
+
expect(result.diagnostics).toMatchObject([
|
|
137
|
+
{
|
|
138
|
+
code: "MANDU_BOUNDARY_UNSUPPORTED_CHILDREN",
|
|
139
|
+
},
|
|
140
|
+
]);
|
|
141
|
+
expect(result.code).toContain("<__ManduClientBoundary");
|
|
142
|
+
expect(result.code).not.toContain("<Card>");
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it("reports invalid HTML host contexts for compiler-owned boundaries", () => {
|
|
146
|
+
const result = transformClientBoundaries(
|
|
147
|
+
`
|
|
148
|
+
import RowActions from "./RowActions.client";
|
|
149
|
+
|
|
150
|
+
export default function Page() {
|
|
151
|
+
return (
|
|
152
|
+
<table>
|
|
153
|
+
<tbody>
|
|
154
|
+
<tr>
|
|
155
|
+
<RowActions id="a" />
|
|
156
|
+
</tr>
|
|
157
|
+
</tbody>
|
|
158
|
+
</table>
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
`,
|
|
162
|
+
{
|
|
163
|
+
routeId: "table-route",
|
|
164
|
+
fileName: "app/table/page.tsx",
|
|
165
|
+
},
|
|
166
|
+
);
|
|
167
|
+
|
|
168
|
+
expect(result.transformed).toBe(true);
|
|
169
|
+
expect(result.diagnostics).toMatchObject([
|
|
170
|
+
{
|
|
171
|
+
code: "MANDU_BOUNDARY_INVALID_HOST_CONTEXT",
|
|
172
|
+
module: "./RowActions.client",
|
|
173
|
+
exportName: "default",
|
|
174
|
+
},
|
|
175
|
+
]);
|
|
176
|
+
expect(result.diagnostics[0]?.message).toContain("<tr>");
|
|
177
|
+
expect(result.diagnostics[0]?.suggestion).toContain("explicit island API");
|
|
178
|
+
expect(result.code).toContain("<__ManduClientBoundary");
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it("reports unsupported function props and refs", () => {
|
|
182
|
+
const result = transformClientBoundaries(
|
|
183
|
+
`
|
|
184
|
+
import Button from "./Button.client";
|
|
185
|
+
|
|
186
|
+
export default function Page({ actionRef }) {
|
|
187
|
+
return <Button ref={actionRef} onClick={() => actionRef.current?.()} label="Save" />;
|
|
188
|
+
}
|
|
189
|
+
`,
|
|
190
|
+
{
|
|
191
|
+
routeId: "actions",
|
|
192
|
+
fileName: "app/actions/page.tsx",
|
|
193
|
+
},
|
|
194
|
+
);
|
|
195
|
+
|
|
196
|
+
expect(result.diagnostics.map((diagnostic) => diagnostic.code)).toEqual([
|
|
197
|
+
"MANDU_BOUNDARY_UNSUPPORTED_REF",
|
|
198
|
+
"MANDU_BOUNDARY_UNSUPPORTED_FUNCTION_PROP",
|
|
199
|
+
]);
|
|
200
|
+
expect(result.diagnostics.every((diagnostic) => diagnostic.severity === "error")).toBe(true);
|
|
201
|
+
expect(result.diagnostics.every((diagnostic) => diagnostic.routeId === "actions")).toBe(true);
|
|
202
|
+
expect(result.diagnostics.every((diagnostic) => diagnostic.boundaryId === "actions--0")).toBe(true);
|
|
203
|
+
expect(result.diagnostics.every((diagnostic) => diagnostic.suggestion.length > 0)).toBe(true);
|
|
204
|
+
expect(result.code).toContain("label: \"Save\"");
|
|
205
|
+
expect(result.code).not.toContain("actionRef.current");
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it("reports statically visible non-serializable prop values", () => {
|
|
209
|
+
const result = transformClientBoundaries(
|
|
210
|
+
`
|
|
211
|
+
import Widget from "./Widget.client";
|
|
212
|
+
|
|
213
|
+
export default function Page() {
|
|
214
|
+
return <Widget config={{ title: "A", onSave: () => "nope" }} icon={<span />} token={Symbol("x")} />;
|
|
215
|
+
}
|
|
216
|
+
`,
|
|
217
|
+
{
|
|
218
|
+
routeId: "settings",
|
|
219
|
+
fileName: "app/settings/page.tsx",
|
|
220
|
+
},
|
|
221
|
+
);
|
|
222
|
+
|
|
223
|
+
expect(result.diagnostics.map((diagnostic) => diagnostic.code)).toEqual([
|
|
224
|
+
"MANDU_BOUNDARY_UNSUPPORTED_PROP_VALUE",
|
|
225
|
+
"MANDU_BOUNDARY_UNSUPPORTED_PROP_VALUE",
|
|
226
|
+
"MANDU_BOUNDARY_UNSUPPORTED_PROP_VALUE",
|
|
227
|
+
]);
|
|
228
|
+
expect(result.diagnostics.map((diagnostic) => diagnostic.module)).toEqual([
|
|
229
|
+
"./Widget.client",
|
|
230
|
+
"./Widget.client",
|
|
231
|
+
"./Widget.client",
|
|
232
|
+
]);
|
|
233
|
+
expect(result.diagnostics.every((diagnostic) => diagnostic.suggestion.includes("serializable"))).toBe(true);
|
|
234
|
+
expect(result.code).toContain("<__ManduClientBoundary");
|
|
235
|
+
expect(result.code).not.toContain("onSave");
|
|
236
|
+
expect(result.code).not.toContain("Symbol");
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
it("supports ordinal offsets for route import graph transforms", () => {
|
|
240
|
+
const result = transformClientBoundaries(
|
|
241
|
+
`
|
|
242
|
+
import { WrapperWidget } from "./WrapperWidget.client";
|
|
243
|
+
|
|
244
|
+
export function Wrapper() {
|
|
245
|
+
return <WrapperWidget />;
|
|
246
|
+
}
|
|
247
|
+
`,
|
|
248
|
+
{
|
|
249
|
+
routeId: "nested",
|
|
250
|
+
fileName: "app/nested/Wrapper.tsx",
|
|
251
|
+
ordinalOffset: 2,
|
|
252
|
+
},
|
|
253
|
+
);
|
|
254
|
+
|
|
255
|
+
expect(result.boundaries).toMatchObject([
|
|
256
|
+
{
|
|
257
|
+
id: "nested--2",
|
|
258
|
+
ordinal: 2,
|
|
259
|
+
source: {
|
|
260
|
+
file: "app/nested/Wrapper.tsx",
|
|
261
|
+
},
|
|
262
|
+
},
|
|
263
|
+
]);
|
|
264
|
+
expect(result.code).toContain('boundaryId="nested--2"');
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
it("replays manifest-owned boundary ids when provided", () => {
|
|
268
|
+
const result = transformClientBoundaries(
|
|
269
|
+
`
|
|
270
|
+
import { WrapperWidget } from "./WrapperWidget.client";
|
|
271
|
+
|
|
272
|
+
export function Wrapper() {
|
|
273
|
+
return <WrapperWidget />;
|
|
274
|
+
}
|
|
275
|
+
`,
|
|
276
|
+
{
|
|
277
|
+
routeId: "nested",
|
|
278
|
+
fileName: "app/nested/Wrapper.tsx",
|
|
279
|
+
boundaryReplay: [
|
|
280
|
+
{
|
|
281
|
+
id: "nested--manifest-owned",
|
|
282
|
+
ordinal: 4,
|
|
283
|
+
},
|
|
284
|
+
],
|
|
285
|
+
},
|
|
286
|
+
);
|
|
287
|
+
|
|
288
|
+
expect(result.boundaries).toMatchObject([
|
|
289
|
+
{
|
|
290
|
+
id: "nested--manifest-owned",
|
|
291
|
+
ordinal: 4,
|
|
292
|
+
},
|
|
293
|
+
]);
|
|
294
|
+
expect(result.code).toContain('boundaryId="nested--manifest-owned"');
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
it("validates compiler-discovered client boundary exports", () => {
|
|
298
|
+
const boundary = {
|
|
299
|
+
id: "profile--0",
|
|
300
|
+
routeId: "profile",
|
|
301
|
+
module: "src/client/Profile.client.tsx",
|
|
302
|
+
exportName: "ProfileCard",
|
|
303
|
+
source: {
|
|
304
|
+
file: "app/profile/page.tsx",
|
|
305
|
+
line: 5,
|
|
306
|
+
column: 12,
|
|
307
|
+
},
|
|
308
|
+
};
|
|
309
|
+
|
|
310
|
+
expect(validateClientBoundaryExport(
|
|
311
|
+
`
|
|
312
|
+
export default function DefaultProfile() {}
|
|
313
|
+
export function ProfileCard() {}
|
|
314
|
+
export const ProfileTabs = () => null;
|
|
315
|
+
export { ProfileTabs as RenamedTabs };
|
|
316
|
+
`,
|
|
317
|
+
boundary,
|
|
318
|
+
).status).toBe("found");
|
|
319
|
+
|
|
320
|
+
const missing = validateClientBoundaryExport(
|
|
321
|
+
"export default function DefaultProfile() {}\nexport const Other = () => null;\n",
|
|
322
|
+
boundary,
|
|
323
|
+
);
|
|
324
|
+
|
|
325
|
+
expect(missing.status).toBe("missing");
|
|
326
|
+
expect(missing.diagnostic).toMatchObject({
|
|
327
|
+
code: "MANDU_BOUNDARY_UNRESOLVED_EXPORT",
|
|
328
|
+
routeId: "profile",
|
|
329
|
+
boundaryId: "profile--0",
|
|
330
|
+
module: "src/client/Profile.client.tsx",
|
|
331
|
+
exportName: "ProfileCard",
|
|
332
|
+
source: {
|
|
333
|
+
file: "app/profile/page.tsx",
|
|
334
|
+
line: 5,
|
|
335
|
+
column: 12,
|
|
336
|
+
},
|
|
337
|
+
});
|
|
338
|
+
expect(missing.diagnostic?.suggestion).toContain("ProfileCard");
|
|
339
|
+
|
|
340
|
+
expect(validateClientBoundaryExport(
|
|
341
|
+
'export { ProfileCard } from "./ProfileCard";\n',
|
|
342
|
+
boundary,
|
|
343
|
+
).status).toBe("unknown");
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
it("validates server-only imports in compiler-discovered client modules", () => {
|
|
347
|
+
const boundary = {
|
|
348
|
+
id: "settings--0",
|
|
349
|
+
routeId: "settings",
|
|
350
|
+
module: "src/client/Settings.client.tsx",
|
|
351
|
+
exportName: "Settings",
|
|
352
|
+
};
|
|
353
|
+
|
|
354
|
+
const diagnostics = validateClientBoundaryServerOnlyImports(
|
|
355
|
+
`
|
|
356
|
+
import { readFile } from "node:fs/promises";
|
|
357
|
+
export { join as joinPath } from "path";
|
|
358
|
+
const lazy = () => import("node:child_process");
|
|
359
|
+
const required = require("os");
|
|
360
|
+
import { Database } from "bun:sqlite";
|
|
361
|
+
import { Mandu } from "@mandujs/core";
|
|
362
|
+
import { internal } from "@mandujs/core/internal/secret";
|
|
363
|
+
import "server-only";
|
|
364
|
+
import data from "./data.server";
|
|
365
|
+
import type { Stats } from "node:fs";
|
|
366
|
+
export type { ServerShape } from "./types.server";
|
|
367
|
+
export function Settings() { return null; }
|
|
368
|
+
`,
|
|
369
|
+
boundary,
|
|
370
|
+
"src/client/Settings.client.tsx",
|
|
371
|
+
);
|
|
372
|
+
|
|
373
|
+
expect(diagnostics.map((diagnostic) => diagnostic.code)).toEqual([
|
|
374
|
+
"MANDU_BOUNDARY_SERVER_ONLY_IMPORT",
|
|
375
|
+
"MANDU_BOUNDARY_SERVER_ONLY_IMPORT",
|
|
376
|
+
"MANDU_BOUNDARY_SERVER_ONLY_IMPORT",
|
|
377
|
+
"MANDU_BOUNDARY_SERVER_ONLY_IMPORT",
|
|
378
|
+
"MANDU_BOUNDARY_SERVER_ONLY_IMPORT",
|
|
379
|
+
"MANDU_BOUNDARY_SERVER_ONLY_IMPORT",
|
|
380
|
+
"MANDU_BOUNDARY_SERVER_ONLY_IMPORT",
|
|
381
|
+
"MANDU_BOUNDARY_SERVER_ONLY_IMPORT",
|
|
382
|
+
"MANDU_BOUNDARY_SERVER_ONLY_IMPORT",
|
|
383
|
+
]);
|
|
384
|
+
expect(diagnostics.map((diagnostic) => diagnostic.module)).toEqual([
|
|
385
|
+
"src/client/Settings.client.tsx",
|
|
386
|
+
"src/client/Settings.client.tsx",
|
|
387
|
+
"src/client/Settings.client.tsx",
|
|
388
|
+
"src/client/Settings.client.tsx",
|
|
389
|
+
"src/client/Settings.client.tsx",
|
|
390
|
+
"src/client/Settings.client.tsx",
|
|
391
|
+
"src/client/Settings.client.tsx",
|
|
392
|
+
"src/client/Settings.client.tsx",
|
|
393
|
+
"src/client/Settings.client.tsx",
|
|
394
|
+
]);
|
|
395
|
+
expect(diagnostics.every((diagnostic) => diagnostic.routeId === "settings")).toBe(true);
|
|
396
|
+
expect(diagnostics.every((diagnostic) => diagnostic.boundaryId === "settings--0")).toBe(true);
|
|
397
|
+
expect(diagnostics.every((diagnostic) => diagnostic.suggestion.includes("server route"))).toBe(true);
|
|
398
|
+
});
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
describe("__ManduClientBoundary", () => {
|
|
402
|
+
it("emits boundary-local props without rendering the client module", () => {
|
|
403
|
+
const html = renderToStaticMarkup(
|
|
404
|
+
renderWithManduClientBoundaryManifest(
|
|
405
|
+
"pledges-$id",
|
|
406
|
+
{
|
|
407
|
+
version: 1,
|
|
408
|
+
buildTime: "2026-05-23T00:00:00.000Z",
|
|
409
|
+
env: "production",
|
|
410
|
+
bundles: {},
|
|
411
|
+
boundaries: {
|
|
412
|
+
"pledges-$id--0": {
|
|
413
|
+
route: "pledges-$id",
|
|
414
|
+
js: "/.mandu/client/pledges-$id--0.boundary.js",
|
|
415
|
+
module: "src/client/CommentsSection.client.tsx",
|
|
416
|
+
exportName: "CommentsSection",
|
|
417
|
+
priority: "visible",
|
|
418
|
+
hydrate: "visible",
|
|
419
|
+
},
|
|
420
|
+
},
|
|
421
|
+
shared: {
|
|
422
|
+
runtime: "/.mandu/client/_runtime.js",
|
|
423
|
+
vendor: "/.mandu/client/_react.js",
|
|
424
|
+
},
|
|
425
|
+
},
|
|
426
|
+
() => __ManduClientBoundary({
|
|
427
|
+
routeId: "pledges-$id",
|
|
428
|
+
boundaryId: "pledges-$id--0",
|
|
429
|
+
module: "src/client/CommentsSection.client.tsx",
|
|
430
|
+
exportName: "CommentsSection",
|
|
431
|
+
hydrate: "visible",
|
|
432
|
+
props: {
|
|
433
|
+
pledgeId: "pledge-1",
|
|
434
|
+
initialComments: [{ id: "c1", body: "serialized comment" }],
|
|
435
|
+
},
|
|
436
|
+
}),
|
|
437
|
+
),
|
|
438
|
+
);
|
|
439
|
+
|
|
440
|
+
expect(html).toContain('data-mandu-island="pledges-$id--0"');
|
|
441
|
+
expect(html).toContain('data-mandu-boundary-id="pledges-$id--0"');
|
|
442
|
+
expect(html).toContain('data-mandu-route-id="pledges-$id"');
|
|
443
|
+
expect(html).toContain('data-mandu-src="/.mandu/client/pledges-$id--0.boundary.js?t=');
|
|
444
|
+
expect(html).toContain('data-mandu-client-module="src/client/CommentsSection.client.tsx"');
|
|
445
|
+
expect(html).toContain('data-mandu-client-export="CommentsSection"');
|
|
446
|
+
expect(html).toContain('type="application/json"');
|
|
447
|
+
expect(html).toContain('data-mandu-props="pledges-$id--0"');
|
|
448
|
+
expect(html).toContain('"pledgeId":"pledge-1"');
|
|
449
|
+
expect(html).toContain('"initialComments"');
|
|
450
|
+
});
|
|
451
|
+
|
|
452
|
+
it("assigns unique island instance ids when one boundary renders multiple times", () => {
|
|
453
|
+
const manifest = {
|
|
454
|
+
version: 1,
|
|
455
|
+
buildTime: "2026-05-23T00:00:00.000Z",
|
|
456
|
+
env: "production" as const,
|
|
457
|
+
bundles: {},
|
|
458
|
+
boundaries: {
|
|
459
|
+
"feed--0": {
|
|
460
|
+
route: "feed",
|
|
461
|
+
js: "/.mandu/client/feed--0.boundary.js",
|
|
462
|
+
module: "src/client/FeedItem.client.tsx",
|
|
463
|
+
exportName: "FeedItem",
|
|
464
|
+
priority: "visible" as const,
|
|
465
|
+
hydrate: "visible",
|
|
466
|
+
},
|
|
467
|
+
},
|
|
468
|
+
shared: {
|
|
469
|
+
runtime: "/.mandu/client/_runtime.js",
|
|
470
|
+
vendor: "/.mandu/client/_react.js",
|
|
471
|
+
},
|
|
472
|
+
};
|
|
473
|
+
|
|
474
|
+
const html = renderWithManduClientBoundaryManifest("feed", manifest, () =>
|
|
475
|
+
renderToStaticMarkup(
|
|
476
|
+
React.createElement(
|
|
477
|
+
React.Fragment,
|
|
478
|
+
null,
|
|
479
|
+
React.createElement(__ManduClientBoundary, {
|
|
480
|
+
routeId: "feed",
|
|
481
|
+
boundaryId: "feed--0",
|
|
482
|
+
module: "src/client/FeedItem.client.tsx",
|
|
483
|
+
exportName: "FeedItem",
|
|
484
|
+
props: { id: "a" },
|
|
485
|
+
}),
|
|
486
|
+
React.createElement(__ManduClientBoundary, {
|
|
487
|
+
routeId: "feed",
|
|
488
|
+
boundaryId: "feed--0",
|
|
489
|
+
module: "src/client/FeedItem.client.tsx",
|
|
490
|
+
exportName: "FeedItem",
|
|
491
|
+
props: { id: "b" },
|
|
492
|
+
}),
|
|
493
|
+
)
|
|
494
|
+
),
|
|
495
|
+
);
|
|
496
|
+
|
|
497
|
+
expect(html).toContain('data-mandu-boundary-id="feed--0"');
|
|
498
|
+
expect(html).toContain('data-mandu-island="feed--0"');
|
|
499
|
+
expect(html).toContain('data-mandu-island="feed--0--1"');
|
|
500
|
+
expect(html).toContain('data-mandu-props="feed--0"');
|
|
501
|
+
expect(html).toContain('data-mandu-props="feed--0--1"');
|
|
502
|
+
expect(html).toContain('"id":"a"');
|
|
503
|
+
expect(html).toContain('"id":"b"');
|
|
504
|
+
});
|
|
505
|
+
|
|
506
|
+
it("fails runtime serialization for dynamic non-serializable boundary props", () => {
|
|
507
|
+
expect(() =>
|
|
508
|
+
renderToStaticMarkup(
|
|
509
|
+
__ManduClientBoundary({
|
|
510
|
+
routeId: "dynamic",
|
|
511
|
+
boundaryId: "dynamic--0",
|
|
512
|
+
module: "src/client/Dynamic.client.tsx",
|
|
513
|
+
exportName: "default",
|
|
514
|
+
props: {
|
|
515
|
+
safe: "value",
|
|
516
|
+
nested: {
|
|
517
|
+
onSave: () => "not serializable",
|
|
518
|
+
},
|
|
519
|
+
},
|
|
520
|
+
}),
|
|
521
|
+
),
|
|
522
|
+
).toThrow(/MANDU_BOUNDARY_UNSERIALIZABLE_PROP.*dynamic--0.*\$\.nested\.onSave/);
|
|
523
|
+
});
|
|
524
|
+
});
|
|
@@ -343,9 +343,18 @@ describe("scanFileImports — end-to-end", () => {
|
|
|
343
343
|
*/
|
|
344
344
|
const WATCH_SETTLE_MS = 400;
|
|
345
345
|
|
|
346
|
-
function sleep(ms: number): Promise<void> {
|
|
347
|
-
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
348
|
-
}
|
|
346
|
+
function sleep(ms: number): Promise<void> {
|
|
347
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
async function waitFor(predicate: () => boolean, timeoutMs = 5_000): Promise<boolean> {
|
|
351
|
+
const deadline = Date.now() + timeoutMs;
|
|
352
|
+
while (Date.now() < deadline) {
|
|
353
|
+
if (predicate()) return true;
|
|
354
|
+
await sleep(50);
|
|
355
|
+
}
|
|
356
|
+
return predicate();
|
|
357
|
+
}
|
|
349
358
|
|
|
350
359
|
function createTempProject(): string {
|
|
351
360
|
const root = mkdtempSync(path.join(tmpdir(), "mandu-189-"));
|
|
@@ -434,37 +443,37 @@ describe.skipIf(process.env.MANDU_SKIP_BUNDLER_TESTS === "1")(
|
|
|
434
443
|
onSSRChange: (filePath) => {
|
|
435
444
|
ssrCalls.push(filePath);
|
|
436
445
|
},
|
|
437
|
-
});
|
|
438
|
-
close = bundler.close;
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
// The
|
|
442
|
-
//
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
// Deep leaf edit — NOT in any known root set, NOT in a default
|
|
446
|
-
// common dir (`app/` isn't a common dir by default).
|
|
447
|
-
writeFileSync(
|
|
448
|
-
path.join(rootDir, "app/_utils/translations/ko.ts"),
|
|
449
|
-
'export const greeting = "안녕하세요";\n',
|
|
450
|
-
);
|
|
451
|
-
|
|
452
|
-
await sleep(WATCH_SETTLE_MS);
|
|
453
|
-
|
|
454
|
-
// The leaf change should reach onSSRChange via the
|
|
455
|
-
// barrel -> page.tsx transitive chain. The callback is
|
|
456
|
-
// invoked with the normalized absolute path of page.tsx
|
|
446
|
+
});
|
|
447
|
+
close = bundler.close;
|
|
448
|
+
await bundler.reverseGraphReady;
|
|
449
|
+
|
|
450
|
+
// The leaf change should reach onSSRChange via the
|
|
451
|
+
// barrel -> page.tsx transitive chain. The callback is
|
|
452
|
+
// invoked with the normalized absolute path of page.tsx
|
|
457
453
|
// (the SSR root that imports the barrel).
|
|
458
454
|
const normalizedPage =
|
|
459
455
|
process.platform === "win32"
|
|
460
456
|
? path
|
|
461
457
|
.resolve(rootDir, "app/page.tsx")
|
|
462
458
|
.replace(/\\/g, "/")
|
|
463
|
-
.toLowerCase()
|
|
464
|
-
: path.resolve(rootDir, "app/page.tsx").replace(/\\/g, "/");
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
//
|
|
459
|
+
.toLowerCase()
|
|
460
|
+
: path.resolve(rootDir, "app/page.tsx").replace(/\\/g, "/");
|
|
461
|
+
|
|
462
|
+
// Deep leaf edit — NOT in any known root set, NOT in a default
|
|
463
|
+
// common dir (`app/` isn't a common dir by default). During the
|
|
464
|
+
// full suite, reverse-graph seeding and Windows watcher arming can
|
|
465
|
+
// race the first write, so retry with distinct contents until the
|
|
466
|
+
// observable SSR root dispatch appears.
|
|
467
|
+
for (let attempt = 0; attempt < 5 && !ssrCalls.includes(normalizedPage); attempt++) {
|
|
468
|
+
writeFileSync(
|
|
469
|
+
path.join(rootDir, "app/_utils/translations/ko.ts"),
|
|
470
|
+
`export const greeting = "안녕하세요-${attempt}";\n`,
|
|
471
|
+
);
|
|
472
|
+
await waitFor(() => ssrCalls.includes(normalizedPage), WATCH_SETTLE_MS);
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
expect(ssrCalls.length).toBeGreaterThanOrEqual(1);
|
|
476
|
+
// Exact path match — defensive so a future refactor that loses
|
|
468
477
|
// the normalization surfaces here, not in production.
|
|
469
478
|
expect(ssrCalls).toContain(normalizedPage);
|
|
470
479
|
});
|
|
@@ -493,11 +502,11 @@ describe.skipIf(process.env.MANDU_SKIP_BUNDLER_TESTS === "1")(
|
|
|
493
502
|
// specific-path dispatch here.
|
|
494
503
|
if (filePath !== SSR_CHANGE_WILDCARD) ssrCalls.push(filePath);
|
|
495
504
|
},
|
|
496
|
-
});
|
|
497
|
-
close = bundler.close;
|
|
498
|
-
await
|
|
499
|
-
|
|
500
|
-
// Write a brand-new file that nothing imports. Has to live
|
|
505
|
+
});
|
|
506
|
+
close = bundler.close;
|
|
507
|
+
await bundler.reverseGraphReady;
|
|
508
|
+
|
|
509
|
+
// Write a brand-new file that nothing imports. Has to live
|
|
501
510
|
// UNDER a watched directory for the watcher to see it at all;
|
|
502
511
|
// `app/` is covered because `page.tsx` itself is there.
|
|
503
512
|
const orphan = path.join(rootDir, "app/orphan.ts");
|