@mandujs/core 0.19.0 → 0.20.0

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.
Files changed (100) hide show
  1. package/README.ko.md +0 -14
  2. package/package.json +4 -1
  3. package/src/brain/architecture/analyzer.ts +4 -4
  4. package/src/brain/doctor/analyzer.ts +18 -14
  5. package/src/bundler/build.test.ts +127 -0
  6. package/src/bundler/build.ts +385 -115
  7. package/src/bundler/css.ts +20 -5
  8. package/src/bundler/dev.ts +55 -2
  9. package/src/bundler/prerender.ts +195 -0
  10. package/src/bundler/types.ts +20 -0
  11. package/src/change/snapshot.ts +4 -23
  12. package/src/change/types.ts +2 -3
  13. package/src/client/Form.tsx +105 -0
  14. package/src/client/__tests__/use-sse.test.ts +153 -0
  15. package/src/client/hooks.ts +105 -6
  16. package/src/client/index.ts +35 -6
  17. package/src/client/router.ts +670 -433
  18. package/src/client/rpc.ts +140 -0
  19. package/src/client/runtime.ts +24 -21
  20. package/src/client/use-fetch.ts +239 -0
  21. package/src/client/use-head.ts +197 -0
  22. package/src/client/use-sse.ts +378 -0
  23. package/src/components/Image.tsx +162 -0
  24. package/src/config/mandu.ts +5 -0
  25. package/src/config/validate.ts +34 -0
  26. package/src/content/index.ts +5 -1
  27. package/src/devtools/client/catchers/error-catcher.ts +17 -0
  28. package/src/devtools/client/catchers/network-proxy.ts +390 -367
  29. package/src/devtools/client/components/kitchen-root.tsx +479 -467
  30. package/src/devtools/client/components/mandu-character.tsx +77 -53
  31. package/src/devtools/client/components/panel/diff-viewer.tsx +219 -0
  32. package/src/devtools/client/components/panel/errors-panel.tsx +2 -2
  33. package/src/devtools/client/components/panel/guard-panel.tsx +363 -234
  34. package/src/devtools/client/components/panel/index.ts +45 -32
  35. package/src/devtools/client/components/panel/islands-panel.tsx +30 -14
  36. package/src/devtools/client/components/panel/network-panel.tsx +2 -3
  37. package/src/devtools/client/components/panel/panel-container.tsx +273 -100
  38. package/src/devtools/client/components/panel/preview-panel.tsx +212 -0
  39. package/src/devtools/client/state-manager.ts +535 -478
  40. package/src/devtools/design-tokens.ts +265 -264
  41. package/src/devtools/init.ts +1 -1
  42. package/src/devtools/types.ts +321 -295
  43. package/src/filling/filling.ts +328 -6
  44. package/src/filling/index.ts +5 -1
  45. package/src/filling/session.ts +216 -0
  46. package/src/filling/ws.ts +78 -0
  47. package/src/generator/generate.ts +2 -2
  48. package/src/guard/auto-correct.ts +0 -29
  49. package/src/guard/check.ts +14 -31
  50. package/src/guard/presets/index.ts +296 -294
  51. package/src/guard/rules.ts +15 -19
  52. package/src/guard/validator.ts +834 -834
  53. package/src/index.ts +6 -1
  54. package/src/island/index.ts +373 -304
  55. package/src/kitchen/api/contract-api.ts +225 -0
  56. package/src/kitchen/api/diff-parser.ts +108 -0
  57. package/src/kitchen/api/file-api.ts +273 -0
  58. package/src/kitchen/api/guard-api.ts +83 -0
  59. package/src/kitchen/api/guard-decisions.ts +100 -0
  60. package/src/kitchen/api/routes-api.ts +50 -0
  61. package/src/kitchen/index.ts +21 -0
  62. package/src/kitchen/kitchen-handler.ts +335 -0
  63. package/src/kitchen/kitchen-ui.ts +1732 -0
  64. package/src/kitchen/stream/activity-sse.ts +145 -0
  65. package/src/kitchen/stream/file-tailer.ts +99 -0
  66. package/src/middleware/compress.ts +62 -0
  67. package/src/middleware/cors.ts +47 -0
  68. package/src/middleware/index.ts +10 -0
  69. package/src/middleware/jwt.ts +134 -0
  70. package/src/middleware/logger.ts +58 -0
  71. package/src/middleware/timeout.ts +55 -0
  72. package/src/observability/event-bus.ts +79 -0
  73. package/src/observability/index.ts +8 -0
  74. package/src/observability/logger-adapter.ts +36 -0
  75. package/src/paths.ts +0 -4
  76. package/src/plugins/hooks.ts +64 -0
  77. package/src/plugins/index.ts +3 -0
  78. package/src/plugins/types.ts +5 -0
  79. package/src/report/build.ts +0 -6
  80. package/src/resource/__tests__/backward-compat.test.ts +0 -1
  81. package/src/router/fs-patterns.ts +11 -1
  82. package/src/router/fs-routes.ts +78 -14
  83. package/src/router/fs-scanner.ts +2 -2
  84. package/src/router/fs-types.ts +2 -1
  85. package/src/runtime/adapter-bun.ts +62 -0
  86. package/src/runtime/adapter.ts +47 -0
  87. package/src/runtime/cache.ts +310 -0
  88. package/src/runtime/handler.ts +65 -0
  89. package/src/runtime/image-handler.ts +195 -0
  90. package/src/runtime/index.ts +13 -0
  91. package/src/runtime/middleware.ts +263 -0
  92. package/src/runtime/ppr.ts +74 -0
  93. package/src/runtime/server.ts +706 -71
  94. package/src/runtime/ssr.ts +70 -31
  95. package/src/runtime/streaming-ssr.ts +121 -78
  96. package/src/spec/index.ts +0 -1
  97. package/src/spec/schema.ts +1 -0
  98. package/src/testing/index.ts +189 -0
  99. package/src/watcher/watcher.ts +27 -1
  100. package/src/spec/lock.ts +0 -56
