@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/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";
|
|
@@ -3,9 +3,10 @@ import { mkdir, mkdtemp, rm, writeFile } from "fs/promises";
|
|
|
3
3
|
import path from "path";
|
|
4
4
|
import {
|
|
5
5
|
findClientComponentImports,
|
|
6
|
-
findRouteLevelClientComponentImport,
|
|
7
|
-
findRouteLevelClientComponentImports,
|
|
8
|
-
resolveRouteLevelClientEntryPath,
|
|
6
|
+
findRouteLevelClientComponentImport,
|
|
7
|
+
findRouteLevelClientComponentImports,
|
|
8
|
+
resolveRouteLevelClientEntryPath,
|
|
9
|
+
validateClientModuleForBrowserBundle,
|
|
9
10
|
} from "./client-entry";
|
|
10
11
|
|
|
11
12
|
const repoTempRoot = path.resolve(import.meta.dir, "../../../..", ".tmp-test-artifacts");
|
|
@@ -230,6 +231,42 @@ describe("findClientComponentImports", () => {
|
|
|
230
231
|
expect(resolved).toBe("src/client/widgets/pledge-form/PledgeForm.tsx");
|
|
231
232
|
} finally {
|
|
232
233
|
await rm(rootDir, { recursive: true, force: true });
|
|
233
|
-
}
|
|
234
|
-
});
|
|
235
|
-
|
|
234
|
+
}
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
it("validates explicit route-level client modules against server-only imports", async () => {
|
|
238
|
+
const rootDir = await mkRepoTempDir("client-entry-server-only-");
|
|
239
|
+
try {
|
|
240
|
+
await mkdir(path.join(rootDir, "app"), { recursive: true });
|
|
241
|
+
await writeFile(
|
|
242
|
+
path.join(rootDir, "app", "dashboard.island.tsx"),
|
|
243
|
+
`"use client";
|
|
244
|
+
import { Database } from "bun:sqlite";
|
|
245
|
+
export default function Dashboard() {
|
|
246
|
+
return String(Database);
|
|
247
|
+
}`,
|
|
248
|
+
);
|
|
249
|
+
|
|
250
|
+
const error = await validateClientModuleForBrowserBundle(
|
|
251
|
+
{
|
|
252
|
+
id: "dashboard",
|
|
253
|
+
kind: "page",
|
|
254
|
+
pattern: "/dashboard",
|
|
255
|
+
module: "app/dashboard.tsx",
|
|
256
|
+
componentModule: "app/dashboard.tsx",
|
|
257
|
+
clientModule: "app/dashboard.island.tsx",
|
|
258
|
+
clientExportName: "default",
|
|
259
|
+
hydration: { strategy: "island", priority: "visible", preload: false },
|
|
260
|
+
},
|
|
261
|
+
rootDir,
|
|
262
|
+
);
|
|
263
|
+
|
|
264
|
+
expect(error).toContain("MANDU_BOUNDARY_SERVER_ONLY_IMPORT");
|
|
265
|
+
expect(error).toContain("bun:sqlite");
|
|
266
|
+
expect(error).toContain("route=dashboard");
|
|
267
|
+
expect(error).toContain("Suggestion:");
|
|
268
|
+
} finally {
|
|
269
|
+
await rm(rootDir, { recursive: true, force: true });
|
|
270
|
+
}
|
|
271
|
+
});
|
|
272
|
+
});
|
|
@@ -1,6 +1,10 @@
|
|
|
1
|
-
import { readFile } from "fs/promises";
|
|
2
|
-
import path from "path";
|
|
3
|
-
import type { RouteSpec } from "../spec/schema";
|
|
1
|
+
import { readFile } from "fs/promises";
|
|
2
|
+
import path from "path";
|
|
3
|
+
import type { RouteSpec } from "../spec/schema";
|
|
4
|
+
import {
|
|
5
|
+
formatClientBoundaryDiagnostics,
|
|
6
|
+
validateClientBoundaryServerOnlyImports,
|
|
7
|
+
} from "../bundler/client-boundary-transform";
|
|
4
8
|
|
|
5
9
|
export interface ClientComponentImport {
|
|
6
10
|
module: string;
|
|
@@ -499,10 +503,10 @@ function isIdentifierChar(value: string): boolean {
|
|
|
499
503
|
return /[A-Za-z0-9_$]/.test(value);
|
|
500
504
|
}
|
|
501
505
|
|
|
502
|
-
export async function validateClientModuleForBrowserBundle(
|
|
503
|
-
route: RouteSpec,
|
|
504
|
-
rootDir: string,
|
|
505
|
-
): Promise<string | null> {
|
|
506
|
+
export async function validateClientModuleForBrowserBundle(
|
|
507
|
+
route: RouteSpec,
|
|
508
|
+
rootDir: string,
|
|
509
|
+
): Promise<string | null> {
|
|
506
510
|
if (!route.clientModule) return null;
|
|
507
511
|
|
|
508
512
|
const source = await readRouteModule(rootDir, route.clientModule);
|
|
@@ -517,17 +521,34 @@ export async function validateClientModuleForBrowserBundle(
|
|
|
517
521
|
if (realClientEntry) {
|
|
518
522
|
route.clientModule = realClientEntry.modulePath;
|
|
519
523
|
route.clientExportName = realClientEntry.exportName;
|
|
520
|
-
|
|
524
|
+
const realSource = await readRouteModule(rootDir, route.clientModule);
|
|
525
|
+
return realSource === null ? null : formatClientModuleBrowserDiagnostics(route, realSource);
|
|
521
526
|
}
|
|
522
527
|
return (
|
|
523
528
|
`[${route.id}] Route component "${route.clientModule}" is configured as clientModule, ` +
|
|
524
529
|
`but it is a server page (missing "use client"). Mandu will not bundle server pages into client islands. ` +
|
|
525
530
|
`Remove the stale clientModule from .mandu/routes.manifest.json or use a *.partial.tsx / spec/slots/${route.id}.client.tsx client entry.`
|
|
526
531
|
);
|
|
527
|
-
}
|
|
528
|
-
|
|
529
|
-
return
|
|
530
|
-
}
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
return formatClientModuleBrowserDiagnostics(route, source);
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
function formatClientModuleBrowserDiagnostics(route: RouteSpec, source: string): string | null {
|
|
538
|
+
if (!route.clientModule) return null;
|
|
539
|
+
const diagnostics = validateClientBoundaryServerOnlyImports(
|
|
540
|
+
source,
|
|
541
|
+
{
|
|
542
|
+
id: `${route.id}--client-module`,
|
|
543
|
+
routeId: route.id,
|
|
544
|
+
module: route.clientModule,
|
|
545
|
+
exportName: route.clientExportName ?? "default",
|
|
546
|
+
},
|
|
547
|
+
route.clientModule,
|
|
548
|
+
);
|
|
549
|
+
if (diagnostics.length === 0) return null;
|
|
550
|
+
return formatClientBoundaryDiagnostics(diagnostics);
|
|
551
|
+
}
|
|
531
552
|
|
|
532
553
|
async function routeComponentHasResolvableClientEntry(
|
|
533
554
|
rootDir: string,
|