@mandujs/core 0.54.18 → 0.54.20
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 +287 -246
- 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/__tests__/searchparams-page-props.test.ts +81 -0
- package/src/runtime/page-render-response.ts +23 -1
- package/src/runtime/server.ts +179 -157
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
formatClientBoundaryDiagnostics,
|
|
6
6
|
validateClientBoundaryServerOnlyImports,
|
|
7
7
|
} from "../bundler/client-boundary-transform";
|
|
8
|
+
import { analyzeRouteSource, type RouteSourceImportRecord } from "./route-source-analyzer";
|
|
8
9
|
|
|
9
10
|
export interface ClientComponentImport {
|
|
10
11
|
module: string;
|
|
@@ -34,13 +35,13 @@ export function normalizeRouteModulePath(value: string | undefined): string {
|
|
|
34
35
|
return (value ?? "").replace(/\\/g, "/").replace(/^\.\//, "");
|
|
35
36
|
}
|
|
36
37
|
|
|
37
|
-
export function hasUseClientDirective(source: string): boolean {
|
|
38
|
-
return
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
export function hasUseServerDirective(source: string): boolean {
|
|
42
|
-
return
|
|
43
|
-
}
|
|
38
|
+
export function hasUseClientDirective(source: string): boolean {
|
|
39
|
+
return analyzeRouteSource(source).directives.useClient;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function hasUseServerDirective(source: string): boolean {
|
|
43
|
+
return analyzeRouteSource(source).directives.useServer;
|
|
44
|
+
}
|
|
44
45
|
|
|
45
46
|
export function clientModuleIsRouteComponent(route: RouteSpec, clientModule = route.clientModule): boolean {
|
|
46
47
|
if (route.kind !== "page" || !clientModule) return false;
|
|
@@ -75,77 +76,65 @@ function toPublicClientComponentImport(entry: ComponentImportRecord): ClientComp
|
|
|
75
76
|
}
|
|
76
77
|
|
|
77
78
|
function findComponentImportRecords(source: string): ComponentImportRecord[] {
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
const sideEffectPattern = /import\s+["']([^"']+)["']/g;
|
|
81
|
-
|
|
82
|
-
for (const match of source.matchAll(importFromPattern)) {
|
|
83
|
-
const clause = (match[1] ?? "").trim();
|
|
84
|
-
const module = match[2] ?? "";
|
|
85
|
-
const names: string[] = [];
|
|
86
|
-
const specifiers: ComponentImportSpecifier[] = [];
|
|
87
|
-
let hasDefault = false;
|
|
88
|
-
let hasNamed = false;
|
|
89
|
-
let hasNamespace = false;
|
|
90
|
-
|
|
91
|
-
const namedMatch = clause.match(/\{([^}]*)\}/);
|
|
92
|
-
if (namedMatch) {
|
|
93
|
-
hasNamed = true;
|
|
94
|
-
for (const rawName of namedMatch[1].split(",")) {
|
|
95
|
-
const name = rawName.trim();
|
|
96
|
-
if (!name) continue;
|
|
97
|
-
const parts = name.split(/\s+as\s+/i).map((part) => part.trim()).filter(Boolean);
|
|
98
|
-
const importedName = parts[0] ?? "";
|
|
99
|
-
const localName = parts[1] ?? importedName;
|
|
100
|
-
names.push(localName);
|
|
101
|
-
specifiers.push({ importedName, localName });
|
|
102
|
-
}
|
|
103
|
-
}
|
|
79
|
+
return findComponentImportRecordsFromAnalysis(analyzeRouteSource(source).imports);
|
|
80
|
+
}
|
|
104
81
|
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
if (namespaceMatch?.[1]) {
|
|
109
|
-
names.push(namespaceMatch[1]);
|
|
110
|
-
specifiers.push({ importedName: "*", localName: namespaceMatch[1] });
|
|
111
|
-
}
|
|
112
|
-
}
|
|
82
|
+
function findComponentImportRecordsFromAnalysis(imports: RouteSourceImportRecord[]): ComponentImportRecord[] {
|
|
83
|
+
return imports.flatMap(toComponentImportRecord);
|
|
84
|
+
}
|
|
113
85
|
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
const localName = beforeNamed.split(",")[0].trim();
|
|
118
|
-
names.push(localName);
|
|
119
|
-
specifiers.push({ importedName: DEFAULT_EXPORT_NAME, localName });
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
const kind =
|
|
123
|
-
(hasDefault && (hasNamed || hasNamespace))
|
|
124
|
-
? "mixed"
|
|
125
|
-
: hasNamed
|
|
126
|
-
? "named"
|
|
127
|
-
: hasNamespace
|
|
128
|
-
? "namespace"
|
|
129
|
-
: "default";
|
|
130
|
-
|
|
131
|
-
imports.push({ module, kind, names, specifiers });
|
|
86
|
+
function toComponentImportRecord(entry: RouteSourceImportRecord): ComponentImportRecord[] {
|
|
87
|
+
if (entry.isSideEffectOnly) {
|
|
88
|
+
return [{ module: entry.source, kind: "side-effect", names: [], specifiers: [] }];
|
|
132
89
|
}
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
90
|
+
if (entry.isTypeOnly) return [];
|
|
91
|
+
|
|
92
|
+
const names: string[] = [];
|
|
93
|
+
const specifiers: ComponentImportSpecifier[] = [];
|
|
94
|
+
let hasDefault = false;
|
|
95
|
+
let hasNamed = false;
|
|
96
|
+
let hasNamespace = false;
|
|
97
|
+
|
|
98
|
+
if (entry.defaultName) {
|
|
99
|
+
hasDefault = true;
|
|
100
|
+
names.push(entry.defaultName);
|
|
101
|
+
specifiers.push({ importedName: DEFAULT_EXPORT_NAME, localName: entry.defaultName });
|
|
138
102
|
}
|
|
139
103
|
|
|
140
|
-
|
|
141
|
-
|
|
104
|
+
if (entry.namespaceName) {
|
|
105
|
+
hasNamespace = true;
|
|
106
|
+
names.push(entry.namespaceName);
|
|
107
|
+
specifiers.push({ importedName: "*", localName: entry.namespaceName });
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
for (const named of entry.named) {
|
|
111
|
+
if (named.isTypeOnly) continue;
|
|
112
|
+
hasNamed = true;
|
|
113
|
+
names.push(named.local);
|
|
114
|
+
specifiers.push({ importedName: named.imported, localName: named.local });
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if (names.length === 0) return [];
|
|
118
|
+
|
|
119
|
+
const kind =
|
|
120
|
+
(hasDefault && (hasNamed || hasNamespace))
|
|
121
|
+
? "mixed"
|
|
122
|
+
: hasNamed
|
|
123
|
+
? "named"
|
|
124
|
+
: hasNamespace
|
|
125
|
+
? "namespace"
|
|
126
|
+
: "default";
|
|
127
|
+
|
|
128
|
+
return [{ module: entry.source, kind, names, specifiers }];
|
|
129
|
+
}
|
|
142
130
|
|
|
143
131
|
export function findRouteLevelClientComponentImport(source: string): RouteLevelClientComponentImport | null {
|
|
144
132
|
return findRouteLevelClientComponentImports(source)[0] ?? null;
|
|
145
133
|
}
|
|
146
134
|
|
|
147
135
|
export function findRouteLevelClientComponentImports(source: string): RouteLevelClientComponentImport[] {
|
|
148
|
-
const
|
|
136
|
+
const analysis = analyzeRouteSource(source);
|
|
137
|
+
const candidates = findComponentImportRecordsFromAnalysis(analysis.imports).flatMap((entry) =>
|
|
149
138
|
isRouteLevelClientEntrySpecifier(entry.module)
|
|
150
139
|
? entry.specifiers
|
|
151
140
|
.filter((specifier) => /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(specifier.localName))
|
|
@@ -156,10 +145,10 @@ export function findRouteLevelClientComponentImports(source: string): RouteLevel
|
|
|
156
145
|
}))
|
|
157
146
|
: []
|
|
158
147
|
);
|
|
159
|
-
|
|
160
|
-
if (candidates.length === 0) return [];
|
|
161
|
-
return defaultExportRendersClientComponents(
|
|
162
|
-
}
|
|
148
|
+
|
|
149
|
+
if (candidates.length === 0) return [];
|
|
150
|
+
return defaultExportRendersClientComponents(analysis.defaultExport.renderedJsxNames, candidates);
|
|
151
|
+
}
|
|
163
152
|
|
|
164
153
|
function isRouteLevelClientEntrySpecifier(specifier: string): boolean {
|
|
165
154
|
const normalized = specifier.replace(/\\/g, "/");
|
|
@@ -272,236 +261,24 @@ function clientSpecifierLooksBrowserOnly(specifier: string): boolean {
|
|
|
272
261
|
return CLIENT_ENTRY_SPECIFIER_PATTERN.test(specifier.replace(/\\/g, "/"));
|
|
273
262
|
}
|
|
274
263
|
|
|
275
|
-
function defaultExportRendersClientComponents(
|
|
276
|
-
|
|
277
|
-
candidates: RouteLevelClientComponentImport[],
|
|
278
|
-
): RouteLevelClientComponentImport[] {
|
|
279
|
-
const
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
return returned !== null ? jsxExpressionRendersClientComponents(returned, candidates) : [];
|
|
294
|
-
}
|
|
295
|
-
|
|
296
|
-
return [];
|
|
297
|
-
}
|
|
298
|
-
|
|
299
|
-
function extractDefaultExportFunctionBody(source: string): string | null {
|
|
300
|
-
const match = /export\s+default\s+(?:async\s+)?function(?:\s+[A-Za-z_$][A-Za-z0-9_$]*)?\s*\([^)]*\)\s*(?::\s*[^{=]+)?\{/m.exec(source);
|
|
301
|
-
if (!match) return null;
|
|
302
|
-
|
|
303
|
-
const openBrace = match.index + match[0].lastIndexOf("{");
|
|
304
|
-
const closeBrace = findMatchingBrace(source, openBrace);
|
|
305
|
-
if (closeBrace === -1) return null;
|
|
306
|
-
return source.slice(openBrace + 1, closeBrace);
|
|
307
|
-
}
|
|
308
|
-
|
|
309
|
-
function extractDefaultExportArrowExpression(source: string): string | null {
|
|
310
|
-
const match = /export\s+default\s+(?:async\s+)?(?:\([^)]*\)|[A-Za-z_$][A-Za-z0-9_$]*)\s*=>\s*/m.exec(source);
|
|
311
|
-
if (!match) return null;
|
|
312
|
-
|
|
313
|
-
const start = match.index + match[0].length;
|
|
314
|
-
const rest = source.slice(start).trim();
|
|
315
|
-
if (rest.startsWith("{")) return null;
|
|
316
|
-
|
|
317
|
-
const semicolon = rest.indexOf(";");
|
|
318
|
-
return semicolon === -1 ? rest : rest.slice(0, semicolon);
|
|
319
|
-
}
|
|
320
|
-
|
|
321
|
-
function extractDefaultExportArrowFunctionBody(source: string): string | null {
|
|
322
|
-
const match = /export\s+default\s+(?:async\s+)?(?:\([^)]*\)|[A-Za-z_$][A-Za-z0-9_$]*)\s*=>\s*\{/m.exec(source);
|
|
323
|
-
if (!match) return null;
|
|
324
|
-
|
|
325
|
-
const openBrace = match.index + match[0].lastIndexOf("{");
|
|
326
|
-
const closeBrace = findMatchingBrace(source, openBrace);
|
|
327
|
-
if (closeBrace === -1) return null;
|
|
328
|
-
return source.slice(openBrace + 1, closeBrace);
|
|
329
|
-
}
|
|
330
|
-
|
|
331
|
-
function extractTopLevelReturnExpression(body: string): string | null {
|
|
332
|
-
let quote: '"' | "'" | "`" | null = null;
|
|
333
|
-
let lineComment = false;
|
|
334
|
-
let blockComment = false;
|
|
335
|
-
let braceDepth = 0;
|
|
336
|
-
let parenDepth = 0;
|
|
337
|
-
let bracketDepth = 0;
|
|
338
|
-
|
|
339
|
-
for (let i = 0; i < body.length; i++) {
|
|
340
|
-
const char = body[i];
|
|
341
|
-
const next = body[i + 1];
|
|
342
|
-
const prev = body[i - 1];
|
|
343
|
-
|
|
344
|
-
if (lineComment) {
|
|
345
|
-
if (char === "\n" || char === "\r") lineComment = false;
|
|
346
|
-
continue;
|
|
347
|
-
}
|
|
348
|
-
|
|
349
|
-
if (blockComment) {
|
|
350
|
-
if (char === "*" && next === "/") {
|
|
351
|
-
blockComment = false;
|
|
352
|
-
i++;
|
|
353
|
-
}
|
|
354
|
-
continue;
|
|
355
|
-
}
|
|
356
|
-
|
|
357
|
-
if (quote) {
|
|
358
|
-
if (char === quote && prev !== "\\") quote = null;
|
|
359
|
-
continue;
|
|
360
|
-
}
|
|
361
|
-
|
|
362
|
-
if (char === "/" && next === "/") {
|
|
363
|
-
lineComment = true;
|
|
364
|
-
i++;
|
|
365
|
-
continue;
|
|
366
|
-
}
|
|
367
|
-
if (char === "/" && next === "*") {
|
|
368
|
-
blockComment = true;
|
|
369
|
-
i++;
|
|
370
|
-
continue;
|
|
371
|
-
}
|
|
372
|
-
if (char === '"' || char === "'" || char === "`") {
|
|
373
|
-
quote = char;
|
|
374
|
-
continue;
|
|
375
|
-
}
|
|
376
|
-
|
|
377
|
-
if (braceDepth === 0 && parenDepth === 0 && bracketDepth === 0 && body.startsWith("return", i)) {
|
|
378
|
-
const before = body[i - 1] ?? "";
|
|
379
|
-
const after = body[i + "return".length] ?? "";
|
|
380
|
-
if (!isIdentifierChar(before) && !isIdentifierChar(after)) {
|
|
381
|
-
const expr = body.slice(i + "return".length).trim();
|
|
382
|
-
return trimTrailingSemicolon(expr);
|
|
383
|
-
}
|
|
384
|
-
}
|
|
385
|
-
|
|
386
|
-
if (char === "{") braceDepth++;
|
|
387
|
-
if (char === "}") braceDepth = Math.max(0, braceDepth - 1);
|
|
388
|
-
if (char === "(") parenDepth++;
|
|
389
|
-
if (char === ")") parenDepth = Math.max(0, parenDepth - 1);
|
|
390
|
-
if (char === "[") bracketDepth++;
|
|
391
|
-
if (char === "]") bracketDepth = Math.max(0, bracketDepth - 1);
|
|
392
|
-
}
|
|
393
|
-
|
|
394
|
-
return null;
|
|
395
|
-
}
|
|
396
|
-
|
|
397
|
-
function trimTrailingSemicolon(value: string): string {
|
|
398
|
-
const trimmed = value.trim();
|
|
399
|
-
return trimmed.endsWith(";") ? trimmed.slice(0, -1).trim() : trimmed;
|
|
400
|
-
}
|
|
401
|
-
|
|
402
|
-
function jsxExpressionRendersClientComponents(
|
|
403
|
-
expression: string,
|
|
404
|
-
candidates: RouteLevelClientComponentImport[],
|
|
405
|
-
): RouteLevelClientComponentImport[] {
|
|
406
|
-
const expr = stripWrappingParentheses(expression.trim());
|
|
407
|
-
const seen = new Set<string>();
|
|
408
|
-
const rendered: RouteLevelClientComponentImport[] = [];
|
|
409
|
-
|
|
410
|
-
for (const candidate of candidates) {
|
|
411
|
-
const key = `${candidate.module}\0${candidate.localName}`;
|
|
412
|
-
if (seen.has(key)) continue;
|
|
413
|
-
if (!jsxExpressionContainsClientElement(expr, candidate.localName)) continue;
|
|
414
|
-
seen.add(key);
|
|
415
|
-
rendered.push(candidate);
|
|
416
|
-
}
|
|
417
|
-
|
|
418
|
-
return rendered;
|
|
419
|
-
}
|
|
420
|
-
|
|
421
|
-
function jsxExpressionContainsClientElement(expression: string, localName: string): boolean {
|
|
422
|
-
const escaped = escapeRegExp(localName);
|
|
423
|
-
return new RegExp(`<${escaped}(?:\\s|/|>)`).test(expression);
|
|
424
|
-
}
|
|
425
|
-
|
|
426
|
-
function stripWrappingParentheses(value: string): string {
|
|
427
|
-
let current = value.trim();
|
|
428
|
-
while (current.startsWith("(") && current.endsWith(")")) {
|
|
429
|
-
const close = findMatchingParen(current, 0);
|
|
430
|
-
if (close !== current.length - 1) break;
|
|
431
|
-
current = current.slice(1, -1).trim();
|
|
432
|
-
}
|
|
433
|
-
return current;
|
|
434
|
-
}
|
|
435
|
-
|
|
436
|
-
function findMatchingBrace(source: string, openIndex: number): number {
|
|
437
|
-
return findMatchingDelimiter(source, openIndex, "{", "}");
|
|
438
|
-
}
|
|
439
|
-
|
|
440
|
-
function findMatchingParen(source: string, openIndex: number): number {
|
|
441
|
-
return findMatchingDelimiter(source, openIndex, "(", ")");
|
|
442
|
-
}
|
|
443
|
-
|
|
444
|
-
function findMatchingDelimiter(source: string, openIndex: number, open: string, close: string): number {
|
|
445
|
-
let depth = 0;
|
|
446
|
-
let quote: '"' | "'" | "`" | null = null;
|
|
447
|
-
let lineComment = false;
|
|
448
|
-
let blockComment = false;
|
|
449
|
-
|
|
450
|
-
for (let i = openIndex; i < source.length; i++) {
|
|
451
|
-
const char = source[i];
|
|
452
|
-
const next = source[i + 1];
|
|
453
|
-
const prev = source[i - 1];
|
|
454
|
-
|
|
455
|
-
if (lineComment) {
|
|
456
|
-
if (char === "\n" || char === "\r") lineComment = false;
|
|
457
|
-
continue;
|
|
458
|
-
}
|
|
459
|
-
|
|
460
|
-
if (blockComment) {
|
|
461
|
-
if (char === "*" && next === "/") {
|
|
462
|
-
blockComment = false;
|
|
463
|
-
i++;
|
|
464
|
-
}
|
|
465
|
-
continue;
|
|
466
|
-
}
|
|
467
|
-
|
|
468
|
-
if (quote) {
|
|
469
|
-
if (char === quote && prev !== "\\") quote = null;
|
|
470
|
-
continue;
|
|
471
|
-
}
|
|
472
|
-
|
|
473
|
-
if (char === "/" && next === "/") {
|
|
474
|
-
lineComment = true;
|
|
475
|
-
i++;
|
|
476
|
-
continue;
|
|
477
|
-
}
|
|
478
|
-
if (char === "/" && next === "*") {
|
|
479
|
-
blockComment = true;
|
|
480
|
-
i++;
|
|
481
|
-
continue;
|
|
482
|
-
}
|
|
483
|
-
if (char === '"' || char === "'" || char === "`") {
|
|
484
|
-
quote = char;
|
|
485
|
-
continue;
|
|
486
|
-
}
|
|
487
|
-
|
|
488
|
-
if (char === open) depth++;
|
|
489
|
-
if (char === close) {
|
|
490
|
-
depth--;
|
|
491
|
-
if (depth === 0) return i;
|
|
492
|
-
}
|
|
493
|
-
}
|
|
494
|
-
|
|
495
|
-
return -1;
|
|
496
|
-
}
|
|
497
|
-
|
|
498
|
-
function escapeRegExp(value: string): string {
|
|
499
|
-
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
500
|
-
}
|
|
501
|
-
|
|
502
|
-
function isIdentifierChar(value: string): boolean {
|
|
503
|
-
return /[A-Za-z0-9_$]/.test(value);
|
|
504
|
-
}
|
|
264
|
+
function defaultExportRendersClientComponents(
|
|
265
|
+
renderedJsxNames: string[],
|
|
266
|
+
candidates: RouteLevelClientComponentImport[],
|
|
267
|
+
): RouteLevelClientComponentImport[] {
|
|
268
|
+
const renderedNames = new Set(renderedJsxNames);
|
|
269
|
+
const seen = new Set<string>();
|
|
270
|
+
const rendered: RouteLevelClientComponentImport[] = [];
|
|
271
|
+
|
|
272
|
+
for (const candidate of candidates) {
|
|
273
|
+
const key = `${candidate.module}\0${candidate.localName}`;
|
|
274
|
+
if (seen.has(key)) continue;
|
|
275
|
+
if (!renderedNames.has(candidate.localName)) continue;
|
|
276
|
+
seen.add(key);
|
|
277
|
+
rendered.push(candidate);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
return rendered;
|
|
281
|
+
}
|
|
505
282
|
|
|
506
283
|
export async function validateClientModuleForBrowserBundle(
|
|
507
284
|
route: RouteSpec,
|
|
@@ -2,6 +2,7 @@ import { describe, expect, it } from "bun:test";
|
|
|
2
2
|
import { mkdir, mkdtemp, rm, writeFile } from "fs/promises";
|
|
3
3
|
import path from "path";
|
|
4
4
|
import { generateManifest } from "./fs-routes";
|
|
5
|
+
import { scanRoutes } from "./fs-scanner";
|
|
5
6
|
|
|
6
7
|
const repoTempRoot = path.resolve(import.meta.dir, "../../../..", ".tmp-test-artifacts");
|
|
7
8
|
|
|
@@ -474,4 +475,58 @@ describe("generateManifest hydration config", () => {
|
|
|
474
475
|
await rm(rootDir, { recursive: true, force: true });
|
|
475
476
|
}
|
|
476
477
|
});
|
|
478
|
+
|
|
479
|
+
it("ignores hydration exports inside comments and strings", async () => {
|
|
480
|
+
const rootDir = await mkRepoTempDir("routes-hydration-ast-");
|
|
481
|
+
try {
|
|
482
|
+
await mkdir(path.join(rootDir, "app", "comment"), { recursive: true });
|
|
483
|
+
await writeFile(
|
|
484
|
+
path.join(rootDir, "app", "comment", "page.tsx"),
|
|
485
|
+
`
|
|
486
|
+
// export const hydration = { strategy: "island", priority: "immediate", preload: true };
|
|
487
|
+
const example = "export const hydration = 'full'";
|
|
488
|
+
|
|
489
|
+
export default function Page() {
|
|
490
|
+
return <main>{example}</main>;
|
|
491
|
+
}
|
|
492
|
+
`,
|
|
493
|
+
"utf-8",
|
|
494
|
+
);
|
|
495
|
+
|
|
496
|
+
const result = await generateManifest(rootDir);
|
|
497
|
+
const route = result.manifest.routes.find((entry) => entry.id === "comment");
|
|
498
|
+
|
|
499
|
+
expect(route?.hydration).toBeUndefined();
|
|
500
|
+
} finally {
|
|
501
|
+
await rm(rootDir, { recursive: true, force: true });
|
|
502
|
+
}
|
|
503
|
+
});
|
|
504
|
+
|
|
505
|
+
it("reports analyzer diagnostics for unsupported hydration initializers", async () => {
|
|
506
|
+
const rootDir = await mkRepoTempDir("routes-hydration-diagnostic-");
|
|
507
|
+
try {
|
|
508
|
+
await mkdir(path.join(rootDir, "app", "diagnostic"), { recursive: true });
|
|
509
|
+
await writeFile(
|
|
510
|
+
path.join(rootDir, "app", "diagnostic", "page.tsx"),
|
|
511
|
+
`
|
|
512
|
+
const getHydration = () => ({ strategy: "island" });
|
|
513
|
+
export const hydration = getHydration();
|
|
514
|
+
|
|
515
|
+
export default function Page() {
|
|
516
|
+
return <main>Diagnostic</main>;
|
|
517
|
+
}
|
|
518
|
+
`,
|
|
519
|
+
"utf-8",
|
|
520
|
+
);
|
|
521
|
+
|
|
522
|
+
const result = await scanRoutes(rootDir);
|
|
523
|
+
|
|
524
|
+
expect(result.errors).toContainEqual(expect.objectContaining({
|
|
525
|
+
type: "route_source_diagnostic",
|
|
526
|
+
message: expect.stringContaining("MANDU_ROUTE_HYDRATION_UNSUPPORTED_INITIALIZER"),
|
|
527
|
+
}));
|
|
528
|
+
} finally {
|
|
529
|
+
await rm(rootDir, { recursive: true, force: true });
|
|
530
|
+
}
|
|
531
|
+
});
|
|
477
532
|
});
|
package/src/router/fs-scanner.ts
CHANGED
|
@@ -36,6 +36,7 @@ import {
|
|
|
36
36
|
resolveClientImportModulePath,
|
|
37
37
|
resolveRouteLevelClientEntry,
|
|
38
38
|
} from "./client-entry";
|
|
39
|
+
import { analyzeRouteSource } from "./route-source-analyzer";
|
|
39
40
|
import {
|
|
40
41
|
assertNoClientBoundaryDiagnostics,
|
|
41
42
|
collectStaticImportSpecifiers,
|
|
@@ -45,44 +46,6 @@ import {
|
|
|
45
46
|
validateClientBoundaryServerOnlyImports,
|
|
46
47
|
} from "../bundler/client-boundary-transform";
|
|
47
48
|
|
|
48
|
-
const HYDRATION_STRATEGIES = new Set(["none", "island", "full", "progressive"]);
|
|
49
|
-
const HYDRATION_PRIORITIES = new Set(["immediate", "visible", "idle", "interaction"]);
|
|
50
|
-
|
|
51
|
-
function parsePageHydrationConfig(source: string): HydrationConfig | undefined {
|
|
52
|
-
const stringMatch = source.match(
|
|
53
|
-
/export\s+const\s+hydration\s*(?::[^=]+)?=\s*["'](none|island|full|progressive)["']/m,
|
|
54
|
-
);
|
|
55
|
-
if (stringMatch?.[1]) {
|
|
56
|
-
return {
|
|
57
|
-
strategy: stringMatch[1] as HydrationConfig["strategy"],
|
|
58
|
-
priority: "visible",
|
|
59
|
-
preload: false,
|
|
60
|
-
};
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
const objectMatch = source.match(
|
|
64
|
-
/export\s+const\s+hydration\s*(?::[^=]+)?=\s*\{([\s\S]*?)\}\s*;?/m,
|
|
65
|
-
);
|
|
66
|
-
const body = objectMatch?.[1];
|
|
67
|
-
if (!body) return undefined;
|
|
68
|
-
|
|
69
|
-
const strategyMatch = body.match(/\bstrategy\s*:\s*["']([^"']+)["']/);
|
|
70
|
-
const strategy = strategyMatch?.[1];
|
|
71
|
-
if (!strategy || !HYDRATION_STRATEGIES.has(strategy)) return undefined;
|
|
72
|
-
|
|
73
|
-
const priorityMatch = body.match(/\bpriority\s*:\s*["']([^"']+)["']/);
|
|
74
|
-
const priority = priorityMatch?.[1];
|
|
75
|
-
const preloadMatch = body.match(/\bpreload\s*:\s*(true|false)\b/);
|
|
76
|
-
|
|
77
|
-
return {
|
|
78
|
-
strategy: strategy as HydrationConfig["strategy"],
|
|
79
|
-
priority: HYDRATION_PRIORITIES.has(priority ?? "")
|
|
80
|
-
? (priority as HydrationConfig["priority"])
|
|
81
|
-
: "visible",
|
|
82
|
-
preload: preloadMatch?.[1] === "true",
|
|
83
|
-
};
|
|
84
|
-
}
|
|
85
|
-
|
|
86
49
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
87
50
|
// Scanner Class
|
|
88
51
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
@@ -427,8 +390,16 @@ export class FSScanner {
|
|
|
427
390
|
pageFileContent = null;
|
|
428
391
|
}
|
|
429
392
|
if (pageFileContent) {
|
|
430
|
-
|
|
431
|
-
|
|
393
|
+
const analysis = analyzeRouteSource(pageFileContent, modulePath);
|
|
394
|
+
hydration = analysis.hydrationConfig;
|
|
395
|
+
pageHasUseClient = analysis.directives.useClient;
|
|
396
|
+
for (const diagnostic of analysis.diagnostics) {
|
|
397
|
+
routeErrors.push({
|
|
398
|
+
type: "route_source_diagnostic",
|
|
399
|
+
message: `${diagnostic.code}: ${diagnostic.message}`,
|
|
400
|
+
filePath: file.absolutePath,
|
|
401
|
+
});
|
|
402
|
+
}
|
|
432
403
|
if (!pageHasUseClient) {
|
|
433
404
|
boundaries = await this.resolveClientBoundaries(
|
|
434
405
|
rootDir,
|
package/src/router/fs-types.ts
CHANGED
|
@@ -226,7 +226,13 @@ export interface ScanResult {
|
|
|
226
226
|
*/
|
|
227
227
|
export interface ScanError {
|
|
228
228
|
/** 에러 타입 */
|
|
229
|
-
type:
|
|
229
|
+
type:
|
|
230
|
+
| "invalid_segment"
|
|
231
|
+
| "duplicate_route"
|
|
232
|
+
| "file_read_error"
|
|
233
|
+
| "pattern_conflict"
|
|
234
|
+
| "hydration_shell_mismatch_risk"
|
|
235
|
+
| "route_source_diagnostic";
|
|
230
236
|
|
|
231
237
|
/** 에러 메시지 */
|
|
232
238
|
message: string;
|