@mandujs/core 0.54.5 → 0.54.7

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.
@@ -30,7 +30,11 @@ import {
30
30
  } from "./fs-patterns";
31
31
  import { mark, measure } from "../perf";
32
32
  import { METADATA_ROUTES } from "../routes/types";
33
- import { hasUseClientDirective } from "./client-entry";
33
+ import {
34
+ findRouteLevelClientComponentImport,
35
+ hasUseClientDirective,
36
+ resolveClientImportModulePath,
37
+ } from "./client-entry";
34
38
 
35
39
  // ═══════════════════════════════════════════════════════════════════════════
36
40
  // Scanner Class
@@ -229,7 +233,7 @@ export class FSScanner {
229
233
  */
230
234
  private async createRouteConfigs(
231
235
  files: ScannedFile[],
232
- _rootDir: string
236
+ rootDir: string
233
237
  ): Promise<{ routes: FSRouteConfig[]; routeErrors: ScanError[] }> {
234
238
  const routes: FSRouteConfig[] = [];
235
239
  const routeErrors: ScanError[] = [];
@@ -389,13 +393,22 @@ export class FSScanner {
389
393
  conflictsWith: islands[0].absolutePath,
390
394
  });
391
395
  }
392
- } else if (file.type === "page" && pageFileContent) {
393
- // page 파일 자체에서 "use client" 확인
396
+ } else if (file.type === "page" && pageFileContent) {
397
+ // page 파일 자체에서 "use client" 확인
394
398
  const hasUseClient = hasUseClientDirective(pageFileContent);
395
- if (hasUseClient) {
396
- clientModule = modulePath;
397
- }
398
- }
399
+ if (hasUseClient) {
400
+ clientModule = modulePath;
401
+ } else {
402
+ const routeLevelClientImport = findRouteLevelClientComponentImport(pageFileContent);
403
+ if (routeLevelClientImport) {
404
+ clientModule = await resolveClientImportModulePath(
405
+ rootDir,
406
+ modulePath,
407
+ routeLevelClientImport.module,
408
+ ) ?? undefined;
409
+ }
410
+ }
411
+ }
399
412
 
400
413
  // 로딩/에러/404 모듈 찾기 — nearest-ancestor resolution.
401
414
  // Phase 18.β: `not-found.tsx` joins `loading.tsx`/`error.tsx` in