@@ -11,11 +11,67 @@ import type {
11
11
  BundleManifest,
12
12
  BundleStats,
13
13
  BundlerOptions,
14
+ IslandFileEntry,
14
15
  } from "./types";
15
16
  import { HYDRATION } from "../constants";
16
17
  import path from "path";
17
18
  import fs from "fs/promises";
18
19
 
20
+ /** Scan for *.island.tsx / *.island.ts files across hydrated route directories. */
21
+ async function scanIslandFiles(routes: RouteSpec[], rootDir: string): Promise<IslandFileEntry[]> {
22
+ const entries: IslandFileEntry[] = [];
23
+ const seenDirs = new Set<string>();
24
+
25
+ for (const route of routes) {
26
+ const dir = path.dirname(path.join(rootDir, route.componentModule ?? route.module));
27
+ if (seenDirs.has(dir)) continue;
28
+ seenDirs.add(dir);
29
+
30
+ let files: string[];
31
+ try { files = await fs.readdir(dir); } catch { continue; }
32
+
33
+ const priority = getRouteHydration(route)?.priority || HYDRATION.DEFAULT_PRIORITY;
34
+ for (const file of files) {
35
+ if (/\.island\.tsx?$/.test(file)) {
36
+ entries.push({
37
+ name: file.replace(/\.island\.tsx?$/, ""),
38
+ filePath: path.join(dir, file),
39
+ routeId: route.id,
40
+ priority,
41
+ });
42
+ }
43
+ }
44
+ }
45
+ return entries;
46
+ }
47
+
48
+ /** Build a single per-island bundle. */
49
+ async function buildPerIslandBundle(
50
+ entry: IslandFileEntry, outDir: string, options: BundlerOptions
51
+ ): Promise<{ name: string; js: string; route: string; priority: IslandFileEntry["priority"] }> {
52
+ const entryPath = path.join(outDir, `_entry_island_${entry.name}.js`);
53
+ const outputName = `${entry.name}.island.js`;
54
+ try {
55
+ await Bun.write(entryPath, generateIslandEntry(entry.name, entry.filePath));
56
+ const result = await Bun.build({
57
+ entrypoints: [entryPath],
58
+ outdir: outDir,
59
+ naming: outputName,
60
+ minify: options.minify ?? process.env.NODE_ENV === "production",
61
+ sourcemap: options.sourcemap ? "external" : "none",
62
+ target: "browser",
63
+ external: ["react", "react-dom", "react-dom/client", ...(options.external || [])],
64
+ define: { "process.env.NODE_ENV": JSON.stringify(process.env.NODE_ENV || "development"), ...options.define },
65
+ });
66
+ await fs.unlink(entryPath).catch(() => {});
67
+ if (!result.success) throw new Error(result.logs.map((l) => l.message).join("\n"));
68
+ return { name: entry.name, js: `/.mandu/client/${outputName}`, route: entry.routeId, priority: entry.priority };
69
+ } catch (error) {
70
+ await fs.unlink(entryPath).catch(() => {});
71
+ throw error;
72
+ }
73
+ }
74
+
19
75
  /**
20
76
  * 빈 매니페스트 생성
21
77
  */
