@mandujs/core 0.54.18 โ 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 +1 -1
- package/src/agent/__tests__/context.test.ts +40 -9
- package/src/agent/verify.ts +55 -24
- package/src/bundler/build.test.ts +40 -3
- package/src/bundler/build.ts +35 -680
- 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 +15 -0
- package/src/diagnose/checks.ts +1 -1
- package/src/router/client-entry.test.ts +111 -23
- package/src/router/client-entry.ts +78 -301
- package/src/router/fs-routes.test.ts +55 -0
- package/src/router/fs-scanner.ts +11 -40
- package/src/router/fs-types.ts +7 -1
- 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 +6 -0
- package/src/runtime/page-render-response.ts +23 -1
- package/src/runtime/server.ts +14 -0
package/src/client/serialize.ts
CHANGED
|
@@ -1,404 +1,50 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Mandu
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
/** ์ฐธ์กฐ ํ
์ด๋ธ */
|
|
52
|
-
refs: object[];
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
/**
|
|
56
|
-
* Props ์ง๋ ฌํ
|
|
57
|
-
*
|
|
58
|
-
* @example
|
|
59
|
-
* ```typescript
|
|
60
|
-
* const props = {
|
|
61
|
-
* date: new Date(),
|
|
62
|
-
* url: new URL('https://example.com'),
|
|
63
|
-
* items: new Set([1, 2, 3]),
|
|
64
|
-
* cache: new Map([['key', 'value']]),
|
|
65
|
-
* };
|
|
66
|
-
*
|
|
67
|
-
* const json = serializeProps(props);
|
|
68
|
-
* // ํด๋ผ์ด์ธํธ๋ก ์ ์ก
|
|
69
|
-
* ```
|
|
70
|
-
*/
|
|
71
|
-
export function serializeProps(props: Record<string, unknown>): string {
|
|
72
|
-
const ctx: SerializeContext = { seen: new Map(), refs: [] };
|
|
73
|
-
return JSON.stringify(serialize(props, ctx));
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
/**
|
|
77
|
-
* ๊ฐ ์ง๋ ฌํ (์ฌ๊ท)
|
|
78
|
-
*/
|
|
79
|
-
function serialize(value: unknown, ctx: SerializeContext): unknown {
|
|
80
|
-
// null
|
|
81
|
-
if (value === null) return null;
|
|
82
|
-
|
|
83
|
-
// undefined
|
|
84
|
-
if (value === undefined) return TYPE_MARKERS.UNDEFINED;
|
|
85
|
-
|
|
86
|
-
// ์์ํ
|
|
87
|
-
if (typeof value === "boolean" || typeof value === "number") {
|
|
88
|
-
return value;
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
if (typeof value === "string") {
|
|
92
|
-
// ํ์
๋ง์ปค์ ์ถฉ๋ ๋ฐฉ์ง (์ฒซ ๋ฌธ์๊ฐ \x00์ธ ๊ฒฝ์ฐ)
|
|
93
|
-
if (value.startsWith("\x00")) {
|
|
94
|
-
return "\x00\x00" + value;
|
|
95
|
-
}
|
|
96
|
-
return value;
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
if (typeof value === "bigint") {
|
|
100
|
-
return TYPE_MARKERS.BIGINT + value.toString();
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
if (typeof value === "symbol") {
|
|
104
|
-
// Symbol์ description๋ง ๋ณด์กด
|
|
105
|
-
return TYPE_MARKERS.SYMBOL + (value.description ?? "");
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
// ํจ์๋ ์ง๋ ฌํ ๋ถ๊ฐ
|
|
109
|
-
if (typeof value === "function") {
|
|
110
|
-
console.warn("[Mandu Serialize] Functions cannot be serialized, skipping");
|
|
111
|
-
return undefined;
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
// ๊ฐ์ฒด ์ํ ์ฐธ์กฐ ์ฒดํฌ
|
|
115
|
-
if (typeof value === "object") {
|
|
116
|
-
const existing = ctx.seen.get(value);
|
|
117
|
-
if (existing !== undefined) {
|
|
118
|
-
return TYPE_MARKERS.REF + existing;
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
const idx = ctx.refs.length;
|
|
122
|
-
ctx.seen.set(value, idx);
|
|
123
|
-
ctx.refs.push(value);
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
// Date
|
|
127
|
-
if (value instanceof Date) {
|
|
128
|
-
return TYPE_MARKERS.DATE + value.toISOString();
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
// URL
|
|
132
|
-
if (value instanceof URL) {
|
|
133
|
-
return TYPE_MARKERS.URL + value.href;
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
// RegExp
|
|
137
|
-
if (value instanceof RegExp) {
|
|
138
|
-
return TYPE_MARKERS.REGEXP + value.toString();
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
// Error
|
|
142
|
-
if (value instanceof Error) {
|
|
143
|
-
return [
|
|
144
|
-
TYPE_MARKERS.ERROR,
|
|
145
|
-
value.name,
|
|
146
|
-
value.message,
|
|
147
|
-
value.stack ?? "",
|
|
148
|
-
];
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
// Map
|
|
152
|
-
if (value instanceof Map) {
|
|
153
|
-
const entries: [unknown, unknown][] = [];
|
|
154
|
-
for (const [k, v] of value.entries()) {
|
|
155
|
-
entries.push([serialize(k, ctx), serialize(v, ctx)]);
|
|
156
|
-
}
|
|
157
|
-
return [TYPE_MARKERS.MAP, ...entries];
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
// Set
|
|
161
|
-
if (value instanceof Set) {
|
|
162
|
-
const items: unknown[] = [];
|
|
163
|
-
for (const item of value) {
|
|
164
|
-
items.push(serialize(item, ctx));
|
|
165
|
-
}
|
|
166
|
-
return [TYPE_MARKERS.SET, ...items];
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
// ๋ฐฐ์ด
|
|
170
|
-
if (Array.isArray(value)) {
|
|
171
|
-
return value.map((item) => serialize(item, ctx));
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
// ์ผ๋ฐ ๊ฐ์ฒด
|
|
175
|
-
const result: Record<string, unknown> = {};
|
|
176
|
-
for (const [k, v] of Object.entries(value as object)) {
|
|
177
|
-
const serialized = serialize(v, ctx);
|
|
178
|
-
if (serialized !== undefined) {
|
|
179
|
-
result[k] = serialized;
|
|
180
|
-
}
|
|
181
|
-
}
|
|
182
|
-
return result;
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
// ============================================
|
|
186
|
-
// ์ญ์ง๋ ฌํ
|
|
187
|
-
// ============================================
|
|
188
|
-
|
|
189
|
-
/**
|
|
190
|
-
* ์ญ์ง๋ ฌํ ์ปจํ
์คํธ (์ํ ์ฐธ์กฐ ๋ณต์)
|
|
191
|
-
*/
|
|
192
|
-
interface DeserializeContext {
|
|
193
|
-
refs: unknown[];
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
/**
|
|
197
|
-
* Props ์ญ์ง๋ ฌํ
|
|
198
|
-
*
|
|
199
|
-
* @example
|
|
200
|
-
* ```typescript
|
|
201
|
-
* // ์๋ฒ์์ ๋ฐ์ JSON
|
|
202
|
-
* const json = '{"date":"\x00D2025-01-28T00:00:00.000Z"}';
|
|
203
|
-
*
|
|
204
|
-
* const props = deserializeProps(json);
|
|
205
|
-
* console.log(props.date instanceof Date); // true
|
|
206
|
-
* ```
|
|
207
|
-
*/
|
|
208
|
-
export function deserializeProps(json: string): Record<string, unknown> {
|
|
209
|
-
const ctx: DeserializeContext = { refs: [] };
|
|
210
|
-
const parsed = JSON.parse(json);
|
|
211
|
-
return deserialize(parsed, ctx) as Record<string, unknown>;
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
/**
|
|
215
|
-
* ๊ฐ ์ญ์ง๋ ฌํ (์ฌ๊ท)
|
|
216
|
-
*/
|
|
217
|
-
function deserialize(value: unknown, ctx: DeserializeContext): unknown {
|
|
218
|
-
// null
|
|
219
|
-
if (value === null) return null;
|
|
220
|
-
|
|
221
|
-
// ๋ฌธ์์ด โ ํ์
๋ง์ปค ์ฒดํฌ
|
|
222
|
-
if (typeof value === "string") {
|
|
223
|
-
// undefined
|
|
224
|
-
if (value === TYPE_MARKERS.UNDEFINED) return undefined;
|
|
225
|
-
|
|
226
|
-
// ์ด์ค์ผ์ดํ๋ ๋ฌธ์์ด (\x00\x00 โ \x00)
|
|
227
|
-
if (value.startsWith("\x00\x00")) {
|
|
228
|
-
return value.slice(2);
|
|
229
|
-
}
|
|
230
|
-
|
|
231
|
-
// Date
|
|
232
|
-
if (value.startsWith(TYPE_MARKERS.DATE)) {
|
|
233
|
-
return new Date(value.slice(2));
|
|
234
|
-
}
|
|
235
|
-
|
|
236
|
-
// URL
|
|
237
|
-
if (value.startsWith(TYPE_MARKERS.URL)) {
|
|
238
|
-
return new URL(value.slice(2));
|
|
239
|
-
}
|
|
240
|
-
|
|
241
|
-
// RegExp
|
|
242
|
-
if (value.startsWith(TYPE_MARKERS.REGEXP)) {
|
|
243
|
-
const str = value.slice(2);
|
|
244
|
-
const match = str.match(/^\/(.*)\/([gimsuy]*)$/);
|
|
245
|
-
if (match) {
|
|
246
|
-
return new RegExp(match[1], match[2]);
|
|
247
|
-
}
|
|
248
|
-
return str; // ํ์ฑ ์คํจ ์ ๋ฌธ์์ด ๋ฐํ
|
|
249
|
-
}
|
|
250
|
-
|
|
251
|
-
// BigInt
|
|
252
|
-
if (value.startsWith(TYPE_MARKERS.BIGINT)) {
|
|
253
|
-
return BigInt(value.slice(2));
|
|
254
|
-
}
|
|
255
|
-
|
|
256
|
-
// Symbol
|
|
257
|
-
if (value.startsWith(TYPE_MARKERS.SYMBOL)) {
|
|
258
|
-
return Symbol(value.slice(2));
|
|
259
|
-
}
|
|
260
|
-
|
|
261
|
-
// ์ํ ์ฐธ์กฐ
|
|
262
|
-
if (value.startsWith(TYPE_MARKERS.REF)) {
|
|
263
|
-
const idx = parseInt(value.slice(2), 10);
|
|
264
|
-
return ctx.refs[idx];
|
|
265
|
-
}
|
|
266
|
-
|
|
267
|
-
return value;
|
|
268
|
-
}
|
|
269
|
-
|
|
270
|
-
// ์์ํ
|
|
271
|
-
if (typeof value === "boolean" || typeof value === "number") {
|
|
272
|
-
return value;
|
|
273
|
-
}
|
|
274
|
-
|
|
275
|
-
// ๋ฐฐ์ด โ ํน์ ํ์
์ฒดํฌ
|
|
276
|
-
if (Array.isArray(value)) {
|
|
277
|
-
const marker = value[0];
|
|
278
|
-
|
|
279
|
-
// Error
|
|
280
|
-
if (marker === TYPE_MARKERS.ERROR) {
|
|
281
|
-
const [, name, message, stack] = value as [string, string, string, string];
|
|
282
|
-
const error = new Error(message);
|
|
283
|
-
error.name = name;
|
|
284
|
-
if (stack) error.stack = stack;
|
|
285
|
-
ctx.refs.push(error);
|
|
286
|
-
return error;
|
|
287
|
-
}
|
|
288
|
-
|
|
289
|
-
// Map
|
|
290
|
-
if (marker === TYPE_MARKERS.MAP) {
|
|
291
|
-
const map = new Map();
|
|
292
|
-
ctx.refs.push(map);
|
|
293
|
-
for (let i = 1; i < value.length; i++) {
|
|
294
|
-
const [k, v] = value[i] as [unknown, unknown];
|
|
295
|
-
map.set(deserialize(k, ctx), deserialize(v, ctx));
|
|
296
|
-
}
|
|
297
|
-
return map;
|
|
298
|
-
}
|
|
299
|
-
|
|
300
|
-
// Set
|
|
301
|
-
if (marker === TYPE_MARKERS.SET) {
|
|
302
|
-
const set = new Set();
|
|
303
|
-
ctx.refs.push(set);
|
|
304
|
-
for (let i = 1; i < value.length; i++) {
|
|
305
|
-
set.add(deserialize(value[i], ctx));
|
|
306
|
-
}
|
|
307
|
-
return set;
|
|
308
|
-
}
|
|
309
|
-
|
|
310
|
-
// ์ผ๋ฐ ๋ฐฐ์ด
|
|
311
|
-
const arr: unknown[] = [];
|
|
312
|
-
ctx.refs.push(arr);
|
|
313
|
-
for (const item of value) {
|
|
314
|
-
arr.push(deserialize(item, ctx));
|
|
315
|
-
}
|
|
316
|
-
return arr;
|
|
317
|
-
}
|
|
318
|
-
|
|
319
|
-
// ์ผ๋ฐ ๊ฐ์ฒด
|
|
320
|
-
if (typeof value === "object") {
|
|
321
|
-
const obj: Record<string, unknown> = {};
|
|
322
|
-
ctx.refs.push(obj);
|
|
323
|
-
for (const [k, v] of Object.entries(value)) {
|
|
324
|
-
obj[k] = deserialize(v, ctx);
|
|
325
|
-
}
|
|
326
|
-
return obj;
|
|
327
|
-
}
|
|
328
|
-
|
|
329
|
-
return value;
|
|
330
|
-
}
|
|
331
|
-
|
|
332
|
-
// ============================================
|
|
333
|
-
// ์ ํธ๋ฆฌํฐ
|
|
334
|
-
// ============================================
|
|
335
|
-
|
|
336
|
-
/**
|
|
337
|
-
* ์ง๋ ฌํ ๊ฐ๋ฅ ์ฌ๋ถ ์ฒดํฌ
|
|
338
|
-
*/
|
|
339
|
-
export function isSerializable(value: unknown): boolean {
|
|
340
|
-
if (value === null || value === undefined) return true;
|
|
341
|
-
|
|
342
|
-
const type = typeof value;
|
|
343
|
-
if (type === "boolean" || type === "number" || type === "string" || type === "bigint") {
|
|
344
|
-
return true;
|
|
345
|
-
}
|
|
346
|
-
|
|
347
|
-
if (type === "function" || type === "symbol") {
|
|
348
|
-
return false;
|
|
349
|
-
}
|
|
350
|
-
|
|
351
|
-
if (value instanceof Date || value instanceof URL || value instanceof RegExp) {
|
|
352
|
-
return true;
|
|
353
|
-
}
|
|
354
|
-
|
|
355
|
-
if (value instanceof Map || value instanceof Set) {
|
|
356
|
-
return true;
|
|
357
|
-
}
|
|
358
|
-
|
|
359
|
-
if (Array.isArray(value)) {
|
|
360
|
-
return value.every(isSerializable);
|
|
361
|
-
}
|
|
362
|
-
|
|
363
|
-
if (type === "object") {
|
|
364
|
-
return Object.values(value as object).every(isSerializable);
|
|
365
|
-
}
|
|
366
|
-
|
|
367
|
-
return false;
|
|
368
|
-
}
|
|
369
|
-
|
|
370
|
-
/**
|
|
371
|
-
* SSR์์ ํด๋ผ์ด์ธํธ๋ก props ์ ๋ฌ์ฉ ์คํฌ๋ฆฝํธ ์์ฑ
|
|
372
|
-
*/
|
|
373
|
-
export function generatePropsScript(
|
|
374
|
-
islandId: string,
|
|
375
|
-
props: Record<string, unknown>
|
|
376
|
-
): string {
|
|
377
|
-
const json = serializeProps(props);
|
|
378
|
-
const escaped = json
|
|
379
|
-
.replace(/</g, "\\u003c")
|
|
380
|
-
.replace(/>/g, "\\u003e")
|
|
381
|
-
.replace(/&/g, "\\u0026");
|
|
382
|
-
|
|
383
|
-
return `<script type="application/json" data-mandu-props="${islandId}">${escaped}</script>`;
|
|
384
|
-
}
|
|
385
|
-
|
|
386
|
-
/**
|
|
387
|
-
* ํด๋ผ์ด์ธํธ์์ props ์คํฌ๋ฆฝํธ ํ์ฑ
|
|
388
|
-
*/
|
|
389
|
-
export function parsePropsScript(islandId: string): Record<string, unknown> | null {
|
|
390
|
-
if (typeof document === "undefined") return null;
|
|
391
|
-
|
|
392
|
-
const script = document.querySelector(
|
|
393
|
-
`script[data-mandu-props="${islandId}"]`
|
|
394
|
-
) as HTMLScriptElement | null;
|
|
395
|
-
|
|
396
|
-
if (!script?.textContent) return null;
|
|
397
|
-
|
|
398
|
-
try {
|
|
399
|
-
return deserializeProps(script.textContent);
|
|
400
|
-
} catch (err) {
|
|
401
|
-
console.error(`[Mandu] Failed to parse props for island ${islandId}:`, err);
|
|
402
|
-
return null;
|
|
403
|
-
}
|
|
404
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Mandu props serialization public API.
|
|
3
|
+
*
|
|
4
|
+
* Core serialization/deserialization lives in `props-serialization.ts` so the
|
|
5
|
+
* browser runtime and server render path share exactly one wire format.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { deserializeProps, serializeProps } from "./props-serialization";
|
|
9
|
+
|
|
10
|
+
export {
|
|
11
|
+
deserializeProps,
|
|
12
|
+
isSerializable,
|
|
13
|
+
serializeProps,
|
|
14
|
+
} from "./props-serialization";
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* SSR์์ ํด๋ผ์ด์ธํธ๋ก props ์ ๋ฌ์ฉ ์คํฌ๋ฆฝํธ ์์ฑ
|
|
18
|
+
*/
|
|
19
|
+
export function generatePropsScript(
|
|
20
|
+
islandId: string,
|
|
21
|
+
props: Record<string, unknown>
|
|
22
|
+
): string {
|
|
23
|
+
const json = serializeProps(props);
|
|
24
|
+
const escaped = json
|
|
25
|
+
.replace(/</g, "\\u003c")
|
|
26
|
+
.replace(/>/g, "\\u003e")
|
|
27
|
+
.replace(/&/g, "\\u0026");
|
|
28
|
+
|
|
29
|
+
return `<script type="application/json" data-mandu-props="${islandId}">${escaped}</script>`;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* ํด๋ผ์ด์ธํธ์์ props ์คํฌ๋ฆฝํธ ํ์ฑ
|
|
34
|
+
*/
|
|
35
|
+
export function parsePropsScript(islandId: string): Record<string, unknown> | null {
|
|
36
|
+
if (typeof document === "undefined") return null;
|
|
37
|
+
|
|
38
|
+
const script = document.querySelector(
|
|
39
|
+
`script[data-mandu-props="${islandId}"]`
|
|
40
|
+
) as HTMLScriptElement | null;
|
|
41
|
+
|
|
42
|
+
if (!script?.textContent) return null;
|
|
43
|
+
|
|
44
|
+
try {
|
|
45
|
+
return deserializeProps(script.textContent);
|
|
46
|
+
} catch (err) {
|
|
47
|
+
console.error(`[Mandu] Failed to parse props for island ${islandId}:`, err);
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
@@ -457,6 +457,21 @@ describe("checkClientBoundaryManifests", () => {
|
|
|
457
457
|
expect(result.details?.missingEntries).toEqual(["home--0"]);
|
|
458
458
|
});
|
|
459
459
|
|
|
460
|
+
it("treats a missing bundle manifest as an error when routes declare compiler-owned boundaries", async () => {
|
|
461
|
+
await writeFile(rootDir, ".mandu/routes.manifest.json", JSON.stringify({
|
|
462
|
+
routes: [
|
|
463
|
+
{
|
|
464
|
+
id: "home",
|
|
465
|
+
boundaries: [{ id: "home--0", routeId: "home", module: "src/client/Home.client.tsx", exportName: "Home" }],
|
|
466
|
+
},
|
|
467
|
+
],
|
|
468
|
+
}));
|
|
469
|
+
const result = await checkClientBoundaryManifests(rootDir);
|
|
470
|
+
expect(result.ok).toBe(false);
|
|
471
|
+
expect(result.severity).toBe("error");
|
|
472
|
+
expect(result.message).toMatch(/bundle manifest is missing/);
|
|
473
|
+
});
|
|
474
|
+
|
|
460
475
|
it("passes when route and bundle boundary manifests line up", async () => {
|
|
461
476
|
await writeFile(rootDir, ".mandu/routes.manifest.json", JSON.stringify({
|
|
462
477
|
routes: [
|
package/src/diagnose/checks.ts
CHANGED
|
@@ -964,7 +964,7 @@ export async function checkClientBoundaryManifests(rootDir: string): Promise<Dia
|
|
|
964
964
|
return {
|
|
965
965
|
ok: false,
|
|
966
966
|
rule: "client_boundary_manifests",
|
|
967
|
-
severity: "
|
|
967
|
+
severity: "error",
|
|
968
968
|
message: `Routes manifest declares ${boundaries.length} client boundary record(s), but bundle manifest is missing.`,
|
|
969
969
|
suggestion: "Run `mandu build` before deploy so boundary bundles are emitted and can be checked.",
|
|
970
970
|
details: { boundaryCount: boundaries.length, bundleManifestPath: path.relative(rootDir, bundleManifestPath) },
|
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
import { describe, expect, it } from "bun:test";
|
|
2
2
|
import { mkdir, mkdtemp, rm, writeFile } from "fs/promises";
|
|
3
3
|
import path from "path";
|
|
4
|
-
import {
|
|
5
|
-
findClientComponentImports,
|
|
4
|
+
import {
|
|
5
|
+
findClientComponentImports,
|
|
6
6
|
findRouteLevelClientComponentImport,
|
|
7
7
|
findRouteLevelClientComponentImports,
|
|
8
8
|
resolveRouteLevelClientEntryPath,
|
|
9
9
|
validateClientModuleForBrowserBundle,
|
|
10
10
|
} from "./client-entry";
|
|
11
|
+
import { analyzeRouteSource } from "./route-source-analyzer";
|
|
11
12
|
|
|
12
13
|
const repoTempRoot = path.resolve(import.meta.dir, "../../../..", ".tmp-test-artifacts");
|
|
13
14
|
|
|
@@ -17,10 +18,10 @@ async function mkRepoTempDir(prefix: string): Promise<string> {
|
|
|
17
18
|
}
|
|
18
19
|
|
|
19
20
|
describe("findClientComponentImports", () => {
|
|
20
|
-
it("detects named .client imports for diagnostics", () => {
|
|
21
|
-
const imports = findClientComponentImports(`
|
|
22
|
-
import { LoginForm, SubmitButton as Button } from "@/client/widgets/login-form/LoginForm.client";
|
|
23
|
-
import Header from "./Header.client.tsx";
|
|
21
|
+
it("detects named .client imports for diagnostics", () => {
|
|
22
|
+
const imports = findClientComponentImports(`
|
|
23
|
+
import { LoginForm, SubmitButton as Button } from "@/client/widgets/login-form/LoginForm.client";
|
|
24
|
+
import Header from "./Header.client.tsx";
|
|
24
25
|
`);
|
|
25
26
|
|
|
26
27
|
expect(imports).toEqual([
|
|
@@ -33,9 +34,34 @@ describe("findClientComponentImports", () => {
|
|
|
33
34
|
module: "./Header.client.tsx",
|
|
34
35
|
kind: "default",
|
|
35
36
|
names: ["Header"],
|
|
36
|
-
},
|
|
37
|
-
]);
|
|
38
|
-
});
|
|
37
|
+
},
|
|
38
|
+
]);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("ignores import syntax inside string literals", () => {
|
|
42
|
+
const imports = findClientComponentImports(`
|
|
43
|
+
const example = "import Fake from './Fake.client'";
|
|
44
|
+
|
|
45
|
+
export default function Page() {
|
|
46
|
+
return <main>{example}</main>;
|
|
47
|
+
}
|
|
48
|
+
`);
|
|
49
|
+
|
|
50
|
+
expect(imports).toEqual([]);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("ignores type-only client imports", () => {
|
|
54
|
+
const imports = findClientComponentImports(`
|
|
55
|
+
import type { LoginForm } from "@/client/widgets/login-form/LoginForm.client";
|
|
56
|
+
import { type SubmitButton } from "@/client/widgets/login-form/LoginForm.client";
|
|
57
|
+
|
|
58
|
+
export default function Page() {
|
|
59
|
+
return <main />;
|
|
60
|
+
}
|
|
61
|
+
`);
|
|
62
|
+
|
|
63
|
+
expect(imports).toEqual([]);
|
|
64
|
+
});
|
|
39
65
|
|
|
40
66
|
it("detects a default-imported client component when the page returns only that component", () => {
|
|
41
67
|
const routeClient = findRouteLevelClientComponentImport(`
|
|
@@ -125,11 +151,11 @@ describe("findClientComponentImports", () => {
|
|
|
125
151
|
});
|
|
126
152
|
});
|
|
127
153
|
|
|
128
|
-
it("promotes client imports when the page wrapper passes props", () => {
|
|
129
|
-
const routeClient = findRouteLevelClientComponentImport(`
|
|
130
|
-
import PledgePage from "@/client/pages/pledges/PledgePage.client";
|
|
131
|
-
|
|
132
|
-
export default function Page({ params }) {
|
|
154
|
+
it("promotes client imports when the page wrapper passes props", () => {
|
|
155
|
+
const routeClient = findRouteLevelClientComponentImport(`
|
|
156
|
+
import PledgePage from "@/client/pages/pledges/PledgePage.client";
|
|
157
|
+
|
|
158
|
+
export default function Page({ params }) {
|
|
133
159
|
return <PledgePage id={params.id} />;
|
|
134
160
|
}
|
|
135
161
|
`);
|
|
@@ -139,7 +165,23 @@ describe("findClientComponentImports", () => {
|
|
|
139
165
|
localName: "PledgePage",
|
|
140
166
|
exportName: "default",
|
|
141
167
|
});
|
|
142
|
-
});
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it("detects client entries rendered by an identifier default export", () => {
|
|
171
|
+
const routeClient = findRouteLevelClientComponentImport(`
|
|
172
|
+
import PledgePage from "@/client/pages/pledges/PledgePage.client";
|
|
173
|
+
|
|
174
|
+
const Page = ({ params }) => <PledgePage id={params.id} />;
|
|
175
|
+
|
|
176
|
+
export default Page;
|
|
177
|
+
`);
|
|
178
|
+
|
|
179
|
+
expect(routeClient).toEqual({
|
|
180
|
+
module: "@/client/pages/pledges/PledgePage.client",
|
|
181
|
+
localName: "PledgePage",
|
|
182
|
+
exportName: "default",
|
|
183
|
+
});
|
|
184
|
+
});
|
|
143
185
|
|
|
144
186
|
it("detects multiple rendered client imports in a server shell", () => {
|
|
145
187
|
const routeClients = findRouteLevelClientComponentImports(`
|
|
@@ -188,17 +230,63 @@ describe("findClientComponentImports", () => {
|
|
|
188
230
|
});
|
|
189
231
|
});
|
|
190
232
|
|
|
191
|
-
it("does not promote shared UI primitives to route-level client entries", () => {
|
|
192
|
-
const routeClient = findRouteLevelClientComponentImport(`
|
|
193
|
-
import { Button } from "@/client/shared/ui/button";
|
|
194
|
-
|
|
195
|
-
export default function Page() {
|
|
233
|
+
it("does not promote shared UI primitives to route-level client entries", () => {
|
|
234
|
+
const routeClient = findRouteLevelClientComponentImport(`
|
|
235
|
+
import { Button } from "@/client/shared/ui/button";
|
|
236
|
+
|
|
237
|
+
export default function Page() {
|
|
196
238
|
return <main><Button>Open</Button></main>;
|
|
197
239
|
}
|
|
198
240
|
`);
|
|
199
|
-
|
|
200
|
-
expect(routeClient).toBeNull();
|
|
201
|
-
});
|
|
241
|
+
|
|
242
|
+
expect(routeClient).toBeNull();
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
it("classifies default re-exports without treating them as rendered JSX", () => {
|
|
246
|
+
const analysis = analyzeRouteSource(`
|
|
247
|
+
export { default } from "./Page";
|
|
248
|
+
`);
|
|
249
|
+
|
|
250
|
+
expect(analysis.defaultExport).toMatchObject({
|
|
251
|
+
kind: "identifier",
|
|
252
|
+
localName: "default",
|
|
253
|
+
source: "./Page",
|
|
254
|
+
referencesJsx: false,
|
|
255
|
+
renderedJsxNames: [],
|
|
256
|
+
});
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
it("diagnoses default export wrapper calls conservatively", () => {
|
|
260
|
+
const analysis = analyzeRouteSource(`
|
|
261
|
+
import { memo } from "react";
|
|
262
|
+
|
|
263
|
+
const Page = () => <main />;
|
|
264
|
+
|
|
265
|
+
export default memo(Page);
|
|
266
|
+
`);
|
|
267
|
+
|
|
268
|
+
expect(analysis.defaultExport.kind).toBe("call");
|
|
269
|
+
expect(analysis.diagnostics.some((diagnostic) =>
|
|
270
|
+
diagnostic.code === "MANDU_ROUTE_DEFAULT_EXPORT_WRAPPER"
|
|
271
|
+
)).toBe(true);
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
it("records namespace imports without promoting member JSX as a route-level client entry", () => {
|
|
275
|
+
const source = `
|
|
276
|
+
import * as Widgets from "@/client/widgets/Widget.client";
|
|
277
|
+
|
|
278
|
+
export default function Page() {
|
|
279
|
+
return <Widgets.Panel />;
|
|
280
|
+
}
|
|
281
|
+
`;
|
|
282
|
+
const analysis = analyzeRouteSource(source);
|
|
283
|
+
|
|
284
|
+
expect(analysis.imports[0]).toMatchObject({
|
|
285
|
+
source: "@/client/widgets/Widget.client",
|
|
286
|
+
namespaceName: "Widgets",
|
|
287
|
+
});
|
|
288
|
+
expect(findRouteLevelClientComponentImport(source)).toBeNull();
|
|
289
|
+
});
|
|
202
290
|
|
|
203
291
|
it("resolves a route-level client entry by reading a use client target without .client in the path", async () => {
|
|
204
292
|
const rootDir = await mkRepoTempDir("client-entry-");
|