@mandujs/core 0.54.16 → 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 (37) 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__/inline-client-hydration.test.ts +134 -0
  31. package/src/runtime/__tests__/page-render-response.test.ts +212 -0
  32. package/src/runtime/handlers.ts +50 -26
  33. package/src/runtime/page-render-response.ts +43 -3
  34. package/src/runtime/server.ts +42 -5
  35. package/src/runtime/ssr.ts +16 -5
  36. package/src/runtime/streaming-ssr.ts +119 -76
  37. package/src/spec/schema.ts +31 -5
@@ -7,7 +7,7 @@
7
7
  */
8
8
 
9
9
  import { stat } from "fs/promises";
10
- import { join, relative, basename, extname } from "path";
10
+ import { dirname, join, relative, basename, extname } from "path";
11
11
  import type {
12
12
  ScannedFile,
13
13
  FSScannerConfig,
@@ -33,8 +33,17 @@ import { METADATA_ROUTES } from "../routes/types";
33
33
  import type { HydrationConfig } from "../spec/schema";
34
34
  import {
35
35
  hasUseClientDirective,
36
+ resolveClientImportModulePath,
36
37
  resolveRouteLevelClientEntry,
37
38
  } from "./client-entry";
39
+ import {
40
+ assertNoClientBoundaryDiagnostics,
41
+ collectStaticImportSpecifiers,
42
+ isClientBoundaryModuleSpecifier,
43
+ transformClientBoundaries,
44
+ validateClientBoundaryExport,
45
+ validateClientBoundaryServerOnlyImports,
46
+ } from "../bundler/client-boundary-transform";
38
47
 
39
48
  const HYDRATION_STRATEGIES = new Set(["none", "island", "full", "progressive"]);
40
49
  const HYDRATION_PRIORITIES = new Set(["immediate", "visible", "idle", "interaction"]);
@@ -406,8 +415,10 @@ export class FSScanner {
406
415
  // clientModule 결정: island 파일 또는 "use client"가 있는 page 자체
407
416
  let clientModule: string | undefined;
408
417
  let clientExportName: string | undefined;
418
+ let boundaries: NonNullable<FSRouteConfig["boundaries"]> = [];
409
419
  let hydration: HydrationConfig | undefined;
410
420
  let pageFileContent: string | null = null;
421
+ let pageHasUseClient = false;
411
422
 
412
423
  if (file.type === "page") {
413
424
  try {
@@ -417,6 +428,23 @@ export class FSScanner {
417
428
  }
418
429
  if (pageFileContent) {
419
430
  hydration = parsePageHydrationConfig(pageFileContent);
431
+ pageHasUseClient = hasUseClientDirective(pageFileContent);
432
+ if (!pageHasUseClient) {
433
+ boundaries = await this.resolveClientBoundaries(
434
+ rootDir,
435
+ modulePath,
436
+ routeId,
437
+ pageFileContent,
438
+ hydration?.priority ?? "visible",
439
+ );
440
+ }
441
+ if (boundaries.length > 0 && !hydration) {
442
+ hydration = {
443
+ strategy: "island",
444
+ priority: "visible",
445
+ preload: false,
446
+ };
447
+ }
420
448
  }
421
449
  }
422
450
 
@@ -436,12 +464,11 @@ export class FSScanner {
436
464
  conflictsWith: islands[0].absolutePath,
437
465
  });
438
466
  }
439
- } else if (file.type === "page" && pageFileContent) {
440
- // page 파일 자체에서 "use client" 확인
441
- const hasUseClient = hasUseClientDirective(pageFileContent);
442
- if (hasUseClient) {
467
+ } else if (file.type === "page" && pageFileContent) {
468
+ // page 파일 자체에서 "use client" 확인
469
+ if (pageHasUseClient) {
443
470
  clientModule = modulePath;
444
- } else {
471
+ } else if (boundaries.length === 0) {
445
472
  const routeClientEntry = await resolveRouteLevelClientEntry(rootDir, modulePath, pageFileContent);
446
473
  clientModule = routeClientEntry?.modulePath;
447
474
  clientExportName = routeClientEntry?.exportName;
@@ -465,6 +492,7 @@ export class FSScanner {
465
492
  componentModule: file.type === "page" ? modulePath : undefined,
466
493
  clientModule,
467
494
  clientExportName,
495
+ boundaries: boundaries && boundaries.length > 0 ? boundaries : undefined,
468
496
  hydration,
469
497
  layoutChain,
470
498
  loadingModule,
@@ -486,7 +514,7 @@ export class FSScanner {
486
514
  return { routes, routeErrors };
487
515
  }
488
516
 
489
- private hasHydrationShellMismatchRisk(pageContent: string, _islandRelativePath: string): boolean {
517
+ private hasHydrationShellMismatchRisk(pageContent: string, _islandRelativePath: string): boolean {
490
518
  // import문에서 island 모듈의 변수명을 직접 파싱
491
519
  const importMatch = pageContent.match(
492
520
  /import\s+([A-Za-z_$][A-Za-z0-9_$]*)\s+from\s+["'][^"']*\.island(?:\.(?:tsx?|jsx?))?["']/
@@ -503,10 +531,116 @@ export class FSScanner {
503
531
  return new RegExp(
504
532
  `typeof\\s+${islandVarName}\\s*!==\\s*["']undefined["']\\s*&&\\s*null`
505
533
  ).test(pageContent);
506
- }
507
-
508
- /**
509
- * 레이아웃 체인 해결
534
+ }
535
+
536
+ private async resolveClientBoundaries(
537
+ rootDir: string,
538
+ routeModule: string,
539
+ routeId: string,
540
+ source: string,
541
+ hydrate: NonNullable<HydrationConfig["priority"]>,
542
+ ): Promise<NonNullable<FSRouteConfig["boundaries"]>> {
543
+ const modules = await this.collectRouteBoundaryModules(rootDir, routeModule, source);
544
+ const boundaries: NonNullable<FSRouteConfig["boundaries"]> = [];
545
+
546
+ for (const moduleInfo of modules) {
547
+ const result = transformClientBoundaries(moduleInfo.source, {
548
+ routeId,
549
+ fileName: moduleInfo.modulePath,
550
+ hydrate,
551
+ ordinalOffset: boundaries.length,
552
+ });
553
+ if (result.boundaries.length === 0) continue;
554
+
555
+ const diagnostics = [...result.diagnostics];
556
+ const resolved = await Promise.all(result.boundaries.map(async (boundary) => {
557
+ const modulePath = await resolveClientImportModulePath(rootDir, boundary.source.file, boundary.module);
558
+ const resolvedBoundary = {
559
+ ...boundary,
560
+ module: modulePath ?? boundary.module,
561
+ };
562
+ if (modulePath) {
563
+ const clientSource = await Bun.file(join(rootDir, modulePath)).text();
564
+ diagnostics.push(...validateClientBoundaryServerOnlyImports(clientSource, resolvedBoundary, modulePath));
565
+ const validation = validateClientBoundaryExport(clientSource, resolvedBoundary, modulePath);
566
+ if (validation.diagnostic) diagnostics.push(validation.diagnostic);
567
+ }
568
+ return resolvedBoundary;
569
+ }));
570
+ assertNoClientBoundaryDiagnostics(diagnostics);
571
+ boundaries.push(...resolved);
572
+ }
573
+
574
+ return boundaries;
575
+ }
576
+
577
+ private async collectRouteBoundaryModules(
578
+ rootDir: string,
579
+ routeModule: string,
580
+ routeSource: string,
581
+ ): Promise<Array<{ modulePath: string; source: string }>> {
582
+ const visited = new Set<string>();
583
+ const modules: Array<{ modulePath: string; source: string }> = [];
584
+
585
+ const visit = async (modulePath: string, source: string): Promise<void> => {
586
+ const normalizedModulePath = modulePath.replace(/\\/g, "/").replace(/^\.\//, "");
587
+ if (visited.has(normalizedModulePath)) return;
588
+ visited.add(normalizedModulePath);
589
+ modules.push({ modulePath: normalizedModulePath, source });
590
+
591
+ for (const specifier of collectStaticImportSpecifiers(source, normalizedModulePath)) {
592
+ if (isClientBoundaryModuleSpecifier(specifier)) continue;
593
+ const resolved = await this.resolveRouteOwnedImportModulePath(rootDir, normalizedModulePath, specifier);
594
+ if (!resolved || visited.has(resolved)) continue;
595
+
596
+ let childSource: string;
597
+ try {
598
+ childSource = await Bun.file(join(rootDir, resolved)).text();
599
+ } catch {
600
+ continue;
601
+ }
602
+ if (hasUseClientDirective(childSource)) continue;
603
+
604
+ await visit(resolved, childSource);
605
+ }
606
+ };
607
+
608
+ await visit(routeModule, routeSource);
609
+ return modules;
610
+ }
611
+
612
+ private async resolveRouteOwnedImportModulePath(
613
+ rootDir: string,
614
+ importerModule: string,
615
+ specifier: string,
616
+ ): Promise<string | null> {
617
+ const normalized = specifier.replace(/\\/g, "/");
618
+ let basePath: string | null = null;
619
+
620
+ if (normalized.startsWith("@/") || normalized.startsWith("~/")) {
621
+ basePath = join(rootDir, "src", normalized.slice(2));
622
+ } else if (normalized.startsWith("./") || normalized.startsWith("../")) {
623
+ basePath = join(rootDir, dirname(importerModule), normalized);
624
+ }
625
+
626
+ if (!basePath) return null;
627
+
628
+ for (const candidate of expandRouteImportCandidates(basePath)) {
629
+ try {
630
+ const entry = await stat(candidate);
631
+ if (entry.isFile()) {
632
+ return relative(rootDir, candidate).replace(/\\/g, "/");
633
+ }
634
+ } catch {
635
+ // Try the next candidate.
636
+ }
637
+ }
638
+
639
+ return null;
640
+ }
641
+
642
+ /**
643
+ * 레이아웃 체인 해결
510
644
  */
511
645
  private resolveLayoutChain(
512
646
  segments: ScannedFile["segments"],
@@ -755,7 +889,7 @@ export function synthesizeLocaleRoutes(
755
889
  return out;
756
890
  }
757
891
 
758
- function prefixRouteWithLocale(route: FSRouteConfig, locale: string): FSRouteConfig {
892
+ function prefixRouteWithLocale(route: FSRouteConfig, locale: string): FSRouteConfig {
759
893
  const prefixedPattern = route.pattern === "/"
760
894
  ? `/${locale}`
761
895
  : `/${locale}${route.pattern.startsWith("/") ? route.pattern : `/${route.pattern}`}`;
@@ -771,12 +905,26 @@ function prefixRouteWithLocale(route: FSRouteConfig, locale: string): FSRouteCon
771
905
  { raw: locale, type: "static" },
772
906
  ...route.segments,
773
907
  ],
774
- };
775
- }
776
-
777
- // ═══════════════════════════════════════════════════════════════════════════
778
- // Factory Function
779
- // ═══════════════════════════════════════════════════════════════════════════
908
+ };
909
+ }
910
+
911
+ function expandRouteImportCandidates(basePath: string): string[] {
912
+ if (/\.[cm]?[jt]sx?$/.test(basePath)) return [basePath];
913
+ return [
914
+ `${basePath}.tsx`,
915
+ `${basePath}.ts`,
916
+ `${basePath}.jsx`,
917
+ `${basePath}.js`,
918
+ join(basePath, "index.tsx"),
919
+ join(basePath, "index.ts"),
920
+ join(basePath, "index.jsx"),
921
+ join(basePath, "index.js"),
922
+ ];
923
+ }
924
+
925
+ // ═══════════════════════════════════════════════════════════════════════════
926
+ // Factory Function
927
+ // ═══════════════════════════════════════════════════════════════════════════
780
928
 
781
929
  /**
782
930
  * 스캐너 생성 팩토리 함수
@@ -6,7 +6,7 @@
6
6
  * @module router/fs-types
7
7
  */
8
8
 
9
- import type { RouteKind, HydrationConfig, SpecHttpMethod } from "../spec/schema";
9
+ import type { RouteKind, HydrationConfig, RouteClientBoundary, SpecHttpMethod } from "../spec/schema";
10
10
 
11
11
  // ═══════════════════════════════════════════════════════════════════════════
12
12
  // Segment Types
@@ -123,6 +123,9 @@ export interface FSRouteConfig {
123
123
  /** Named export used by the route-level client module, if not default. */
124
124
  clientExportName?: string;
125
125
 
126
+ /** Compiler-discovered client boundaries rendered by this route. */
127
+ boundaries?: RouteClientBoundary[];
128
+
126
129
  /** 적용할 레이아웃 체인 */
127
130
  layoutChain: string[];
128
131
 
@@ -0,0 +1,134 @@
1
+ import { afterEach, describe, expect, it } from "bun:test";
2
+ import { mkdir, rm, writeFile } from "node:fs/promises";
3
+ import path from "node:path";
4
+ import { pathToFileURL } from "node:url";
5
+ import React from "react";
6
+ import type { BundleManifest } from "../../bundler/types";
7
+ import type { RoutesManifest } from "../../spec/schema";
8
+ import {
9
+ createServerRegistry,
10
+ startServer,
11
+ type ManduServer,
12
+ } from "../server";
13
+
14
+ const TEST_ROOT = path.resolve(process.cwd(), ".tmp-test-artifacts", "inline-client-hydration");
15
+
16
+ function hydratedManifest(routeId: string): BundleManifest {
17
+ return {
18
+ version: 1,
19
+ buildTime: "2026-05-23T00:00:00.000Z",
20
+ env: "production",
21
+ bundles: {
22
+ [routeId]: {
23
+ js: `/.mandu/client/${routeId}.island.js`,
24
+ dependencies: ["_runtime", "_react"],
25
+ priority: "visible",
26
+ },
27
+ },
28
+ shared: {
29
+ runtime: "/.mandu/client/_runtime.js",
30
+ vendor: "/.mandu/client/_react.js",
31
+ router: "/.mandu/client/_router.js",
32
+ },
33
+ };
34
+ }
35
+
36
+ async function resetTestRoot(): Promise<void> {
37
+ await rm(TEST_ROOT, { recursive: true, force: true });
38
+ await mkdir(TEST_ROOT, { recursive: true });
39
+ }
40
+
41
+ afterEach(async () => {
42
+ await rm(TEST_ROOT, { recursive: true, force: true });
43
+ });
44
+
45
+ describe("startServer inline client hydration", () => {
46
+ it("captures component props through sync server pages and inferred named client exports", async () => {
47
+ await resetTestRoot();
48
+
49
+ const routeId = "pledges-$id";
50
+ const clientModule = "src/client/widgets/comments-section/CommentsSection.client.tsx";
51
+ const clientPath = path.join(TEST_ROOT, clientModule);
52
+ await mkdir(path.dirname(clientPath), { recursive: true });
53
+ await writeFile(
54
+ clientPath,
55
+ `
56
+ import React from "react";
57
+
58
+ export function CommentsSection({ pledgeId, initialComments }) {
59
+ return React.createElement(
60
+ "section",
61
+ { "data-pledge-id": pledgeId },
62
+ initialComments.map((comment) =>
63
+ React.createElement("p", { key: comment.id }, comment.body)
64
+ )
65
+ );
66
+ }
67
+ `,
68
+ "utf-8",
69
+ );
70
+
71
+ const imported = await import(`${pathToFileURL(clientPath).href}?t=${Date.now()}`);
72
+ const CommentsSection = imported.CommentsSection as React.ComponentType<{
73
+ pledgeId: string;
74
+ initialComments: Array<{ id: string; body: string }>;
75
+ }>;
76
+
77
+ function PledgePage(): React.ReactElement {
78
+ return React.createElement(
79
+ "main",
80
+ null,
81
+ React.createElement(CommentsSection, {
82
+ pledgeId: "pledge-1",
83
+ initialComments: [{ id: "c1", body: "serialized comment" }],
84
+ }),
85
+ );
86
+ }
87
+
88
+ const registry = createServerRegistry();
89
+ registry.registerRouteComponent(routeId, PledgePage);
90
+
91
+ const manifest: RoutesManifest = {
92
+ version: 1,
93
+ routes: [
94
+ {
95
+ id: routeId,
96
+ kind: "page",
97
+ pattern: "/pledges/:id",
98
+ module: "app/pledges/[id]/page.tsx",
99
+ componentModule: "app/pledges/[id]/page.tsx",
100
+ clientModule,
101
+ hydration: { strategy: "island", priority: "visible", preload: false },
102
+ },
103
+ ],
104
+ };
105
+
106
+ let server: ManduServer | undefined;
107
+ try {
108
+ server = startServer(manifest, {
109
+ port: 0,
110
+ registry,
111
+ rootDir: TEST_ROOT,
112
+ bundleManifest: hydratedManifest(routeId),
113
+ transitions: false,
114
+ prefetch: false,
115
+ spa: false,
116
+ devtools: false,
117
+ silent: true,
118
+ });
119
+
120
+ const response = await fetch(`http://127.0.0.1:${server.server.port}/pledges/pledge-1`);
121
+ const html = await response.text();
122
+
123
+ expect(response.status).toBe(200);
124
+ expect(html).toContain('data-mandu-island="pledges-$id--0"');
125
+ expect(html).toContain('type="application/json" data-mandu-props="pledges-$id--0"');
126
+ expect(html).toContain('"pledgeId":"pledge-1"');
127
+ expect(html).toContain('"initialComments"');
128
+ expect(html).toContain("serialized comment");
129
+ expect(html).not.toContain('data-mandu-island="pledges-$id"');
130
+ } finally {
131
+ server?.stop();
132
+ }
133
+ });
134
+ });
@@ -2,6 +2,7 @@ import { describe, expect, it } from "bun:test";
2
2
  import React from "react";
3
3
  import { renderPageResponse } from "../page-render-response";
4
4
  import type { BundleManifest } from "../../bundler/types";
5
+ import { __ManduClientBoundary } from "../../internal/client-boundary";
5
6
 
6
7
  const HYDRATED_MANIFEST: BundleManifest = {
7
8
  version: 1,
@@ -102,6 +103,164 @@ describe("runtime page render response orchestration", () => {
102
103
  expect(html).toContain('data-hydrate="interaction"');
103
104
  });
104
105
 
106
+ it("does not duplicate pre-wrapped streaming islands and preloads split island chunks", async () => {
107
+ const response = await renderPageResponse({
108
+ app: React.createElement(
109
+ "div",
110
+ {
111
+ "data-mandu-island": "home",
112
+ "data-mandu-src": "/.mandu/client/home.island.js?t=prewrapped",
113
+ "data-mandu-priority": "visible",
114
+ "data-hydrate": "visible",
115
+ style: { display: "contents" },
116
+ },
117
+ React.createElement("main", null, "prewrapped-stream-page"),
118
+ ),
119
+ useStreaming: true,
120
+ title: "Prewrapped Stream",
121
+ headTags: "",
122
+ isDev: false,
123
+ routeId: "home",
124
+ routePattern: "/",
125
+ loaderData: undefined,
126
+ hydration: { strategy: "island", priority: "visible", preload: false },
127
+ bundleManifest: {
128
+ ...HYDRATED_MANIFEST,
129
+ islands: {
130
+ "home-widget": {
131
+ route: "home",
132
+ js: "/.mandu/client/home-widget.island.js",
133
+ priority: "visible",
134
+ },
135
+ },
136
+ },
137
+ islandPreWrapped: true,
138
+ transitions: false,
139
+ prefetch: false,
140
+ spa: false,
141
+ devtools: false,
142
+ });
143
+
144
+ const html = await response.text();
145
+ expect(html.match(/data-mandu-island="home"/g)?.length).toBe(1);
146
+ expect(html).toContain("prewrapped-stream-page");
147
+ expect(html).toContain('<link rel="modulepreload" href="/.mandu/client/home-widget.island.js?v=');
148
+ expect(html).not.toContain('<link rel="modulepreload" href="/.mandu/client/home.island.js?v=');
149
+ });
150
+
151
+ it("serializes compiler-owned client boundaries on the streaming path", async () => {
152
+ const routeId = "stream-boundary";
153
+ const manifest: BundleManifest = {
154
+ ...HYDRATED_MANIFEST,
155
+ bundles: {},
156
+ boundaries: {
157
+ "stream-boundary--0": {
158
+ route: routeId,
159
+ js: "/.mandu/client/stream-boundary--0.boundary.js",
160
+ module: "src/client/Counter.client.tsx",
161
+ exportName: "Counter",
162
+ priority: "visible",
163
+ hydrate: "visible",
164
+ },
165
+ },
166
+ };
167
+
168
+ const response = await renderPageResponse({
169
+ app: React.createElement(
170
+ "main",
171
+ null,
172
+ React.createElement(__ManduClientBoundary, {
173
+ routeId,
174
+ boundaryId: "stream-boundary--0",
175
+ module: "src/client/Counter.client.tsx",
176
+ exportName: "Counter",
177
+ hydrate: "visible",
178
+ props: { count: 7 },
179
+ }),
180
+ ),
181
+ useStreaming: true,
182
+ title: "Stream Boundary",
183
+ headTags: "",
184
+ isDev: false,
185
+ routeId,
186
+ routePattern: "/stream-boundary",
187
+ loaderData: undefined,
188
+ hydration: { strategy: "island", priority: "visible", preload: false },
189
+ bundleManifest: manifest,
190
+ transitions: false,
191
+ prefetch: false,
192
+ spa: false,
193
+ devtools: false,
194
+ });
195
+
196
+ const html = await response.text();
197
+ expect(html).toContain('data-mandu-island="stream-boundary--0"');
198
+ expect(html).toContain('data-mandu-boundary-id="stream-boundary--0"');
199
+ expect(html).toContain('data-mandu-client-export="Counter"');
200
+ expect(html).toContain('data-mandu-src="/.mandu/client/stream-boundary--0.boundary.js?t=');
201
+ expect(html).toContain('type="application/json" data-mandu-props="stream-boundary--0"');
202
+ expect(html).toContain('"count":7');
203
+ expect(html).toContain('<link rel="modulepreload" href="/.mandu/client/stream-boundary--0.boundary.js?v=');
204
+ expect(html).not.toContain('data-mandu-island="stream-boundary" data-mandu-src=');
205
+ });
206
+
207
+ it("keeps boundary context across async streaming server components", async () => {
208
+ const routeId = "async-stream-boundary";
209
+ const manifest: BundleManifest = {
210
+ ...HYDRATED_MANIFEST,
211
+ bundles: {},
212
+ boundaries: {
213
+ "async-stream-boundary--0": {
214
+ route: routeId,
215
+ js: "/.mandu/client/async-stream-boundary--0.boundary.js",
216
+ module: "src/client/AsyncCounter.client.tsx",
217
+ exportName: "AsyncCounter",
218
+ priority: "visible",
219
+ hydrate: "visible",
220
+ },
221
+ },
222
+ };
223
+
224
+ async function AsyncPage() {
225
+ await new Promise((resolve) => setTimeout(resolve, 1));
226
+ return React.createElement(
227
+ "main",
228
+ null,
229
+ React.createElement(__ManduClientBoundary, {
230
+ routeId,
231
+ boundaryId: "async-stream-boundary--0",
232
+ module: "src/client/AsyncCounter.client.tsx",
233
+ exportName: "AsyncCounter",
234
+ hydrate: "visible",
235
+ props: { count: 11 },
236
+ }),
237
+ );
238
+ }
239
+
240
+ const response = await renderPageResponse({
241
+ app: React.createElement(AsyncPage),
242
+ useStreaming: true,
243
+ title: "Async Stream Boundary",
244
+ headTags: "",
245
+ isDev: false,
246
+ routeId,
247
+ routePattern: "/async-stream-boundary",
248
+ loaderData: undefined,
249
+ hydration: { strategy: "island", priority: "visible", preload: false },
250
+ bundleManifest: manifest,
251
+ transitions: false,
252
+ prefetch: false,
253
+ spa: false,
254
+ devtools: false,
255
+ });
256
+
257
+ const html = await response.text();
258
+ expect(html).toContain('data-mandu-island="async-stream-boundary--0"');
259
+ expect(html).toContain('data-mandu-src="/.mandu/client/async-stream-boundary--0.boundary.js?t=');
260
+ expect(html).toContain('data-mandu-props="async-stream-boundary--0"');
261
+ expect(html).toContain('"count":11');
262
+ });
263
+
105
264
  it("serializes non-streaming loaderData as the route server data exactly once", async () => {
106
265
  const response = await renderPageResponse({
107
266
  app: React.createElement("main", null, "hydrated-page"),
@@ -188,6 +347,59 @@ describe("runtime page render response orchestration", () => {
188
347
  expect(html).not.toContain('data-mandu-island="candidates-$id"');
189
348
  });
190
349
 
350
+ it("serializes inline client props through a sync server wrapper fallback", async () => {
351
+ function ClientWidget({ label }: { label: string }) {
352
+ return React.createElement("button", null, label);
353
+ }
354
+
355
+ function ServerWrapper({ label }: { label: string }) {
356
+ return React.createElement("section", null, React.createElement(ClientWidget, { label }));
357
+ }
358
+
359
+ function WrappedPage() {
360
+ return React.createElement("main", null, React.createElement(ServerWrapper, { label: "wrapped" }));
361
+ }
362
+
363
+ const response = await renderPageResponse({
364
+ app: React.createElement(WrappedPage),
365
+ useStreaming: false,
366
+ title: "Wrapped",
367
+ headTags: "",
368
+ isDev: false,
369
+ routeId: "wrapped-fallback",
370
+ routePattern: "/wrapped",
371
+ hydration: { strategy: "island", priority: "visible", preload: false },
372
+ bundleManifest: {
373
+ ...HYDRATED_MANIFEST,
374
+ bundles: {
375
+ "wrapped-fallback": {
376
+ js: "/.mandu/client/wrapped-fallback.island.js",
377
+ dependencies: ["_runtime", "_react"],
378
+ priority: "visible",
379
+ },
380
+ },
381
+ },
382
+ loaderData: undefined,
383
+ transitions: false,
384
+ prefetch: false,
385
+ spa: false,
386
+ devtools: false,
387
+ inlineClientHydration: {
388
+ routeId: "wrapped-fallback",
389
+ src: "/.mandu/client/wrapped-fallback.island.js",
390
+ priority: "visible",
391
+ component: ClientWidget,
392
+ },
393
+ });
394
+
395
+ const html = await response.text();
396
+ expect(html).toContain('data-mandu-island="wrapped-fallback--0"');
397
+ expect(html).toContain('data-mandu-src="/.mandu/client/wrapped-fallback.island.js"');
398
+ expect(html).toContain('data-mandu-props="wrapped-fallback--0"');
399
+ expect(html).toContain('"label":"wrapped"');
400
+ expect(html).not.toContain('data-mandu-island="wrapped-fallback"');
401
+ });
402
+
191
403
  it("does not invoke sync function components while looking for inline client targets", async () => {
192
404
  function ClientWidget({ label }: { label: string }) {
193
405
  return React.createElement("button", null, label);