@mandujs/core 0.54.13 → 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 +1 -1
- package/src/bundler/__tests__/build-runner.ts +31 -12
- package/src/bundler/__tests__/fast-refresh.test.ts +24 -17
- package/src/bundler/build.test.ts +71 -34
- package/src/bundler/build.ts +28 -19
- package/src/router/client-entry.test.ts +15 -8
- package/src/router/client-entry.ts +22 -15
- package/src/router/fs-routes.test.ts +90 -0
- package/src/router/fs-routes.ts +13 -7
- package/src/router/fs-scanner.ts +60 -16
- package/src/runtime/__tests__/page-render-response.test.ts +61 -0
- package/src/runtime/page-render-response.ts +106 -4
- package/src/runtime/router.test.ts +4 -4
- package/src/runtime/router.ts +10 -12
- package/src/runtime/server.ts +121 -58
package/package.json
CHANGED
|
@@ -100,11 +100,11 @@ const manifest: RoutesManifest = mode === "server-page-client-module"
|
|
|
100
100
|
},
|
|
101
101
|
],
|
|
102
102
|
}
|
|
103
|
-
: mode === "hydration-no-client-module"
|
|
104
|
-
? {
|
|
105
|
-
version: 1,
|
|
106
|
-
routes: [
|
|
107
|
-
{
|
|
103
|
+
: mode === "hydration-no-client-module"
|
|
104
|
+
? {
|
|
105
|
+
version: 1,
|
|
106
|
+
routes: [
|
|
107
|
+
{
|
|
108
108
|
id: "login",
|
|
109
109
|
kind: "page",
|
|
110
110
|
pattern: "/login",
|
|
@@ -115,13 +115,32 @@ const manifest: RoutesManifest = mode === "server-page-client-module"
|
|
|
115
115
|
priority: "immediate",
|
|
116
116
|
preload: false,
|
|
117
117
|
},
|
|
118
|
-
},
|
|
119
|
-
],
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
118
|
+
},
|
|
119
|
+
],
|
|
120
|
+
}
|
|
121
|
+
: mode === "i18n-locale-route-id"
|
|
122
|
+
? {
|
|
123
|
+
version: 1,
|
|
124
|
+
routes: [
|
|
125
|
+
{
|
|
126
|
+
id: "ko::demo",
|
|
127
|
+
kind: "page",
|
|
128
|
+
pattern: "/ko",
|
|
129
|
+
module: "app/page.tsx",
|
|
130
|
+
componentModule: "app/page.tsx",
|
|
131
|
+
clientModule: "app/demo.client.tsx",
|
|
132
|
+
hydration: {
|
|
133
|
+
strategy: "island",
|
|
134
|
+
priority: "visible",
|
|
135
|
+
preload: false,
|
|
136
|
+
},
|
|
137
|
+
},
|
|
138
|
+
],
|
|
139
|
+
}
|
|
140
|
+
: {
|
|
141
|
+
version: 1,
|
|
142
|
+
routes: [
|
|
143
|
+
{
|
|
125
144
|
id: "demo",
|
|
126
145
|
kind: "page",
|
|
127
146
|
pattern: "/",
|
|
@@ -19,9 +19,9 @@
|
|
|
19
19
|
* packages/core/src/runtime/fast-refresh-runtime.ts
|
|
20
20
|
*/
|
|
21
21
|
|
|
22
|
-
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
23
|
-
import { mkdtemp, mkdir, rm, writeFile, readFile } from "fs/promises";
|
|
24
|
-
import path from "path";
|
|
22
|
+
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
23
|
+
import { mkdtemp, mkdir, rm, writeFile, readFile } from "fs/promises";
|
|
24
|
+
import path from "path";
|
|
25
25
|
|
|
26
26
|
import {
|
|
27
27
|
appendBoundary,
|
|
@@ -40,12 +40,19 @@ import {
|
|
|
40
40
|
_isRefreshScheduledForTests,
|
|
41
41
|
type ReactRefreshRuntime,
|
|
42
42
|
} from "../../runtime/fast-refresh-runtime";
|
|
43
|
-
import {
|
|
44
|
-
createManduHot,
|
|
45
|
-
dispatchReplacement,
|
|
46
|
-
_resetRegistryForTests,
|
|
47
|
-
} from "../../runtime/hmr-client";
|
|
48
|
-
import { generateFastRefreshPreamble } from "../dev";
|
|
43
|
+
import {
|
|
44
|
+
createManduHot,
|
|
45
|
+
dispatchReplacement,
|
|
46
|
+
_resetRegistryForTests,
|
|
47
|
+
} from "../../runtime/hmr-client";
|
|
48
|
+
import { generateFastRefreshPreamble } from "../dev";
|
|
49
|
+
|
|
50
|
+
const repoTempRoot = path.resolve(import.meta.dir, "../../../../..", ".tmp-test-artifacts");
|
|
51
|
+
|
|
52
|
+
async function mkRepoTempDir(prefix: string): Promise<string> {
|
|
53
|
+
await mkdir(repoTempRoot, { recursive: true });
|
|
54
|
+
return mkdtemp(path.join(repoTempRoot, prefix));
|
|
55
|
+
}
|
|
49
56
|
|
|
50
57
|
// ═══════════════════════════════════════════════════════════════════
|
|
51
58
|
// Section A — plugin pure unit tests
|
|
@@ -263,10 +270,10 @@ describe("manduHMR — __MANDU_HMR__ global behavior", () => {
|
|
|
263
270
|
describe.skipIf(process.env.MANDU_SKIP_BUNDLER_TESTS === "1")(
|
|
264
271
|
"Bun.build({ reactFastRefresh: true }) + fastRefreshPlugin()",
|
|
265
272
|
() => {
|
|
266
|
-
let rootDir: string;
|
|
267
|
-
|
|
268
|
-
beforeAll(async () => {
|
|
269
|
-
rootDir = await
|
|
273
|
+
let rootDir: string;
|
|
274
|
+
|
|
275
|
+
beforeAll(async () => {
|
|
276
|
+
rootDir = await mkRepoTempDir("fr-build-");
|
|
270
277
|
// Three source files — one boundary, one plain, one .island.tsx —
|
|
271
278
|
// give us the minimal matrix for the plugin's include filter.
|
|
272
279
|
await writeFile(
|
|
@@ -512,10 +519,10 @@ describe("dispatchReplacement + __MANDU_HMR__ integration", () => {
|
|
|
512
519
|
describe.skipIf(process.env.MANDU_SKIP_BUNDLER_TESTS === "1")(
|
|
513
520
|
"buildVendorShims emits fast-refresh shims in dev mode",
|
|
514
521
|
() => {
|
|
515
|
-
let rootDir: string;
|
|
516
|
-
|
|
517
|
-
beforeAll(async () => {
|
|
518
|
-
rootDir = await
|
|
522
|
+
let rootDir: string;
|
|
523
|
+
|
|
524
|
+
beforeAll(async () => {
|
|
525
|
+
rootDir = await mkRepoTempDir("fr-vendor-");
|
|
519
526
|
await mkdir(path.join(rootDir, "app"), { recursive: true });
|
|
520
527
|
await writeFile(
|
|
521
528
|
path.join(rootDir, "package.json"),
|
|
@@ -1,11 +1,17 @@
|
|
|
1
|
-
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
2
|
-
import { mkdtemp, mkdir, readFile, rm, writeFile } from "fs/promises";
|
|
3
|
-
import path from "path";
|
|
4
|
-
import { pathToFileURL } from "url";
|
|
5
|
-
|
|
6
|
-
// 모든 테스트가 하나의 빌드 결과를 공유 — 병렬 Bun.build 충돌 방지
|
|
7
|
-
let rootDir: string;
|
|
8
|
-
let result: { success: boolean; errors: string[] };
|
|
1
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
2
|
+
import { mkdtemp, mkdir, readFile, rm, writeFile } from "fs/promises";
|
|
3
|
+
import path from "path";
|
|
4
|
+
import { pathToFileURL } from "url";
|
|
5
|
+
|
|
6
|
+
// 모든 테스트가 하나의 빌드 결과를 공유 — 병렬 Bun.build 충돌 방지
|
|
7
|
+
let rootDir: string;
|
|
8
|
+
let result: { success: boolean; errors: string[] };
|
|
9
|
+
const repoTempRoot = path.resolve(import.meta.dir, "../../../..", ".tmp-test-artifacts");
|
|
10
|
+
|
|
11
|
+
async function mkRepoTempDir(prefix: string): Promise<string> {
|
|
12
|
+
await mkdir(repoTempRoot, { recursive: true });
|
|
13
|
+
return mkdtemp(path.join(repoTempRoot, prefix));
|
|
14
|
+
}
|
|
9
15
|
|
|
10
16
|
async function importBuiltModule(relativePath: string): Promise<Record<string, unknown>> {
|
|
11
17
|
const fileUrl = pathToFileURL(path.join(rootDir, relativePath)).href;
|
|
@@ -24,10 +30,13 @@ async function importBuiltModule(relativePath: string): Promise<Record<string, u
|
|
|
24
30
|
* See `__tests__/build-runner.ts` for the subprocess entrypoint and more
|
|
25
31
|
* background.
|
|
26
32
|
*/
|
|
27
|
-
async function runBuildInSubprocess(root: string, mode?: string): Promise<{
|
|
28
|
-
success: boolean;
|
|
29
|
-
errors: string[];
|
|
30
|
-
|
|
33
|
+
async function runBuildInSubprocess(root: string, mode?: string): Promise<{
|
|
34
|
+
success: boolean;
|
|
35
|
+
errors: string[];
|
|
36
|
+
manifest?: {
|
|
37
|
+
bundles?: Record<string, { js?: string }>;
|
|
38
|
+
} | null;
|
|
39
|
+
}> {
|
|
31
40
|
const runner = path.join(
|
|
32
41
|
import.meta.dir,
|
|
33
42
|
"__tests__",
|
|
@@ -52,25 +61,27 @@ async function runBuildInSubprocess(root: string, mode?: string): Promise<{
|
|
|
52
61
|
const last = lines[lines.length - 1] ?? "";
|
|
53
62
|
try {
|
|
54
63
|
const parsed = JSON.parse(last);
|
|
55
|
-
return {
|
|
56
|
-
success: parsed.success === true,
|
|
57
|
-
errors: Array.isArray(parsed.errors) ? parsed.errors : [],
|
|
58
|
-
|
|
64
|
+
return {
|
|
65
|
+
success: parsed.success === true,
|
|
66
|
+
errors: Array.isArray(parsed.errors) ? parsed.errors : [],
|
|
67
|
+
manifest: parsed.manifest ?? null,
|
|
68
|
+
};
|
|
59
69
|
} catch (e) {
|
|
60
|
-
return {
|
|
61
|
-
success: false,
|
|
62
|
-
errors: [
|
|
63
|
-
`build-runner output could not be parsed as JSON: ${String(e)}\nLast stdout line: ${last}`,
|
|
64
|
-
],
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
}
|
|
70
|
+
return {
|
|
71
|
+
success: false,
|
|
72
|
+
errors: [
|
|
73
|
+
`build-runner output could not be parsed as JSON: ${String(e)}\nLast stdout line: ${last}`,
|
|
74
|
+
],
|
|
75
|
+
manifest: null,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
} catch (err) {
|
|
79
|
+
return { success: false, errors: [`spawn failed: ${String(err)}`], manifest: null };
|
|
80
|
+
}
|
|
81
|
+
}
|
|
71
82
|
|
|
72
|
-
beforeAll(async () => {
|
|
73
|
-
rootDir = await
|
|
83
|
+
beforeAll(async () => {
|
|
84
|
+
rootDir = await mkRepoTempDir("bundler-");
|
|
74
85
|
|
|
75
86
|
await mkdir(path.join(rootDir, "app"), { recursive: true });
|
|
76
87
|
await writeFile(
|
|
@@ -186,8 +197,8 @@ describe("buildClientBundles vendor shims", () => {
|
|
|
186
197
|
expect(runtimeSource).toContain("JSON.parse");
|
|
187
198
|
});
|
|
188
199
|
|
|
189
|
-
test("does not bundle a server page when stale manifest marks page.tsx as clientModule", async () => {
|
|
190
|
-
const staleRoot = await
|
|
200
|
+
test("does not bundle a server page when stale manifest marks page.tsx as clientModule", async () => {
|
|
201
|
+
const staleRoot = await mkRepoTempDir("stale-client-module-");
|
|
191
202
|
try {
|
|
192
203
|
await mkdir(path.join(staleRoot, "app"), { recursive: true });
|
|
193
204
|
await mkdir(path.join(staleRoot, "src", "shared", "contracts"), { recursive: true });
|
|
@@ -220,7 +231,7 @@ describe("buildClientBundles vendor shims", () => {
|
|
|
220
231
|
});
|
|
221
232
|
|
|
222
233
|
test("rewrites route-component clientModule to the real client import before bundling", async () => {
|
|
223
|
-
const routeClientRoot = await
|
|
234
|
+
const routeClientRoot = await mkRepoTempDir("route-client-import-");
|
|
224
235
|
try {
|
|
225
236
|
await mkdir(path.join(routeClientRoot, "app", "login"), { recursive: true });
|
|
226
237
|
await mkdir(path.join(routeClientRoot, "src", "client", "pages", "login"), { recursive: true });
|
|
@@ -267,7 +278,7 @@ describe("buildClientBundles vendor shims", () => {
|
|
|
267
278
|
});
|
|
268
279
|
|
|
269
280
|
test("bundles route-level named client exports without requiring a default export", async () => {
|
|
270
|
-
const routeClientRoot = await
|
|
281
|
+
const routeClientRoot = await mkRepoTempDir("route-named-client-import-");
|
|
271
282
|
try {
|
|
272
283
|
await mkdir(path.join(routeClientRoot, "app", "login"), { recursive: true });
|
|
273
284
|
await mkdir(path.join(routeClientRoot, "src", "client", "pages", "login"), { recursive: true });
|
|
@@ -317,8 +328,34 @@ describe("buildClientBundles vendor shims", () => {
|
|
|
317
328
|
}
|
|
318
329
|
});
|
|
319
330
|
|
|
331
|
+
test("uses Windows-safe asset filenames for locale-prefixed route ids", async () => {
|
|
332
|
+
const localeRoot = await mkRepoTempDir("i18n-locale-route-id-");
|
|
333
|
+
try {
|
|
334
|
+
await mkdir(path.join(localeRoot, "app"), { recursive: true });
|
|
335
|
+
await writeFile(
|
|
336
|
+
path.join(localeRoot, "package.json"),
|
|
337
|
+
JSON.stringify({ name: "mandu-i18n-route-id-test", type: "module" }, null, 2),
|
|
338
|
+
"utf-8",
|
|
339
|
+
);
|
|
340
|
+
await writeFile(
|
|
341
|
+
path.join(localeRoot, "app", "demo.client.tsx"),
|
|
342
|
+
"export default function DemoIsland() { return null; }\n",
|
|
343
|
+
"utf-8",
|
|
344
|
+
);
|
|
345
|
+
|
|
346
|
+
const localeResult = await runBuildInSubprocess(localeRoot, "i18n-locale-route-id");
|
|
347
|
+
expect(localeResult.success).toBe(true);
|
|
348
|
+
const bundle = localeResult.manifest?.bundles?.["ko::demo"];
|
|
349
|
+
expect(bundle?.js).toBe("/.mandu/client/ko_3a__3a_demo.island.js");
|
|
350
|
+
expect(bundle?.js).not.toContain(":");
|
|
351
|
+
expect(await Bun.file(path.join(localeRoot, ".mandu", "client", "ko_3a__3a_demo.island.js")).exists()).toBe(true);
|
|
352
|
+
} finally {
|
|
353
|
+
await rm(localeRoot, { recursive: true, force: true });
|
|
354
|
+
}
|
|
355
|
+
});
|
|
356
|
+
|
|
320
357
|
test("fails when hydration is enabled but no clientModule can be resolved", async () => {
|
|
321
|
-
const missingRoot = await
|
|
358
|
+
const missingRoot = await mkRepoTempDir("hydration-no-client-");
|
|
322
359
|
try {
|
|
323
360
|
await mkdir(path.join(missingRoot, "app", "login"), { recursive: true });
|
|
324
361
|
await mkdir(path.join(missingRoot, "src", "client", "widgets", "login-form"), { recursive: true });
|
package/src/bundler/build.ts
CHANGED
|
@@ -1929,25 +1929,34 @@ async function buildVendorShims(
|
|
|
1929
1929
|
};
|
|
1930
1930
|
}
|
|
1931
1931
|
|
|
1932
|
-
function vendorShimFailureHint(shimName: string): string {
|
|
1933
|
-
if (shimName.includes("react-refresh")) {
|
|
1934
|
-
return "Hint: install the optional dev peer dependency with `bun add -d react-refresh`.";
|
|
1935
|
-
}
|
|
1936
|
-
return "Hint: check the import paths and ensure the vendor package is installed.";
|
|
1937
|
-
}
|
|
1938
|
-
|
|
1939
|
-
|
|
1940
|
-
|
|
1941
|
-
|
|
1932
|
+
function vendorShimFailureHint(shimName: string): string {
|
|
1933
|
+
if (shimName.includes("react-refresh")) {
|
|
1934
|
+
return "Hint: install the optional dev peer dependency with `bun add -d react-refresh`.";
|
|
1935
|
+
}
|
|
1936
|
+
return "Hint: check the import paths and ensure the vendor package is installed.";
|
|
1937
|
+
}
|
|
1938
|
+
|
|
1939
|
+
function routeIdToAssetStem(routeId: string): string {
|
|
1940
|
+
const safe = routeId.replace(/[<>:"/\\|?*\x00-\x1F]/g, (ch) =>
|
|
1941
|
+
`_${ch.codePointAt(0)!.toString(16)}_`
|
|
1942
|
+
);
|
|
1943
|
+
return safe.replace(/[. ]+$/g, "") || "route";
|
|
1944
|
+
}
|
|
1945
|
+
|
|
1946
|
+
/**
|
|
1947
|
+
* 단일 Island 번들 빌드
|
|
1948
|
+
*/
|
|
1942
1949
|
async function buildIsland(
|
|
1943
1950
|
route: RouteSpec,
|
|
1944
1951
|
rootDir: string,
|
|
1945
1952
|
outDir: string,
|
|
1946
1953
|
options: BundlerOptions
|
|
1947
|
-
): Promise<BundleOutput> {
|
|
1948
|
-
const clientModulePath = path.join(rootDir, route.clientModule!);
|
|
1949
|
-
const
|
|
1950
|
-
const
|
|
1954
|
+
): Promise<BundleOutput> {
|
|
1955
|
+
const clientModulePath = path.join(rootDir, route.clientModule!);
|
|
1956
|
+
const assetStem = routeIdToAssetStem(route.id);
|
|
1957
|
+
const entryStem = `_entry_${assetStem}`;
|
|
1958
|
+
const entryPath = path.join(outDir, `${entryStem}.js`);
|
|
1959
|
+
const outputName = `${assetStem}.island.js`;
|
|
1951
1960
|
|
|
1952
1961
|
// Phase 7.1 B-1/B-4: wire native Fast Refresh transform + Mandu's
|
|
1953
1962
|
// boundary injection plugin. Dev-only; prod bundles remain clean.
|
|
@@ -1988,11 +1997,11 @@ async function buildIsland(
|
|
|
1988
1997
|
let actualOutputPath: string;
|
|
1989
1998
|
let actualOutputName: string;
|
|
1990
1999
|
|
|
1991
|
-
if (options.splitting && result.outputs.length > 0) {
|
|
1992
|
-
// splitting 모드: 결과에서 엔트리 파일 찾기
|
|
1993
|
-
const entryOutput = result.outputs.find(
|
|
1994
|
-
(o) => o.kind === "entry-point" || o.path.includes(
|
|
1995
|
-
);
|
|
2000
|
+
if (options.splitting && result.outputs.length > 0) {
|
|
2001
|
+
// splitting 모드: 결과에서 엔트리 파일 찾기
|
|
2002
|
+
const entryOutput = result.outputs.find(
|
|
2003
|
+
(o) => o.kind === "entry-point" || o.path.includes(entryStem) || o.path.includes(assetStem)
|
|
2004
|
+
);
|
|
1996
2005
|
if (entryOutput) {
|
|
1997
2006
|
actualOutputPath = entryOutput.path;
|
|
1998
2007
|
actualOutputName = path.basename(entryOutput.path);
|
|
@@ -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";
|
|
@@ -192,8 +199,8 @@ describe("findClientComponentImports", () => {
|
|
|
192
199
|
expect(routeClient).toBeNull();
|
|
193
200
|
});
|
|
194
201
|
|
|
195
|
-
it("resolves a route-level client entry by reading a use client target without .client in the path", async () => {
|
|
196
|
-
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-");
|
|
197
204
|
try {
|
|
198
205
|
await mkdir(path.join(rootDir, "app", "pledges", "new"), { recursive: true });
|
|
199
206
|
await mkdir(path.join(rootDir, "src", "client", "widgets", "pledge-form"), { recursive: true });
|
|
@@ -216,21 +216,28 @@ export async function resolveRouteLevelClientEntry(
|
|
|
216
216
|
return null;
|
|
217
217
|
}
|
|
218
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
|
-
|
|
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
|
+
}
|
|
234
241
|
|
|
235
242
|
function resolveImportBasePath(rootDir: string, importerModule: string, specifier: string): string | null {
|
|
236
243
|
const normalized = specifier.replace(/\\/g, "/");
|
|
@@ -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
|
@@ -94,10 +94,12 @@ export function fsRouteToRouteSpec(fsRoute: FSRouteConfig): RouteSpec {
|
|
|
94
94
|
hydration: fsRoute.hydration ?? {
|
|
95
95
|
strategy: "island" as const,
|
|
96
96
|
priority: "immediate" as const,
|
|
97
|
-
preload: false,
|
|
98
|
-
},
|
|
99
|
-
}
|
|
100
|
-
:
|
|
97
|
+
preload: false,
|
|
98
|
+
},
|
|
99
|
+
}
|
|
100
|
+
: fsRoute.hydration
|
|
101
|
+
? { hydration: fsRoute.hydration }
|
|
102
|
+
: {}),
|
|
101
103
|
...(fsRoute.layoutChain && fsRoute.layoutChain.length > 0
|
|
102
104
|
? { layoutChain: fsRoute.layoutChain.map(normalizePath) }
|
|
103
105
|
: {}),
|
|
@@ -322,9 +324,13 @@ export async function generateManifest(
|
|
|
322
324
|
route.clientModule = prev.clientModule;
|
|
323
325
|
route.clientExportName = prev.clientExportName;
|
|
324
326
|
}
|
|
325
|
-
if (prev.hydration && !route.hydration) {
|
|
326
|
-
|
|
327
|
-
|
|
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
|
+
}
|
|
328
334
|
// Issue #214 — preserve prerender-time fields (`dynamicParams`,
|
|
329
335
|
// `staticParams`) across manifest rescans. `mandu build`'s prerender
|
|
330
336
|
// phase stamps these onto the manifest; a subsequent `mandu dev`
|
package/src/router/fs-scanner.ts
CHANGED
|
@@ -28,16 +28,55 @@ import {
|
|
|
28
28
|
sortRoutesByPriority,
|
|
29
29
|
getPatternShape,
|
|
30
30
|
} from "./fs-patterns";
|
|
31
|
-
import { mark, measure } from "../perf";
|
|
32
|
-
import { METADATA_ROUTES } from "../routes/types";
|
|
31
|
+
import { mark, measure } from "../perf";
|
|
32
|
+
import { METADATA_ROUTES } from "../routes/types";
|
|
33
|
+
import type { HydrationConfig } from "../spec/schema";
|
|
33
34
|
import {
|
|
34
35
|
hasUseClientDirective,
|
|
35
36
|
resolveRouteLevelClientEntry,
|
|
36
37
|
} from "./client-entry";
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
38
|
+
|
|
39
|
+
const HYDRATION_STRATEGIES = new Set(["none", "island", "full", "progressive"]);
|
|
40
|
+
const HYDRATION_PRIORITIES = new Set(["immediate", "visible", "idle", "interaction"]);
|
|
41
|
+
|
|
42
|
+
function parsePageHydrationConfig(source: string): HydrationConfig | undefined {
|
|
43
|
+
const stringMatch = source.match(
|
|
44
|
+
/export\s+const\s+hydration\s*(?::[^=]+)?=\s*["'](none|island|full|progressive)["']/m,
|
|
45
|
+
);
|
|
46
|
+
if (stringMatch?.[1]) {
|
|
47
|
+
return {
|
|
48
|
+
strategy: stringMatch[1] as HydrationConfig["strategy"],
|
|
49
|
+
priority: "visible",
|
|
50
|
+
preload: false,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const objectMatch = source.match(
|
|
55
|
+
/export\s+const\s+hydration\s*(?::[^=]+)?=\s*\{([\s\S]*?)\}\s*;?/m,
|
|
56
|
+
);
|
|
57
|
+
const body = objectMatch?.[1];
|
|
58
|
+
if (!body) return undefined;
|
|
59
|
+
|
|
60
|
+
const strategyMatch = body.match(/\bstrategy\s*:\s*["']([^"']+)["']/);
|
|
61
|
+
const strategy = strategyMatch?.[1];
|
|
62
|
+
if (!strategy || !HYDRATION_STRATEGIES.has(strategy)) return undefined;
|
|
63
|
+
|
|
64
|
+
const priorityMatch = body.match(/\bpriority\s*:\s*["']([^"']+)["']/);
|
|
65
|
+
const priority = priorityMatch?.[1];
|
|
66
|
+
const preloadMatch = body.match(/\bpreload\s*:\s*(true|false)\b/);
|
|
67
|
+
|
|
68
|
+
return {
|
|
69
|
+
strategy: strategy as HydrationConfig["strategy"],
|
|
70
|
+
priority: HYDRATION_PRIORITIES.has(priority ?? "")
|
|
71
|
+
? (priority as HydrationConfig["priority"])
|
|
72
|
+
: "visible",
|
|
73
|
+
preload: preloadMatch?.[1] === "true",
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
78
|
+
// Scanner Class
|
|
79
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
41
80
|
|
|
42
81
|
/**
|
|
43
82
|
* FS Routes 스캐너
|
|
@@ -367,15 +406,19 @@ export class FSScanner {
|
|
|
367
406
|
// clientModule 결정: island 파일 또는 "use client"가 있는 page 자체
|
|
368
407
|
let clientModule: string | undefined;
|
|
369
408
|
let clientExportName: string | undefined;
|
|
409
|
+
let hydration: HydrationConfig | undefined;
|
|
370
410
|
let pageFileContent: string | null = null;
|
|
371
|
-
|
|
372
|
-
if (file.type === "page") {
|
|
373
|
-
try {
|
|
374
|
-
pageFileContent = await Bun.file(file.absolutePath).text();
|
|
375
|
-
} catch {
|
|
376
|
-
pageFileContent = null;
|
|
377
|
-
}
|
|
378
|
-
|
|
411
|
+
|
|
412
|
+
if (file.type === "page") {
|
|
413
|
+
try {
|
|
414
|
+
pageFileContent = await Bun.file(file.absolutePath).text();
|
|
415
|
+
} catch {
|
|
416
|
+
pageFileContent = null;
|
|
417
|
+
}
|
|
418
|
+
if (pageFileContent) {
|
|
419
|
+
hydration = parsePageHydrationConfig(pageFileContent);
|
|
420
|
+
}
|
|
421
|
+
}
|
|
379
422
|
|
|
380
423
|
if (islands?.[0]) {
|
|
381
424
|
// 우선순위: 명시적 island 파일
|
|
@@ -422,9 +465,10 @@ export class FSScanner {
|
|
|
422
465
|
componentModule: file.type === "page" ? modulePath : undefined,
|
|
423
466
|
clientModule,
|
|
424
467
|
clientExportName,
|
|
468
|
+
hydration,
|
|
425
469
|
layoutChain,
|
|
426
|
-
loadingModule,
|
|
427
|
-
errorModule,
|
|
470
|
+
loadingModule,
|
|
471
|
+
errorModule,
|
|
428
472
|
notFoundModule,
|
|
429
473
|
sourceFile: file.absolutePath,
|
|
430
474
|
};
|
|
@@ -100,4 +100,65 @@ describe("runtime page render response orchestration", () => {
|
|
|
100
100
|
expect(html).toContain('"home":{"serverData":{"items":["a","b"]}');
|
|
101
101
|
expect(html).not.toContain('"serverData":{"home"');
|
|
102
102
|
});
|
|
103
|
+
|
|
104
|
+
it("serializes props for inline client components rendered by async server pages", async () => {
|
|
105
|
+
function PledgeAccordion({ pledges }: { pledges: Array<{ id: string; title: string }> }) {
|
|
106
|
+
return React.createElement(
|
|
107
|
+
"ul",
|
|
108
|
+
null,
|
|
109
|
+
pledges.map((pledge) => React.createElement("li", { key: pledge.id }, pledge.title)),
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async function CandidatePage() {
|
|
114
|
+
const pledges = [
|
|
115
|
+
{ id: "p1", title: "Public transit" },
|
|
116
|
+
{ id: "p2", title: "Housing" },
|
|
117
|
+
];
|
|
118
|
+
return React.createElement(
|
|
119
|
+
"main",
|
|
120
|
+
null,
|
|
121
|
+
React.createElement(PledgeAccordion, { pledges }),
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const response = await renderPageResponse({
|
|
126
|
+
app: React.createElement(CandidatePage),
|
|
127
|
+
useStreaming: false,
|
|
128
|
+
title: "Candidate",
|
|
129
|
+
headTags: "",
|
|
130
|
+
isDev: false,
|
|
131
|
+
routeId: "candidates-$id",
|
|
132
|
+
routePattern: "/candidates/:id",
|
|
133
|
+
hydration: { strategy: "island", priority: "immediate", preload: false },
|
|
134
|
+
bundleManifest: {
|
|
135
|
+
...HYDRATED_MANIFEST,
|
|
136
|
+
bundles: {
|
|
137
|
+
"candidates-$id": {
|
|
138
|
+
js: "/.mandu/client/candidates-$id.island.js",
|
|
139
|
+
dependencies: ["_runtime", "_react"],
|
|
140
|
+
priority: "immediate",
|
|
141
|
+
},
|
|
142
|
+
},
|
|
143
|
+
},
|
|
144
|
+
loaderData: undefined,
|
|
145
|
+
transitions: false,
|
|
146
|
+
prefetch: false,
|
|
147
|
+
spa: false,
|
|
148
|
+
devtools: false,
|
|
149
|
+
inlineClientHydration: {
|
|
150
|
+
routeId: "candidates-$id",
|
|
151
|
+
src: "/.mandu/client/candidates-$id.island.js",
|
|
152
|
+
priority: "immediate",
|
|
153
|
+
component: PledgeAccordion,
|
|
154
|
+
},
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
const html = await response.text();
|
|
158
|
+
expect(html).toContain('data-mandu-island="candidates-$id--0"');
|
|
159
|
+
expect(html).toContain('data-mandu-src="/.mandu/client/candidates-$id.island.js"');
|
|
160
|
+
expect(html).toContain(""pledges"");
|
|
161
|
+
expect(html).toContain("Public transit");
|
|
162
|
+
expect(html).not.toContain('data-mandu-island="candidates-$id"');
|
|
163
|
+
});
|
|
103
164
|
});
|
|
@@ -1,8 +1,16 @@
|
|
|
1
|
-
import
|
|
1
|
+
import React from "react";
|
|
2
2
|
import type { BundleManifest } from "../bundler/types";
|
|
3
3
|
import type { HydrationConfig } from "../spec/schema";
|
|
4
4
|
import type { CookieManager } from "../filling/context";
|
|
5
5
|
import { renderSSR, renderStreamingResponse, resolveAsyncElement } from "./ssr";
|
|
6
|
+
import { serializeProps } from "../client/serialize";
|
|
7
|
+
|
|
8
|
+
export interface InlineClientHydrationTarget {
|
|
9
|
+
routeId: string;
|
|
10
|
+
src: string;
|
|
11
|
+
priority: NonNullable<HydrationConfig["priority"]>;
|
|
12
|
+
component: unknown;
|
|
13
|
+
}
|
|
6
14
|
|
|
7
15
|
export interface PageRenderResponseOptions {
|
|
8
16
|
app: React.ReactElement;
|
|
@@ -23,6 +31,7 @@ export interface PageRenderResponseOptions {
|
|
|
23
31
|
spa?: boolean;
|
|
24
32
|
devtools?: boolean;
|
|
25
33
|
islandPreWrapped?: boolean;
|
|
34
|
+
inlineClientHydration?: InlineClientHydrationTarget;
|
|
26
35
|
cookies?: CookieManager;
|
|
27
36
|
}
|
|
28
37
|
|
|
@@ -30,18 +39,111 @@ export async function renderPageResponse(
|
|
|
30
39
|
options: PageRenderResponseOptions
|
|
31
40
|
): Promise<Response> {
|
|
32
41
|
let app = options.app;
|
|
42
|
+
let islandPreWrapped = !!options.islandPreWrapped;
|
|
33
43
|
|
|
34
44
|
if (!options.useStreaming) {
|
|
35
|
-
|
|
45
|
+
if (options.inlineClientHydration) {
|
|
46
|
+
const resolved = await resolveAndWrapInlineClientHydration(
|
|
47
|
+
app,
|
|
48
|
+
options.inlineClientHydration,
|
|
49
|
+
);
|
|
50
|
+
app = resolved.node as React.ReactElement;
|
|
51
|
+
islandPreWrapped = islandPreWrapped || resolved.didWrap;
|
|
52
|
+
} else {
|
|
53
|
+
app = (await resolveAsyncElement(app)) as React.ReactElement;
|
|
54
|
+
}
|
|
36
55
|
}
|
|
37
56
|
|
|
57
|
+
const effectiveOptions = islandPreWrapped === !!options.islandPreWrapped
|
|
58
|
+
? options
|
|
59
|
+
: { ...options, islandPreWrapped };
|
|
60
|
+
|
|
38
61
|
const response = options.useStreaming
|
|
39
|
-
? await renderStreamingPageResponse(app,
|
|
40
|
-
: renderNonStreamingPageResponse(app,
|
|
62
|
+
? await renderStreamingPageResponse(app, effectiveOptions)
|
|
63
|
+
: renderNonStreamingPageResponse(app, effectiveOptions);
|
|
41
64
|
|
|
42
65
|
return options.cookies ? options.cookies.applyToResponse(response) : response;
|
|
43
66
|
}
|
|
44
67
|
|
|
68
|
+
async function resolveAndWrapInlineClientHydration(
|
|
69
|
+
node: React.ReactNode,
|
|
70
|
+
target: InlineClientHydrationTarget,
|
|
71
|
+
counter = { value: 0 },
|
|
72
|
+
): Promise<{ node: React.ReactNode; didWrap: boolean }> {
|
|
73
|
+
if (node == null || typeof node !== "object") {
|
|
74
|
+
return { node, didWrap: false };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (Array.isArray(node)) {
|
|
78
|
+
let didWrap = false;
|
|
79
|
+
const children = await Promise.all(
|
|
80
|
+
node.map(async (child) => {
|
|
81
|
+
const result = await resolveAndWrapInlineClientHydration(child, target, counter);
|
|
82
|
+
didWrap = didWrap || result.didWrap;
|
|
83
|
+
return result.node;
|
|
84
|
+
}),
|
|
85
|
+
);
|
|
86
|
+
return { node: children, didWrap };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (!React.isValidElement(node)) {
|
|
90
|
+
return { node, didWrap: false };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const element = node as React.ReactElement<Record<string, unknown>>;
|
|
94
|
+
const type = element.type;
|
|
95
|
+
|
|
96
|
+
if (type === target.component) {
|
|
97
|
+
const id = `${target.routeId}--${counter.value++}`;
|
|
98
|
+
return {
|
|
99
|
+
node: React.createElement(
|
|
100
|
+
"div",
|
|
101
|
+
{
|
|
102
|
+
"data-mandu-island": id,
|
|
103
|
+
"data-mandu-src": target.src,
|
|
104
|
+
"data-mandu-priority": target.priority,
|
|
105
|
+
"data-hydrate": priorityToHydrate(target.priority),
|
|
106
|
+
"data-props": serializeProps(element.props ?? {}),
|
|
107
|
+
style: { display: "contents" },
|
|
108
|
+
},
|
|
109
|
+
element,
|
|
110
|
+
),
|
|
111
|
+
didWrap: true,
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (typeof type === "function" && !isClassComponent(type)) {
|
|
116
|
+
const rendered = await (type as (props: Record<string, unknown>) => React.ReactNode | Promise<React.ReactNode>)(
|
|
117
|
+
element.props ?? {},
|
|
118
|
+
);
|
|
119
|
+
return resolveAndWrapInlineClientHydration(rendered, target, counter);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const props = element.props;
|
|
123
|
+
const rawChildren = props?.children as React.ReactNode | undefined;
|
|
124
|
+
if (rawChildren === undefined) {
|
|
125
|
+
return { node: element, didWrap: false };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const resolvedChildren = await resolveAndWrapInlineClientHydration(rawChildren, target, counter);
|
|
129
|
+
if (!resolvedChildren.didWrap && resolvedChildren.node === rawChildren) {
|
|
130
|
+
return { node: element, didWrap: false };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const cloned = Array.isArray(resolvedChildren.node)
|
|
134
|
+
? React.cloneElement(element, undefined, ...resolvedChildren.node)
|
|
135
|
+
: React.cloneElement(element, undefined, resolvedChildren.node);
|
|
136
|
+
return { node: cloned, didWrap: resolvedChildren.didWrap };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function isClassComponent(type: Function): boolean {
|
|
140
|
+
return !!(type.prototype && type.prototype.isReactComponent);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function priorityToHydrate(priority: InlineClientHydrationTarget["priority"]): string {
|
|
144
|
+
return priority === "immediate" ? "load" : priority;
|
|
145
|
+
}
|
|
146
|
+
|
|
45
147
|
async function renderStreamingPageResponse(
|
|
46
148
|
app: React.ReactElement,
|
|
47
149
|
options: PageRenderResponseOptions
|
|
@@ -157,10 +157,10 @@ describe("Wildcard Matching", () => {
|
|
|
157
157
|
|
|
158
158
|
const result = router.match("/docs");
|
|
159
159
|
|
|
160
|
-
expect(result).not.toBeNull();
|
|
161
|
-
expect(result!.route.id).toBe("docs");
|
|
162
|
-
expect(result!.params).toEqual({});
|
|
163
|
-
});
|
|
160
|
+
expect(result).not.toBeNull();
|
|
161
|
+
expect(result!.route.id).toBe("docs");
|
|
162
|
+
expect(result!.params).toEqual({ path: "" });
|
|
163
|
+
});
|
|
164
164
|
|
|
165
165
|
test("optional wildcard matches with remaining path", () => {
|
|
166
166
|
const router = createRouter([
|
package/src/runtime/router.ts
CHANGED
|
@@ -415,12 +415,10 @@ export class Router {
|
|
|
415
415
|
route,
|
|
416
416
|
};
|
|
417
417
|
|
|
418
|
-
// Optional wildcard
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
return;
|
|
423
|
-
}
|
|
418
|
+
// Optional wildcard base paths are matched from wildcardConfig so
|
|
419
|
+
// the named wildcard param is still materialized as an empty string.
|
|
420
|
+
return;
|
|
421
|
+
}
|
|
424
422
|
|
|
425
423
|
// Regular parameter: :param
|
|
426
424
|
const paramName = seg.slice(1);
|
|
@@ -528,12 +526,12 @@ export class Router {
|
|
|
528
526
|
if (node.wildcardConfig.optional) {
|
|
529
527
|
if (this.debug) {
|
|
530
528
|
console.log(`[Router] Optional wildcard match: ${node.wildcardConfig.route.id} with empty path`);
|
|
531
|
-
}
|
|
532
|
-
return {
|
|
533
|
-
route: node.wildcardConfig.route,
|
|
534
|
-
params,
|
|
535
|
-
};
|
|
536
|
-
}
|
|
529
|
+
}
|
|
530
|
+
return {
|
|
531
|
+
route: node.wildcardConfig.route,
|
|
532
|
+
params: { ...params, [node.wildcardConfig.name]: "" },
|
|
533
|
+
};
|
|
534
|
+
}
|
|
537
535
|
// Non-optional wildcard: /files/:path* does NOT match /files
|
|
538
536
|
if (this.debug) {
|
|
539
537
|
console.log(`[Router] Wildcard policy: ${pathname} does not match non-optional wildcard`);
|
package/src/runtime/server.ts
CHANGED
|
@@ -93,7 +93,7 @@ import {
|
|
|
93
93
|
} from "./static-files";
|
|
94
94
|
export { __clearStaticEtagCacheForTests } from "./static-files";
|
|
95
95
|
import { extractShellHtml, createPPRResponse } from "./ppr";
|
|
96
|
-
import { renderPageResponse } from "./page-render-response";
|
|
96
|
+
import { renderPageResponse, type InlineClientHydrationTarget } from "./page-render-response";
|
|
97
97
|
import { isRedirectResponse } from "./redirect";
|
|
98
98
|
import { isNotFoundResponse } from "./not-found";
|
|
99
99
|
import { newId } from "../id";
|
|
@@ -479,8 +479,12 @@ export type ErrorLoader = () => Promise<{ default: ErrorComponent }>;
|
|
|
479
479
|
* - component: React 컴포넌트
|
|
480
480
|
* - filling: Slot의 ManduFilling 인스턴스 (loader 포함)
|
|
481
481
|
*/
|
|
482
|
-
export interface PageRegistration {
|
|
483
|
-
component: React.ComponentType<{
|
|
482
|
+
export interface PageRegistration {
|
|
483
|
+
component: React.ComponentType<{
|
|
484
|
+
params: Record<string, string>;
|
|
485
|
+
loaderData?: unknown;
|
|
486
|
+
__manduHydration?: InlineClientHydrationTarget;
|
|
487
|
+
}>;
|
|
484
488
|
filling?: ManduFilling<unknown>;
|
|
485
489
|
/** #186: page 모듈의 static `metadata` export (선택) */
|
|
486
490
|
metadata?: Metadata;
|
|
@@ -502,15 +506,20 @@ export type PageHandler = () => Promise<PageRegistration>;
|
|
|
502
506
|
*/
|
|
503
507
|
export type MetadataHandler = () => Promise<unknown>;
|
|
504
508
|
|
|
505
|
-
export interface AppContext {
|
|
506
|
-
routeId: string;
|
|
507
|
-
url: string;
|
|
508
|
-
params: Record<string, string>;
|
|
509
|
-
/** SSR loader에서 로드한 데이터 */
|
|
510
|
-
loaderData?: unknown;
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
509
|
+
export interface AppContext {
|
|
510
|
+
routeId: string;
|
|
511
|
+
url: string;
|
|
512
|
+
params: Record<string, string>;
|
|
513
|
+
/** SSR loader에서 로드한 데이터 */
|
|
514
|
+
loaderData?: unknown;
|
|
515
|
+
__manduHydration?: InlineClientHydrationTarget;
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
type RouteComponent = (props: {
|
|
519
|
+
params: Record<string, string>;
|
|
520
|
+
loaderData?: unknown;
|
|
521
|
+
__manduHydration?: InlineClientHydrationTarget;
|
|
522
|
+
}) => React.ReactElement;
|
|
514
523
|
type CreateAppFn = (context: AppContext) => React.ReactElement;
|
|
515
524
|
|
|
516
525
|
// ========== Server Registry (인스턴스별 분리) ==========
|
|
@@ -1086,8 +1095,8 @@ async function wrapWithLayouts(
|
|
|
1086
1095
|
}
|
|
1087
1096
|
|
|
1088
1097
|
// Default createApp implementation (registry 기반)
|
|
1089
|
-
function createDefaultAppFactory(registry: ServerRegistry) {
|
|
1090
|
-
return function defaultCreateApp(context: AppContext): React.ReactElement {
|
|
1098
|
+
function createDefaultAppFactory(registry: ServerRegistry) {
|
|
1099
|
+
return function defaultCreateApp(context: AppContext): React.ReactElement {
|
|
1091
1100
|
const Component = registry.routeComponents.get(context.routeId);
|
|
1092
1101
|
|
|
1093
1102
|
if (!Component) {
|
|
@@ -1097,14 +1106,53 @@ function createDefaultAppFactory(registry: ServerRegistry) {
|
|
|
1097
1106
|
);
|
|
1098
1107
|
}
|
|
1099
1108
|
|
|
1100
|
-
return React.createElement(Component, {
|
|
1101
|
-
params: context.params,
|
|
1102
|
-
loaderData: context.loaderData,
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
}
|
|
1106
|
-
|
|
1107
|
-
|
|
1109
|
+
return React.createElement(Component, {
|
|
1110
|
+
params: context.params,
|
|
1111
|
+
loaderData: context.loaderData,
|
|
1112
|
+
__manduHydration: context.__manduHydration,
|
|
1113
|
+
});
|
|
1114
|
+
};
|
|
1115
|
+
}
|
|
1116
|
+
|
|
1117
|
+
async function resolveInlineClientHydrationTarget(
|
|
1118
|
+
route: {
|
|
1119
|
+
id: string;
|
|
1120
|
+
clientModule?: string;
|
|
1121
|
+
clientExportName?: string;
|
|
1122
|
+
hydration?: HydrationConfig;
|
|
1123
|
+
},
|
|
1124
|
+
rootDir: string,
|
|
1125
|
+
src: string,
|
|
1126
|
+
): Promise<InlineClientHydrationTarget | undefined> {
|
|
1127
|
+
if (!route.clientModule || !route.clientExportName || !src) {
|
|
1128
|
+
return undefined;
|
|
1129
|
+
}
|
|
1130
|
+
|
|
1131
|
+
try {
|
|
1132
|
+
const module = await import(path.join(rootDir, route.clientModule));
|
|
1133
|
+
const exportName = route.clientExportName;
|
|
1134
|
+
const component = exportName === "default"
|
|
1135
|
+
? module.default
|
|
1136
|
+
: module[exportName] ?? module.default;
|
|
1137
|
+
|
|
1138
|
+
if (!component) return undefined;
|
|
1139
|
+
|
|
1140
|
+
return {
|
|
1141
|
+
routeId: route.id,
|
|
1142
|
+
src,
|
|
1143
|
+
priority: route.hydration?.priority ?? "visible",
|
|
1144
|
+
component,
|
|
1145
|
+
};
|
|
1146
|
+
} catch (error) {
|
|
1147
|
+
console.warn(
|
|
1148
|
+
`[Mandu] Failed to resolve inline client hydration target for "${route.id}":`,
|
|
1149
|
+
error,
|
|
1150
|
+
);
|
|
1151
|
+
return undefined;
|
|
1152
|
+
}
|
|
1153
|
+
}
|
|
1154
|
+
|
|
1155
|
+
const INTERNAL_CACHE_ENDPOINT = "/_mandu/cache";
|
|
1108
1156
|
|
|
1109
1157
|
// ========== Request Handler ==========
|
|
1110
1158
|
|
|
@@ -2017,8 +2065,19 @@ function extractTitleText(titleHtml: string): string | null {
|
|
|
2017
2065
|
/**
|
|
2018
2066
|
* SSR 렌더링 (Streaming/Non-streaming)
|
|
2019
2067
|
*/
|
|
2020
|
-
async function renderPageSSR(
|
|
2021
|
-
route: {
|
|
2068
|
+
async function renderPageSSR(
|
|
2069
|
+
route: {
|
|
2070
|
+
id: string;
|
|
2071
|
+
pattern: string;
|
|
2072
|
+
layoutChain?: string[];
|
|
2073
|
+
streaming?: boolean;
|
|
2074
|
+
hydration?: HydrationConfig;
|
|
2075
|
+
errorModule?: string;
|
|
2076
|
+
loadingModule?: string;
|
|
2077
|
+
notFoundModule?: string;
|
|
2078
|
+
clientModule?: string;
|
|
2079
|
+
clientExportName?: string;
|
|
2080
|
+
},
|
|
2022
2081
|
params: Record<string, string>,
|
|
2023
2082
|
loaderData: unknown,
|
|
2024
2083
|
url: string,
|
|
@@ -2028,15 +2087,30 @@ async function renderPageSSR(
|
|
|
2028
2087
|
): Promise<Result<Response>> {
|
|
2029
2088
|
const settings = registry.settings;
|
|
2030
2089
|
const defaultAppCreator = createDefaultAppFactory(registry);
|
|
2031
|
-
const appCreator = registry.createAppFn || defaultAppCreator;
|
|
2032
|
-
|
|
2033
|
-
try {
|
|
2034
|
-
|
|
2035
|
-
|
|
2036
|
-
|
|
2037
|
-
|
|
2038
|
-
|
|
2039
|
-
|
|
2090
|
+
const appCreator = registry.createAppFn || defaultAppCreator;
|
|
2091
|
+
|
|
2092
|
+
try {
|
|
2093
|
+
const useStreaming = route.streaming !== undefined
|
|
2094
|
+
? route.streaming
|
|
2095
|
+
: settings.streaming;
|
|
2096
|
+
const needsIslandHydration = !!(
|
|
2097
|
+
route.hydration &&
|
|
2098
|
+
route.hydration.strategy !== "none" &&
|
|
2099
|
+
settings.bundleManifest
|
|
2100
|
+
);
|
|
2101
|
+
const routeBundle = settings.bundleManifest?.bundles[route.id];
|
|
2102
|
+
const bundleSrc = routeBundle?.js ? `${routeBundle.js}?t=${Date.now()}` : "";
|
|
2103
|
+
const inlineClientHydration = needsIslandHydration && !useStreaming
|
|
2104
|
+
? await resolveInlineClientHydrationTarget(route, settings.rootDir, bundleSrc)
|
|
2105
|
+
: undefined;
|
|
2106
|
+
|
|
2107
|
+
let app = appCreator({
|
|
2108
|
+
routeId: route.id,
|
|
2109
|
+
url,
|
|
2110
|
+
params,
|
|
2111
|
+
loaderData,
|
|
2112
|
+
__manduHydration: inlineClientHydration,
|
|
2113
|
+
});
|
|
2040
2114
|
|
|
2041
2115
|
// Phase 18.β — per-route Suspense wrapper (Next.js `loading.tsx` parity).
|
|
2042
2116
|
// If the route declared a `loading.tsx`, wrap the page element in a
|
|
@@ -2064,20 +2138,13 @@ async function renderPageSSR(
|
|
|
2064
2138
|
|
|
2065
2139
|
// Island 래핑: 레이아웃 적용 전에 페이지 콘텐츠만 island div로 감쌈
|
|
2066
2140
|
// 이렇게 하면 레이아웃은 island 바깥에 위치하여 하이드레이션 시 레이아웃이 유지됨
|
|
2067
|
-
const
|
|
2068
|
-
|
|
2069
|
-
|
|
2070
|
-
|
|
2071
|
-
|
|
2072
|
-
|
|
2073
|
-
|
|
2074
|
-
const needsIslandWrap = needsIslandHydration && bundleSrc.length > 0;
|
|
2075
|
-
|
|
2076
|
-
if (needsIslandHydration && !needsIslandWrap && settings.isDev) {
|
|
2077
|
-
console.warn(
|
|
2078
|
-
`[Mandu] Hydration requested for route "${route.id}" but no client bundle was found. ` +
|
|
2079
|
-
`Run mandu build/generate and ensure the route has a clientModule.`,
|
|
2080
|
-
);
|
|
2141
|
+
const needsIslandWrap = needsIslandHydration && bundleSrc.length > 0 && !inlineClientHydration;
|
|
2142
|
+
|
|
2143
|
+
if (needsIslandHydration && bundleSrc.length === 0 && settings.isDev) {
|
|
2144
|
+
console.warn(
|
|
2145
|
+
`[Mandu] Hydration requested for route "${route.id}" but no client bundle was found. ` +
|
|
2146
|
+
`Run mandu build/generate and ensure the route has a clientModule.`,
|
|
2147
|
+
);
|
|
2081
2148
|
}
|
|
2082
2149
|
|
|
2083
2150
|
if (needsIslandWrap) {
|
|
@@ -2098,12 +2165,7 @@ async function renderPageSSR(
|
|
|
2098
2165
|
// #186: layout chain + page metadata 병합
|
|
2099
2166
|
const builtMeta = await buildSSRMetadata(route, params, url, registry);
|
|
2100
2167
|
|
|
2101
|
-
|
|
2102
|
-
const useStreaming = route.streaming !== undefined
|
|
2103
|
-
? route.streaming
|
|
2104
|
-
: settings.streaming;
|
|
2105
|
-
|
|
2106
|
-
const pageResponse = await renderPageResponse({
|
|
2168
|
+
const pageResponse = await renderPageResponse({
|
|
2107
2169
|
app,
|
|
2108
2170
|
useStreaming,
|
|
2109
2171
|
title: builtMeta.title,
|
|
@@ -2114,11 +2176,12 @@ async function renderPageSSR(
|
|
|
2114
2176
|
routePattern: route.pattern,
|
|
2115
2177
|
layoutChain: route.layoutChain,
|
|
2116
2178
|
hydration: route.hydration,
|
|
2117
|
-
bundleManifest: settings.bundleManifest,
|
|
2118
|
-
loaderData,
|
|
2119
|
-
cssPath: settings.cssPath,
|
|
2120
|
-
islandPreWrapped: needsIslandWrap,
|
|
2121
|
-
|
|
2179
|
+
bundleManifest: settings.bundleManifest,
|
|
2180
|
+
loaderData,
|
|
2181
|
+
cssPath: settings.cssPath,
|
|
2182
|
+
islandPreWrapped: needsIslandWrap,
|
|
2183
|
+
inlineClientHydration,
|
|
2184
|
+
transitions: settings.transitions,
|
|
2122
2185
|
prefetch: settings.prefetch,
|
|
2123
2186
|
spa: settings.spa,
|
|
2124
2187
|
devtools: settings.devtools,
|