@@ -47,6 +103,78 @@ function getHydratedRoutes(manifest: RoutesManifest): RouteSpec[] {
47
103
  );
48
104
  }
49
105
 
106
+ const REACT_SHIM_EXPORTS = [
107
+ "Activity",
108
+ "Children",
109
+ "Component",
110
+ "Fragment",
111
+ "Profiler",
112
+ "PureComponent",
113
+ "StrictMode",
114
+ "Suspense",
115
+ "__COMPILER_RUNTIME",
116
+ "act",
117
+ "cache",
118
+ "cacheSignal",
119
+ "captureOwnerStack",
120
+ "cloneElement",
121
+ "createContext",
122
+ "createElement",
123
+ "createRef",
124
+ "forwardRef",
125
+ "isValidElement",
126
+ "lazy",
127
+ "memo",
128
+ "startTransition",
129
+ "unstable_useCacheRefresh",
130
+ "use",
131
+ "useActionState",
132
+ "useCallback",
133
+ "useContext",
134
+ "useDebugValue",
135
+ "useDeferredValue",
136
+ "useEffect",
137
+ "useEffectEvent",
138
+ "useId",
139
+ "useImperativeHandle",
140
+ "useInsertionEffect",
141
+ "useLayoutEffect",
142
+ "useMemo",
143
+ "useOptimistic",
144
+ "useReducer",
145
+ "useRef",
146
+ "useState",
147
+ "useSyncExternalStore",
148
+ "useTransition",
149
+ "version",
150
+ ] as const;
151
+
152
+ const REACT_DOM_SHIM_EXPORTS = [
153
+ "createPortal",
154
+ "flushSync",
155
+ "preconnect",
156
+ "prefetchDNS",
157
+ "preinit",
158
+ "preinitModule",
159
+ "preload",
160
+ "preloadModule",
161
+ "requestFormReset",
162
+ "unstable_batchedUpdates",
163
+ "useFormState",
164
+ "useFormStatus",
165
+ "version",
166
+ ] as const;
167
+
168
+ const REACT_DOM_CLIENT_SHIM_EXPORTS = [
169
+ "createRoot",
170
+ "hydrateRoot",
171
+ "version",
172
+ ] as const;
173
+
174
+ function formatShimBindings(names: readonly string[], indent = " "): string {
175
+ return names.map((name) => `${indent}${name},`).join("\n");
176
+ }
177
+
50
178
  /**
51
179
  * Runtime 번들 소스 생성 (v0.8.0 재설계)
52
180
  *
@@ -66,7 +194,7 @@ function generateRuntimeSource(): string {
66
194
 
67
195
  // React 정적 import (Island와 같은 인스턴스 공유)
68
196
  import React, { useState, useEffect, Component } from 'react';
69
- import { hydrateRoot } from 'react-dom/client';
197
+ import { hydrateRoot, createRoot } from 'react-dom/client';
70
198
 
71
199
  // Hydrated roots 추적 (unmount용) - 전역 초기화
72
200
  window.__MANDU_ROOTS__ = window.__MANDU_ROOTS__ || new Map();
@@ -147,6 +275,67 @@ function IslandLoadingWrapper({ children, loading, isReady }) {
147
275
  return children;
148
276
  }
149
277
 
278
+ function resolveHydrationTarget(element) {
279
+ if (!(element instanceof HTMLElement)) {
280
+ return element;
281
+ }
282
+
283
+ if (getComputedStyle(element).display !== 'contents') {
284
+ return element;
285
+ }
286
+
287
+ const queue = Array.from(element.children);
288
+ while (queue.length > 0) {
289
+ const candidate = queue.shift();
290
+ if (candidate instanceof HTMLElement) {
291
+ return candidate;
292
+ }
293
+ if (candidate) {
294
+ queue.push(...candidate.children);
295
+ }
296
+ }
297
+
298
+ return element.parentElement || element;
299
+ }
300
+
301
+ function hasHydratableMarkup(element) {
302
+ for (const node of element.childNodes) {
303
+ if (node.nodeType === Node.ELEMENT_NODE) {
304
+ return true;
305
+ }
306
+
307
+ if (node.nodeType === Node.TEXT_NODE && node.textContent && node.textContent.trim() !== '') {
308
+ return true;
309
+ }
310
+ }
311
+
312
+ return false;
313
+ }
314
+
315
+ function shouldHydrateCompiledIsland(element) {
316
+ return (
317
+ element.getAttribute('data-mandu-loading') !== 'true' &&
318
+ hasHydratableMarkup(element)
319
+ );
320
+ }
321
+
322
+ function createHydrationOptions(element, id, mode) {
323
+ return {
324
+ onRecoverableError(error) {
325
+ element.setAttribute('data-mandu-recoverable-error', 'true');
326
+ console.warn('[Mandu] Recoverable hydration error:', id, mode, error);
327
+ element.dispatchEvent(new CustomEvent('mandu:recoverable-hydration-error', {
328
+ bubbles: true,
329
+ detail: {
330
+ id,
331
+ mode,
332
+ error: error instanceof Error ? error.message : String(error),
333
+ },
334
+ }));
335
+ },
336
+ };
337
+ }
338
+
150
339
  /**
151
340
  * Hydration 스케줄러
152
341
  */
