@mandujs/core 0.54.19 → 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/bundler/build.test.ts +247 -243
- package/src/runtime/__tests__/searchparams-page-props.test.ts +81 -0
- package/src/runtime/server.ts +179 -171
package/package.json
CHANGED
|
@@ -17,7 +17,7 @@ async function mkRepoTempDir(prefix: string): Promise<string> {
|
|
|
17
17
|
await mkdir(repoTempRoot, { recursive: true });
|
|
18
18
|
return mkdtemp(path.join(repoTempRoot, prefix));
|
|
19
19
|
}
|
|
20
|
-
|
|
20
|
+
|
|
21
21
|
async function importBuiltModule(relativePath: string): Promise<Record<string, unknown>> {
|
|
22
22
|
const fileUrl = pathToFileURL(path.join(rootDir, relativePath)).href;
|
|
23
23
|
return import(`${fileUrl}?t=${Date.now()}`);
|
|
@@ -42,7 +42,11 @@ async function evaluateGeneratedHydrationRuntime(): Promise<{ hydrateIslands: ()
|
|
|
42
42
|
);
|
|
43
43
|
|
|
44
44
|
const reactStub = {
|
|
45
|
-
Component: class {
|
|
45
|
+
Component: class {
|
|
46
|
+
render() {
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
},
|
|
46
50
|
createElement(type: unknown, props: Record<string, unknown> | null, ...children: unknown[]) {
|
|
47
51
|
if (
|
|
48
52
|
typeof type === "function" &&
|
|
@@ -90,19 +94,19 @@ async function waitForRuntimeAssertion(assertion: () => boolean): Promise<void>
|
|
|
90
94
|
}
|
|
91
95
|
expect(assertion()).toBe(true);
|
|
92
96
|
}
|
|
93
|
-
|
|
94
|
-
/**
|
|
95
|
-
* Run `buildClientBundles` in an isolated `bun` subprocess.
|
|
96
|
-
*
|
|
97
|
-
* Bun 1.3.x exhibits a deterministic `AggregateError: Bundle failed` when
|
|
98
|
-
* `buildClientBundles` is called from a test file AND the same `bun test`
|
|
99
|
-
* process has previously imported `react` / `react-dom` through any sibling
|
|
100
|
-
* test file (happens transitively through almost every `src/testing/*` or
|
|
101
|
-
* `src/runtime/*` consumer). Retrying in-process does not recover — the
|
|
102
|
-
* resolver state is sticky. A fresh subprocess has a clean module graph.
|
|
103
|
-
* See `__tests__/build-runner.ts` for the subprocess entrypoint and more
|
|
104
|
-
* background.
|
|
105
|
-
*/
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Run `buildClientBundles` in an isolated `bun` subprocess.
|
|
100
|
+
*
|
|
101
|
+
* Bun 1.3.x exhibits a deterministic `AggregateError: Bundle failed` when
|
|
102
|
+
* `buildClientBundles` is called from a test file AND the same `bun test`
|
|
103
|
+
* process has previously imported `react` / `react-dom` through any sibling
|
|
104
|
+
* test file (happens transitively through almost every `src/testing/*` or
|
|
105
|
+
* `src/runtime/*` consumer). Retrying in-process does not recover — the
|
|
106
|
+
* resolver state is sticky. A fresh subprocess has a clean module graph.
|
|
107
|
+
* See `__tests__/build-runner.ts` for the subprocess entrypoint and more
|
|
108
|
+
* background.
|
|
109
|
+
*/
|
|
106
110
|
async function runBuildInSubprocess(root: string, mode?: string): Promise<{
|
|
107
111
|
success: boolean;
|
|
108
112
|
errors: string[];
|
|
@@ -134,36 +138,36 @@ async function runBuildInSubprocess(root: string, mode?: string): Promise<{
|
|
|
134
138
|
boundaries?: Record<string, { js?: string; route?: string; module?: string; exportName?: string; hydrate?: string }>;
|
|
135
139
|
} | null;
|
|
136
140
|
}> {
|
|
137
|
-
const runner = path.join(
|
|
138
|
-
import.meta.dir,
|
|
139
|
-
"__tests__",
|
|
140
|
-
"build-runner.ts",
|
|
141
|
-
);
|
|
142
|
-
try {
|
|
143
|
-
const args = [process.execPath, "run", runner, root];
|
|
144
|
-
if (mode) args.push(mode);
|
|
145
|
-
const proc = Bun.spawn(args, {
|
|
146
|
-
cwd: path.resolve(import.meta.dir, "..", ".."),
|
|
147
|
-
stdin: "ignore",
|
|
148
|
-
stdout: "pipe",
|
|
149
|
-
stderr: "inherit",
|
|
150
|
-
});
|
|
151
|
-
const out = await new Response(proc.stdout).text();
|
|
152
|
-
await proc.exited;
|
|
153
|
-
|
|
154
|
-
// Find the final JSON line — the runner may log Mandu dev banners
|
|
155
|
-
// (e.g. "[Mandu] DevTools …") before emitting the payload. The
|
|
156
|
-
// contract is: last non-empty line is the JSON blob.
|
|
157
|
-
const lines = out.split(/\r?\n/).filter((l) => l.trim().length > 0);
|
|
158
|
-
const last = lines[lines.length - 1] ?? "";
|
|
159
|
-
try {
|
|
160
|
-
const parsed = JSON.parse(last);
|
|
141
|
+
const runner = path.join(
|
|
142
|
+
import.meta.dir,
|
|
143
|
+
"__tests__",
|
|
144
|
+
"build-runner.ts",
|
|
145
|
+
);
|
|
146
|
+
try {
|
|
147
|
+
const args = [process.execPath, "run", runner, root];
|
|
148
|
+
if (mode) args.push(mode);
|
|
149
|
+
const proc = Bun.spawn(args, {
|
|
150
|
+
cwd: path.resolve(import.meta.dir, "..", ".."),
|
|
151
|
+
stdin: "ignore",
|
|
152
|
+
stdout: "pipe",
|
|
153
|
+
stderr: "inherit",
|
|
154
|
+
});
|
|
155
|
+
const out = await new Response(proc.stdout).text();
|
|
156
|
+
await proc.exited;
|
|
157
|
+
|
|
158
|
+
// Find the final JSON line — the runner may log Mandu dev banners
|
|
159
|
+
// (e.g. "[Mandu] DevTools …") before emitting the payload. The
|
|
160
|
+
// contract is: last non-empty line is the JSON blob.
|
|
161
|
+
const lines = out.split(/\r?\n/).filter((l) => l.trim().length > 0);
|
|
162
|
+
const last = lines[lines.length - 1] ?? "";
|
|
163
|
+
try {
|
|
164
|
+
const parsed = JSON.parse(last);
|
|
161
165
|
return {
|
|
162
166
|
success: parsed.success === true,
|
|
163
167
|
errors: Array.isArray(parsed.errors) ? parsed.errors : [],
|
|
164
168
|
manifest: parsed.manifest ?? null,
|
|
165
169
|
};
|
|
166
|
-
} catch (e) {
|
|
170
|
+
} catch (e) {
|
|
167
171
|
return {
|
|
168
172
|
success: false,
|
|
169
173
|
errors: [
|
|
@@ -176,54 +180,54 @@ async function runBuildInSubprocess(root: string, mode?: string): Promise<{
|
|
|
176
180
|
return { success: false, errors: [`spawn failed: ${String(err)}`], manifest: null };
|
|
177
181
|
}
|
|
178
182
|
}
|
|
179
|
-
|
|
183
|
+
|
|
180
184
|
beforeAll(async () => {
|
|
181
185
|
rootDir = await mkRepoTempDir("bundler-");
|
|
182
|
-
|
|
183
|
-
await mkdir(path.join(rootDir, "app"), { recursive: true });
|
|
184
|
-
await writeFile(
|
|
185
|
-
path.join(rootDir, "package.json"),
|
|
186
|
-
JSON.stringify({ name: "mandu-build-test", type: "module" }, null, 2),
|
|
187
|
-
"utf-8",
|
|
188
|
-
);
|
|
189
|
-
await writeFile(
|
|
190
|
-
path.join(rootDir, "app", "demo.client.tsx"),
|
|
191
|
-
"export default function DemoIsland() { return null; }\n",
|
|
192
|
-
"utf-8",
|
|
193
|
-
);
|
|
194
|
-
|
|
195
|
-
result = await runBuildInSubprocess(rootDir);
|
|
196
|
-
});
|
|
197
|
-
|
|
198
|
-
afterAll(async () => {
|
|
199
|
-
if (rootDir) {
|
|
200
|
-
await rm(rootDir, { recursive: true, force: true });
|
|
201
|
-
}
|
|
202
|
-
});
|
|
203
|
-
|
|
204
|
-
// Historical note — `MANDU_SKIP_BUNDLER_TESTS` gate REMOVED.
|
|
205
|
-
//
|
|
206
|
-
// A previous revision gated this describe block behind
|
|
207
|
-
// `describe.skipIf(MANDU_SKIP_BUNDLER_TESTS === "1")` because running
|
|
208
|
-
// `bun test src/bundler/` without the gate hung indefinitely on Windows
|
|
209
|
-
// (see Phase 0.6 and `docs/qa/wave-R2-integration-report.md`). Root cause
|
|
210
|
-
// was NOT actually in THIS file — it was a deadlock in `safe-build.test.ts`'s
|
|
211
|
-
// "slot handoff" regression test, which drove Bun's microtask queue with a
|
|
212
|
-
// `while (!stop) { await Promise.resolve() }` sampler. That starved libuv
|
|
213
|
-
// I/O callbacks, so the 7 parallel `safeBuild()` calls never completed, the
|
|
214
|
-
// whole test process hung, and downstream test files (including this one
|
|
215
|
-
// when run in the same invocation) looked flaky when they were simply
|
|
216
|
-
// never reached. The handoff sampler now yields via `setImmediate`, which
|
|
217
|
-
// unblocks Bun.build completion and makes `bun test src/bundler/` finish
|
|
218
|
-
// deterministically in ~35s on Windows. Confirmed green 3/3 runs without
|
|
219
|
-
// the gate on 2026-04-20. If you are tempted to re-introduce the skip here,
|
|
220
|
-
// first check whether a sibling test is starving the event loop.
|
|
221
|
-
//
|
|
222
|
-
// A second, independent flake — Bun.build `AggregateError: Bundle failed`
|
|
223
|
-
// when another test file in the same invocation has imported `react` —
|
|
224
|
-
// is now sidestepped by running `buildClientBundles` in a spawned `bun`
|
|
225
|
-
// subprocess via `__tests__/build-runner.ts`. In-process retry does not
|
|
226
|
-
// recover from that one; a fresh module graph does.
|
|
186
|
+
|
|
187
|
+
await mkdir(path.join(rootDir, "app"), { recursive: true });
|
|
188
|
+
await writeFile(
|
|
189
|
+
path.join(rootDir, "package.json"),
|
|
190
|
+
JSON.stringify({ name: "mandu-build-test", type: "module" }, null, 2),
|
|
191
|
+
"utf-8",
|
|
192
|
+
);
|
|
193
|
+
await writeFile(
|
|
194
|
+
path.join(rootDir, "app", "demo.client.tsx"),
|
|
195
|
+
"export default function DemoIsland() { return null; }\n",
|
|
196
|
+
"utf-8",
|
|
197
|
+
);
|
|
198
|
+
|
|
199
|
+
result = await runBuildInSubprocess(rootDir);
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
afterAll(async () => {
|
|
203
|
+
if (rootDir) {
|
|
204
|
+
await rm(rootDir, { recursive: true, force: true });
|
|
205
|
+
}
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
// Historical note — `MANDU_SKIP_BUNDLER_TESTS` gate REMOVED.
|
|
209
|
+
//
|
|
210
|
+
// A previous revision gated this describe block behind
|
|
211
|
+
// `describe.skipIf(MANDU_SKIP_BUNDLER_TESTS === "1")` because running
|
|
212
|
+
// `bun test src/bundler/` without the gate hung indefinitely on Windows
|
|
213
|
+
// (see Phase 0.6 and `docs/qa/wave-R2-integration-report.md`). Root cause
|
|
214
|
+
// was NOT actually in THIS file — it was a deadlock in `safe-build.test.ts`'s
|
|
215
|
+
// "slot handoff" regression test, which drove Bun's microtask queue with a
|
|
216
|
+
// `while (!stop) { await Promise.resolve() }` sampler. That starved libuv
|
|
217
|
+
// I/O callbacks, so the 7 parallel `safeBuild()` calls never completed, the
|
|
218
|
+
// whole test process hung, and downstream test files (including this one
|
|
219
|
+
// when run in the same invocation) looked flaky when they were simply
|
|
220
|
+
// never reached. The handoff sampler now yields via `setImmediate`, which
|
|
221
|
+
// unblocks Bun.build completion and makes `bun test src/bundler/` finish
|
|
222
|
+
// deterministically in ~35s on Windows. Confirmed green 3/3 runs without
|
|
223
|
+
// the gate on 2026-04-20. If you are tempted to re-introduce the skip here,
|
|
224
|
+
// first check whether a sibling test is starving the event loop.
|
|
225
|
+
//
|
|
226
|
+
// A second, independent flake — Bun.build `AggregateError: Bundle failed`
|
|
227
|
+
// when another test file in the same invocation has imported `react` —
|
|
228
|
+
// is now sidestepped by running `buildClientBundles` in a spawned `bun`
|
|
229
|
+
// subprocess via `__tests__/build-runner.ts`. In-process retry does not
|
|
230
|
+
// recover from that one; a fresh module graph does.
|
|
227
231
|
describe("buildClientBundles vendor shims", () => {
|
|
228
232
|
test("build succeeds", () => {
|
|
229
233
|
if (!result.success) {
|
|
@@ -245,61 +249,61 @@ describe("buildClientBundles vendor shims", () => {
|
|
|
245
249
|
});
|
|
246
250
|
|
|
247
251
|
test("re-exports modern React 19 APIs used by islands", async () => {
|
|
248
|
-
const reactShim = await importBuiltModule(".mandu/client/_react.js");
|
|
249
|
-
const requiredExports = [
|
|
250
|
-
"Activity",
|
|
251
|
-
"__COMPILER_RUNTIME",
|
|
252
|
-
"cache",
|
|
253
|
-
"cacheSignal",
|
|
254
|
-
"startTransition",
|
|
255
|
-
"use",
|
|
256
|
-
"useActionState",
|
|
257
|
-
"useEffectEvent",
|
|
258
|
-
"useOptimistic",
|
|
259
|
-
"unstable_useCacheRefresh",
|
|
260
|
-
];
|
|
261
|
-
|
|
262
|
-
for (const exportName of requiredExports) {
|
|
263
|
-
expect(exportName in reactShim).toBe(true);
|
|
264
|
-
}
|
|
265
|
-
});
|
|
266
|
-
|
|
267
|
-
test("re-exports modern react-dom and react-dom/client APIs", async () => {
|
|
268
|
-
const reactDomShim = await importBuiltModule(".mandu/client/_react-dom.js");
|
|
269
|
-
for (const exportName of [
|
|
270
|
-
"preconnect",
|
|
271
|
-
"prefetchDNS",
|
|
272
|
-
"preinit",
|
|
273
|
-
"preinitModule",
|
|
274
|
-
"preload",
|
|
275
|
-
"preloadModule",
|
|
276
|
-
"requestFormReset",
|
|
277
|
-
"unstable_batchedUpdates",
|
|
278
|
-
"useFormState",
|
|
279
|
-
"useFormStatus",
|
|
280
|
-
]) {
|
|
281
|
-
expect(exportName in reactDomShim).toBe(true);
|
|
282
|
-
}
|
|
283
|
-
|
|
284
|
-
const reactDomClientShim = await importBuiltModule(".mandu/client/_react-dom-client.js");
|
|
285
|
-
for (const exportName of ["createRoot", "hydrateRoot", "version"]) {
|
|
286
|
-
expect(exportName in reactDomClientShim).toBe(true);
|
|
287
|
-
}
|
|
288
|
-
});
|
|
289
|
-
|
|
290
|
-
test("embeds hydration guards for deferred trigger strategies", async () => {
|
|
291
|
-
const runtimeSource = await readFile(path.join(rootDir, ".mandu", "client", "_runtime.js"), "utf-8");
|
|
292
|
-
expect(runtimeSource).toContain("function resolveHydrationTarget");
|
|
293
|
-
expect(runtimeSource).toContain("function hasHydratableMarkup");
|
|
294
|
-
expect(runtimeSource).toContain("function shouldHydrateCompiledIsland");
|
|
295
|
-
expect(runtimeSource).toContain("onRecoverableError");
|
|
296
|
-
expect(runtimeSource).toContain("data-mandu-hydrating");
|
|
297
|
-
expect(runtimeSource).toContain("data-mandu-render-mode");
|
|
298
|
-
expect(runtimeSource).toContain("data-mandu-recoverable-error");
|
|
252
|
+
const reactShim = await importBuiltModule(".mandu/client/_react.js");
|
|
253
|
+
const requiredExports = [
|
|
254
|
+
"Activity",
|
|
255
|
+
"__COMPILER_RUNTIME",
|
|
256
|
+
"cache",
|
|
257
|
+
"cacheSignal",
|
|
258
|
+
"startTransition",
|
|
259
|
+
"use",
|
|
260
|
+
"useActionState",
|
|
261
|
+
"useEffectEvent",
|
|
262
|
+
"useOptimistic",
|
|
263
|
+
"unstable_useCacheRefresh",
|
|
264
|
+
];
|
|
265
|
+
|
|
266
|
+
for (const exportName of requiredExports) {
|
|
267
|
+
expect(exportName in reactShim).toBe(true);
|
|
268
|
+
}
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
test("re-exports modern react-dom and react-dom/client APIs", async () => {
|
|
272
|
+
const reactDomShim = await importBuiltModule(".mandu/client/_react-dom.js");
|
|
273
|
+
for (const exportName of [
|
|
274
|
+
"preconnect",
|
|
275
|
+
"prefetchDNS",
|
|
276
|
+
"preinit",
|
|
277
|
+
"preinitModule",
|
|
278
|
+
"preload",
|
|
279
|
+
"preloadModule",
|
|
280
|
+
"requestFormReset",
|
|
281
|
+
"unstable_batchedUpdates",
|
|
282
|
+
"useFormState",
|
|
283
|
+
"useFormStatus",
|
|
284
|
+
]) {
|
|
285
|
+
expect(exportName in reactDomShim).toBe(true);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
const reactDomClientShim = await importBuiltModule(".mandu/client/_react-dom-client.js");
|
|
289
|
+
for (const exportName of ["createRoot", "hydrateRoot", "version"]) {
|
|
290
|
+
expect(exportName in reactDomClientShim).toBe(true);
|
|
291
|
+
}
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
test("embeds hydration guards for deferred trigger strategies", async () => {
|
|
295
|
+
const runtimeSource = await readFile(path.join(rootDir, ".mandu", "client", "_runtime.js"), "utf-8");
|
|
296
|
+
expect(runtimeSource).toContain("function resolveHydrationTarget");
|
|
297
|
+
expect(runtimeSource).toContain("function hasHydratableMarkup");
|
|
298
|
+
expect(runtimeSource).toContain("function shouldHydrateCompiledIsland");
|
|
299
|
+
expect(runtimeSource).toContain("onRecoverableError");
|
|
300
|
+
expect(runtimeSource).toContain("data-mandu-hydrating");
|
|
301
|
+
expect(runtimeSource).toContain("data-mandu-render-mode");
|
|
302
|
+
expect(runtimeSource).toContain("data-mandu-recoverable-error");
|
|
299
303
|
expect(runtimeSource).toContain('"click"');
|
|
300
304
|
expect(runtimeSource).not.toContain("pointerdown");
|
|
301
|
-
});
|
|
302
|
-
|
|
305
|
+
});
|
|
306
|
+
|
|
303
307
|
test("runtime parses SSR data script before island setup", async () => {
|
|
304
308
|
const runtimeSource = await readFile(path.join(rootDir, ".mandu", "client", "_runtime.js"), "utf-8");
|
|
305
309
|
expect(runtimeSource).toContain("function readManduData");
|
|
@@ -476,81 +480,81 @@ describe("buildClientBundles vendor shims", () => {
|
|
|
476
480
|
|
|
477
481
|
test("does not bundle a server page when stale manifest marks page.tsx as clientModule", async () => {
|
|
478
482
|
const staleRoot = await mkRepoTempDir("stale-client-module-");
|
|
479
|
-
try {
|
|
480
|
-
await mkdir(path.join(staleRoot, "app"), { recursive: true });
|
|
481
|
-
await mkdir(path.join(staleRoot, "src", "shared", "contracts"), { recursive: true });
|
|
482
|
-
await writeFile(
|
|
483
|
-
path.join(staleRoot, "package.json"),
|
|
484
|
-
JSON.stringify({ name: "mandu-stale-client-module-test", type: "module" }, null, 2),
|
|
485
|
-
"utf-8",
|
|
486
|
-
);
|
|
487
|
-
await writeFile(
|
|
488
|
-
path.join(staleRoot, "src", "shared", "contracts", "api.ts"),
|
|
489
|
-
'export const INTERNAL_BASE = process.env.MANDU_INTERNAL_URL ?? "http://localhost:3333";\n',
|
|
490
|
-
"utf-8",
|
|
491
|
-
);
|
|
492
|
-
await writeFile(
|
|
493
|
-
path.join(staleRoot, "app", "page.tsx"),
|
|
494
|
-
'import { INTERNAL_BASE } from "../src/shared/contracts/api";\n' +
|
|
495
|
-
"export default async function HomePage() {\n" +
|
|
496
|
-
" return <main>{INTERNAL_BASE}</main>;\n" +
|
|
497
|
-
"}\n",
|
|
498
|
-
"utf-8",
|
|
499
|
-
);
|
|
500
|
-
|
|
501
|
-
const staleResult = await runBuildInSubprocess(staleRoot, "server-page-client-module");
|
|
502
|
-
expect(staleResult.success).toBe(false);
|
|
503
|
-
expect(staleResult.errors.join("\n")).toContain("missing \"use client\"");
|
|
504
|
-
expect(await Bun.file(path.join(staleRoot, ".mandu", "client", "index.island.js")).exists()).toBe(false);
|
|
505
|
-
} finally {
|
|
506
|
-
await rm(staleRoot, { recursive: true, force: true });
|
|
507
|
-
}
|
|
508
|
-
});
|
|
509
|
-
|
|
483
|
+
try {
|
|
484
|
+
await mkdir(path.join(staleRoot, "app"), { recursive: true });
|
|
485
|
+
await mkdir(path.join(staleRoot, "src", "shared", "contracts"), { recursive: true });
|
|
486
|
+
await writeFile(
|
|
487
|
+
path.join(staleRoot, "package.json"),
|
|
488
|
+
JSON.stringify({ name: "mandu-stale-client-module-test", type: "module" }, null, 2),
|
|
489
|
+
"utf-8",
|
|
490
|
+
);
|
|
491
|
+
await writeFile(
|
|
492
|
+
path.join(staleRoot, "src", "shared", "contracts", "api.ts"),
|
|
493
|
+
'export const INTERNAL_BASE = process.env.MANDU_INTERNAL_URL ?? "http://localhost:3333";\n',
|
|
494
|
+
"utf-8",
|
|
495
|
+
);
|
|
496
|
+
await writeFile(
|
|
497
|
+
path.join(staleRoot, "app", "page.tsx"),
|
|
498
|
+
'import { INTERNAL_BASE } from "../src/shared/contracts/api";\n' +
|
|
499
|
+
"export default async function HomePage() {\n" +
|
|
500
|
+
" return <main>{INTERNAL_BASE}</main>;\n" +
|
|
501
|
+
"}\n",
|
|
502
|
+
"utf-8",
|
|
503
|
+
);
|
|
504
|
+
|
|
505
|
+
const staleResult = await runBuildInSubprocess(staleRoot, "server-page-client-module");
|
|
506
|
+
expect(staleResult.success).toBe(false);
|
|
507
|
+
expect(staleResult.errors.join("\n")).toContain("missing \"use client\"");
|
|
508
|
+
expect(await Bun.file(path.join(staleRoot, ".mandu", "client", "index.island.js")).exists()).toBe(false);
|
|
509
|
+
} finally {
|
|
510
|
+
await rm(staleRoot, { recursive: true, force: true });
|
|
511
|
+
}
|
|
512
|
+
});
|
|
513
|
+
|
|
510
514
|
test("rewrites route-component clientModule to the real client import before bundling", async () => {
|
|
511
515
|
const routeClientRoot = await mkRepoTempDir("route-client-import-");
|
|
512
|
-
try {
|
|
513
|
-
await mkdir(path.join(routeClientRoot, "app", "login"), { recursive: true });
|
|
514
|
-
await mkdir(path.join(routeClientRoot, "src", "client", "pages", "login"), { recursive: true });
|
|
515
|
-
await writeFile(
|
|
516
|
-
path.join(routeClientRoot, "package.json"),
|
|
517
|
-
JSON.stringify({ name: "mandu-route-client-import-test", type: "module" }, null, 2),
|
|
518
|
-
"utf-8",
|
|
519
|
-
);
|
|
520
|
-
await writeFile(
|
|
521
|
-
path.join(routeClientRoot, "app", "login", "page.tsx"),
|
|
522
|
-
'import LoginPage from "@/client/pages/login/LoginPage.client";\n' +
|
|
523
|
-
"export default function Page() {\n" +
|
|
524
|
-
" return <LoginPage />;\n" +
|
|
525
|
-
"}\n",
|
|
526
|
-
"utf-8",
|
|
527
|
-
);
|
|
528
|
-
await writeFile(
|
|
529
|
-
path.join(routeClientRoot, "src", "client", "pages", "login", "LoginPage.client.tsx"),
|
|
530
|
-
'"use client";\n' +
|
|
531
|
-
'import { useState } from "react";\n' +
|
|
532
|
-
"export default function LoginPage() {\n" +
|
|
533
|
-
' const [email, setEmail] = useState("");\n' +
|
|
534
|
-
' return <form><input value={email} onChange={(event) => setEmail(event.currentTarget.value)} /></form>;\n' +
|
|
535
|
-
"}\n",
|
|
536
|
-
"utf-8",
|
|
537
|
-
);
|
|
538
|
-
|
|
539
|
-
const routeClientResult = await runBuildInSubprocess(routeClientRoot, "server-page-route-client-import");
|
|
540
|
-
expect(routeClientResult.success).toBe(true);
|
|
541
|
-
const bundlePath = path.join(routeClientRoot, ".mandu", "client", "login.island.js");
|
|
542
|
-
expect(await Bun.file(bundlePath).exists()).toBe(true);
|
|
543
|
-
|
|
544
|
-
const bundleSource = await readFile(bundlePath, "utf-8");
|
|
545
|
-
expect(bundleSource).not.toContain("var LoginPage = LoginPage;");
|
|
546
|
-
const parseResult = await Bun.build({
|
|
547
|
-
entrypoints: [bundlePath],
|
|
548
|
-
target: "browser",
|
|
549
|
-
external: ["react", "react-dom", "react-dom/client", "react/jsx-dev-runtime"],
|
|
550
|
-
});
|
|
551
|
-
expect(parseResult.success).toBe(true);
|
|
552
|
-
} finally {
|
|
553
|
-
await rm(routeClientRoot, { recursive: true, force: true });
|
|
516
|
+
try {
|
|
517
|
+
await mkdir(path.join(routeClientRoot, "app", "login"), { recursive: true });
|
|
518
|
+
await mkdir(path.join(routeClientRoot, "src", "client", "pages", "login"), { recursive: true });
|
|
519
|
+
await writeFile(
|
|
520
|
+
path.join(routeClientRoot, "package.json"),
|
|
521
|
+
JSON.stringify({ name: "mandu-route-client-import-test", type: "module" }, null, 2),
|
|
522
|
+
"utf-8",
|
|
523
|
+
);
|
|
524
|
+
await writeFile(
|
|
525
|
+
path.join(routeClientRoot, "app", "login", "page.tsx"),
|
|
526
|
+
'import LoginPage from "@/client/pages/login/LoginPage.client";\n' +
|
|
527
|
+
"export default function Page() {\n" +
|
|
528
|
+
" return <LoginPage />;\n" +
|
|
529
|
+
"}\n",
|
|
530
|
+
"utf-8",
|
|
531
|
+
);
|
|
532
|
+
await writeFile(
|
|
533
|
+
path.join(routeClientRoot, "src", "client", "pages", "login", "LoginPage.client.tsx"),
|
|
534
|
+
'"use client";\n' +
|
|
535
|
+
'import { useState } from "react";\n' +
|
|
536
|
+
"export default function LoginPage() {\n" +
|
|
537
|
+
' const [email, setEmail] = useState("");\n' +
|
|
538
|
+
' return <form><input value={email} onChange={(event) => setEmail(event.currentTarget.value)} /></form>;\n' +
|
|
539
|
+
"}\n",
|
|
540
|
+
"utf-8",
|
|
541
|
+
);
|
|
542
|
+
|
|
543
|
+
const routeClientResult = await runBuildInSubprocess(routeClientRoot, "server-page-route-client-import");
|
|
544
|
+
expect(routeClientResult.success).toBe(true);
|
|
545
|
+
const bundlePath = path.join(routeClientRoot, ".mandu", "client", "login.island.js");
|
|
546
|
+
expect(await Bun.file(bundlePath).exists()).toBe(true);
|
|
547
|
+
|
|
548
|
+
const bundleSource = await readFile(bundlePath, "utf-8");
|
|
549
|
+
expect(bundleSource).not.toContain("var LoginPage = LoginPage;");
|
|
550
|
+
const parseResult = await Bun.build({
|
|
551
|
+
entrypoints: [bundlePath],
|
|
552
|
+
target: "browser",
|
|
553
|
+
external: ["react", "react-dom", "react-dom/client", "react/jsx-dev-runtime"],
|
|
554
|
+
});
|
|
555
|
+
expect(parseResult.success).toBe(true);
|
|
556
|
+
} finally {
|
|
557
|
+
await rm(routeClientRoot, { recursive: true, force: true });
|
|
554
558
|
}
|
|
555
559
|
});
|
|
556
560
|
|
|
@@ -633,37 +637,37 @@ describe("buildClientBundles vendor shims", () => {
|
|
|
633
637
|
|
|
634
638
|
test("fails when hydration is enabled but no clientModule can be resolved", async () => {
|
|
635
639
|
const missingRoot = await mkRepoTempDir("hydration-no-client-");
|
|
636
|
-
try {
|
|
637
|
-
await mkdir(path.join(missingRoot, "app", "login"), { recursive: true });
|
|
638
|
-
await mkdir(path.join(missingRoot, "src", "client", "widgets", "login-form"), { recursive: true });
|
|
639
|
-
await writeFile(
|
|
640
|
-
path.join(missingRoot, "package.json"),
|
|
641
|
-
JSON.stringify({ name: "mandu-hydration-no-client-test", type: "module" }, null, 2),
|
|
642
|
-
"utf-8",
|
|
643
|
-
);
|
|
644
|
-
await writeFile(
|
|
645
|
-
path.join(missingRoot, "app", "login", "page.tsx"),
|
|
646
|
-
'import { LoginForm } from "../../src/client/widgets/login-form/LoginForm.client";\n' +
|
|
647
|
-
"export default function LoginPage() {\n" +
|
|
648
|
-
" return <main><LoginForm /></main>;\n" +
|
|
649
|
-
"}\n",
|
|
650
|
-
"utf-8",
|
|
651
|
-
);
|
|
652
|
-
await writeFile(
|
|
653
|
-
path.join(missingRoot, "src", "client", "widgets", "login-form", "LoginForm.client.tsx"),
|
|
654
|
-
"export function LoginForm() { return <form />; }\n",
|
|
655
|
-
"utf-8",
|
|
656
|
-
);
|
|
657
|
-
|
|
658
|
-
const noClientResult = await runBuildInSubprocess(missingRoot, "hydration-no-client-module");
|
|
659
|
-
const errors = noClientResult.errors.join("\n");
|
|
660
|
-
expect(noClientResult.success).toBe(false);
|
|
661
|
-
expect(errors).toContain("no clientModule could be resolved");
|
|
662
|
-
expect(errors).toContain("LoginForm.client");
|
|
663
|
-
expect(errors).toContain("run mandu generate");
|
|
664
|
-
} finally {
|
|
665
|
-
await rm(missingRoot, { recursive: true, force: true });
|
|
666
|
-
}
|
|
640
|
+
try {
|
|
641
|
+
await mkdir(path.join(missingRoot, "app", "login"), { recursive: true });
|
|
642
|
+
await mkdir(path.join(missingRoot, "src", "client", "widgets", "login-form"), { recursive: true });
|
|
643
|
+
await writeFile(
|
|
644
|
+
path.join(missingRoot, "package.json"),
|
|
645
|
+
JSON.stringify({ name: "mandu-hydration-no-client-test", type: "module" }, null, 2),
|
|
646
|
+
"utf-8",
|
|
647
|
+
);
|
|
648
|
+
await writeFile(
|
|
649
|
+
path.join(missingRoot, "app", "login", "page.tsx"),
|
|
650
|
+
'import { LoginForm } from "../../src/client/widgets/login-form/LoginForm.client";\n' +
|
|
651
|
+
"export default function LoginPage() {\n" +
|
|
652
|
+
" return <main><LoginForm /></main>;\n" +
|
|
653
|
+
"}\n",
|
|
654
|
+
"utf-8",
|
|
655
|
+
);
|
|
656
|
+
await writeFile(
|
|
657
|
+
path.join(missingRoot, "src", "client", "widgets", "login-form", "LoginForm.client.tsx"),
|
|
658
|
+
"export function LoginForm() { return <form />; }\n",
|
|
659
|
+
"utf-8",
|
|
660
|
+
);
|
|
661
|
+
|
|
662
|
+
const noClientResult = await runBuildInSubprocess(missingRoot, "hydration-no-client-module");
|
|
663
|
+
const errors = noClientResult.errors.join("\n");
|
|
664
|
+
expect(noClientResult.success).toBe(false);
|
|
665
|
+
expect(errors).toContain("no clientModule could be resolved");
|
|
666
|
+
expect(errors).toContain("LoginForm.client");
|
|
667
|
+
expect(errors).toContain("run mandu generate");
|
|
668
|
+
} finally {
|
|
669
|
+
await rm(missingRoot, { recursive: true, force: true });
|
|
670
|
+
}
|
|
667
671
|
});
|
|
668
672
|
});
|
|
669
673
|
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test";
|
|
2
|
+
import React from "react";
|
|
3
|
+
import type { RoutesManifest } from "../../spec/schema";
|
|
4
|
+
import {
|
|
5
|
+
createServerRegistry,
|
|
6
|
+
startServer,
|
|
7
|
+
type ManduServer,
|
|
8
|
+
} from "../server";
|
|
9
|
+
|
|
10
|
+
// Issue #314 — server page components must receive `searchParams` (query
|
|
11
|
+
// string) as a prop, matching the value `generateMetadata` already gets.
|
|
12
|
+
// SearchPage renders the query so we can assert it survives SSR.
|
|
13
|
+
function SearchPage({
|
|
14
|
+
params,
|
|
15
|
+
searchParams,
|
|
16
|
+
}: {
|
|
17
|
+
params: Record<string, string>;
|
|
18
|
+
searchParams: Record<string, string>;
|
|
19
|
+
}): React.ReactElement {
|
|
20
|
+
return React.createElement(
|
|
21
|
+
"main",
|
|
22
|
+
null,
|
|
23
|
+
React.createElement("p", { "data-testid": "q" }, `q = ${searchParams.q ?? "(undefined)"}`),
|
|
24
|
+
React.createElement("p", { "data-testid": "id" }, `id = ${params.id ?? "(none)"}`),
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function searchManifest(): RoutesManifest {
|
|
29
|
+
return {
|
|
30
|
+
version: 1,
|
|
31
|
+
routes: [
|
|
32
|
+
{
|
|
33
|
+
id: "search",
|
|
34
|
+
kind: "page",
|
|
35
|
+
pattern: "/search",
|
|
36
|
+
module: "app/search/page.tsx",
|
|
37
|
+
componentModule: "app/search/page.tsx",
|
|
38
|
+
hydration: { strategy: "none", priority: "visible", preload: false },
|
|
39
|
+
},
|
|
40
|
+
],
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async function fetchSearch(query: string): Promise<{ status: number; html: string }> {
|
|
45
|
+
const registry = createServerRegistry();
|
|
46
|
+
registry.registerRouteComponent("search", SearchPage);
|
|
47
|
+
|
|
48
|
+
let server: ManduServer | undefined;
|
|
49
|
+
try {
|
|
50
|
+
server = startServer(searchManifest(), {
|
|
51
|
+
port: 0,
|
|
52
|
+
registry,
|
|
53
|
+
transitions: false,
|
|
54
|
+
prefetch: false,
|
|
55
|
+
spa: false,
|
|
56
|
+
devtools: false,
|
|
57
|
+
silent: true,
|
|
58
|
+
});
|
|
59
|
+
const response = await fetch(`http://127.0.0.1:${server.server.port}/search${query}`);
|
|
60
|
+
return { status: response.status, html: await response.text() };
|
|
61
|
+
} finally {
|
|
62
|
+
server?.stop();
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
describe("server page searchParams prop (#314)", () => {
|
|
67
|
+
it("passes the query string to the page component", async () => {
|
|
68
|
+
const { status, html } = await fetchSearch("?q=foo");
|
|
69
|
+
|
|
70
|
+
expect(status).toBe(200);
|
|
71
|
+
expect(html).toContain("q = foo");
|
|
72
|
+
expect(html).not.toContain("q = (undefined)");
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("renders gracefully when the query string is absent", async () => {
|
|
76
|
+
const { status, html } = await fetchSearch("");
|
|
77
|
+
|
|
78
|
+
expect(status).toBe(200);
|
|
79
|
+
expect(html).toContain("q = (undefined)");
|
|
80
|
+
});
|
|
81
|
+
});
|
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, type InlineClientHydrationTarget } 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,12 +479,13 @@ 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<{
|
|
484
|
-
params: Record<string, string>;
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
482
|
+
export interface PageRegistration {
|
|
483
|
+
component: React.ComponentType<{
|
|
484
|
+
params: Record<string, string>;
|
|
485
|
+
searchParams: Record<string, string>;
|
|
486
|
+
loaderData?: unknown;
|
|
487
|
+
__manduHydration?: InlineClientHydrationTarget;
|
|
488
|
+
}>;
|
|
488
489
|
filling?: ManduFilling<unknown>;
|
|
489
490
|
/** #186: page 모듈의 static `metadata` export (선택) */
|
|
490
491
|
metadata?: Metadata;
|
|
@@ -506,20 +507,23 @@ export type PageHandler = () => Promise<PageRegistration>;
|
|
|
506
507
|
*/
|
|
507
508
|
export type MetadataHandler = () => Promise<unknown>;
|
|
508
509
|
|
|
509
|
-
export interface AppContext {
|
|
510
|
-
routeId: string;
|
|
511
|
-
url: string;
|
|
512
|
-
params: Record<string, string>;
|
|
513
|
-
/**
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
510
|
+
export interface AppContext {
|
|
511
|
+
routeId: string;
|
|
512
|
+
url: string;
|
|
513
|
+
params: Record<string, string>;
|
|
514
|
+
/** 요청 URL의 쿼리스트링 (generateMetadata와 동일한 값) */
|
|
515
|
+
searchParams: Record<string, string>;
|
|
516
|
+
/** SSR loader에서 로드한 데이터 */
|
|
517
|
+
loaderData?: unknown;
|
|
518
|
+
__manduHydration?: InlineClientHydrationTarget;
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
type RouteComponent = (props: {
|
|
522
|
+
params: Record<string, string>;
|
|
523
|
+
searchParams: Record<string, string>;
|
|
524
|
+
loaderData?: unknown;
|
|
525
|
+
__manduHydration?: InlineClientHydrationTarget;
|
|
526
|
+
}) => React.ReactElement;
|
|
523
527
|
type CreateAppFn = (context: AppContext) => React.ReactElement;
|
|
524
528
|
|
|
525
529
|
// ========== Server Registry (인스턴스별 분리) ==========
|
|
@@ -1095,8 +1099,8 @@ async function wrapWithLayouts(
|
|
|
1095
1099
|
}
|
|
1096
1100
|
|
|
1097
1101
|
// Default createApp implementation (registry 기반)
|
|
1098
|
-
function createDefaultAppFactory(registry: ServerRegistry) {
|
|
1099
|
-
return function defaultCreateApp(context: AppContext): React.ReactElement {
|
|
1102
|
+
function createDefaultAppFactory(registry: ServerRegistry) {
|
|
1103
|
+
return function defaultCreateApp(context: AppContext): React.ReactElement {
|
|
1100
1104
|
const Component = registry.routeComponents.get(context.routeId);
|
|
1101
1105
|
|
|
1102
1106
|
if (!Component) {
|
|
@@ -1106,102 +1110,103 @@ function createDefaultAppFactory(registry: ServerRegistry) {
|
|
|
1106
1110
|
);
|
|
1107
1111
|
}
|
|
1108
1112
|
|
|
1109
|
-
return React.createElement(Component, {
|
|
1110
|
-
params: context.params,
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
}
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
const
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
}
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
const
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
.
|
|
1199
|
-
.
|
|
1200
|
-
.
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1113
|
+
return React.createElement(Component, {
|
|
1114
|
+
params: context.params,
|
|
1115
|
+
searchParams: context.searchParams,
|
|
1116
|
+
loaderData: context.loaderData,
|
|
1117
|
+
__manduHydration: context.__manduHydration,
|
|
1118
|
+
});
|
|
1119
|
+
};
|
|
1120
|
+
}
|
|
1121
|
+
|
|
1122
|
+
async function resolveInlineClientHydrationTarget(
|
|
1123
|
+
route: {
|
|
1124
|
+
id: string;
|
|
1125
|
+
componentModule?: string;
|
|
1126
|
+
clientModule?: string;
|
|
1127
|
+
clientExportName?: string;
|
|
1128
|
+
hydration?: HydrationConfig;
|
|
1129
|
+
boundaries?: unknown[];
|
|
1130
|
+
},
|
|
1131
|
+
rootDir: string,
|
|
1132
|
+
src: string,
|
|
1133
|
+
): Promise<InlineClientHydrationTarget | undefined> {
|
|
1134
|
+
if (!route.clientModule || !src) {
|
|
1135
|
+
return undefined;
|
|
1136
|
+
}
|
|
1137
|
+
if (route.boundaries && route.boundaries.length > 0) {
|
|
1138
|
+
return undefined;
|
|
1139
|
+
}
|
|
1140
|
+
|
|
1141
|
+
const legacyRuntimeScan = process.env.MANDU_LEGACY_RUNTIME_PARTIAL_SCAN === "1";
|
|
1142
|
+
if (!legacyRuntimeScan) {
|
|
1143
|
+
return undefined;
|
|
1144
|
+
}
|
|
1145
|
+
|
|
1146
|
+
try {
|
|
1147
|
+
const module = await import(path.join(rootDir, route.clientModule));
|
|
1148
|
+
const component = resolveInlineClientHydrationComponent(module, route);
|
|
1149
|
+
|
|
1150
|
+
if (!component) return undefined;
|
|
1151
|
+
|
|
1152
|
+
return {
|
|
1153
|
+
routeId: route.id,
|
|
1154
|
+
src,
|
|
1155
|
+
priority: route.hydration?.priority ?? "visible",
|
|
1156
|
+
component,
|
|
1157
|
+
legacyRuntimeScan,
|
|
1158
|
+
sourceFile: route.componentModule ?? route.clientModule,
|
|
1159
|
+
};
|
|
1160
|
+
} catch (error) {
|
|
1161
|
+
console.warn(
|
|
1162
|
+
`[Mandu] Failed to resolve inline client hydration target for "${route.id}":`,
|
|
1163
|
+
error,
|
|
1164
|
+
);
|
|
1165
|
+
return undefined;
|
|
1166
|
+
}
|
|
1167
|
+
}
|
|
1168
|
+
|
|
1169
|
+
function resolveInlineClientHydrationComponent(
|
|
1170
|
+
module: Record<string, unknown>,
|
|
1171
|
+
route: { id: string; clientModule?: string; clientExportName?: string },
|
|
1172
|
+
): unknown {
|
|
1173
|
+
if (module.default) return module.default;
|
|
1174
|
+
|
|
1175
|
+
const candidates = [
|
|
1176
|
+
route.clientExportName && route.clientExportName !== "default" ? route.clientExportName : undefined,
|
|
1177
|
+
inferInlineClientExportNameFromPath(route.clientModule),
|
|
1178
|
+
inferInlineClientExportNameFromRouteId(route.id),
|
|
1179
|
+
].filter((candidate, index, values): candidate is string =>
|
|
1180
|
+
!!candidate && values.indexOf(candidate) === index
|
|
1181
|
+
);
|
|
1182
|
+
|
|
1183
|
+
for (const candidate of candidates) {
|
|
1184
|
+
if (module[candidate]) return module[candidate];
|
|
1185
|
+
}
|
|
1186
|
+
|
|
1187
|
+
const runtimeExports = Object.keys(module).filter((name) => name !== "__esModule");
|
|
1188
|
+
return runtimeExports.length === 1 ? module[runtimeExports[0]] : undefined;
|
|
1189
|
+
}
|
|
1190
|
+
|
|
1191
|
+
function inferInlineClientExportNameFromPath(clientModulePath: string | undefined): string | undefined {
|
|
1192
|
+
if (!clientModulePath) return undefined;
|
|
1193
|
+
const basename = path.basename(clientModulePath).replace(/\.[cm]?[jt]sx?$/, "");
|
|
1194
|
+
const withoutClientSuffix = basename.replace(/\.(client|island)$/, "");
|
|
1195
|
+
return /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(withoutClientSuffix)
|
|
1196
|
+
? withoutClientSuffix
|
|
1197
|
+
: undefined;
|
|
1198
|
+
}
|
|
1199
|
+
|
|
1200
|
+
function inferInlineClientExportNameFromRouteId(routeId: string): string | undefined {
|
|
1201
|
+
const pascal = routeId
|
|
1202
|
+
.split(/[^A-Za-z0-9]+/)
|
|
1203
|
+
.filter(Boolean)
|
|
1204
|
+
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
|
1205
|
+
.join("");
|
|
1206
|
+
return /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(pascal) ? pascal : undefined;
|
|
1207
|
+
}
|
|
1208
|
+
|
|
1209
|
+
const INTERNAL_CACHE_ENDPOINT = "/_mandu/cache";
|
|
1205
1210
|
|
|
1206
1211
|
// ========== Request Handler ==========
|
|
1207
1212
|
|
|
@@ -2114,21 +2119,21 @@ function extractTitleText(titleHtml: string): string | null {
|
|
|
2114
2119
|
/**
|
|
2115
2120
|
* SSR 렌더링 (Streaming/Non-streaming)
|
|
2116
2121
|
*/
|
|
2117
|
-
async function renderPageSSR(
|
|
2118
|
-
route: {
|
|
2119
|
-
id: string;
|
|
2120
|
-
pattern: string;
|
|
2121
|
-
layoutChain?: string[];
|
|
2122
|
-
streaming?: boolean;
|
|
2123
|
-
hydration?: HydrationConfig;
|
|
2124
|
-
errorModule?: string;
|
|
2125
|
-
loadingModule?: string;
|
|
2126
|
-
notFoundModule?: string;
|
|
2127
|
-
componentModule?: string;
|
|
2128
|
-
clientModule?: string;
|
|
2129
|
-
clientExportName?: string;
|
|
2130
|
-
boundaries?: unknown[];
|
|
2131
|
-
},
|
|
2122
|
+
async function renderPageSSR(
|
|
2123
|
+
route: {
|
|
2124
|
+
id: string;
|
|
2125
|
+
pattern: string;
|
|
2126
|
+
layoutChain?: string[];
|
|
2127
|
+
streaming?: boolean;
|
|
2128
|
+
hydration?: HydrationConfig;
|
|
2129
|
+
errorModule?: string;
|
|
2130
|
+
loadingModule?: string;
|
|
2131
|
+
notFoundModule?: string;
|
|
2132
|
+
componentModule?: string;
|
|
2133
|
+
clientModule?: string;
|
|
2134
|
+
clientExportName?: string;
|
|
2135
|
+
boundaries?: unknown[];
|
|
2136
|
+
},
|
|
2132
2137
|
params: Record<string, string>,
|
|
2133
2138
|
loaderData: unknown,
|
|
2134
2139
|
url: string,
|
|
@@ -2138,30 +2143,31 @@ async function renderPageSSR(
|
|
|
2138
2143
|
): Promise<Result<Response>> {
|
|
2139
2144
|
const settings = registry.settings;
|
|
2140
2145
|
const defaultAppCreator = createDefaultAppFactory(registry);
|
|
2141
|
-
const appCreator = registry.createAppFn || defaultAppCreator;
|
|
2142
|
-
|
|
2143
|
-
try {
|
|
2144
|
-
const useStreaming = route.streaming !== undefined
|
|
2145
|
-
? route.streaming
|
|
2146
|
-
: settings.streaming;
|
|
2147
|
-
const needsIslandHydration = !!(
|
|
2148
|
-
route.hydration &&
|
|
2149
|
-
route.hydration.strategy !== "none" &&
|
|
2150
|
-
settings.bundleManifest
|
|
2151
|
-
);
|
|
2152
|
-
const routeBundle = settings.bundleManifest?.bundles[route.id];
|
|
2153
|
-
const bundleSrc = routeBundle?.js ? `${routeBundle.js}?t=${Date.now()}` : "";
|
|
2154
|
-
const inlineClientHydration = needsIslandHydration && !useStreaming
|
|
2155
|
-
? await resolveInlineClientHydrationTarget(route, settings.rootDir, bundleSrc)
|
|
2156
|
-
: undefined;
|
|
2157
|
-
|
|
2158
|
-
let app = appCreator({
|
|
2159
|
-
routeId: route.id,
|
|
2160
|
-
url,
|
|
2161
|
-
params,
|
|
2162
|
-
|
|
2163
|
-
|
|
2164
|
-
|
|
2146
|
+
const appCreator = registry.createAppFn || defaultAppCreator;
|
|
2147
|
+
|
|
2148
|
+
try {
|
|
2149
|
+
const useStreaming = route.streaming !== undefined
|
|
2150
|
+
? route.streaming
|
|
2151
|
+
: settings.streaming;
|
|
2152
|
+
const needsIslandHydration = !!(
|
|
2153
|
+
route.hydration &&
|
|
2154
|
+
route.hydration.strategy !== "none" &&
|
|
2155
|
+
settings.bundleManifest
|
|
2156
|
+
);
|
|
2157
|
+
const routeBundle = settings.bundleManifest?.bundles[route.id];
|
|
2158
|
+
const bundleSrc = routeBundle?.js ? `${routeBundle.js}?t=${Date.now()}` : "";
|
|
2159
|
+
const inlineClientHydration = needsIslandHydration && !useStreaming
|
|
2160
|
+
? await resolveInlineClientHydrationTarget(route, settings.rootDir, bundleSrc)
|
|
2161
|
+
: undefined;
|
|
2162
|
+
|
|
2163
|
+
let app = appCreator({
|
|
2164
|
+
routeId: route.id,
|
|
2165
|
+
url,
|
|
2166
|
+
params,
|
|
2167
|
+
searchParams: extractSearchParams(url),
|
|
2168
|
+
loaderData,
|
|
2169
|
+
__manduHydration: inlineClientHydration,
|
|
2170
|
+
});
|
|
2165
2171
|
|
|
2166
2172
|
// Phase 18.β — per-route Suspense wrapper (Next.js `loading.tsx` parity).
|
|
2167
2173
|
// If the route declared a `loading.tsx`, wrap the page element in a
|
|
@@ -2189,13 +2195,13 @@ async function renderPageSSR(
|
|
|
2189
2195
|
|
|
2190
2196
|
// Island 래핑: 레이아웃 적용 전에 페이지 콘텐츠만 island div로 감쌈
|
|
2191
2197
|
// 이렇게 하면 레이아웃은 island 바깥에 위치하여 하이드레이션 시 레이아웃이 유지됨
|
|
2192
|
-
const needsIslandWrap = needsIslandHydration && bundleSrc.length > 0 && !inlineClientHydration;
|
|
2198
|
+
const needsIslandWrap = needsIslandHydration && bundleSrc.length > 0 && !inlineClientHydration;
|
|
2193
2199
|
|
|
2194
|
-
if (needsIslandHydration && bundleSrc.length === 0 && settings.isDev) {
|
|
2195
|
-
console.warn(
|
|
2196
|
-
`[Mandu] Hydration requested for route "${route.id}" but no client bundle was found. ` +
|
|
2197
|
-
`Run mandu build/generate and ensure the route has a clientModule.`,
|
|
2198
|
-
);
|
|
2200
|
+
if (needsIslandHydration && bundleSrc.length === 0 && settings.isDev) {
|
|
2201
|
+
console.warn(
|
|
2202
|
+
`[Mandu] Hydration requested for route "${route.id}" but no client bundle was found. ` +
|
|
2203
|
+
`Run mandu build/generate and ensure the route has a clientModule.`,
|
|
2204
|
+
);
|
|
2199
2205
|
}
|
|
2200
2206
|
|
|
2201
2207
|
if (needsIslandWrap) {
|
|
@@ -2216,7 +2222,7 @@ async function renderPageSSR(
|
|
|
2216
2222
|
// #186: layout chain + page metadata 병합
|
|
2217
2223
|
const builtMeta = await buildSSRMetadata(route, params, url, registry);
|
|
2218
2224
|
|
|
2219
|
-
const pageResponse = await renderPageResponse({
|
|
2225
|
+
const pageResponse = await renderPageResponse({
|
|
2220
2226
|
app,
|
|
2221
2227
|
useStreaming,
|
|
2222
2228
|
title: builtMeta.title,
|
|
@@ -2227,12 +2233,12 @@ async function renderPageSSR(
|
|
|
2227
2233
|
routePattern: route.pattern,
|
|
2228
2234
|
layoutChain: route.layoutChain,
|
|
2229
2235
|
hydration: route.hydration,
|
|
2230
|
-
bundleManifest: settings.bundleManifest,
|
|
2231
|
-
loaderData,
|
|
2232
|
-
cssPath: settings.cssPath,
|
|
2233
|
-
islandPreWrapped: needsIslandWrap,
|
|
2234
|
-
inlineClientHydration,
|
|
2235
|
-
transitions: settings.transitions,
|
|
2236
|
+
bundleManifest: settings.bundleManifest,
|
|
2237
|
+
loaderData,
|
|
2238
|
+
cssPath: settings.cssPath,
|
|
2239
|
+
islandPreWrapped: needsIslandWrap,
|
|
2240
|
+
inlineClientHydration,
|
|
2241
|
+
transitions: settings.transitions,
|
|
2236
2242
|
prefetch: settings.prefetch,
|
|
2237
2243
|
spa: settings.spa,
|
|
2238
2244
|
devtools: settings.devtools,
|
|
@@ -2422,6 +2428,7 @@ async function renderNotFoundPage(
|
|
|
2422
2428
|
// streaming, no island bundling — a 404 page is plain).
|
|
2423
2429
|
let app: React.ReactElement = React.createElement(NotFoundComponent, {
|
|
2424
2430
|
params,
|
|
2431
|
+
searchParams: extractSearchParams(req.url),
|
|
2425
2432
|
loaderData,
|
|
2426
2433
|
});
|
|
2427
2434
|
if (route.layoutChain && route.layoutChain.length > 0) {
|
|
@@ -3450,6 +3457,7 @@ async function handleRequestInternal(
|
|
|
3450
3457
|
}
|
|
3451
3458
|
const rawApp = React.createElement(registration.component, {
|
|
3452
3459
|
params: {},
|
|
3460
|
+
searchParams: extractSearchParams(req.url),
|
|
3453
3461
|
loaderData,
|
|
3454
3462
|
});
|
|
3455
3463
|
// Issue #198 — pre-resolve in case the not-found component is async.
|