@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
@@ -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
- // Give the seedReverseGraph fire-and-forget a chance to finish.
441
- // The seed is O(|routes| * fs.readFile) which on a 2-route
442
- // manifest completes in <50 ms on any dev machine.
443
- await sleep(100);
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
- expect(ssrCalls.length).toBeGreaterThanOrEqual(1);
467
- // Exact path match defensive so a future refactor that loses
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 sleep(100);
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");