@mandujs/core 0.54.17 → 0.54.19
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +3 -1
- package/src/agent/__tests__/context.test.ts +94 -25
- package/src/agent/context.ts +17 -0
- package/src/agent/types.ts +32 -12
- package/src/agent/verify.ts +55 -24
- package/src/bundler/__snapshots__/build.test.ts.snap +5 -0
- package/src/bundler/__tests__/build-runner.ts +130 -17
- package/src/bundler/__tests__/client-boundary-transform.test.ts +524 -0
- package/src/bundler/__tests__/reverse-import-graph.test.ts +42 -33
- package/src/bundler/build.test.ts +478 -9
- package/src/bundler/build.ts +424 -746
- package/src/bundler/client-boundary-transform.ts +977 -0
- package/src/bundler/dev.ts +39 -112
- package/src/bundler/fast-refresh-preamble.ts +47 -0
- package/src/bundler/index.ts +3 -2
- package/src/bundler/manifest-schema.ts +10 -0
- package/src/bundler/types.ts +20 -2
- package/src/client/__tests__/props-serialization.test.ts +37 -0
- package/src/client/hydrate.ts +2 -2
- package/src/client/index.ts +1 -1
- package/src/client/props-serialization.ts +233 -0
- package/src/client/runtime-entry.ts +567 -0
- package/src/client/runtime.ts +1 -1
- package/src/client/serialize.ts +50 -404
- package/src/diagnose/__tests__/checks.test.ts +132 -17
- package/src/diagnose/checks.ts +184 -3
- package/src/diagnose/run.ts +10 -8
- package/src/generator/templates.test.ts +48 -5
- package/src/generator/templates.ts +10 -1
- package/src/internal/client-boundary.ts +266 -0
- package/src/internal/index.ts +2 -1
- package/src/router/client-entry.test.ts +154 -29
- package/src/router/client-entry.ts +111 -313
- package/src/router/fs-routes.test.ts +443 -1
- package/src/router/fs-routes.ts +16 -3
- package/src/router/fs-scanner.ts +176 -57
- package/src/router/fs-types.ts +11 -2
- package/src/router/route-source-analyzer.ts +521 -0
- package/src/runtime/__tests__/inline-client-hydration.test.ts +104 -1
- package/src/runtime/__tests__/page-render-response.test.ts +218 -0
- package/src/runtime/handlers.ts +50 -26
- package/src/runtime/page-render-response.ts +24 -1
- package/src/runtime/server.ts +14 -0
- package/src/runtime/ssr.ts +16 -5
- package/src/runtime/streaming-ssr.ts +119 -76
- package/src/spec/schema.ts +31 -5
package/src/diagnose/checks.ts
CHANGED
|
@@ -767,7 +767,7 @@ async function listMandujsSiblings(rootDir: string): Promise<string[]> {
|
|
|
767
767
|
* version to the hoisted core. A mismatch is reported as `error`
|
|
768
768
|
* (boot-breaking on the user's machine) with a copy-pastable fix.
|
|
769
769
|
*/
|
|
770
|
-
export async function checkNestedInternalCore(rootDir: string): Promise<DiagnoseCheckResult> {
|
|
770
|
+
export async function checkNestedInternalCore(rootDir: string): Promise<DiagnoseCheckResult> {
|
|
771
771
|
const hoistedPath = path.join(rootDir, "node_modules", "@mandujs", "core", "package.json");
|
|
772
772
|
const hoistedVersion = await readPackageVersion(hoistedPath);
|
|
773
773
|
|
|
@@ -828,5 +828,186 @@ export async function checkNestedInternalCore(rootDir: string): Promise<Diagnose
|
|
|
828
828
|
mismatchCount: mismatches.length,
|
|
829
829
|
mismatches,
|
|
830
830
|
},
|
|
831
|
-
};
|
|
832
|
-
}
|
|
831
|
+
};
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
// ────────────────────────────────────────────────────────────────────────
|
|
835
|
+
// 8. client_boundary_manifests (F42/F45)
|
|
836
|
+
// ────────────────────────────────────────────────────────────────────────
|
|
837
|
+
|
|
838
|
+
interface DiagnoseRouteBoundary {
|
|
839
|
+
id?: unknown;
|
|
840
|
+
routeId?: unknown;
|
|
841
|
+
module?: unknown;
|
|
842
|
+
exportName?: unknown;
|
|
843
|
+
source?: unknown;
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
interface DiagnoseRouteRecord {
|
|
847
|
+
id?: unknown;
|
|
848
|
+
boundaries?: unknown;
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
async function readJsonObject(filePath: string): Promise<Record<string, unknown> | null> {
|
|
852
|
+
try {
|
|
853
|
+
const raw = await fs.readFile(filePath, "utf-8");
|
|
854
|
+
const parsed = JSON.parse(raw) as unknown;
|
|
855
|
+
return parsed && typeof parsed === "object" && !Array.isArray(parsed)
|
|
856
|
+
? parsed as Record<string, unknown>
|
|
857
|
+
: null;
|
|
858
|
+
} catch {
|
|
859
|
+
return null;
|
|
860
|
+
}
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
function toRouteRecords(value: unknown): DiagnoseRouteRecord[] {
|
|
864
|
+
if (!Array.isArray(value)) return [];
|
|
865
|
+
return value.filter((entry): entry is DiagnoseRouteRecord =>
|
|
866
|
+
!!entry && typeof entry === "object" && !Array.isArray(entry)
|
|
867
|
+
);
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
function toRouteBoundaries(route: DiagnoseRouteRecord): DiagnoseRouteBoundary[] {
|
|
871
|
+
if (!Array.isArray(route.boundaries)) return [];
|
|
872
|
+
return route.boundaries.filter((entry): entry is DiagnoseRouteBoundary =>
|
|
873
|
+
!!entry && typeof entry === "object" && !Array.isArray(entry)
|
|
874
|
+
);
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
function bundleAssetExists(rootDir: string, jsPath: unknown): Promise<boolean> {
|
|
878
|
+
if (typeof jsPath !== "string" || jsPath.length === 0) return Promise.resolve(false);
|
|
879
|
+
const relativePath = jsPath.startsWith("/")
|
|
880
|
+
? jsPath.slice(1)
|
|
881
|
+
: jsPath;
|
|
882
|
+
return fs.access(path.join(rootDir, relativePath)).then(
|
|
883
|
+
() => true,
|
|
884
|
+
() => false,
|
|
885
|
+
);
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
/**
|
|
889
|
+
* F42/F45 check: validate compiler-owned client boundary records.
|
|
890
|
+
*
|
|
891
|
+
* This does not rebuild the app. It inspects generated route and bundle
|
|
892
|
+
* manifests and reports:
|
|
893
|
+
* - duplicate boundary ids in `.mandu/routes.manifest.json`
|
|
894
|
+
* - malformed boundary records missing id/module/exportName
|
|
895
|
+
* - missing boundary bundle entries in `.mandu/manifest.json`
|
|
896
|
+
* - boundary bundle entries whose JS asset is absent on disk
|
|
897
|
+
*/
|
|
898
|
+
export async function checkClientBoundaryManifests(rootDir: string): Promise<DiagnoseCheckResult> {
|
|
899
|
+
const routesManifestPath = path.join(rootDir, ".mandu", "routes.manifest.json");
|
|
900
|
+
const routesManifest = await readJsonObject(routesManifestPath);
|
|
901
|
+
|
|
902
|
+
if (!routesManifest) {
|
|
903
|
+
return {
|
|
904
|
+
ok: true,
|
|
905
|
+
rule: "client_boundary_manifests",
|
|
906
|
+
message: "Routes manifest is missing or unreadable — boundary manifest check skipped.",
|
|
907
|
+
details: { skipped: true, routesManifestPath: path.relative(rootDir, routesManifestPath) },
|
|
908
|
+
};
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
const boundaries: Array<{ routeId: string; id: string; module: string; exportName: string }> = [];
|
|
912
|
+
const malformed: Array<{ routeId: string; reason: string }> = [];
|
|
913
|
+
const seen = new Map<string, string>();
|
|
914
|
+
const duplicates: Array<{ id: string; firstRouteId: string; duplicateRouteId: string }> = [];
|
|
915
|
+
|
|
916
|
+
for (const route of toRouteRecords(routesManifest.routes)) {
|
|
917
|
+
const routeId = typeof route.id === "string" ? route.id : "(unknown-route)";
|
|
918
|
+
for (const boundary of toRouteBoundaries(route)) {
|
|
919
|
+
const id = typeof boundary.id === "string" ? boundary.id : "";
|
|
920
|
+
const module = typeof boundary.module === "string" ? boundary.module : "";
|
|
921
|
+
const exportName = typeof boundary.exportName === "string" ? boundary.exportName : "";
|
|
922
|
+
if (!id || !module || !exportName) {
|
|
923
|
+
malformed.push({ routeId, reason: "Boundary record must include id, module, and exportName." });
|
|
924
|
+
continue;
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
const firstRouteId = seen.get(id);
|
|
928
|
+
if (firstRouteId) {
|
|
929
|
+
duplicates.push({ id, firstRouteId, duplicateRouteId: routeId });
|
|
930
|
+
} else {
|
|
931
|
+
seen.set(id, routeId);
|
|
932
|
+
}
|
|
933
|
+
boundaries.push({ routeId, id, module, exportName });
|
|
934
|
+
}
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
if (malformed.length > 0 || duplicates.length > 0) {
|
|
938
|
+
return {
|
|
939
|
+
ok: false,
|
|
940
|
+
rule: "client_boundary_manifests",
|
|
941
|
+
severity: "error",
|
|
942
|
+
message: `Client boundary route manifest has ${malformed.length} malformed record(s) and ${duplicates.length} duplicate id(s).`,
|
|
943
|
+
suggestion: "Regenerate routes with `mandu generate` or rerun the build; boundary ids must be unique and include id/module/exportName.",
|
|
944
|
+
details: {
|
|
945
|
+
boundaryCount: boundaries.length,
|
|
946
|
+
malformed: malformed.slice(0, 10),
|
|
947
|
+
duplicates: duplicates.slice(0, 10),
|
|
948
|
+
},
|
|
949
|
+
};
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
if (boundaries.length === 0) {
|
|
953
|
+
return {
|
|
954
|
+
ok: true,
|
|
955
|
+
rule: "client_boundary_manifests",
|
|
956
|
+
message: "No compiler-owned client boundaries recorded in the routes manifest.",
|
|
957
|
+
details: { boundaryCount: 0 },
|
|
958
|
+
};
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
const bundleManifestPath = path.join(rootDir, ".mandu", "manifest.json");
|
|
962
|
+
const bundleManifest = await readJsonObject(bundleManifestPath);
|
|
963
|
+
if (!bundleManifest) {
|
|
964
|
+
return {
|
|
965
|
+
ok: false,
|
|
966
|
+
rule: "client_boundary_manifests",
|
|
967
|
+
severity: "error",
|
|
968
|
+
message: `Routes manifest declares ${boundaries.length} client boundary record(s), but bundle manifest is missing.`,
|
|
969
|
+
suggestion: "Run `mandu build` before deploy so boundary bundles are emitted and can be checked.",
|
|
970
|
+
details: { boundaryCount: boundaries.length, bundleManifestPath: path.relative(rootDir, bundleManifestPath) },
|
|
971
|
+
};
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
const bundleBoundaries = bundleManifest.boundaries && typeof bundleManifest.boundaries === "object" && !Array.isArray(bundleManifest.boundaries)
|
|
975
|
+
? bundleManifest.boundaries as Record<string, unknown>
|
|
976
|
+
: {};
|
|
977
|
+
const missingEntries: string[] = [];
|
|
978
|
+
const missingAssets: string[] = [];
|
|
979
|
+
|
|
980
|
+
for (const boundary of boundaries) {
|
|
981
|
+
const entry = bundleBoundaries[boundary.id];
|
|
982
|
+
if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
|
|
983
|
+
missingEntries.push(boundary.id);
|
|
984
|
+
continue;
|
|
985
|
+
}
|
|
986
|
+
const jsPath = (entry as { js?: unknown }).js;
|
|
987
|
+
if (!(await bundleAssetExists(rootDir, jsPath))) {
|
|
988
|
+
missingAssets.push(boundary.id);
|
|
989
|
+
}
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
if (missingEntries.length > 0 || missingAssets.length > 0) {
|
|
993
|
+
return {
|
|
994
|
+
ok: false,
|
|
995
|
+
rule: "client_boundary_manifests",
|
|
996
|
+
severity: "error",
|
|
997
|
+
message: `Client boundary bundle manifest is incomplete: ${missingEntries.length} missing entr${missingEntries.length === 1 ? "y" : "ies"}, ${missingAssets.length} missing asset(s).`,
|
|
998
|
+
suggestion: "Run `mandu clean && mandu build`; if the issue persists, inspect `mandu.route.boundaries` with `includeBundle: true`.",
|
|
999
|
+
details: {
|
|
1000
|
+
boundaryCount: boundaries.length,
|
|
1001
|
+
missingEntries: missingEntries.slice(0, 10),
|
|
1002
|
+
missingAssets: missingAssets.slice(0, 10),
|
|
1003
|
+
},
|
|
1004
|
+
};
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
return {
|
|
1008
|
+
ok: true,
|
|
1009
|
+
rule: "client_boundary_manifests",
|
|
1010
|
+
message: `Client boundary manifests are consistent for ${boundaries.length} boundary record(s).`,
|
|
1011
|
+
details: { boundaryCount: boundaries.length },
|
|
1012
|
+
};
|
|
1013
|
+
}
|
package/src/diagnose/run.ts
CHANGED
|
@@ -11,10 +11,11 @@ import {
|
|
|
11
11
|
checkPrerenderPollution,
|
|
12
12
|
checkCloneElementWarnings,
|
|
13
13
|
checkDevArtifactsInProd,
|
|
14
|
-
checkPackageExportGaps,
|
|
15
|
-
checkNestedInternalCore,
|
|
16
|
-
checkA11yHints,
|
|
17
|
-
|
|
14
|
+
checkPackageExportGaps,
|
|
15
|
+
checkNestedInternalCore,
|
|
16
|
+
checkA11yHints,
|
|
17
|
+
checkClientBoundaryManifests,
|
|
18
|
+
} from "./checks";
|
|
18
19
|
|
|
19
20
|
/**
|
|
20
21
|
* Registered extended checks (Issue #215 + Phase 18.χ).
|
|
@@ -30,10 +31,11 @@ export const EXTENDED_CHECKS = [
|
|
|
30
31
|
{ name: "prerender_pollution", run: checkPrerenderPollution },
|
|
31
32
|
{ name: "cloneelement_warnings", run: checkCloneElementWarnings },
|
|
32
33
|
{ name: "dev_artifacts_in_prod", run: checkDevArtifactsInProd },
|
|
33
|
-
{ name: "package_export_gaps", run: checkPackageExportGaps },
|
|
34
|
-
{ name: "nested_internal_core", run: checkNestedInternalCore },
|
|
35
|
-
{ name: "
|
|
36
|
-
|
|
34
|
+
{ name: "package_export_gaps", run: checkPackageExportGaps },
|
|
35
|
+
{ name: "nested_internal_core", run: checkNestedInternalCore },
|
|
36
|
+
{ name: "client_boundary_manifests", run: checkClientBoundaryManifests },
|
|
37
|
+
{ name: "a11y_hints", run: checkA11yHints },
|
|
38
|
+
] as const;
|
|
37
39
|
|
|
38
40
|
/**
|
|
39
41
|
* Run every extended check in parallel and return the aggregate report.
|
|
@@ -20,7 +20,7 @@ describe("generatePageComponent", () => {
|
|
|
20
20
|
expect(() => generatePageComponent(route)).toThrow("no clientModule");
|
|
21
21
|
});
|
|
22
22
|
|
|
23
|
-
test("imports the real page module for static routes instead of emitting a placeholder", () => {
|
|
23
|
+
test("imports the real page module for static routes instead of emitting a placeholder", () => {
|
|
24
24
|
const route: RouteSpec = {
|
|
25
25
|
id: "about",
|
|
26
26
|
kind: "page",
|
|
@@ -35,10 +35,53 @@ describe("generatePageComponent", () => {
|
|
|
35
35
|
expect(generated).toContain('import pageModule from "../../../../app/about/page.tsx"');
|
|
36
36
|
expect(generated).toContain("React.createElement(pageModule");
|
|
37
37
|
expect(generated).not.toContain("About Page");
|
|
38
|
-
expect(generated).not.toContain('React.createElement("p", null, "Route ID: about")');
|
|
39
|
-
});
|
|
40
|
-
|
|
41
|
-
test("
|
|
38
|
+
expect(generated).not.toContain('React.createElement("p", null, "Route ID: about")');
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("imports the real page module for compiler-owned boundary routes without a clientModule", () => {
|
|
42
|
+
const route: RouteSpec = {
|
|
43
|
+
id: "login",
|
|
44
|
+
kind: "page",
|
|
45
|
+
pattern: "/login",
|
|
46
|
+
module: "app/login/page.tsx",
|
|
47
|
+
componentModule: "app/login/page.tsx",
|
|
48
|
+
hydration: {
|
|
49
|
+
strategy: "island",
|
|
50
|
+
priority: "visible",
|
|
51
|
+
preload: false,
|
|
52
|
+
},
|
|
53
|
+
boundaries: [
|
|
54
|
+
{
|
|
55
|
+
id: "login--0",
|
|
56
|
+
routeId: "login",
|
|
57
|
+
module: "src/client/pages/login/LoginPage.client.tsx",
|
|
58
|
+
importSpecifier: "@/client/pages/login/LoginPage.client",
|
|
59
|
+
exportName: "default",
|
|
60
|
+
localName: "LoginPage",
|
|
61
|
+
hydrate: "visible",
|
|
62
|
+
ordinal: 0,
|
|
63
|
+
propsSource: "inline",
|
|
64
|
+
propsKeys: [],
|
|
65
|
+
hasSpreadProps: false,
|
|
66
|
+
source: {
|
|
67
|
+
file: "app/login/page.tsx",
|
|
68
|
+
line: 6,
|
|
69
|
+
column: 10,
|
|
70
|
+
},
|
|
71
|
+
},
|
|
72
|
+
],
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
const generated = generatePageComponent(route);
|
|
76
|
+
|
|
77
|
+
expect(generated).toContain("Page Module: app/login/page.tsx");
|
|
78
|
+
expect(generated).toContain('import pageModule from "../../../../app/login/page.tsx"');
|
|
79
|
+
expect(generated).toContain("React.createElement(pageModule");
|
|
80
|
+
expect(generated).not.toContain("Client Module:");
|
|
81
|
+
expect(generated).not.toContain("Login Page");
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test("renders route-level default-imported client components through the page module", () => {
|
|
42
85
|
const route: RouteSpec = {
|
|
43
86
|
id: "login",
|
|
44
87
|
kind: "page",
|
|
@@ -244,7 +244,9 @@ export function generatePageComponent(route: RouteSpec): string {
|
|
|
244
244
|
return generatePageComponentWithIsland(route);
|
|
245
245
|
}
|
|
246
246
|
|
|
247
|
-
|
|
247
|
+
const hasCompilerOwnedBoundaries = (route.boundaries?.length ?? 0) > 0;
|
|
248
|
+
|
|
249
|
+
if (needsHydration(route) && !hasCompilerOwnedBoundaries) {
|
|
248
250
|
throw new Error(
|
|
249
251
|
`[${route.id}] Route has hydration strategy "${route.hydration?.strategy}" but no clientModule. ` +
|
|
250
252
|
"Refusing to generate a placeholder page because it would disagree with runtime hydration state.",
|
|
@@ -260,6 +262,13 @@ export function generatePageComponent(route: RouteSpec): string {
|
|
|
260
262
|
return generatePageComponentFromModule(route);
|
|
261
263
|
}
|
|
262
264
|
|
|
265
|
+
if (needsHydration(route)) {
|
|
266
|
+
throw new Error(
|
|
267
|
+
`[${route.id}] Route has hydration strategy "${route.hydration?.strategy}" and compiler-owned boundaries, ` +
|
|
268
|
+
"but no componentModule. Refusing to generate a placeholder page because it would disagree with runtime hydration state.",
|
|
269
|
+
);
|
|
270
|
+
}
|
|
271
|
+
|
|
263
272
|
const pageName = toPascalCase(route.id);
|
|
264
273
|
|
|
265
274
|
// Legacy fallback for malformed historical manifests that lack componentModule.
|
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
3
|
+
|
|
4
|
+
import type { BundleManifest } from "../bundler/types";
|
|
5
|
+
import { serializeProps } from "../client/serialize";
|
|
6
|
+
import { escapeJsonForInlineScript } from "../runtime/escape";
|
|
7
|
+
|
|
8
|
+
export interface ManduClientBoundaryProps {
|
|
9
|
+
routeId: string;
|
|
10
|
+
boundaryId: string;
|
|
11
|
+
module: string;
|
|
12
|
+
exportName: string;
|
|
13
|
+
props?: Record<string, unknown>;
|
|
14
|
+
hydrate?: string;
|
|
15
|
+
src?: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
interface BoundaryRenderContext {
|
|
19
|
+
routeId?: string;
|
|
20
|
+
bundleManifest?: BundleManifest;
|
|
21
|
+
instanceCounts: Map<string, number>;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface ManduClientBoundaryRenderScope {
|
|
25
|
+
<T>(render: () => T): T;
|
|
26
|
+
wrapElement(element: React.ReactElement): React.ReactElement;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const boundaryContextStack: BoundaryRenderContext[] = [];
|
|
30
|
+
const boundaryAsyncStorage = new AsyncLocalStorage<BoundaryRenderContext>();
|
|
31
|
+
const BoundaryReactContext = React.createContext<BoundaryRenderContext | undefined>(undefined);
|
|
32
|
+
|
|
33
|
+
export function createManduClientBoundaryRenderScope(
|
|
34
|
+
routeId: string | undefined,
|
|
35
|
+
bundleManifest: BundleManifest | undefined,
|
|
36
|
+
): ManduClientBoundaryRenderScope {
|
|
37
|
+
const context: BoundaryRenderContext = { routeId, bundleManifest, instanceCounts: new Map() };
|
|
38
|
+
const scope = (<T>(render: () => T): T => runWithBoundaryContext(context, render)) as ManduClientBoundaryRenderScope;
|
|
39
|
+
scope.wrapElement = (element: React.ReactElement): React.ReactElement =>
|
|
40
|
+
React.createElement(BoundaryReactContext.Provider, { value: context }, element);
|
|
41
|
+
return scope;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function renderWithManduClientBoundaryManifest<T>(
|
|
45
|
+
routeId: string | undefined,
|
|
46
|
+
bundleManifest: BundleManifest | undefined,
|
|
47
|
+
render: () => T,
|
|
48
|
+
): T {
|
|
49
|
+
return createManduClientBoundaryRenderScope(routeId, bundleManifest)(render);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function runWithBoundaryContext<T>(
|
|
53
|
+
context: BoundaryRenderContext,
|
|
54
|
+
render: () => T,
|
|
55
|
+
): T {
|
|
56
|
+
boundaryContextStack.push(context);
|
|
57
|
+
try {
|
|
58
|
+
return boundaryAsyncStorage.run(context, render);
|
|
59
|
+
} catch (error) {
|
|
60
|
+
throw error;
|
|
61
|
+
} finally {
|
|
62
|
+
boundaryContextStack.pop();
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function __ManduClientBoundary({
|
|
67
|
+
routeId,
|
|
68
|
+
boundaryId,
|
|
69
|
+
module,
|
|
70
|
+
exportName,
|
|
71
|
+
props = {},
|
|
72
|
+
hydrate = "visible",
|
|
73
|
+
src,
|
|
74
|
+
}: ManduClientBoundaryProps): React.ReactElement {
|
|
75
|
+
const fallbackContext = getCurrentBoundaryContext();
|
|
76
|
+
return React.createElement(
|
|
77
|
+
BoundaryReactContext.Consumer,
|
|
78
|
+
{
|
|
79
|
+
children: (reactContext: BoundaryRenderContext | undefined) =>
|
|
80
|
+
renderClientBoundaryElement({
|
|
81
|
+
routeId,
|
|
82
|
+
boundaryId,
|
|
83
|
+
module,
|
|
84
|
+
exportName,
|
|
85
|
+
props,
|
|
86
|
+
hydrate,
|
|
87
|
+
src,
|
|
88
|
+
}, reactContext ?? fallbackContext),
|
|
89
|
+
},
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function renderClientBoundaryElement(
|
|
94
|
+
{
|
|
95
|
+
routeId,
|
|
96
|
+
boundaryId,
|
|
97
|
+
module,
|
|
98
|
+
exportName,
|
|
99
|
+
props = {},
|
|
100
|
+
hydrate = "visible",
|
|
101
|
+
src,
|
|
102
|
+
}: ManduClientBoundaryProps,
|
|
103
|
+
context: BoundaryRenderContext | undefined,
|
|
104
|
+
): React.ReactElement {
|
|
105
|
+
assertSerializableBoundaryProps({ routeId, boundaryId, module, exportName, props });
|
|
106
|
+
const serializedProps = serializeProps(props);
|
|
107
|
+
const priority = hydrate === "load" ? "immediate" : hydrate;
|
|
108
|
+
const resolvedSrc = src ?? resolveBoundarySrc(context, routeId, boundaryId);
|
|
109
|
+
const instanceId = nextBoundaryInstanceId(context, boundaryId);
|
|
110
|
+
|
|
111
|
+
return React.createElement(
|
|
112
|
+
React.Fragment,
|
|
113
|
+
null,
|
|
114
|
+
React.createElement("div", {
|
|
115
|
+
"data-mandu-island": instanceId,
|
|
116
|
+
"data-mandu-boundary-id": boundaryId,
|
|
117
|
+
"data-mandu-route-id": routeId,
|
|
118
|
+
"data-mandu-src": resolvedSrc,
|
|
119
|
+
"data-mandu-priority": priority,
|
|
120
|
+
"data-hydrate": hydrate,
|
|
121
|
+
"data-mandu-client-module": module,
|
|
122
|
+
"data-mandu-client-export": exportName,
|
|
123
|
+
style: { display: "contents" },
|
|
124
|
+
}),
|
|
125
|
+
React.createElement("script", {
|
|
126
|
+
type: "application/json",
|
|
127
|
+
"data-mandu-props": instanceId,
|
|
128
|
+
dangerouslySetInnerHTML: {
|
|
129
|
+
__html: escapeJsonForInlineScript(serializedProps),
|
|
130
|
+
},
|
|
131
|
+
}),
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function assertSerializableBoundaryProps({
|
|
136
|
+
routeId,
|
|
137
|
+
boundaryId,
|
|
138
|
+
module,
|
|
139
|
+
exportName,
|
|
140
|
+
props,
|
|
141
|
+
}: Required<Pick<ManduClientBoundaryProps, "routeId" | "boundaryId" | "module" | "exportName" | "props">>): void {
|
|
142
|
+
const reason = findNonSerializableBoundaryValue(props, "$", new WeakSet<object>());
|
|
143
|
+
if (!reason) return;
|
|
144
|
+
|
|
145
|
+
throw new Error(
|
|
146
|
+
`[MANDU_BOUNDARY_UNSERIALIZABLE_PROP] Client boundary props are not serializable for route "${routeId}", boundary "${boundaryId}" (${module}#${exportName}): ${reason}. ` +
|
|
147
|
+
"Pass plain serializable data, or construct functions, React elements, symbols, promises, and class instances inside the client component.",
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function findNonSerializableBoundaryValue(
|
|
152
|
+
value: unknown,
|
|
153
|
+
path: string,
|
|
154
|
+
seen: WeakSet<object>,
|
|
155
|
+
): string | null {
|
|
156
|
+
if (value === null || value === undefined) return null;
|
|
157
|
+
|
|
158
|
+
const valueType = typeof value;
|
|
159
|
+
if (valueType === "string" || valueType === "number" || valueType === "boolean" || valueType === "bigint") {
|
|
160
|
+
return null;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
if (valueType === "function") {
|
|
164
|
+
return `${path} is a function`;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
if (valueType === "symbol") {
|
|
168
|
+
return `${path} is a symbol`;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
if (valueType !== "object") {
|
|
172
|
+
return `${path} has unsupported type "${valueType}"`;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
if (React.isValidElement(value)) {
|
|
176
|
+
return `${path} is a React element`;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
if (value instanceof Date || value instanceof URL || value instanceof RegExp || value instanceof Error) {
|
|
180
|
+
return null;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
if (seen.has(value)) {
|
|
184
|
+
return null;
|
|
185
|
+
}
|
|
186
|
+
seen.add(value);
|
|
187
|
+
|
|
188
|
+
if (value instanceof Promise) {
|
|
189
|
+
return `${path} is a Promise`;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
if (value instanceof Map) {
|
|
193
|
+
let index = 0;
|
|
194
|
+
for (const [key, entryValue] of value.entries()) {
|
|
195
|
+
const keyReason = findNonSerializableBoundaryValue(key, `${path}.<map-key:${index}>`, seen);
|
|
196
|
+
if (keyReason) return keyReason;
|
|
197
|
+
const valueReason = findNonSerializableBoundaryValue(entryValue, `${path}.<map-value:${index}>`, seen);
|
|
198
|
+
if (valueReason) return valueReason;
|
|
199
|
+
index++;
|
|
200
|
+
}
|
|
201
|
+
return null;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
if (value instanceof Set) {
|
|
205
|
+
let index = 0;
|
|
206
|
+
for (const entryValue of value.values()) {
|
|
207
|
+
const reason = findNonSerializableBoundaryValue(entryValue, `${path}.<set:${index}>`, seen);
|
|
208
|
+
if (reason) return reason;
|
|
209
|
+
index++;
|
|
210
|
+
}
|
|
211
|
+
return null;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
if (Array.isArray(value)) {
|
|
215
|
+
for (let index = 0; index < value.length; index++) {
|
|
216
|
+
const reason = findNonSerializableBoundaryValue(value[index], `${path}[${index}]`, seen);
|
|
217
|
+
if (reason) return reason;
|
|
218
|
+
}
|
|
219
|
+
return null;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const prototype = Object.getPrototypeOf(value);
|
|
223
|
+
if (prototype !== Object.prototype && prototype !== null) {
|
|
224
|
+
const name = prototype?.constructor?.name ?? "unknown";
|
|
225
|
+
return `${path} is a ${name} instance`;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
for (const [key, entryValue] of Object.entries(value as Record<string, unknown>)) {
|
|
229
|
+
const reason = findNonSerializableBoundaryValue(entryValue, `${path}.${key}`, seen);
|
|
230
|
+
if (reason) return reason;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
return null;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function nextBoundaryInstanceId(context: BoundaryRenderContext | undefined, boundaryId: string): string {
|
|
237
|
+
if (!context) return boundaryId;
|
|
238
|
+
|
|
239
|
+
const count = context.instanceCounts.get(boundaryId) ?? 0;
|
|
240
|
+
context.instanceCounts.set(boundaryId, count + 1);
|
|
241
|
+
return count === 0 ? boundaryId : `${boundaryId}--${count}`;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function resolveBoundarySrc(
|
|
245
|
+
context: BoundaryRenderContext | undefined,
|
|
246
|
+
routeId: string,
|
|
247
|
+
boundaryId: string,
|
|
248
|
+
): string | undefined {
|
|
249
|
+
const manifest = context?.bundleManifest;
|
|
250
|
+
if (!manifest) return undefined;
|
|
251
|
+
|
|
252
|
+
const boundary = manifest.boundaries?.[boundaryId];
|
|
253
|
+
if (boundary?.js) return cacheBust(boundary.js);
|
|
254
|
+
|
|
255
|
+
const effectiveRouteId = routeId || context?.routeId;
|
|
256
|
+
const routeBundle = effectiveRouteId ? manifest.bundles[effectiveRouteId] : undefined;
|
|
257
|
+
return routeBundle?.js ? cacheBust(routeBundle.js) : undefined;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function cacheBust(src: string): string {
|
|
261
|
+
return `${src}${src.includes("?") ? "&" : "?"}t=${Date.now()}`;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function getCurrentBoundaryContext(): BoundaryRenderContext | undefined {
|
|
265
|
+
return boundaryAsyncStorage.getStore() ?? boundaryContextStack[boundaryContextStack.length - 1];
|
|
266
|
+
}
|
package/src/internal/index.ts
CHANGED
|
@@ -11,7 +11,8 @@ export * as watcher from "../watcher";
|
|
|
11
11
|
export * as runtimeCache from "../runtime/cache";
|
|
12
12
|
export * as runtimeRouter from "../runtime/router";
|
|
13
13
|
export * as runtimeServer from "../runtime/server";
|
|
14
|
-
export * as runtimeFastRefreshTypes from "../runtime/fast-refresh-types";
|
|
14
|
+
export * as runtimeFastRefreshTypes from "../runtime/fast-refresh-types";
|
|
15
|
+
export * as clientBoundary from "./client-boundary";
|
|
15
16
|
|
|
16
17
|
export * as resourceDdlDiff from "../resource/ddl/diff";
|
|
17
18
|
export * as resourceDdlEmit from "../resource/ddl/emit";
|