@mandujs/core 0.54.18 → 0.54.20
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/agent/__tests__/context.test.ts +40 -9
- package/src/agent/verify.ts +55 -24
- package/src/bundler/build.test.ts +287 -246
- package/src/bundler/build.ts +35 -680
- package/src/client/__tests__/props-serialization.test.ts +37 -0
- package/src/client/hydrate.ts +2 -2
- package/src/client/index.ts +1 -1
- package/src/client/props-serialization.ts +233 -0
- package/src/client/runtime-entry.ts +567 -0
- package/src/client/runtime.ts +1 -1
- package/src/client/serialize.ts +50 -404
- package/src/diagnose/__tests__/checks.test.ts +15 -0
- package/src/diagnose/checks.ts +1 -1
- package/src/router/client-entry.test.ts +111 -23
- package/src/router/client-entry.ts +78 -301
- package/src/router/fs-routes.test.ts +55 -0
- package/src/router/fs-scanner.ts +11 -40
- package/src/router/fs-types.ts +7 -1
- package/src/router/route-source-analyzer.ts +521 -0
- package/src/runtime/__tests__/inline-client-hydration.test.ts +104 -1
- package/src/runtime/__tests__/page-render-response.test.ts +6 -0
- package/src/runtime/__tests__/searchparams-page-props.test.ts +81 -0
- package/src/runtime/page-render-response.ts +23 -1
- package/src/runtime/server.ts +179 -157
|
@@ -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()}`);
|
|
@@ -29,11 +29,11 @@ async function evaluateGeneratedHydrationRuntime(): Promise<{ hydrateIslands: ()
|
|
|
29
29
|
const runtimeSource = await readFile(await Bun.file(sourcePath).exists() ? sourcePath : bundledPath, "utf-8");
|
|
30
30
|
const instrumented = runtimeSource
|
|
31
31
|
.replace(
|
|
32
|
-
/import\s+React,\s*\{
|
|
32
|
+
/import\s+React,\s*\{[^}]*\}\s+from\s+['"]react['"];?/,
|
|
33
33
|
"const React = globalThis.__MANDU_TEST_REACT__; const { useState, useEffect, Component } = React;",
|
|
34
34
|
)
|
|
35
35
|
.replace(
|
|
36
|
-
/import\s+\{
|
|
36
|
+
/import\s+\{[^}]*\}\s+from\s+['"]react-dom\/client['"];?/,
|
|
37
37
|
"const { hydrateRoot, createRoot } = globalThis.__MANDU_TEST_REACT_DOM_CLIENT__;",
|
|
38
38
|
)
|
|
39
39
|
.replace(
|
|
@@ -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");
|
|
@@ -308,7 +312,7 @@ describe("buildClientBundles vendor shims", () => {
|
|
|
308
312
|
expect(runtimeSource).toContain("data-mandu-props");
|
|
309
313
|
expect(runtimeSource).toContain("Missing boundary-local props for transformed client boundary");
|
|
310
314
|
expect(runtimeSource).toContain("warnedBoundaryPropFallbacks");
|
|
311
|
-
expect(runtimeSource).toContain("
|
|
315
|
+
expect(runtimeSource).toContain("deserializeProps");
|
|
312
316
|
expect(runtimeSource).toContain("new Date");
|
|
313
317
|
expect(runtimeSource).toContain("new Map");
|
|
314
318
|
});
|
|
@@ -437,83 +441,120 @@ describe("buildClientBundles vendor shims", () => {
|
|
|
437
441
|
);
|
|
438
442
|
});
|
|
439
443
|
|
|
444
|
+
test("runtime falls back to route-level server data when boundary-local props are absent", async () => {
|
|
445
|
+
const modulePath = path.join(rootDir, ".mandu", "client", "runtime-route-data-fallback.js");
|
|
446
|
+
await writeFile(
|
|
447
|
+
modulePath,
|
|
448
|
+
`
|
|
449
|
+
export default function RouteFallbackBoundary(props) {
|
|
450
|
+
globalThis.__MANDU_TEST_ROUTE_DATA_FALLBACK_PROPS__ = props;
|
|
451
|
+
return null;
|
|
452
|
+
}
|
|
453
|
+
`,
|
|
454
|
+
"utf-8",
|
|
455
|
+
);
|
|
456
|
+
Object.assign(globalThis, {
|
|
457
|
+
__MANDU_TEST_ROUTE_DATA_FALLBACK_PROPS__: undefined,
|
|
458
|
+
});
|
|
459
|
+
document.body.innerHTML = `
|
|
460
|
+
<div
|
|
461
|
+
data-mandu-island="route-fallback--0"
|
|
462
|
+
data-mandu-boundary-id="route-fallback--0"
|
|
463
|
+
data-mandu-route-id="route-fallback"
|
|
464
|
+
data-mandu-src="${pathToFileURL(modulePath).href}?t=${Date.now()}"
|
|
465
|
+
data-hydrate="load"
|
|
466
|
+
></div>
|
|
467
|
+
`;
|
|
468
|
+
(window as typeof window & { __MANDU_DATA__?: unknown; __MANDU_ROOTS__?: Map<string, unknown> }).__MANDU_DATA__ = {
|
|
469
|
+
"route-fallback": { serverData: { label: "route-level" } },
|
|
470
|
+
};
|
|
471
|
+
(window as typeof window & { __MANDU_ROOTS__?: Map<string, unknown> }).__MANDU_ROOTS__ = new Map();
|
|
472
|
+
|
|
473
|
+
const runtime = await evaluateGeneratedHydrationRuntime();
|
|
474
|
+
runtime.hydrateIslands();
|
|
475
|
+
|
|
476
|
+
await waitForRuntimeAssertion(() =>
|
|
477
|
+
(globalThis as typeof globalThis & { __MANDU_TEST_ROUTE_DATA_FALLBACK_PROPS__?: { label?: string } }).__MANDU_TEST_ROUTE_DATA_FALLBACK_PROPS__?.label === "route-level"
|
|
478
|
+
);
|
|
479
|
+
});
|
|
480
|
+
|
|
440
481
|
test("does not bundle a server page when stale manifest marks page.tsx as clientModule", async () => {
|
|
441
482
|
const staleRoot = await mkRepoTempDir("stale-client-module-");
|
|
442
|
-
try {
|
|
443
|
-
await mkdir(path.join(staleRoot, "app"), { recursive: true });
|
|
444
|
-
await mkdir(path.join(staleRoot, "src", "shared", "contracts"), { recursive: true });
|
|
445
|
-
await writeFile(
|
|
446
|
-
path.join(staleRoot, "package.json"),
|
|
447
|
-
JSON.stringify({ name: "mandu-stale-client-module-test", type: "module" }, null, 2),
|
|
448
|
-
"utf-8",
|
|
449
|
-
);
|
|
450
|
-
await writeFile(
|
|
451
|
-
path.join(staleRoot, "src", "shared", "contracts", "api.ts"),
|
|
452
|
-
'export const INTERNAL_BASE = process.env.MANDU_INTERNAL_URL ?? "http://localhost:3333";\n',
|
|
453
|
-
"utf-8",
|
|
454
|
-
);
|
|
455
|
-
await writeFile(
|
|
456
|
-
path.join(staleRoot, "app", "page.tsx"),
|
|
457
|
-
'import { INTERNAL_BASE } from "../src/shared/contracts/api";\n' +
|
|
458
|
-
"export default async function HomePage() {\n" +
|
|
459
|
-
" return <main>{INTERNAL_BASE}</main>;\n" +
|
|
460
|
-
"}\n",
|
|
461
|
-
"utf-8",
|
|
462
|
-
);
|
|
463
|
-
|
|
464
|
-
const staleResult = await runBuildInSubprocess(staleRoot, "server-page-client-module");
|
|
465
|
-
expect(staleResult.success).toBe(false);
|
|
466
|
-
expect(staleResult.errors.join("\n")).toContain("missing \"use client\"");
|
|
467
|
-
expect(await Bun.file(path.join(staleRoot, ".mandu", "client", "index.island.js")).exists()).toBe(false);
|
|
468
|
-
} finally {
|
|
469
|
-
await rm(staleRoot, { recursive: true, force: true });
|
|
470
|
-
}
|
|
471
|
-
});
|
|
472
|
-
|
|
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
|
+
|
|
473
514
|
test("rewrites route-component clientModule to the real client import before bundling", async () => {
|
|
474
515
|
const routeClientRoot = await mkRepoTempDir("route-client-import-");
|
|
475
|
-
try {
|
|
476
|
-
await mkdir(path.join(routeClientRoot, "app", "login"), { recursive: true });
|
|
477
|
-
await mkdir(path.join(routeClientRoot, "src", "client", "pages", "login"), { recursive: true });
|
|
478
|
-
await writeFile(
|
|
479
|
-
path.join(routeClientRoot, "package.json"),
|
|
480
|
-
JSON.stringify({ name: "mandu-route-client-import-test", type: "module" }, null, 2),
|
|
481
|
-
"utf-8",
|
|
482
|
-
);
|
|
483
|
-
await writeFile(
|
|
484
|
-
path.join(routeClientRoot, "app", "login", "page.tsx"),
|
|
485
|
-
'import LoginPage from "@/client/pages/login/LoginPage.client";\n' +
|
|
486
|
-
"export default function Page() {\n" +
|
|
487
|
-
" return <LoginPage />;\n" +
|
|
488
|
-
"}\n",
|
|
489
|
-
"utf-8",
|
|
490
|
-
);
|
|
491
|
-
await writeFile(
|
|
492
|
-
path.join(routeClientRoot, "src", "client", "pages", "login", "LoginPage.client.tsx"),
|
|
493
|
-
'"use client";\n' +
|
|
494
|
-
'import { useState } from "react";\n' +
|
|
495
|
-
"export default function LoginPage() {\n" +
|
|
496
|
-
' const [email, setEmail] = useState("");\n' +
|
|
497
|
-
' return <form><input value={email} onChange={(event) => setEmail(event.currentTarget.value)} /></form>;\n' +
|
|
498
|
-
"}\n",
|
|
499
|
-
"utf-8",
|
|
500
|
-
);
|
|
501
|
-
|
|
502
|
-
const routeClientResult = await runBuildInSubprocess(routeClientRoot, "server-page-route-client-import");
|
|
503
|
-
expect(routeClientResult.success).toBe(true);
|
|
504
|
-
const bundlePath = path.join(routeClientRoot, ".mandu", "client", "login.island.js");
|
|
505
|
-
expect(await Bun.file(bundlePath).exists()).toBe(true);
|
|
506
|
-
|
|
507
|
-
const bundleSource = await readFile(bundlePath, "utf-8");
|
|
508
|
-
expect(bundleSource).not.toContain("var LoginPage = LoginPage;");
|
|
509
|
-
const parseResult = await Bun.build({
|
|
510
|
-
entrypoints: [bundlePath],
|
|
511
|
-
target: "browser",
|
|
512
|
-
external: ["react", "react-dom", "react-dom/client", "react/jsx-dev-runtime"],
|
|
513
|
-
});
|
|
514
|
-
expect(parseResult.success).toBe(true);
|
|
515
|
-
} finally {
|
|
516
|
-
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 });
|
|
517
558
|
}
|
|
518
559
|
});
|
|
519
560
|
|
|
@@ -596,37 +637,37 @@ describe("buildClientBundles vendor shims", () => {
|
|
|
596
637
|
|
|
597
638
|
test("fails when hydration is enabled but no clientModule can be resolved", async () => {
|
|
598
639
|
const missingRoot = await mkRepoTempDir("hydration-no-client-");
|
|
599
|
-
try {
|
|
600
|
-
await mkdir(path.join(missingRoot, "app", "login"), { recursive: true });
|
|
601
|
-
await mkdir(path.join(missingRoot, "src", "client", "widgets", "login-form"), { recursive: true });
|
|
602
|
-
await writeFile(
|
|
603
|
-
path.join(missingRoot, "package.json"),
|
|
604
|
-
JSON.stringify({ name: "mandu-hydration-no-client-test", type: "module" }, null, 2),
|
|
605
|
-
"utf-8",
|
|
606
|
-
);
|
|
607
|
-
await writeFile(
|
|
608
|
-
path.join(missingRoot, "app", "login", "page.tsx"),
|
|
609
|
-
'import { LoginForm } from "../../src/client/widgets/login-form/LoginForm.client";\n' +
|
|
610
|
-
"export default function LoginPage() {\n" +
|
|
611
|
-
" return <main><LoginForm /></main>;\n" +
|
|
612
|
-
"}\n",
|
|
613
|
-
"utf-8",
|
|
614
|
-
);
|
|
615
|
-
await writeFile(
|
|
616
|
-
path.join(missingRoot, "src", "client", "widgets", "login-form", "LoginForm.client.tsx"),
|
|
617
|
-
"export function LoginForm() { return <form />; }\n",
|
|
618
|
-
"utf-8",
|
|
619
|
-
);
|
|
620
|
-
|
|
621
|
-
const noClientResult = await runBuildInSubprocess(missingRoot, "hydration-no-client-module");
|
|
622
|
-
const errors = noClientResult.errors.join("\n");
|
|
623
|
-
expect(noClientResult.success).toBe(false);
|
|
624
|
-
expect(errors).toContain("no clientModule could be resolved");
|
|
625
|
-
expect(errors).toContain("LoginForm.client");
|
|
626
|
-
expect(errors).toContain("run mandu generate");
|
|
627
|
-
} finally {
|
|
628
|
-
await rm(missingRoot, { recursive: true, force: true });
|
|
629
|
-
}
|
|
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
|
+
}
|
|
630
671
|
});
|
|
631
672
|
});
|
|
632
673
|
|