@mandujs/core 0.54.17 → 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__/page-render-response.test.ts +212 -0
- package/src/runtime/handlers.ts +50 -26
- package/src/runtime/page-render-response.ts +1 -0
- 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
|
|
|
@@ -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);
|
package/src/runtime/handlers.ts
CHANGED
|
@@ -23,7 +23,7 @@ import {
|
|
|
23
23
|
type PageRegistration,
|
|
24
24
|
} from "./server";
|
|
25
25
|
import { registerManifest } from "./registry";
|
|
26
|
-
import { needsHydration, type RoutesManifest } from "../spec/schema";
|
|
26
|
+
import { needsHydration, type RouteClientBoundary, type RoutesManifest } from "../spec/schema";
|
|
27
27
|
|
|
28
28
|
type RouteModule = Record<string, unknown>;
|
|
29
29
|
|
|
@@ -84,7 +84,7 @@ function createMethodDispatcher(module: RouteModule, routeId: string) {
|
|
|
84
84
|
};
|
|
85
85
|
}
|
|
86
86
|
|
|
87
|
-
export interface RegisterHandlersOptions {
|
|
87
|
+
export interface RegisterHandlersOptions {
|
|
88
88
|
/**
|
|
89
89
|
* Module import function (dev: importFresh, start: standard import).
|
|
90
90
|
* The optional `opts.changedFile` is forwarded into Phase 7.0 B5's
|
|
@@ -92,7 +92,17 @@ export interface RegisterHandlersOptions {
|
|
|
92
92
|
* module's import graph, `importFn` returns the cached bundle in ~0.1 ms
|
|
93
93
|
* instead of re-running Bun.build.
|
|
94
94
|
*/
|
|
95
|
-
importFn: (
|
|
95
|
+
importFn: (
|
|
96
|
+
modulePath: string,
|
|
97
|
+
opts?: {
|
|
98
|
+
changedFile?: string;
|
|
99
|
+
clientBoundaryTransform?: {
|
|
100
|
+
routeId: string;
|
|
101
|
+
hydrate?: string;
|
|
102
|
+
boundaries?: RouteClientBoundary[];
|
|
103
|
+
};
|
|
104
|
+
},
|
|
105
|
+
) => Promise<unknown>;
|
|
96
106
|
/** Set for tracking already registered layout paths */
|
|
97
107
|
registeredLayouts: Set<string>;
|
|
98
108
|
/** Clear layout cache on reload */
|
|
@@ -115,9 +125,23 @@ export async function registerManifestHandlers(
|
|
|
115
125
|
rootDir: string,
|
|
116
126
|
options: RegisterHandlersOptions
|
|
117
127
|
): Promise<void> {
|
|
118
|
-
const { importFn, registeredLayouts, isReload = false, changedFile } = options;
|
|
119
|
-
const
|
|
120
|
-
changedFile !== undefined ? { changedFile } : undefined;
|
|
128
|
+
const { importFn, registeredLayouts, isReload = false, changedFile } = options;
|
|
129
|
+
const baseImportOpts: { changedFile?: string } | undefined =
|
|
130
|
+
changedFile !== undefined ? { changedFile } : undefined;
|
|
131
|
+
const importOptsForRoute = (route?: RoutesManifest["routes"][number]) => {
|
|
132
|
+
const boundaryTransform = route?.kind === "page" && route.boundaries?.length
|
|
133
|
+
? {
|
|
134
|
+
routeId: route.id,
|
|
135
|
+
hydrate: route.hydration?.priority ?? "visible",
|
|
136
|
+
boundaries: route.boundaries,
|
|
137
|
+
}
|
|
138
|
+
: undefined;
|
|
139
|
+
if (!baseImportOpts && !boundaryTransform) return undefined;
|
|
140
|
+
return {
|
|
141
|
+
...baseImportOpts,
|
|
142
|
+
...(boundaryTransform ? { clientBoundaryTransform: boundaryTransform } : {}),
|
|
143
|
+
};
|
|
144
|
+
};
|
|
121
145
|
|
|
122
146
|
if (isReload) {
|
|
123
147
|
registeredLayouts.clear();
|
|
@@ -134,19 +158,19 @@ export async function registerManifestHandlers(
|
|
|
134
158
|
// runtime dispatcher invokes the default export on each request
|
|
135
159
|
// so HMR reloads pick up edits automatically (same pattern as
|
|
136
160
|
// API routes below).
|
|
137
|
-
if (route.kind === "metadata") {
|
|
138
|
-
const modulePath = path.resolve(rootDir, route.module);
|
|
139
|
-
registerMetadataHandler(route.id, async () => {
|
|
140
|
-
return importFn(modulePath,
|
|
141
|
-
});
|
|
142
|
-
console.log(` 🗺️ Metadata: ${route.pattern} -> ${route.id}`);
|
|
143
|
-
continue;
|
|
161
|
+
if (route.kind === "metadata") {
|
|
162
|
+
const modulePath = path.resolve(rootDir, route.module);
|
|
163
|
+
registerMetadataHandler(route.id, async () => {
|
|
164
|
+
return importFn(modulePath, importOptsForRoute(route));
|
|
165
|
+
});
|
|
166
|
+
console.log(` 🗺️ Metadata: ${route.pattern} -> ${route.id}`);
|
|
167
|
+
continue;
|
|
144
168
|
}
|
|
145
169
|
|
|
146
|
-
if (route.kind === "api") {
|
|
147
|
-
const modulePath = path.resolve(rootDir, route.module);
|
|
148
|
-
try {
|
|
149
|
-
const module = (await importFn(modulePath,
|
|
170
|
+
if (route.kind === "api") {
|
|
171
|
+
const modulePath = path.resolve(rootDir, route.module);
|
|
172
|
+
try {
|
|
173
|
+
const module = (await importFn(modulePath, importOptsForRoute(route))) as RouteModule;
|
|
150
174
|
let handler: unknown = module.default ?? module.handler ?? module;
|
|
151
175
|
|
|
152
176
|
// 1) ManduFilling instance
|
|
@@ -193,7 +217,7 @@ export async function registerManifestHandlers(
|
|
|
193
217
|
// Layout modules must export a default component. Runtime
|
|
194
218
|
// validation in `renderToHTML` / page-loader asserts this —
|
|
195
219
|
// so casting the unknown `importFn` result is safe here.
|
|
196
|
-
return importFn(absLayoutPath,
|
|
220
|
+
return importFn(absLayoutPath, baseImportOpts);
|
|
197
221
|
}) as Parameters<typeof registerLayoutLoader>[1]);
|
|
198
222
|
registeredLayouts.add(layoutPath);
|
|
199
223
|
console.log(` 🎨 Layout: ${layoutPath}`);
|
|
@@ -204,7 +228,7 @@ export async function registerManifestHandlers(
|
|
|
204
228
|
// Use PageHandler if slotModule exists (filling.loader support)
|
|
205
229
|
if (route.slotModule) {
|
|
206
230
|
registerPageHandler(route.id, async () => {
|
|
207
|
-
const mod = (await importFn(componentPath,
|
|
231
|
+
const mod = (await importFn(componentPath, importOptsForRoute(route))) as Record<string, unknown>;
|
|
208
232
|
// Normalize the page module shape. Users write pages in two styles:
|
|
209
233
|
// (a) `export default function Page() {…}` + `export const filling = …`
|
|
210
234
|
// (b) `export default { component: …, filling: … }`
|
|
@@ -242,7 +266,7 @@ export async function registerManifestHandlers(
|
|
|
242
266
|
` 📄 Page: ${route.pattern} -> ${route.id} (with loader)${isIsland ? " 🏝️" : ""}${hasLayout ? " 🎨" : ""}`
|
|
243
267
|
);
|
|
244
268
|
} else {
|
|
245
|
-
registerPageLoader(route.id, (() => importFn(componentPath,
|
|
269
|
+
registerPageLoader(route.id, (() => importFn(componentPath, importOptsForRoute(route))) as Parameters<typeof registerPageLoader>[1]);
|
|
246
270
|
console.log(
|
|
247
271
|
` 📄 Page: ${route.pattern} -> ${route.id}${isIsland ? " 🏝️" : ""}${hasLayout ? " 🎨" : ""}`
|
|
248
272
|
);
|
|
@@ -252,7 +276,7 @@ export async function registerManifestHandlers(
|
|
|
252
276
|
|
|
253
277
|
// Phase 6.3: register `app/not-found.tsx` if it exists. Global, one per
|
|
254
278
|
// app — the server falls through to the built-in 404 if unregistered.
|
|
255
|
-
await registerAppNotFound(rootDir, importFn,
|
|
279
|
+
await registerAppNotFound(rootDir, importFn, baseImportOpts);
|
|
256
280
|
}
|
|
257
281
|
|
|
258
282
|
/**
|
|
@@ -260,11 +284,11 @@ export async function registerManifestHandlers(
|
|
|
260
284
|
* project root and register it as the app-level 404 handler. Silent
|
|
261
285
|
* no-op if no file exists — the server's built-in 404 covers that case.
|
|
262
286
|
*/
|
|
263
|
-
async function registerAppNotFound(
|
|
264
|
-
rootDir: string,
|
|
265
|
-
importFn:
|
|
266
|
-
importOpts?: { changedFile?: string },
|
|
267
|
-
): Promise<void> {
|
|
287
|
+
async function registerAppNotFound(
|
|
288
|
+
rootDir: string,
|
|
289
|
+
importFn: RegisterHandlersOptions["importFn"],
|
|
290
|
+
importOpts?: { changedFile?: string },
|
|
291
|
+
): Promise<void> {
|
|
268
292
|
const candidates = [
|
|
269
293
|
"app/not-found.tsx",
|
|
270
294
|
"app/not-found.ts",
|
|
@@ -214,6 +214,7 @@ async function renderStreamingPageResponse(
|
|
|
214
214
|
criticalData: options.loaderData as Record<string, unknown> | undefined,
|
|
215
215
|
enableClientRouter: true,
|
|
216
216
|
cssPath: options.cssPath,
|
|
217
|
+
islandPreWrapped: !!options.islandPreWrapped,
|
|
217
218
|
transitions: options.transitions,
|
|
218
219
|
prefetch: options.prefetch,
|
|
219
220
|
spa: options.spa,
|