@@ -2232,18 +2232,26 @@ async function renderPageSSR(
2232
2232
 
2233
2233
  // Island 래핑: 레이아웃 적용 전에 페이지 콘텐츠만 island div로 감쌈
2234
2234
  // 이렇게 하면 레이아웃은 island 바깥에 위치하여 하이드레이션 시 레이아웃이 유지됨
2235
- const needsIslandWrap = !!(
2235
+ const needsIslandHydration = !!(
2236
2236
  route.hydration &&
2237
2237
  route.hydration.strategy !== "none" &&
2238
2238
  settings.bundleManifest
2239
2239
  );
2240
-
2241
- if (needsIslandWrap) {
2242
- const bundle = settings.bundleManifest?.bundles[route.id];
2243
- const bundleSrc = bundle?.js ? `${bundle.js}?t=${Date.now()}` : "";
2244
- const priority = route.hydration!.priority || "visible";
2245
- app = React.createElement("div", {
2246
- "data-mandu-island": route.id,
2240
+ const routeBundle = settings.bundleManifest?.bundles[route.id];
2241
+ const bundleSrc = routeBundle?.js ? `${routeBundle.js}?t=${Date.now()}` : "";
2242
+ const needsIslandWrap = needsIslandHydration && bundleSrc.length > 0;
2243
+
2244
+ if (needsIslandHydration && !needsIslandWrap && settings.isDev) {
2245
+ console.warn(
2246
+ `[Mandu] Hydration requested for route "${route.id}" but no client bundle was found. ` +
2247
+ `Run mandu build/generate and ensure the route has a clientModule.`,
2248
+ );
2249
+ }
2250
+
2251
+ if (needsIslandWrap) {
2252
+ const priority = route.hydration!.priority || "visible";
2253
+ app = React.createElement("div", {
2254
+ "data-mandu-island": route.id,
2247
2255
  "data-mandu-src": bundleSrc,
2248
2256
  "data-mandu-priority": priority,
2249
2257
  style: { display: "contents" },
@@ -0,0 +1,59 @@
1
+ import { afterEach, expect, test } from "bun:test";
2
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "fs";
3
+ import { tmpdir } from "os";
4
+ import { join } from "path";
5
+ import { hasRecentGenerateStamp } from "../watcher";
6
+
7
+ const tmpRoots: string[] = [];
8
+
9
+ function makeRoot(): string {
10
+ const root = mkdtempSync(join(tmpdir(), "mandu-watch-"));
11
+ tmpRoots.push(root);
12
+ return root;
13
+ }
14
+
15
+ afterEach(() => {
16
+ for (const root of tmpRoots.splice(0)) {
17
+ rmSync(root, { recursive: true, force: true });
18
+ }
19
+ });
20
+
21
+ test("hasRecentGenerateStamp suppresses generated events using the root stamp", () => {
22
+ const root = makeRoot();
23
+ const now = 1_700_000_000_000;
24
+ mkdirSync(join(root, ".mandu"), { recursive: true });
25
+ writeFileSync(join(root, ".mandu", "generate.stamp"), String(now - 500));
26
+
27
+ const generatedFile = join(
28
+ root,
29
+ ".mandu",
30
+ "generated",
31
+ "server",
32
+ "repos",
33
+ "notification.repo.ts",
34
+ );
35
+
36
+ expect(hasRecentGenerateStamp(root, generatedFile, now)).toBe(true);
37
+ });
38
+
39
+ test("hasRecentGenerateStamp does not suppress stale generated events", () => {
40
+ const root = makeRoot();
41
+ const now = 1_700_000_000_000;
42
+ mkdirSync(join(root, ".mandu"), { recursive: true });
43
+ writeFileSync(join(root, ".mandu", "generate.stamp"), String(now - 30_000));
44
+
45
+ const generatedFile = join(root, ".mandu", "generated", "server", "old.route.ts");
46
+
47
+ expect(hasRecentGenerateStamp(root, generatedFile, now)).toBe(false);
48
+ });
49
+
50
+ test("hasRecentGenerateStamp does not suppress source files after generation", () => {
51
+ const root = makeRoot();
52
+ const now = 1_700_000_000_000;
53
+ mkdirSync(join(root, ".mandu"), { recursive: true });
54
+ writeFileSync(join(root, ".mandu", "generate.stamp"), String(now - 500));
55
+
56
+ const sourceFile = join(root, "app", "page.tsx");
57
+
58
+ expect(hasRecentGenerateStamp(root, sourceFile, now)).toBe(false);
59
+ });
@@ -61,13 +61,63 @@ const DEFAULT_CONFIG: Partial<WatcherConfig> = {
61
61
  * These cause EISDIR/ENOENT errors when file watchers try to access them.
62
62
  * See: https://learn.microsoft.com/en-us/windows/win32/fileio/naming-a-file
63
63
  */
64
- const WINDOWS_RESERVED_NAMES = new Set([
65
- "CON", "PRN", "AUX", "NUL",
66
- "COM0", "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8", "COM9",
67
- "LPT0", "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9",
68
- ]);
69
-
70
- export class FileWatcher {
64
+ const WINDOWS_RESERVED_NAMES = new Set([
65
+ "CON", "PRN", "AUX", "NUL",
66
+ "COM0", "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8", "COM9",
67
+ "LPT0", "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9",
68
+ ]);
69
+
70
+ const GENERATE_STAMP_SUPPRESS_MS = 10_000;
71
+
72
+ function readGenerateStamp(stampFile: string): number | null {
73
+ try {
74
+ const stamp = Number.parseInt(fs.readFileSync(stampFile, "utf-8"), 10);
75
+ return Number.isFinite(stamp) ? stamp : null;
76
+ } catch {
77
+ return null;
78
+ }
79
+ }
80
+
81
+ function isManduGeneratedPath(rootDir: string, filePath: string): boolean {
82
+ const relativePath = path.relative(rootDir, filePath).replace(/\\/g, "/");
83
+ return relativePath === ".mandu/generated" || relativePath.startsWith(".mandu/generated/");
84
+ }
85
+
86
+ export function hasRecentGenerateStamp(
87
+ rootDir: string,
88
+ filePath: string,
89
+ now: number = Date.now()
90
+ ): boolean {
91
+ if (!isManduGeneratedPath(rootDir, filePath)) {
92
+ return false;
93
+ }
94
+
95
+ const candidates = new Set<string>([
96
+ path.join(rootDir, ".mandu", "generate.stamp"),
97
+ ]);
98
+ const resolvedRoot = path.resolve(rootDir);
99
+ let stampDir = path.dirname(path.resolve(filePath));
100
+
101
+ while (stampDir !== path.dirname(stampDir)) {
102
+ candidates.add(path.join(stampDir, ".mandu", "generate.stamp"));
103
+ if (stampDir === resolvedRoot) break;
104
+ stampDir = path.dirname(stampDir);
105
+ }
106
+
107
+ for (const stampFile of candidates) {
108
+ const stamp = readGenerateStamp(stampFile);
109
+ if (stamp === null) continue;
110
+
111
+ const ageMs = now - stamp;
112
+ if (ageMs >= 0 && ageMs < GENERATE_STAMP_SUPPRESS_MS) {
113
+ return true;
114
+ }
115
+ }
116
+
117
+ return false;
118
+ }
119
+
120
+ export class FileWatcher {
71
121
  private config: WatcherConfig;
72
122
  private chokidarWatcher: FSWatcher | null = null;
73
123
  private handlers: Set<WatchEventHandler> = new Set();
@@ -311,21 +361,10 @@ export class FileWatcher {
311
361
 
312
362
  const { rootDir } = this.config;
313
363
 
314
- // Cross-process: skip if generate finished within last 2 seconds
315
- // Walk up from the changed file to find nearest .mandu/generate.stamp
316
- let stampDir = path.dirname(filePath);
317
- while (stampDir !== path.dirname(stampDir)) {
318
- const stampFile = path.join(stampDir, ".mandu", "generate.stamp");
319
- try {
320
- const stamp = parseInt(fs.readFileSync(stampFile, "utf-8"), 10);
321
- if (Date.now() - stamp < 2000) return;
322
- break;
323
- } catch {}
324
- stampDir = path.dirname(stampDir);
325
- }
326
-
327
-
328
- // Validate file against rules
364
+ // Cross-process: skip generated-file churn from a recent mandu generate.
365
+ if (hasRecentGenerateStamp(rootDir, filePath)) return;
366
+
367
+ // Validate file against rules
329
368
  try {
330
369
  const warnings = await validateFile(filePath, event, rootDir);
331
370