@mandujs/core 0.54.13 → 0.54.15

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mandujs/core",
3
- "version": "0.54.13",
3
+ "version": "0.54.15",
4
4
  "description": "Mandu Framework Core - Spec, Generator, Guard, Runtime",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
@@ -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
- version: 1,
123
- routes: [
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 mkdtemp(path.join(import.meta.dir, ".tmp-fr-build-"));
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 mkdtemp(path.join(import.meta.dir, ".tmp-fr-vendor-"));
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"),
@@ -204,9 +204,16 @@ describe("Phase 7.2 Agent B — findRouteIdForSlot", () => {
204
204
  // Section B — HMR server broadcast of slot-refetch
205
205
  // -----------------------------------------------------------------------------
206
206
 
207
- function pickPort(): number {
208
- return 40000 + Math.floor(Math.random() * 10000);
209
- }
207
+ const HMR_TEST_PORT_STATE = "__MANDU_HMR_TEST_PORT_STATE__";
208
+
209
+ function pickPort(): number {
210
+ const stateGlobal = globalThis as typeof globalThis & {
211
+ __MANDU_HMR_TEST_PORT_STATE__?: { next: number };
212
+ };
213
+ stateGlobal.__MANDU_HMR_TEST_PORT_STATE__ ??= { next: 0 };
214
+ const index = stateGlobal.__MANDU_HMR_TEST_PORT_STATE__.next++;
215
+ return 41000 + (((process.pid % 3500) * 2 + index * 2) % 7000);
216
+ }
210
217
 
211
218
  describe("Phase 7.2 Agent B — slot-refetch broadcast", () => {
212
219
  let server: HMRServer | null = null;
@@ -172,17 +172,22 @@ describe("createManduHot — Vite-compat import.meta.hot runtime", () => {
172
172
  * Utility: spin up an HMR server and return it plus the public port the
173
173
  * client should dial. The caller owns teardown via `afterEach`.
174
174
  *
175
- * We pass `port: 0` ... almost. `createHMRServer` computes
176
- * `port + PORTS.HMR_OFFSET` internally, so if we want an ephemeral
177
- * listener we'd need to bind ahead of time. For these tests that would
178
- * complicate setup; we pick a random port in the high range instead
179
- * and accept the tiny risk of collision (test is < 1 s).
180
- */
181
- function pickPort(): number {
182
- // 40000–49999 range — avoids common dev ports while staying well
183
- // below ephemeral ranges Bun may pick for outbound sockets.
184
- return 40000 + Math.floor(Math.random() * 10000);
185
- }
175
+ * We pass `port: 0` ... almost. `createHMRServer` computes
176
+ * `port + PORTS.HMR_OFFSET` internally, so if we want an ephemeral
177
+ * listener we'd need to bind ahead of time. These tests instead use a
178
+ * process-local monotonic port allocator to avoid random collisions
179
+ * during the full parallel core suite.
180
+ */
181
+ const HMR_TEST_PORT_STATE = "__MANDU_HMR_TEST_PORT_STATE__";
182
+
183
+ function pickPort(): number {
184
+ const stateGlobal = globalThis as typeof globalThis & {
185
+ __MANDU_HMR_TEST_PORT_STATE__?: { next: number };
186
+ };
187
+ stateGlobal.__MANDU_HMR_TEST_PORT_STATE__ ??= { next: 0 };
188
+ const index = stateGlobal.__MANDU_HMR_TEST_PORT_STATE__.next++;
189
+ return 41000 + (((process.pid % 3500) * 2 + index * 2) % 7000);
190
+ }
186
191
 
187
192
  /**
188
193
  * A wrapper around WebSocket that stashes every incoming message in an
@@ -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
- } catch (err) {
68
- return { success: false, errors: [`spawn failed: ${String(err)}`] };
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 mkdtemp(path.join(import.meta.dir, ".tmp-bundler-"));
83
+ beforeAll(async () => {
84
+ rootDir = await mkRepoTempDir("bundler-");
74
85
 
75
86
  await mkdir(path.join(rootDir, "app"), { recursive: true });
76
87
  await writeFile(
@@ -117,14 +128,26 @@ afterAll(async () => {
117
128
  // subprocess via `__tests__/build-runner.ts`. In-process retry does not
118
129
  // recover from that one; a fresh module graph does.
119
130
  describe("buildClientBundles vendor shims", () => {
120
- test("build succeeds", () => {
121
- if (!result.success) {
122
- console.error("[build.test] errors:", result.errors);
123
- }
124
- expect(result.success).toBe(true);
125
- });
126
-
127
- test("re-exports modern React 19 APIs used by islands", async () => {
131
+ test("build succeeds", () => {
132
+ if (!result.success) {
133
+ console.error("[build.test] errors:", result.errors);
134
+ }
135
+ expect(result.success).toBe(true);
136
+ });
137
+
138
+ test("runtime reads canonical data-hydrate strategies", async () => {
139
+ const runtimePath = path.join(rootDir, ".mandu", "client", "_runtime.js");
140
+ const runtimeSource = await readFile(runtimePath, "utf-8");
141
+
142
+ expect(runtimeSource).toContain("data-hydrate");
143
+ expect(runtimeSource).toContain("matchMedia");
144
+ expect(runtimeSource).toContain("200px");
145
+ expect(runtimeSource).toContain('"click"');
146
+ expect(runtimeSource).not.toContain("mouseenter");
147
+ expect(runtimeSource).not.toContain("pointerdown");
148
+ });
149
+
150
+ test("re-exports modern React 19 APIs used by islands", async () => {
128
151
  const reactShim = await importBuiltModule(".mandu/client/_react.js");
129
152
  const requiredExports = [
130
153
  "Activity",
@@ -176,18 +199,21 @@ describe("buildClientBundles vendor shims", () => {
176
199
  expect(runtimeSource).toContain("data-mandu-hydrating");
177
200
  expect(runtimeSource).toContain("data-mandu-render-mode");
178
201
  expect(runtimeSource).toContain("data-mandu-recoverable-error");
179
- expect(runtimeSource).toContain("pointerdown");
202
+ expect(runtimeSource).toContain('"click"');
203
+ expect(runtimeSource).not.toContain("pointerdown");
180
204
  });
181
205
 
182
- test("runtime parses SSR data script before island setup", async () => {
183
- const runtimeSource = await readFile(path.join(rootDir, ".mandu", "client", "_runtime.js"), "utf-8");
184
- expect(runtimeSource).toContain("function readManduData");
185
- expect(runtimeSource).toContain("document.getElementById(\"__MANDU_DATA__\")");
186
- expect(runtimeSource).toContain("JSON.parse");
187
- });
206
+ test("runtime parses SSR data script before island setup", async () => {
207
+ const runtimeSource = await readFile(path.join(rootDir, ".mandu", "client", "_runtime.js"), "utf-8");
208
+ expect(runtimeSource).toContain("function readManduData");
209
+ expect(runtimeSource).toContain("document.getElementById(\"__MANDU_DATA__\")");
210
+ expect(runtimeSource).toContain("deserializeManduProps");
211
+ expect(runtimeSource).toContain("new Date");
212
+ expect(runtimeSource).toContain("new Map");
213
+ });
188
214
 
189
- test("does not bundle a server page when stale manifest marks page.tsx as clientModule", async () => {
190
- const staleRoot = await mkdtemp(path.join(import.meta.dir, ".tmp-stale-client-module-"));
215
+ test("does not bundle a server page when stale manifest marks page.tsx as clientModule", async () => {
216
+ const staleRoot = await mkRepoTempDir("stale-client-module-");
191
217
  try {
192
218
  await mkdir(path.join(staleRoot, "app"), { recursive: true });
193
219
  await mkdir(path.join(staleRoot, "src", "shared", "contracts"), { recursive: true });
@@ -220,7 +246,7 @@ describe("buildClientBundles vendor shims", () => {
220
246
  });
221
247
 
222
248
  test("rewrites route-component clientModule to the real client import before bundling", async () => {
223
- const routeClientRoot = await mkdtemp(path.join(import.meta.dir, ".tmp-route-client-import-"));
249
+ const routeClientRoot = await mkRepoTempDir("route-client-import-");
224
250
  try {
225
251
  await mkdir(path.join(routeClientRoot, "app", "login"), { recursive: true });
226
252
  await mkdir(path.join(routeClientRoot, "src", "client", "pages", "login"), { recursive: true });
@@ -267,7 +293,7 @@ describe("buildClientBundles vendor shims", () => {
267
293
  });
268
294
 
269
295
  test("bundles route-level named client exports without requiring a default export", async () => {
270
- const routeClientRoot = await mkdtemp(path.join(import.meta.dir, ".tmp-route-named-client-import-"));
296
+ const routeClientRoot = await mkRepoTempDir("route-named-client-import-");
271
297
  try {
272
298
  await mkdir(path.join(routeClientRoot, "app", "login"), { recursive: true });
273
299
  await mkdir(path.join(routeClientRoot, "src", "client", "pages", "login"), { recursive: true });
@@ -317,8 +343,34 @@ describe("buildClientBundles vendor shims", () => {
317
343
  }
318
344
  });
319
345
 
346
+ test("uses Windows-safe asset filenames for locale-prefixed route ids", async () => {
347
+ const localeRoot = await mkRepoTempDir("i18n-locale-route-id-");
348
+ try {
349
+ await mkdir(path.join(localeRoot, "app"), { recursive: true });
350
+ await writeFile(
351
+ path.join(localeRoot, "package.json"),
352
+ JSON.stringify({ name: "mandu-i18n-route-id-test", type: "module" }, null, 2),
353
+ "utf-8",
354
+ );
355
+ await writeFile(
356
+ path.join(localeRoot, "app", "demo.client.tsx"),
357
+ "export default function DemoIsland() { return null; }\n",
358
+ "utf-8",
359
+ );
360
+
361
+ const localeResult = await runBuildInSubprocess(localeRoot, "i18n-locale-route-id");
362
+ expect(localeResult.success).toBe(true);
363
+ const bundle = localeResult.manifest?.bundles?.["ko::demo"];
364
+ expect(bundle?.js).toBe("/.mandu/client/ko_3a__3a_demo.island.js");
365
+ expect(bundle?.js).not.toContain(":");
366
+ expect(await Bun.file(path.join(localeRoot, ".mandu", "client", "ko_3a__3a_demo.island.js")).exists()).toBe(true);
367
+ } finally {
368
+ await rm(localeRoot, { recursive: true, force: true });
369
+ }
370
+ });
371
+
320
372
  test("fails when hydration is enabled but no clientModule can be resolved", async () => {
321
- const missingRoot = await mkdtemp(path.join(import.meta.dir, ".tmp-hydration-no-client-"));
373
+ const missingRoot = await mkRepoTempDir("hydration-no-client-");
322
374
  try {
323
375
  await mkdir(path.join(missingRoot, "app", "login"), { recursive: true });
324
376
  await mkdir(path.join(missingRoot, "src", "client", "widgets", "login-form"), { recursive: true });