@mandujs/core 0.54.12 → 0.54.14
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 +9 -1
- package/scripts/postinstall-lock.ts +153 -153
- package/src/a11y/run-audit.ts +15 -15
- package/src/agent/__tests__/context.test.ts +49 -1
- package/src/agent/context.ts +535 -535
- package/src/agent/index.ts +6 -6
- package/src/agent/plan.ts +282 -282
- package/src/agent/repair.ts +171 -171
- package/src/agent/sync.ts +200 -200
- package/src/agent/types.ts +8 -0
- package/src/agent/verify.ts +100 -2
- package/src/brain/doctor/analyzer.ts +7 -7
- package/src/bundler/__tests__/build-runner.ts +36 -16
- package/src/bundler/__tests__/cold-start.test.ts +60 -60
- package/src/bundler/__tests__/css.test.ts +20 -20
- package/src/bundler/__tests__/fast-refresh.test.ts +24 -17
- package/src/bundler/analyzer.ts +15 -15
- package/src/bundler/build.test.ts +136 -48
- package/src/bundler/build.ts +193 -122
- package/src/bundler/css.ts +42 -42
- package/src/bundler/manifest-schema.ts +21 -21
- package/src/bundler/plugins/__tests__/block-generated-imports.test.ts +13 -13
- package/src/bundler/plugins/block-generated-imports.ts +13 -13
- package/src/bundler/types.ts +31 -31
- package/src/client/island.ts +79 -79
- package/src/config/validate.ts +1 -1
- package/src/contract/schema.ts +7 -0
- package/src/deploy/inference/context.ts +82 -82
- package/src/devtools/client/components/panel/islands-panel.tsx +16 -16
- package/src/devtools/client/components/panel/panel-container.tsx +1 -1
- package/src/error/formatter.ts +10 -1
- package/src/experimental/index.ts +10 -0
- package/src/filling/context.ts +17 -17
- package/src/filling/filling.ts +22 -1
- package/src/filling/index.ts +15 -1
- package/src/generator/generate.ts +30 -30
- package/src/generator/index.ts +3 -3
- package/src/generator/templates.ts +210 -210
- package/src/guard/check.ts +9 -9
- package/src/guard/config-guard.ts +13 -13
- package/src/guard/fs-routes-policy.ts +51 -51
- package/src/guard/index.ts +11 -11
- package/src/index.ts +0 -10
- package/src/internal/index.ts +25 -0
- package/src/kitchen/api/file-api.ts +11 -11
- package/src/report/index.ts +1 -1
- package/src/resource/__tests__/generator.test.ts +6 -6
- package/src/resource/__tests__/schema.test.ts +14 -14
- package/src/resource/ddl/__tests__/emit.test.ts +165 -165
- package/src/resource/ddl/emit.ts +146 -146
- package/src/resource/generator-schema.ts +11 -11
- package/src/resource/generators/slot.ts +72 -72
- package/src/resource/schema.ts +21 -21
- package/src/router/client-entry.test.ts +84 -41
- package/src/router/client-entry.ts +156 -89
- package/src/router/fs-routes.test.ts +90 -0
- package/src/router/fs-routes.ts +37 -29
- package/src/router/fs-scanner.ts +81 -33
- package/src/router/fs-types.ts +8 -5
- package/src/runtime/__tests__/devtools-adapter.test.ts +68 -68
- package/src/runtime/__tests__/observability-lifecycle.test.ts +103 -103
- package/src/runtime/__tests__/page-render-response.test.ts +164 -103
- package/src/runtime/__tests__/request-middleware.test.ts +70 -70
- package/src/runtime/devtools-adapter.ts +68 -68
- package/src/runtime/escape.ts +34 -34
- package/src/runtime/image-feature.ts +15 -0
- package/src/runtime/observability-lifecycle.ts +290 -290
- package/src/runtime/page-render-response.ts +208 -106
- package/src/runtime/rate-limit.ts +231 -0
- package/src/runtime/request-middleware.ts +31 -31
- package/src/runtime/router.test.ts +4 -4
- package/src/runtime/router.ts +10 -12
- package/src/runtime/scheduler-lifecycle.ts +64 -0
- package/src/runtime/server.ts +148 -353
- package/src/runtime/ssr.ts +59 -59
- package/src/runtime/static-files.ts +289 -289
- package/src/runtime/streaming-ssr.ts +22 -22
- package/src/spec/schema.ts +4 -3
- package/src/watcher/__tests__/watcher.test.ts +59 -59
- package/src/watcher/watcher.ts +61 -61
|
@@ -1,14 +1,21 @@
|
|
|
1
|
-
import { describe, expect, it } from "bun:test";
|
|
2
|
-
import { mkdir, mkdtemp, rm, writeFile } from "fs/promises";
|
|
3
|
-
import path from "path";
|
|
1
|
+
import { describe, expect, it } from "bun:test";
|
|
2
|
+
import { mkdir, mkdtemp, rm, writeFile } from "fs/promises";
|
|
3
|
+
import path from "path";
|
|
4
4
|
import {
|
|
5
5
|
findClientComponentImports,
|
|
6
6
|
findRouteLevelClientComponentImport,
|
|
7
7
|
findRouteLevelClientComponentImports,
|
|
8
8
|
resolveRouteLevelClientEntryPath,
|
|
9
|
-
} from "./client-entry";
|
|
10
|
-
|
|
11
|
-
|
|
9
|
+
} from "./client-entry";
|
|
10
|
+
|
|
11
|
+
const repoTempRoot = path.resolve(import.meta.dir, "../../../..", ".tmp-test-artifacts");
|
|
12
|
+
|
|
13
|
+
async function mkRepoTempDir(prefix: string): Promise<string> {
|
|
14
|
+
await mkdir(repoTempRoot, { recursive: true });
|
|
15
|
+
return mkdtemp(path.join(repoTempRoot, prefix));
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
describe("findClientComponentImports", () => {
|
|
12
19
|
it("detects named .client imports for diagnostics", () => {
|
|
13
20
|
const imports = findClientComponentImports(`
|
|
14
21
|
import { LoginForm, SubmitButton as Button } from "@/client/widgets/login-form/LoginForm.client";
|
|
@@ -40,10 +47,11 @@ describe("findClientComponentImports", () => {
|
|
|
40
47
|
}
|
|
41
48
|
`);
|
|
42
49
|
|
|
43
|
-
expect(routeClient).toEqual({
|
|
44
|
-
module: "@/client/pages/login/LoginPage.client",
|
|
45
|
-
localName: "LoginPage",
|
|
46
|
-
|
|
50
|
+
expect(routeClient).toEqual({
|
|
51
|
+
module: "@/client/pages/login/LoginPage.client",
|
|
52
|
+
localName: "LoginPage",
|
|
53
|
+
exportName: "default",
|
|
54
|
+
});
|
|
47
55
|
});
|
|
48
56
|
|
|
49
57
|
it("promotes a fragment wrapper with head-only elements and a bare client component", () => {
|
|
@@ -58,10 +66,11 @@ describe("findClientComponentImports", () => {
|
|
|
58
66
|
}
|
|
59
67
|
`);
|
|
60
68
|
|
|
61
|
-
expect(routeClient).toEqual({
|
|
62
|
-
module: "@/client/pages/home/HomeApp.client",
|
|
63
|
-
localName: "HomeApp",
|
|
64
|
-
|
|
69
|
+
expect(routeClient).toEqual({
|
|
70
|
+
module: "@/client/pages/home/HomeApp.client",
|
|
71
|
+
localName: "HomeApp",
|
|
72
|
+
exportName: "default",
|
|
73
|
+
});
|
|
65
74
|
});
|
|
66
75
|
|
|
67
76
|
it("detects a named-imported client component when the page returns only that component", () => {
|
|
@@ -73,11 +82,28 @@ describe("findClientComponentImports", () => {
|
|
|
73
82
|
}
|
|
74
83
|
`);
|
|
75
84
|
|
|
76
|
-
expect(routeClient).toEqual({
|
|
77
|
-
module: "@/client/pages/notifications/NotificationsPage.client",
|
|
78
|
-
localName: "NotificationsPage",
|
|
79
|
-
|
|
80
|
-
|
|
85
|
+
expect(routeClient).toEqual({
|
|
86
|
+
module: "@/client/pages/notifications/NotificationsPage.client",
|
|
87
|
+
localName: "NotificationsPage",
|
|
88
|
+
exportName: "NotificationsPage",
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("preserves the source export name for aliased named imports", () => {
|
|
93
|
+
const routeClient = findRouteLevelClientComponentImport(`
|
|
94
|
+
import { NotificationsPage as PageIsland } from "@/client/pages/notifications/NotificationsPage.client";
|
|
95
|
+
|
|
96
|
+
export default function Page() {
|
|
97
|
+
return <PageIsland />;
|
|
98
|
+
}
|
|
99
|
+
`);
|
|
100
|
+
|
|
101
|
+
expect(routeClient).toEqual({
|
|
102
|
+
module: "@/client/pages/notifications/NotificationsPage.client",
|
|
103
|
+
localName: "PageIsland",
|
|
104
|
+
exportName: "NotificationsPage",
|
|
105
|
+
});
|
|
106
|
+
});
|
|
81
107
|
|
|
82
108
|
it("promotes embedded client imports inside a larger server page", () => {
|
|
83
109
|
const routeClient = findRouteLevelClientComponentImport(`
|
|
@@ -91,10 +117,11 @@ describe("findClientComponentImports", () => {
|
|
|
91
117
|
}
|
|
92
118
|
`);
|
|
93
119
|
|
|
94
|
-
expect(routeClient).toEqual({
|
|
95
|
-
module: "@/client/pages/home/HomeApp.client",
|
|
96
|
-
localName: "HomeApp",
|
|
97
|
-
|
|
120
|
+
expect(routeClient).toEqual({
|
|
121
|
+
module: "@/client/pages/home/HomeApp.client",
|
|
122
|
+
localName: "HomeApp",
|
|
123
|
+
exportName: "default",
|
|
124
|
+
});
|
|
98
125
|
});
|
|
99
126
|
|
|
100
127
|
it("promotes client imports when the page wrapper passes props", () => {
|
|
@@ -106,10 +133,11 @@ describe("findClientComponentImports", () => {
|
|
|
106
133
|
}
|
|
107
134
|
`);
|
|
108
135
|
|
|
109
|
-
expect(routeClient).toEqual({
|
|
110
|
-
module: "@/client/pages/pledges/PledgePage.client",
|
|
111
|
-
localName: "PledgePage",
|
|
112
|
-
|
|
136
|
+
expect(routeClient).toEqual({
|
|
137
|
+
module: "@/client/pages/pledges/PledgePage.client",
|
|
138
|
+
localName: "PledgePage",
|
|
139
|
+
exportName: "default",
|
|
140
|
+
});
|
|
113
141
|
});
|
|
114
142
|
|
|
115
143
|
it("detects multiple rendered client imports in a server shell", () => {
|
|
@@ -130,14 +158,16 @@ describe("findClientComponentImports", () => {
|
|
|
130
158
|
`);
|
|
131
159
|
|
|
132
160
|
expect(routeClients).toEqual([
|
|
133
|
-
{
|
|
134
|
-
module: "@/client/widgets/comments-section/CommentsSection.client",
|
|
135
|
-
localName: "CommentsSection",
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
161
|
+
{
|
|
162
|
+
module: "@/client/widgets/comments-section/CommentsSection.client",
|
|
163
|
+
localName: "CommentsSection",
|
|
164
|
+
exportName: "CommentsSection",
|
|
165
|
+
},
|
|
166
|
+
{
|
|
167
|
+
module: "@/client/widgets/pledge-actions/PledgeActions.client",
|
|
168
|
+
localName: "PledgeActions",
|
|
169
|
+
exportName: "PledgeActions",
|
|
170
|
+
},
|
|
141
171
|
]);
|
|
142
172
|
});
|
|
143
173
|
|
|
@@ -150,14 +180,27 @@ describe("findClientComponentImports", () => {
|
|
|
150
180
|
}
|
|
151
181
|
`);
|
|
152
182
|
|
|
153
|
-
expect(routeClient).toEqual({
|
|
154
|
-
module: "@/client/widgets/pledge-form/PledgeForm",
|
|
155
|
-
localName: "PledgeForm",
|
|
156
|
-
|
|
183
|
+
expect(routeClient).toEqual({
|
|
184
|
+
module: "@/client/widgets/pledge-form/PledgeForm",
|
|
185
|
+
localName: "PledgeForm",
|
|
186
|
+
exportName: "PledgeForm",
|
|
187
|
+
});
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it("does not promote shared UI primitives to route-level client entries", () => {
|
|
191
|
+
const routeClient = findRouteLevelClientComponentImport(`
|
|
192
|
+
import { Button } from "@/client/shared/ui/button";
|
|
193
|
+
|
|
194
|
+
export default function Page() {
|
|
195
|
+
return <main><Button>Open</Button></main>;
|
|
196
|
+
}
|
|
197
|
+
`);
|
|
198
|
+
|
|
199
|
+
expect(routeClient).toBeNull();
|
|
157
200
|
});
|
|
158
201
|
|
|
159
|
-
it("resolves a route-level client entry by reading a use client target without .client in the path", async () => {
|
|
160
|
-
const rootDir = await
|
|
202
|
+
it("resolves a route-level client entry by reading a use client target without .client in the path", async () => {
|
|
203
|
+
const rootDir = await mkRepoTempDir("client-entry-");
|
|
161
204
|
try {
|
|
162
205
|
await mkdir(path.join(rootDir, "app", "pledges", "new"), { recursive: true });
|
|
163
206
|
await mkdir(path.join(rootDir, "src", "client", "widgets", "pledge-form"), { recursive: true });
|
|
@@ -8,12 +8,23 @@ export interface ClientComponentImport {
|
|
|
8
8
|
names: string[];
|
|
9
9
|
}
|
|
10
10
|
|
|
11
|
-
export interface RouteLevelClientComponentImport {
|
|
12
|
-
module: string;
|
|
13
|
-
localName: string;
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
11
|
+
export interface RouteLevelClientComponentImport {
|
|
12
|
+
module: string;
|
|
13
|
+
localName: string;
|
|
14
|
+
exportName: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const CLIENT_ENTRY_SPECIFIER_PATTERN = /\.(?:client|island)(?:\.[tj]sx?)?$/;
|
|
18
|
+
const DEFAULT_EXPORT_NAME = "default";
|
|
19
|
+
|
|
20
|
+
interface ComponentImportSpecifier {
|
|
21
|
+
importedName: string;
|
|
22
|
+
localName: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
interface ComponentImportRecord extends ClientComponentImport {
|
|
26
|
+
specifiers: ComponentImportSpecifier[];
|
|
27
|
+
}
|
|
17
28
|
|
|
18
29
|
export function normalizeRouteModulePath(value: string | undefined): string {
|
|
19
30
|
return (value ?? "").replace(/\\/g, "/").replace(/^\.\//, "");
|
|
@@ -45,45 +56,64 @@ async function readRouteModule(rootDir: string, modulePath: string): Promise<str
|
|
|
45
56
|
}
|
|
46
57
|
}
|
|
47
58
|
|
|
48
|
-
export function findClientComponentImports(source: string): ClientComponentImport[] {
|
|
49
|
-
return
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
59
|
+
export function findClientComponentImports(source: string): ClientComponentImport[] {
|
|
60
|
+
return findComponentImportRecords(source)
|
|
61
|
+
.filter((entry) => clientSpecifierLooksBrowserOnly(entry.module))
|
|
62
|
+
.map(toPublicClientComponentImport);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function toPublicClientComponentImport(entry: ComponentImportRecord): ClientComponentImport {
|
|
66
|
+
return {
|
|
67
|
+
module: entry.module,
|
|
68
|
+
kind: entry.kind,
|
|
69
|
+
names: entry.names,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function findComponentImportRecords(source: string): ComponentImportRecord[] {
|
|
74
|
+
const imports: ComponentImportRecord[] = [];
|
|
75
|
+
const importFromPattern = /import\s+(?!type\b)([\s\S]*?)\s+from\s+["']([^"']+)["']/g;
|
|
76
|
+
const sideEffectPattern = /import\s+["']([^"']+)["']/g;
|
|
56
77
|
|
|
57
78
|
for (const match of source.matchAll(importFromPattern)) {
|
|
58
79
|
const clause = (match[1] ?? "").trim();
|
|
59
|
-
const module = match[2] ?? "";
|
|
60
|
-
const names: string[] = [];
|
|
61
|
-
|
|
62
|
-
let
|
|
63
|
-
let
|
|
80
|
+
const module = match[2] ?? "";
|
|
81
|
+
const names: string[] = [];
|
|
82
|
+
const specifiers: ComponentImportSpecifier[] = [];
|
|
83
|
+
let hasDefault = false;
|
|
84
|
+
let hasNamed = false;
|
|
85
|
+
let hasNamespace = false;
|
|
64
86
|
|
|
65
87
|
const namedMatch = clause.match(/\{([^}]*)\}/);
|
|
66
88
|
if (namedMatch) {
|
|
67
89
|
hasNamed = true;
|
|
68
90
|
for (const rawName of namedMatch[1].split(",")) {
|
|
69
|
-
const name = rawName.trim();
|
|
70
|
-
if (!name) continue;
|
|
71
|
-
const parts = name.split(/\s+as\s+/i).map((part) => part.trim()).filter(Boolean);
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
}
|
|
91
|
+
const name = rawName.trim();
|
|
92
|
+
if (!name) continue;
|
|
93
|
+
const parts = name.split(/\s+as\s+/i).map((part) => part.trim()).filter(Boolean);
|
|
94
|
+
const importedName = parts[0] ?? "";
|
|
95
|
+
const localName = parts[1] ?? importedName;
|
|
96
|
+
names.push(localName);
|
|
97
|
+
specifiers.push({ importedName, localName });
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (/\*\s+as\s+/.test(clause)) {
|
|
102
|
+
hasNamespace = true;
|
|
103
|
+
const namespaceMatch = clause.match(/\*\s+as\s+([A-Za-z_$][A-Za-z0-9_$]*)/);
|
|
104
|
+
if (namespaceMatch?.[1]) {
|
|
105
|
+
names.push(namespaceMatch[1]);
|
|
106
|
+
specifiers.push({ importedName: "*", localName: namespaceMatch[1] });
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const beforeNamed = clause.split("{")[0]?.replace(/,\s*$/, "").trim() ?? "";
|
|
111
|
+
if (beforeNamed && !beforeNamed.startsWith("*")) {
|
|
112
|
+
hasDefault = true;
|
|
113
|
+
const localName = beforeNamed.split(",")[0].trim();
|
|
114
|
+
names.push(localName);
|
|
115
|
+
specifiers.push({ importedName: DEFAULT_EXPORT_NAME, localName });
|
|
116
|
+
}
|
|
87
117
|
|
|
88
118
|
const kind =
|
|
89
119
|
(hasDefault && (hasNamed || hasNamespace))
|
|
@@ -94,33 +124,51 @@ function findComponentImports(source: string): ClientComponentImport[] {
|
|
|
94
124
|
? "namespace"
|
|
95
125
|
: "default";
|
|
96
126
|
|
|
97
|
-
imports.push({ module, kind, names });
|
|
98
|
-
}
|
|
127
|
+
imports.push({ module, kind, names, specifiers });
|
|
128
|
+
}
|
|
99
129
|
|
|
100
130
|
for (const match of source.matchAll(sideEffectPattern)) {
|
|
101
131
|
const module = match[1] ?? "";
|
|
102
132
|
if (imports.some((entry) => entry.module === module)) continue;
|
|
103
|
-
imports.push({ module, kind: "side-effect", names: [] });
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
return imports;
|
|
133
|
+
imports.push({ module, kind: "side-effect", names: [], specifiers: [] });
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return imports;
|
|
107
137
|
}
|
|
108
138
|
|
|
109
139
|
export function findRouteLevelClientComponentImport(source: string): RouteLevelClientComponentImport | null {
|
|
110
140
|
return findRouteLevelClientComponentImports(source)[0] ?? null;
|
|
111
141
|
}
|
|
112
142
|
|
|
113
|
-
export function findRouteLevelClientComponentImports(source: string): RouteLevelClientComponentImport[] {
|
|
114
|
-
const candidates =
|
|
115
|
-
entry.
|
|
116
|
-
.
|
|
117
|
-
|
|
118
|
-
|
|
143
|
+
export function findRouteLevelClientComponentImports(source: string): RouteLevelClientComponentImport[] {
|
|
144
|
+
const candidates = findComponentImportRecords(source).flatMap((entry) =>
|
|
145
|
+
isRouteLevelClientEntrySpecifier(entry.module)
|
|
146
|
+
? entry.specifiers
|
|
147
|
+
.filter((specifier) => /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(specifier.localName))
|
|
148
|
+
.map((specifier) => ({
|
|
149
|
+
module: entry.module,
|
|
150
|
+
localName: specifier.localName,
|
|
151
|
+
exportName: specifier.importedName,
|
|
152
|
+
}))
|
|
153
|
+
: []
|
|
154
|
+
);
|
|
119
155
|
|
|
120
156
|
if (candidates.length === 0) return [];
|
|
121
157
|
return defaultExportRendersClientComponents(source, candidates);
|
|
122
158
|
}
|
|
123
159
|
|
|
160
|
+
function isRouteLevelClientEntrySpecifier(specifier: string): boolean {
|
|
161
|
+
const normalized = specifier.replace(/\\/g, "/");
|
|
162
|
+
if (
|
|
163
|
+
normalized.includes("/client/shared/") ||
|
|
164
|
+
normalized.startsWith("@/client/shared/") ||
|
|
165
|
+
normalized.startsWith("src/client/shared/")
|
|
166
|
+
) {
|
|
167
|
+
return false;
|
|
168
|
+
}
|
|
169
|
+
return true;
|
|
170
|
+
}
|
|
171
|
+
|
|
124
172
|
export async function resolveClientImportModulePath(
|
|
125
173
|
rootDir: string,
|
|
126
174
|
importerModule: string,
|
|
@@ -138,40 +186,58 @@ export async function resolveClientImportModulePath(
|
|
|
138
186
|
return null;
|
|
139
187
|
}
|
|
140
188
|
|
|
141
|
-
export async function resolveRouteLevelClientEntryPath(
|
|
142
|
-
rootDir: string,
|
|
143
|
-
routeModule: string,
|
|
144
|
-
source: string,
|
|
145
|
-
): Promise<string | null> {
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
189
|
+
export async function resolveRouteLevelClientEntryPath(
|
|
190
|
+
rootDir: string,
|
|
191
|
+
routeModule: string,
|
|
192
|
+
source: string,
|
|
193
|
+
): Promise<string | null> {
|
|
194
|
+
return (await resolveRouteLevelClientEntry(rootDir, routeModule, source))?.modulePath ?? null;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
export async function resolveRouteLevelClientEntry(
|
|
198
|
+
rootDir: string,
|
|
199
|
+
routeModule: string,
|
|
200
|
+
source: string,
|
|
201
|
+
): Promise<{ modulePath: string; exportName: string } | null> {
|
|
202
|
+
const routeLevelClientImports = findRouteLevelClientComponentImports(source);
|
|
203
|
+
for (const routeLevelClientImport of routeLevelClientImports) {
|
|
204
|
+
const resolved = await resolveClientImportModulePath(rootDir, routeModule, routeLevelClientImport.module);
|
|
205
|
+
if (!resolved) continue;
|
|
206
|
+
if (clientSpecifierLooksBrowserOnly(routeLevelClientImport.module)) {
|
|
207
|
+
return { modulePath: resolved, exportName: routeLevelClientImport.exportName };
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const importedSource = await readRouteModule(rootDir, resolved);
|
|
211
|
+
if (importedSource !== null && hasUseClientDirective(importedSource)) {
|
|
212
|
+
return { modulePath: resolved, exportName: routeLevelClientImport.exportName };
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
return null;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
export async function shouldPreserveExistingClientModule(
|
|
220
|
+
route: RouteSpec,
|
|
221
|
+
clientModule: string,
|
|
222
|
+
rootDir: string,
|
|
223
|
+
): Promise<boolean> {
|
|
224
|
+
if (!isRouteLevelClientEntrySpecifier(clientModule)) return false;
|
|
225
|
+
const source = await readRouteModule(rootDir, clientModule);
|
|
226
|
+
if (source === null) return false;
|
|
227
|
+
if (hasUseServerDirective(source)) return false;
|
|
228
|
+
if (clientModuleIsRouteComponent(route, clientModule)) {
|
|
229
|
+
if (hasUseClientDirective(source)) return true;
|
|
230
|
+
return await routeComponentHasResolvableClientEntry(rootDir, clientModule, source);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
if (route.kind !== "page" || !route.componentModule) return false;
|
|
234
|
+
|
|
235
|
+
const routeSource = await readRouteModule(rootDir, route.componentModule);
|
|
236
|
+
if (routeSource === null) return false;
|
|
237
|
+
|
|
238
|
+
const currentEntry = await resolveRouteLevelClientEntry(rootDir, route.componentModule, routeSource);
|
|
239
|
+
return normalizeRouteModulePath(currentEntry?.modulePath) === normalizeRouteModulePath(clientModule);
|
|
240
|
+
}
|
|
175
241
|
|
|
176
242
|
function resolveImportBasePath(rootDir: string, importerModule: string, specifier: string): string | null {
|
|
177
243
|
const normalized = specifier.replace(/\\/g, "/");
|
|
@@ -446,12 +512,13 @@ export async function validateClientModuleForBrowserBundle(
|
|
|
446
512
|
return `[${route.id}] Client module "${route.clientModule}" has a "use server" directive and cannot be bundled for the browser.`;
|
|
447
513
|
}
|
|
448
514
|
|
|
449
|
-
if (clientModuleIsRouteComponent(route) && !hasUseClientDirective(source)) {
|
|
450
|
-
const realClientEntry = await
|
|
451
|
-
if (realClientEntry) {
|
|
452
|
-
route.clientModule = realClientEntry;
|
|
453
|
-
|
|
454
|
-
|
|
515
|
+
if (clientModuleIsRouteComponent(route) && !hasUseClientDirective(source)) {
|
|
516
|
+
const realClientEntry = await resolveRouteLevelClientEntry(rootDir, route.clientModule, source);
|
|
517
|
+
if (realClientEntry) {
|
|
518
|
+
route.clientModule = realClientEntry.modulePath;
|
|
519
|
+
route.clientExportName = realClientEntry.exportName;
|
|
520
|
+
return null;
|
|
521
|
+
}
|
|
455
522
|
return (
|
|
456
523
|
`[${route.id}] Route component "${route.clientModule}" is configured as clientModule, ` +
|
|
457
524
|
`but it is a server page (missing "use client"). Mandu will not bundle server pages into client islands. ` +
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test";
|
|
2
|
+
import { mkdir, mkdtemp, rm, writeFile } from "fs/promises";
|
|
3
|
+
import path from "path";
|
|
4
|
+
import { generateManifest } from "./fs-routes";
|
|
5
|
+
|
|
6
|
+
const repoTempRoot = path.resolve(import.meta.dir, "../../../..", ".tmp-test-artifacts");
|
|
7
|
+
|
|
8
|
+
async function mkRepoTempDir(prefix: string): Promise<string> {
|
|
9
|
+
await mkdir(repoTempRoot, { recursive: true });
|
|
10
|
+
return mkdtemp(path.join(repoTempRoot, prefix));
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
describe("generateManifest hydration config", () => {
|
|
14
|
+
it("does not preserve stale island hydration after the client entry disappears", async () => {
|
|
15
|
+
const rootDir = await mkRepoTempDir("routes-hydration-stale-");
|
|
16
|
+
try {
|
|
17
|
+
await mkdir(path.join(rootDir, "app", "candidates", "[id]"), { recursive: true });
|
|
18
|
+
await mkdir(path.join(rootDir, "src", "client", "widgets"), { recursive: true });
|
|
19
|
+
await writeFile(
|
|
20
|
+
path.join(rootDir, "app", "candidates", "[id]", "page.tsx"),
|
|
21
|
+
`
|
|
22
|
+
import { PledgeAccordion } from "@/client/widgets/PledgeAccordion.client";
|
|
23
|
+
export default function Page() {
|
|
24
|
+
return <main><PledgeAccordion pledges={[]} /></main>;
|
|
25
|
+
}
|
|
26
|
+
`,
|
|
27
|
+
"utf-8",
|
|
28
|
+
);
|
|
29
|
+
await writeFile(
|
|
30
|
+
path.join(rootDir, "src", "client", "widgets", "PledgeAccordion.client.tsx"),
|
|
31
|
+
`
|
|
32
|
+
"use client";
|
|
33
|
+
export function PledgeAccordion() {
|
|
34
|
+
return <div />;
|
|
35
|
+
}
|
|
36
|
+
`,
|
|
37
|
+
"utf-8",
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
const first = await generateManifest(rootDir);
|
|
41
|
+
const firstRoute = first.manifest.routes.find((route) => route.id === "candidates-$id");
|
|
42
|
+
expect(firstRoute?.clientModule).toContain("PledgeAccordion.client.tsx");
|
|
43
|
+
expect(firstRoute?.hydration?.strategy).toBe("island");
|
|
44
|
+
|
|
45
|
+
await writeFile(
|
|
46
|
+
path.join(rootDir, "app", "candidates", "[id]", "page.tsx"),
|
|
47
|
+
`
|
|
48
|
+
export default function Page() {
|
|
49
|
+
return <main><details><summary>server only</summary></details></main>;
|
|
50
|
+
}
|
|
51
|
+
`,
|
|
52
|
+
"utf-8",
|
|
53
|
+
);
|
|
54
|
+
|
|
55
|
+
const second = await generateManifest(rootDir);
|
|
56
|
+
const secondRoute = second.manifest.routes.find((route) => route.id === "candidates-$id");
|
|
57
|
+
expect(secondRoute?.clientModule).toBeUndefined();
|
|
58
|
+
expect(secondRoute?.hydration?.strategy).not.toBe("island");
|
|
59
|
+
} finally {
|
|
60
|
+
await rm(rootDir, { recursive: true, force: true });
|
|
61
|
+
}
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("reads page-level hydration exports from the current page source", async () => {
|
|
65
|
+
const rootDir = await mkRepoTempDir("routes-hydration-export-");
|
|
66
|
+
try {
|
|
67
|
+
await mkdir(path.join(rootDir, "app", "about"), { recursive: true });
|
|
68
|
+
await writeFile(
|
|
69
|
+
path.join(rootDir, "app", "about", "page.tsx"),
|
|
70
|
+
`
|
|
71
|
+
export const hydration = { strategy: "none", priority: "idle", preload: true };
|
|
72
|
+
export default function Page() {
|
|
73
|
+
return <main>About</main>;
|
|
74
|
+
}
|
|
75
|
+
`,
|
|
76
|
+
"utf-8",
|
|
77
|
+
);
|
|
78
|
+
|
|
79
|
+
const result = await generateManifest(rootDir);
|
|
80
|
+
const route = result.manifest.routes.find((entry) => entry.id === "about");
|
|
81
|
+
expect(route?.hydration).toEqual({
|
|
82
|
+
strategy: "none",
|
|
83
|
+
priority: "idle",
|
|
84
|
+
preload: true,
|
|
85
|
+
});
|
|
86
|
+
} finally {
|
|
87
|
+
await rm(rootDir, { recursive: true, force: true });
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
});
|
package/src/router/fs-routes.ts
CHANGED
|
@@ -14,11 +14,11 @@ import { DEFAULT_SCANNER_CONFIG } from "./fs-types";
|
|
|
14
14
|
import { scanRoutes } from "./fs-scanner";
|
|
15
15
|
import { loadManduConfig } from "../config";
|
|
16
16
|
import type { ManduPlugin, ManduHooks } from "../plugins/hooks";
|
|
17
|
-
import {
|
|
18
|
-
runOnRouteRegistered,
|
|
19
|
-
runOnManifestBuilt,
|
|
20
|
-
} from "../plugins/runner";
|
|
21
|
-
import { shouldPreserveExistingClientModule } from "./client-entry";
|
|
17
|
+
import {
|
|
18
|
+
runOnRouteRegistered,
|
|
19
|
+
runOnManifestBuilt,
|
|
20
|
+
} from "../plugins/runner";
|
|
21
|
+
import { shouldPreserveExistingClientModule } from "./client-entry";
|
|
22
22
|
|
|
23
23
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
24
24
|
// Types
|
|
@@ -87,16 +87,19 @@ export function fsRouteToRouteSpec(fsRoute: FSRouteConfig): RouteSpec {
|
|
|
87
87
|
...base,
|
|
88
88
|
kind: "page" as const,
|
|
89
89
|
componentModule: normalizePath(fsRoute.componentModule ?? ""),
|
|
90
|
-
...(fsRoute.clientModule
|
|
91
|
-
? {
|
|
92
|
-
clientModule: normalizePath(fsRoute.clientModule),
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
90
|
+
...(fsRoute.clientModule
|
|
91
|
+
? {
|
|
92
|
+
clientModule: normalizePath(fsRoute.clientModule),
|
|
93
|
+
...(fsRoute.clientExportName ? { clientExportName: fsRoute.clientExportName } : {}),
|
|
94
|
+
hydration: fsRoute.hydration ?? {
|
|
95
|
+
strategy: "island" as const,
|
|
96
|
+
priority: "immediate" as const,
|
|
97
|
+
preload: false,
|
|
98
|
+
},
|
|
99
|
+
}
|
|
100
|
+
: fsRoute.hydration
|
|
101
|
+
? { hydration: fsRoute.hydration }
|
|
102
|
+
: {}),
|
|
100
103
|
...(fsRoute.layoutChain && fsRoute.layoutChain.length > 0
|
|
101
104
|
? { layoutChain: fsRoute.layoutChain.map(normalizePath) }
|
|
102
105
|
: {}),
|
|
@@ -309,20 +312,25 @@ export async function generateManifest(
|
|
|
309
312
|
for (const route of manifest.routes) {
|
|
310
313
|
const prev = existingMap.get(route.id);
|
|
311
314
|
if (!prev) continue;
|
|
312
|
-
// 사용자가 설정한 clientModule/hydration 보존.
|
|
313
|
-
// If app/page.tsx used to be a client page and later becomes a server
|
|
314
|
-
// page, preserving the old clientModule would leak the server graph into
|
|
315
|
-
// the browser bundle.
|
|
316
|
-
if (
|
|
317
|
-
prev.clientModule &&
|
|
318
|
-
!route.clientModule &&
|
|
319
|
-
await shouldPreserveExistingClientModule(route, prev.clientModule, rootDir)
|
|
320
|
-
) {
|
|
321
|
-
route.clientModule = prev.clientModule;
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
315
|
+
// 사용자가 설정한 clientModule/hydration 보존.
|
|
316
|
+
// If app/page.tsx used to be a client page and later becomes a server
|
|
317
|
+
// page, preserving the old clientModule would leak the server graph into
|
|
318
|
+
// the browser bundle.
|
|
319
|
+
if (
|
|
320
|
+
prev.clientModule &&
|
|
321
|
+
!route.clientModule &&
|
|
322
|
+
await shouldPreserveExistingClientModule(route, prev.clientModule, rootDir)
|
|
323
|
+
) {
|
|
324
|
+
route.clientModule = prev.clientModule;
|
|
325
|
+
route.clientExportName = prev.clientExportName;
|
|
326
|
+
}
|
|
327
|
+
if (prev.hydration && !route.hydration) {
|
|
328
|
+
const canPreserveHydration =
|
|
329
|
+
!!route.clientModule || prev.hydration.strategy === "none";
|
|
330
|
+
if (canPreserveHydration) {
|
|
331
|
+
route.hydration = prev.hydration;
|
|
332
|
+
}
|
|
333
|
+
}
|
|
326
334
|
// Issue #214 — preserve prerender-time fields (`dynamicParams`,
|
|
327
335
|
// `staticParams`) across manifest rescans. `mandu build`'s prerender
|
|
328
336
|
// phase stamps these onto the manifest; a subsequent `mandu dev`
|