@@ -164,7 +353,8 @@ function scheduleHydration(element, src, priority) {
164
353
  loadAndHydrate(element, src);
165
354
  }
166
355
  }, { rootMargin: '50px' });
167
- observer.observe(element);
356
+ const target = resolveHydrationTarget(element);
357
+ observer.observe(target);
168
358
  } else {
169
359
  loadAndHydrate(element, src);
170
360
  }
@@ -179,15 +369,20 @@ function scheduleHydration(element, src, priority) {
179
369
  break;
180
370
 
181
371
  case 'interaction': {
372
+ const target = resolveHydrationTarget(element);
182
373
  const hydrate = () => {
183
- element.removeEventListener('mouseenter', hydrate);
184
- element.removeEventListener('focusin', hydrate);
185
- element.removeEventListener('touchstart', hydrate);
374
+ target.removeEventListener('mouseenter', hydrate);
375
+ target.removeEventListener('focusin', hydrate);
376
+ target.removeEventListener('touchstart', hydrate);
377
+ target.removeEventListener('pointerdown', hydrate);
378
+ target.removeEventListener('keydown', hydrate);
186
379
  loadAndHydrate(element, src);
187
380
  };
188
- element.addEventListener('mouseenter', hydrate, { once: true, passive: true });
189
- element.addEventListener('focusin', hydrate, { once: true });
190
- element.addEventListener('touchstart', hydrate, { once: true, passive: true });
381
+ target.addEventListener('mouseenter', hydrate, { once: true, passive: true });
382
+ target.addEventListener('focusin', hydrate, { once: true });
383
+ target.addEventListener('touchstart', hydrate, { once: true, passive: true });
384
+ target.addEventListener('pointerdown', hydrate, { once: true, passive: true });
385
+ target.addEventListener('keydown', hydrate, { once: true });
191
386
  break;
192
387
  }
193
388
  }
