@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.
- package/package.json +3 -1
- package/src/agent/__tests__/context.test.ts +54 -16
- package/src/agent/context.ts +17 -0
- package/src/agent/types.ts +32 -12
- 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 +440 -8
- package/src/bundler/build.ts +455 -132
- 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/diagnose/__tests__/checks.test.ts +117 -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 +43 -6
- package/src/router/client-entry.ts +33 -12
- package/src/router/fs-routes.test.ts +388 -1
- package/src/router/fs-routes.ts +16 -3
- package/src/router/fs-scanner.ts +166 -18
- package/src/router/fs-types.ts +4 -1
- package/src/runtime/__tests__/inline-client-hydration.test.ts +134 -0
- package/src/runtime/__tests__/page-render-response.test.ts +212 -0
- package/src/runtime/handlers.ts +50 -26
- package/src/runtime/page-render-response.ts +43 -3
- package/src/runtime/server.ts +42 -5
- package/src/runtime/ssr.ts +16 -5
- package/src/runtime/streaming-ssr.ts +119 -76
- package/src/spec/schema.ts +31 -5
package/src/router/fs-scanner.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
* 스캐너 생성 팩토리 함수
|
package/src/router/fs-types.ts
CHANGED
|
@@ -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);
|