@@ -200,20 +395,47 @@ function scheduleHydration(element, src, priority) {
200
395
  */
201
396
  async function loadAndHydrate(element, src) {
202
397
  const id = element.getAttribute('data-mandu-island');
398
+ if (!id) {
399
+ return;
400
+ }
401
+
402
+ if (
403
+ hydratedRoots.has(id) ||
404
+ element.hasAttribute('data-mandu-hydrated') ||
405
+ element.getAttribute('data-mandu-hydrating') === 'true'
406
+ ) {
407
+ return;
408
+ }
409
+
410
+ element.setAttribute('data-mandu-hydrating', 'true');
203
411
 
204
412
  try {
205
413
  // Dynamic import - 이 시점에 Island 모듈 로드
206
414
  const module = await import(src);
207
415
  const island = module.default;
208
- const data = getServerData(id);
416
+ let data = getServerData(id);
417
+
418
+ // Fallback: read data-props from child element if __MANDU_DATA__ is empty
419
+ if (!data || Object.keys(data).length === 0) {
420
+ const propsEl = element.querySelector('[data-props]');
421
+ if (propsEl) {
422
+ try {
423
+ data = JSON.parse(propsEl.getAttribute('data-props'));
424
+ } catch (e) {
425
+ console.warn('[Mandu] Failed to parse data-props fallback:', e);
426
+ }
427
+ }
428
+ }
209
429
 
210
430
  // Mandu Island (preferred)
211
431
  if (island && island.__mandu_island === true) {
212
432
  const { definition } = island;
433
+ const shouldHydrate = shouldHydrateCompiledIsland(element);
434
+ const renderMode = shouldHydrate ? 'hydrate' : 'mount';
213
435
 
214
436
  // Island 컴포넌트 (Error Boundary + Loading 지원)
215
- function IslandComponent() {
216
- const [isReady, setIsReady] = useState(false);
437
+ function IslandComponent({ initialReady }) {
438
+ const [isReady, setIsReady] = useState(initialReady);
217
439
 
218
440
  useEffect(() => {
219
441
  setIsReady(true);
@@ -238,11 +460,22 @@ async function loadAndHydrate(element, src) {
238
460
  }, wrappedContent);
239
461
  }
240
462
 
241
- // Hydrate (SSR DOM 재사용 + 이벤트 연결)
242
- const root = hydrateRoot(element, React.createElement(IslandComponent));
463
+ const root = shouldHydrate
464
+ ? hydrateRoot(
465
+ element,
466
+ React.createElement(IslandComponent, { initialReady: true }),
467
+ createHydrationOptions(element, id, renderMode)
468
+ )
469
+ : createRoot(element);
470
+
471
+ if (!shouldHydrate) {
472
+ root.render(React.createElement(IslandComponent, { initialReady: false }));
473
+ }
474
+
243
475
  hydratedRoots.set(id, root);
244
476
 
245
477
  // 완료 표시
478
+ element.setAttribute('data-mandu-render-mode', renderMode);
246
479
  element.setAttribute('data-mandu-hydrated', 'true');
247
480
 
248
481
  // 성능 마커
@@ -253,22 +486,47 @@ async function loadAndHydrate(element, src) {
253
486
  // 이벤트 발송
254
487
  element.dispatchEvent(new CustomEvent('mandu:hydrated', {
255
488
  bubbles: true,
256
- detail: { id, data }
489
+ detail: { id, data, mode: renderMode }
257
490
  }));
258
491
 
259
- console.log('[Mandu] Hydrated:', id);
492
+ // Kitchen DevTools에 island 등록
493
+ if (window.__MANDU_DEVTOOLS_HOOK__) {
494
+ const hydrateTime = performance.now ? performance.now() : Date.now();
495
+ window.__MANDU_DEVTOOLS_HOOK__.emit({
496
+ type: 'island:register',
497
+ timestamp: Date.now(),
498
+ data: {
499
+ id,
500
+ name: id,
501
+ strategy: element.getAttribute('data-mandu-priority') || 'visible',
502
+ status: 'hydrated',
503
+ renderMode,
504
+ hydrateStartTime: hydrateTime - 10,
505
+ hydrateEndTime: hydrateTime,
506
+ propsSize: JSON.stringify(data).length,
507
+ },
508
+ });
509
+ }
510
+
511
+ console.log('[Mandu] Hydrated:', id, '(' + renderMode + ')');
260
512
  }
261
513
  // Plain React component fallback (e.g. "use client" pages)
262
514
  else if (typeof island === 'function' || React.isValidElement(island)) {
263
515
  console.warn('[Mandu] Plain component hydration:', id);
516
+ const renderMode = 'hydrate';
264
517
 
265
518
  const root = typeof island === 'function'
266
- ? hydrateRoot(element, React.createElement(island, data))
267
- : hydrateRoot(element, island);
519
+ ? hydrateRoot(
520
+ element,
521
+ React.createElement(island, data),
522
+ createHydrationOptions(element, id, renderMode)
523
+ )
524
+ : hydrateRoot(element, island, createHydrationOptions(element, id, renderMode));
268
525
 
269
526
  hydratedRoots.set(id, root);
270
527
 
271
528
  // 완료 표시
529
+ element.setAttribute('data-mandu-render-mode', renderMode);
272
530
  element.setAttribute('data-mandu-hydrated', 'true');
273
531
 
274
532
  // 성능 마커
@@ -279,10 +537,10 @@ async function loadAndHydrate(element, src) {
279
537
  // 이벤트 발송
280
538
  element.dispatchEvent(new CustomEvent('mandu:hydrated', {
281
539
  bubbles: true,
282
- detail: { id, data }
540
+ detail: { id, data, mode: renderMode }
283
541
  }));
284
542
 
285
- console.log('[Mandu] Plain component hydrated:', id);
543
+ console.log('[Mandu] Plain component hydrated:', id, '(' + renderMode + ')');
286
544
  }
287
545
  else {
288
546
  throw new Error('[Mandu] Invalid module: expected Mandu island or React component: ' + id);
@@ -296,6 +554,8 @@ async function loadAndHydrate(element, src) {
296
554
  bubbles: true,
297
555
  detail: { id, error: error.message }
298
556
  }));
557
+ } finally {
558
+ element.removeAttribute('data-mandu-hydrating');
299
559
  }
300
560
  }
301
561
 
@@ -363,42 +623,7 @@ function generateReactShimSource(): string {
363
623
  * import map을 통해 bare specifier 해결
364
624
  */
365
625
  import React, {
366
- // Core
367
- createElement,
368
- cloneElement,
369
- createContext,
370
- createRef,
371
- forwardRef,
372
- isValidElement,
373
- memo,
374
- lazy,
375
- // Hooks
376
- useState,
377
- useEffect,
378
- useContext,
379
- useReducer,
380
- useCallback,
381
- useMemo,
382
- useRef,
383
- useLayoutEffect,
384
- useImperativeHandle,
385
- useDebugValue,
386
- useDeferredValue,
387
- useTransition,
388
- useId,
389
- useSyncExternalStore,
390
- useInsertionEffect,
391
- // Components
392
- Fragment,
393
- Suspense,
394
- StrictMode,
395
- Profiler,
396
- // Misc
397
- version,
398
- // Types
399
- Component,
400
- PureComponent,
401
- Children,
626
+ ${formatShimBindings(REACT_SHIM_EXPORTS)}
402
627
  } from 'react';
403
628
 
404
629
  // JSX Runtime functions (JSX 변환에 필요)
@@ -426,37 +651,7 @@ if (typeof window !== 'undefined') {
426
651
 
427
652
  // Named exports
428
653
  export {
429
- createElement,
430
- cloneElement,
431
- createContext,
432
- createRef,
433
- forwardRef,
434
- isValidElement,
435
- memo,
436
- lazy,
437
- useState,
438
- useEffect,
439
- useContext,
440
- useReducer,
441
- useCallback,
442
- useMemo,
443
- useRef,
444
- useLayoutEffect,
445
- useImperativeHandle,
446
- useDebugValue,
447
- useDeferredValue,
448
- useTransition,
449
- useId,
450
- useSyncExternalStore,
451
- useInsertionEffect,
452
- Fragment,
453
- Suspense,
454
- StrictMode,
455
- Profiler,
456
- version,
457
- Component,
458
- PureComponent,
459
- Children,
654
+ ${formatShimBindings(REACT_SHIM_EXPORTS)}
460
655
  __CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE,
461
656
  __SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED,
462
657
  // JSX Runtime exports
@@ -480,16 +675,12 @@ function generateReactDOMShimSource(): string {
480
675
  * Mandu React DOM Shim (Generated)
481
676
  */
482
677
  import ReactDOM, {
483
- createPortal,
484
- flushSync,
485
- version,
678
+ ${formatShimBindings(REACT_DOM_SHIM_EXPORTS)}
486
679
  } from 'react-dom';
487
680
 
488
681
  // Named exports
489
682
  export {
490
- createPortal,
491
- flushSync,
492
- version,
683
+ ${formatShimBindings(REACT_DOM_SHIM_EXPORTS)}
493
684
  };
494
685
 
495
686
  // Default export
@@ -506,13 +697,19 @@ function generateReactDOMClientShimSource(): string {
506
697
  /**
507
698
  * Mandu React DOM Client Shim (Generated)
508
699
  */
509
- import { createRoot, hydrateRoot } from 'react-dom/client';
700
+ import {
701
+ ${formatShimBindings(REACT_DOM_CLIENT_SHIM_EXPORTS)}
702
+ } from 'react-dom/client';
510
703
 
511
704
  // Named exports (명시적으로 re-export)
512
- export { createRoot, hydrateRoot };
705
+ export {
706
+ ${formatShimBindings(REACT_DOM_CLIENT_SHIM_EXPORTS)}
707
+ };
513
708
 
514
709
  // Default export
515
- export default { createRoot, hydrateRoot };
710
+ export default {
711
+ ${formatShimBindings(REACT_DOM_CLIENT_SHIM_EXPORTS)}
712
+ };
516
713
  `;
517
714
  }
518
715
 
@@ -1087,7 +1284,7 @@ async function buildIsland(
1087
1284
  minify: options.minify ?? process.env.NODE_ENV === "production",
1088
1285
  sourcemap: options.sourcemap ? "external" : "none",
1089
1286
  target: "browser",
1090
- splitting: options.splitting ?? false,
1287
+ splitting: options.splitting ?? (process.env.NODE_ENV === "production"),
1091
1288
  external: ["react", "react-dom", "react-dom/client", ...(options.external || [])],
1092
1289
  define: {
1093
1290
  "process.env.NODE_ENV": JSON.stringify(process.env.NODE_ENV || "development"),
@@ -1151,7 +1348,8 @@ function createBundleManifest(
1151
1348
  runtimePath: string,
1152
1349
  vendorResult: VendorBuildResult,
1153
1350
  routerPath: string,
1154
- env: "development" | "production"
1351
+ env: "development" | "production",
1352
+ islandBundles?: Array<{ name: string; js: string; route: string; priority: IslandFileEntry["priority"] }>
1155
1353
  ): BundleManifest {
1156
1354
  const bundles: BundleManifest["bundles"] = {};
1157
1355
 
@@ -1166,11 +1364,25 @@ function createBundleManifest(
1166
1364
  };
1167
1365
  }
1168
1366
 
1367
+ // Per-island bundles (code splitting)
1368
+ let islands: BundleManifest["islands"];
1369
+ if (islandBundles && islandBundles.length > 0) {
1370
+ islands = {};
1371
+ for (const ib of islandBundles) {
1372
+ islands[ib.name] = {
1373
+ js: ib.js,
1374
+ route: ib.route,
1375
+ priority: ib.priority,
1376
+ };
1377
+ }
1378
+ }
1379
+
1169
1380
  return {
1170
1381
  version: 1,
1171
1382
  buildTime: new Date().toISOString(),
1172
1383
  env,
1173
1384
  bundles,
1385
+ ...(islands ? { islands } : {}),
1174
1386
  shared: {
1175
1387
  runtime: runtimePath,
1176
1388
  vendor: vendorResult.react, // primary vendor for backwards compatibility
@@ -1305,24 +1517,28 @@ export async function buildClientBundles(
1305
1517
  return buildClientBundles(manifest, rootDir, { ...options, targetRouteIds: undefined });
1306
1518
  }
1307
1519
 
1308
- for (const output of outputs) {
1309
- if (existingManifest.bundles[output.routeId]) {
1310
- existingManifest.bundles[output.routeId].js = output.outputPath;
1311
- } else {
1312
- const route = targetRoutes.find((r) => r.id === output.routeId);
1313
- const hydration = route ? getRouteHydration(route) : null;
1314
- existingManifest.bundles[output.routeId] = {
1315
- js: output.outputPath,
1316
- dependencies: ["_runtime", "_react"],
1317
- priority: hydration?.priority || HYDRATION.DEFAULT_PRIORITY,
1318
- };
1520
+ // Only update manifest with successfully built outputs (#10: preserve previous good manifest on failure)
1521
+ if (outputs.length > 0) {
1522
+ for (const output of outputs) {
1523
+ if (existingManifest.bundles[output.routeId]) {
1524
+ existingManifest.bundles[output.routeId].js = output.outputPath;
1525
+ } else {
1526
+ const route = targetRoutes.find((r) => r.id === output.routeId);
1527
+ const hydration = route ? getRouteHydration(route) : null;
1528
+ existingManifest.bundles[output.routeId] = {
1529
+ js: output.outputPath,
1530
+ dependencies: ["_runtime", "_react"],
1531
+ priority: hydration?.priority || HYDRATION.DEFAULT_PRIORITY,
1532
+ };
1533
+ }
1319
1534
  }
1320
- }
1321
1535
 
1322
- await fs.writeFile(
1323
- path.join(rootDir, ".mandu/manifest.json"),
1324
- JSON.stringify(existingManifest, null, 2)
1325
- );
1536
+ await fs.writeFile(
1537
+ path.join(rootDir, ".mandu/manifest.json"),
1538
+ JSON.stringify(existingManifest, null, 2)
1539
+ );
1540
+ }
1541
+ // When all builds failed, do NOT overwrite manifest — keep previous good state
1326
1542
 
1327
1543
  const stats = calculateStats(outputs, startTime);
1328
1544
  return { success: errors.length === 0, outputs, errors, manifest: existingManifest, stats };
@@ -1356,13 +1572,66 @@ export async function buildClientBundles(
1356
1572
  console.warn("[Mandu] DevTools bundle build failed:", devtoolsResult.errors.join(", "));
1357
1573
  }
1358
1574
 
1575
+ // 4.5. Pre-build validation: detect wrong import paths in island files
1576
+ for (const route of hydratedRoutes) {
1577
+ if (route.clientModule) {
1578
+ const clientModulePath = path.join(rootDir, route.clientModule);
1579
+ try {
1580
+ const source = await fs.readFile(clientModulePath, "utf-8");
1581
+ // Match imports from "@mandujs/core" but NOT "@mandujs/core/client" or other subpaths
1582
+ const wrongImportPattern = /(?:import|from)\s+['"]@mandujs\/core['"]|require\s*\(\s*['"]@mandujs\/core['"]\s*\)/;
1583
+ if (wrongImportPattern.test(source)) {
1584
+ const errMsg =
1585
+ `[${route.id}] Island file "${route.clientModule}" imports from "@mandujs/core" which is a server-side module.\n` +
1586
+ ` Fix: Change the import to "@mandujs/core/client".\n` +
1587
+ ` Client islands cannot use server-side modules.`;
1588
+ console.error(`\n\x1b[31mERROR: ${errMsg}\x1b[0m\n`);
1589
+ errors.push(errMsg);
1590
+ }
1591
+ } catch {
1592
+ // File read failure will be caught later during build
1593
+ }
1594
+ }
1595
+ }
1596
+
1359
1597
  // 5. 각 Island 번들 빌드
1360
1598
  for (const route of hydratedRoutes) {
1361
1599
  try {
1362
1600
  const result = await buildIsland(route, rootDir, outDir, options);
1363
1601
  outputs.push(result);
1364
1602
  } catch (error) {
1365
- errors.push(`[${route.id}] ${String(error)}`);
1603
+ const errorStr = String(error);
1604
+ // Detect common mistake: importing @mandujs/core (server module) in client island
1605
+ if (errorStr.includes("AggregateError") || errorStr.includes("Could not resolve")) {
1606
+ const clientModule = route.clientModule || "";
1607
+ errors.push(
1608
+ `[${route.id}] ${errorStr}\n` +
1609
+ ` 💡 Hint: If your island imports from "@mandujs/core", change it to "@mandujs/core/client".\n` +
1610
+ ` Client islands cannot use server-side modules. File: ${clientModule}`
1611
+ );
1612
+ } else {
1613
+ errors.push(`[${route.id}] ${errorStr}`);
1614
+ }
1615
+ }
1616
+ }
1617
+
1618
+ // 5.5. Per-island code splitting: scan and build individual island bundles
1619
+ const islandFiles = await scanIslandFiles(hydratedRoutes, rootDir);
1620
+ const islandBundles: Array<{ name: string; js: string; route: string; priority: IslandFileEntry["priority"] }> = [];
1621
+
1622
+ if (islandFiles.length > 0) {
1623
+ const islandResults = await Promise.all(
1624
+ islandFiles.map(async (entry) => {
1625
+ try {
1626
+ return await buildPerIslandBundle(entry, outDir, options);
1627
+ } catch (error) {
1628
+ errors.push(`[island:${entry.name}] ${String(error)}`);
1629
+ return null;
1630
+ }
1631
+ })
1632
+ );
1633
+ for (const result of islandResults) {
1634
+ if (result) islandBundles.push(result);
1366
1635
  }
1367
1636
  }
1368
1637
 
@@ -1373,7 +1642,8 @@ export async function buildClientBundles(
1373
1642
  runtimeResult.outputPath,
1374
1643
  vendorResult,
1375
1644
  routerResult.outputPath,
1376
- env
1645
+ env,
1646
+ islandBundles
1377
1647
  );
1378
1648
 
1379
1649
  await fs.writeFile(