@mandujs/core 0.7.7 → 0.8.1
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.ts +75 -75
- package/src/client/index.ts +3 -18
- package/src/client/runtime.ts +41 -271
- package/src/runtime/ssr.ts +13 -7
package/package.json
CHANGED
package/src/bundler/build.ts
CHANGED
|
@@ -47,44 +47,38 @@ function getHydratedRoutes(manifest: RoutesManifest): RouteSpec[] {
|
|
|
47
47
|
}
|
|
48
48
|
|
|
49
49
|
/**
|
|
50
|
-
* Runtime 번들 소스 생성
|
|
50
|
+
* Runtime 번들 소스 생성 (v0.8.0 재설계)
|
|
51
|
+
*
|
|
52
|
+
* 설계 원칙:
|
|
53
|
+
* - 글로벌 레지스트리 없음 (Island가 스스로 등록 안함)
|
|
54
|
+
* - Runtime이 Island를 dynamic import()로 로드
|
|
55
|
+
* - HTML의 data-mandu-src 속성에서 번들 URL 읽기
|
|
56
|
+
* - 실행 순서 문제 완전 해결
|
|
51
57
|
*/
|
|
52
58
|
function generateRuntimeSource(): string {
|
|
53
59
|
return `
|
|
54
60
|
/**
|
|
55
|
-
* Mandu Hydration Runtime (Generated)
|
|
61
|
+
* Mandu Hydration Runtime v0.8.0 (Generated)
|
|
62
|
+
* Fresh-style dynamic import architecture
|
|
56
63
|
*/
|
|
57
64
|
|
|
58
|
-
//
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
window.__MANDU_ISLANDS__ = window.__MANDU_ISLANDS__ || new Map();
|
|
62
|
-
window.__MANDU_ROOTS__ = window.__MANDU_ROOTS__ || new Map();
|
|
65
|
+
// React 정적 import (Island와 같은 인스턴스 공유)
|
|
66
|
+
import React from 'react';
|
|
67
|
+
import { createRoot } from 'react-dom/client';
|
|
63
68
|
|
|
64
|
-
//
|
|
65
|
-
const
|
|
69
|
+
// Hydrated roots 추적 (unmount용)
|
|
70
|
+
const hydratedRoots = new Map();
|
|
66
71
|
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
*/
|
|
70
|
-
export function registerIsland(id, loader) {
|
|
71
|
-
window.__MANDU_ISLANDS__.set(id, loader);
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
/**
|
|
75
|
-
* 서버 데이터 가져오기
|
|
76
|
-
*/
|
|
77
|
-
export function getServerData(id) {
|
|
78
|
-
return serverData[id]?.serverData;
|
|
79
|
-
}
|
|
72
|
+
// 서버 데이터
|
|
73
|
+
const getServerData = (id) => (window.__MANDU_DATA__ || {})[id]?.serverData || {};
|
|
80
74
|
|
|
81
75
|
/**
|
|
82
76
|
* Hydration 스케줄러
|
|
83
77
|
*/
|
|
84
|
-
function scheduleHydration(element,
|
|
78
|
+
function scheduleHydration(element, src, priority) {
|
|
85
79
|
switch (priority) {
|
|
86
80
|
case 'immediate':
|
|
87
|
-
|
|
81
|
+
loadAndHydrate(element, src);
|
|
88
82
|
break;
|
|
89
83
|
|
|
90
84
|
case 'visible':
|
|
@@ -92,20 +86,20 @@ function scheduleHydration(element, id, priority, data) {
|
|
|
92
86
|
const observer = new IntersectionObserver((entries) => {
|
|
93
87
|
if (entries[0].isIntersecting) {
|
|
94
88
|
observer.disconnect();
|
|
95
|
-
|
|
89
|
+
loadAndHydrate(element, src);
|
|
96
90
|
}
|
|
97
91
|
}, { rootMargin: '50px' });
|
|
98
92
|
observer.observe(element);
|
|
99
93
|
} else {
|
|
100
|
-
|
|
94
|
+
loadAndHydrate(element, src);
|
|
101
95
|
}
|
|
102
96
|
break;
|
|
103
97
|
|
|
104
98
|
case 'idle':
|
|
105
99
|
if ('requestIdleCallback' in window) {
|
|
106
|
-
requestIdleCallback(() =>
|
|
100
|
+
requestIdleCallback(() => loadAndHydrate(element, src));
|
|
107
101
|
} else {
|
|
108
|
-
setTimeout(() =>
|
|
102
|
+
setTimeout(() => loadAndHydrate(element, src), 200);
|
|
109
103
|
}
|
|
110
104
|
break;
|
|
111
105
|
|
|
@@ -114,7 +108,7 @@ function scheduleHydration(element, id, priority, data) {
|
|
|
114
108
|
element.removeEventListener('mouseenter', hydrate);
|
|
115
109
|
element.removeEventListener('focusin', hydrate);
|
|
116
110
|
element.removeEventListener('touchstart', hydrate);
|
|
117
|
-
|
|
111
|
+
loadAndHydrate(element, src);
|
|
118
112
|
};
|
|
119
113
|
element.addEventListener('mouseenter', hydrate, { once: true, passive: true });
|
|
120
114
|
element.addEventListener('focusin', hydrate, { once: true });
|
|
@@ -125,40 +119,35 @@ function scheduleHydration(element, id, priority, data) {
|
|
|
125
119
|
}
|
|
126
120
|
|
|
127
121
|
/**
|
|
128
|
-
*
|
|
129
|
-
*
|
|
122
|
+
* Island 로드 및 hydrate (핵심 함수)
|
|
123
|
+
* Dynamic import로 Island 모듈 로드 후 렌더링
|
|
130
124
|
*/
|
|
131
|
-
async function
|
|
132
|
-
const
|
|
133
|
-
if (!loader) {
|
|
134
|
-
console.warn('[Mandu] Island not found:', id);
|
|
135
|
-
return;
|
|
136
|
-
}
|
|
125
|
+
async function loadAndHydrate(element, src) {
|
|
126
|
+
const id = element.getAttribute('data-mandu-island');
|
|
137
127
|
|
|
138
128
|
try {
|
|
139
|
-
|
|
129
|
+
// Dynamic import - 이 시점에 Island 모듈 로드
|
|
130
|
+
const module = await import(src);
|
|
131
|
+
const island = module.default;
|
|
140
132
|
|
|
141
|
-
// Island
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
throw new Error('[Mandu] Invalid island: ' + id);
|
|
133
|
+
// Island 유효성 검사
|
|
134
|
+
if (!island || !island.__mandu_island) {
|
|
135
|
+
throw new Error('[Mandu] Invalid island module: ' + id);
|
|
145
136
|
}
|
|
146
137
|
|
|
147
|
-
const { definition } =
|
|
148
|
-
const
|
|
149
|
-
const React = await import('react');
|
|
138
|
+
const { definition } = island;
|
|
139
|
+
const data = getServerData(id);
|
|
150
140
|
|
|
151
|
-
// Island 컴포넌트
|
|
141
|
+
// Island 컴포넌트 (정적 import된 React 사용)
|
|
152
142
|
function IslandComponent() {
|
|
153
143
|
const setupResult = definition.setup(data);
|
|
154
144
|
return definition.render(setupResult);
|
|
155
145
|
}
|
|
156
146
|
|
|
157
|
-
// Mount (createRoot 사용
|
|
158
|
-
// hydrateRoot 대신 createRoot 사용: Island는 SSR과 다른 컨텐츠를 렌더링할 수 있음
|
|
147
|
+
// Mount (상단에서 import한 createRoot 사용)
|
|
159
148
|
const root = createRoot(element);
|
|
160
149
|
root.render(React.createElement(IslandComponent));
|
|
161
|
-
|
|
150
|
+
hydratedRoots.set(id, root);
|
|
162
151
|
|
|
163
152
|
// 완료 표시
|
|
164
153
|
element.setAttribute('data-mandu-hydrated', 'true');
|
|
@@ -173,6 +162,8 @@ async function hydrateIsland(element, id, data) {
|
|
|
173
162
|
bubbles: true,
|
|
174
163
|
detail: { id, data }
|
|
175
164
|
}));
|
|
165
|
+
|
|
166
|
+
console.log('[Mandu] Hydrated:', id);
|
|
176
167
|
} catch (error) {
|
|
177
168
|
console.error('[Mandu] Hydration failed for', id, error);
|
|
178
169
|
element.setAttribute('data-mandu-error', 'true');
|
|
@@ -180,37 +171,47 @@ async function hydrateIsland(element, id, data) {
|
|
|
180
171
|
}
|
|
181
172
|
|
|
182
173
|
/**
|
|
183
|
-
* 모든 Island hydrate
|
|
174
|
+
* 모든 Island hydrate 시작
|
|
184
175
|
*/
|
|
185
|
-
|
|
176
|
+
function hydrateIslands() {
|
|
186
177
|
const islands = document.querySelectorAll('[data-mandu-island]');
|
|
187
178
|
|
|
188
179
|
for (const el of islands) {
|
|
189
180
|
const id = el.getAttribute('data-mandu-island');
|
|
190
|
-
|
|
191
|
-
|
|
181
|
+
const src = el.getAttribute('data-mandu-src');
|
|
192
182
|
const priority = el.getAttribute('data-mandu-priority') || 'visible';
|
|
193
|
-
const data = serverData[id]?.serverData || {};
|
|
194
183
|
|
|
195
|
-
|
|
184
|
+
if (!id || !src) {
|
|
185
|
+
console.warn('[Mandu] Island missing id or src:', el);
|
|
186
|
+
continue;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
scheduleHydration(el, src, priority);
|
|
196
190
|
}
|
|
197
191
|
}
|
|
198
192
|
|
|
199
193
|
/**
|
|
200
|
-
*
|
|
201
|
-
* queueMicrotask로 지연: Island 등록 코드가 먼저 실행되도록 함
|
|
194
|
+
* Island unmount
|
|
202
195
|
*/
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
196
|
+
function unmountIsland(id) {
|
|
197
|
+
const root = hydratedRoots.get(id);
|
|
198
|
+
if (root) {
|
|
199
|
+
root.unmount();
|
|
200
|
+
hydratedRoots.delete(id);
|
|
201
|
+
return true;
|
|
208
202
|
}
|
|
209
|
-
|
|
203
|
+
return false;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// 자동 초기화
|
|
207
|
+
if (document.readyState === 'loading') {
|
|
208
|
+
document.addEventListener('DOMContentLoaded', hydrateIslands);
|
|
209
|
+
} else {
|
|
210
|
+
hydrateIslands();
|
|
211
|
+
}
|
|
210
212
|
|
|
211
|
-
//
|
|
212
|
-
export
|
|
213
|
-
export const getHydratedRoots = () => window.__MANDU_ROOTS__;
|
|
213
|
+
// Export for external use
|
|
214
|
+
export { hydrateIslands, unmountIsland, hydratedRoots };
|
|
214
215
|
`;
|
|
215
216
|
}
|
|
216
217
|
|
|
@@ -612,23 +613,22 @@ async function buildRouterRuntime(
|
|
|
612
613
|
}
|
|
613
614
|
|
|
614
615
|
/**
|
|
615
|
-
* Island 엔트리 래퍼 생성
|
|
616
|
-
*
|
|
616
|
+
* Island 엔트리 래퍼 생성 (v0.8.0 재설계)
|
|
617
|
+
*
|
|
618
|
+
* 설계 원칙:
|
|
619
|
+
* - 순수 export만 (부작용 없음)
|
|
620
|
+
* - Runtime이 dynamic import로 로드
|
|
621
|
+
* - 등록/초기화 코드 없음
|
|
617
622
|
*/
|
|
618
623
|
function generateIslandEntry(routeId: string, clientModulePath: string): string {
|
|
619
624
|
// Windows 경로의 백슬래시를 슬래시로 변환 (JS escape 문제 방지)
|
|
620
625
|
const normalizedPath = clientModulePath.replace(/\\/g, "/");
|
|
621
626
|
return `
|
|
622
627
|
/**
|
|
623
|
-
* Mandu Island
|
|
628
|
+
* Mandu Island: ${routeId} (Generated)
|
|
629
|
+
* Pure export - no side effects
|
|
624
630
|
*/
|
|
625
|
-
|
|
626
631
|
import island from "${normalizedPath}";
|
|
627
|
-
|
|
628
|
-
// 글로벌 레지스트리에 직접 등록 (런타임과 공유)
|
|
629
|
-
window.__MANDU_ISLANDS__ = window.__MANDU_ISLANDS__ || new Map();
|
|
630
|
-
window.__MANDU_ISLANDS__.set("${routeId}", () => island);
|
|
631
|
-
|
|
632
632
|
export default island;
|
|
633
633
|
`;
|
|
634
634
|
}
|
package/src/client/index.ts
CHANGED
|
@@ -42,15 +42,12 @@ export {
|
|
|
42
42
|
|
|
43
43
|
// Runtime API
|
|
44
44
|
export {
|
|
45
|
-
registerIsland,
|
|
46
|
-
getRegisteredIslands,
|
|
47
45
|
getServerData,
|
|
48
|
-
hydrateIslands,
|
|
49
46
|
getHydrationState,
|
|
50
47
|
unmountIsland,
|
|
51
48
|
unmountAllIslands,
|
|
52
|
-
|
|
53
|
-
type
|
|
49
|
+
type HydrationState,
|
|
50
|
+
type HydrationPriority,
|
|
54
51
|
} from "./runtime";
|
|
55
52
|
|
|
56
53
|
// Client-side Router API
|
|
@@ -100,12 +97,12 @@ export {
|
|
|
100
97
|
|
|
101
98
|
// Re-export as Mandu namespace for consistent API
|
|
102
99
|
import { island, wrapComponent } from "./island";
|
|
103
|
-
import { hydrateIslands, initializeRuntime } from "./runtime";
|
|
104
100
|
import { navigate, prefetch, initializeRouter } from "./router";
|
|
105
101
|
import { Link, NavLink } from "./Link";
|
|
106
102
|
|
|
107
103
|
/**
|
|
108
104
|
* Mandu Client namespace
|
|
105
|
+
* v0.8.0: Hydration은 자동으로 처리됨 (generateRuntimeSource에서 생성)
|
|
109
106
|
*/
|
|
110
107
|
export const Mandu = {
|
|
111
108
|
/**
|
|
@@ -120,18 +117,6 @@ export const Mandu = {
|
|
|
120
117
|
*/
|
|
121
118
|
wrapComponent,
|
|
122
119
|
|
|
123
|
-
/**
|
|
124
|
-
* Hydrate all islands on the page
|
|
125
|
-
* @see hydrateIslands
|
|
126
|
-
*/
|
|
127
|
-
hydrate: hydrateIslands,
|
|
128
|
-
|
|
129
|
-
/**
|
|
130
|
-
* Initialize the hydration runtime
|
|
131
|
-
* @see initializeRuntime
|
|
132
|
-
*/
|
|
133
|
-
init: initializeRuntime,
|
|
134
|
-
|
|
135
120
|
/**
|
|
136
121
|
* Navigate to a URL (client-side)
|
|
137
122
|
* @see navigate
|
package/src/client/runtime.ts
CHANGED
|
@@ -1,67 +1,41 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Mandu Hydration Runtime 🌊
|
|
3
|
-
*
|
|
3
|
+
* v0.8.0: Dynamic Import 기반 아키텍처
|
|
4
|
+
*
|
|
5
|
+
* 이 파일은 타입 정의와 유틸리티 함수를 제공합니다.
|
|
6
|
+
* 실제 Hydration Runtime은 bundler/build.ts의 generateRuntimeSource()에서 생성됩니다.
|
|
4
7
|
*/
|
|
5
8
|
|
|
6
|
-
import { hydrateRoot } from "react-dom/client";
|
|
7
9
|
import type { Root } from "react-dom/client";
|
|
8
|
-
import type { CompiledIsland } from "./island";
|
|
9
|
-
import type { ReactNode } from "react";
|
|
10
|
-
import React from "react";
|
|
11
10
|
|
|
12
11
|
/**
|
|
13
|
-
*
|
|
14
|
-
*/
|
|
15
|
-
export type IslandLoader = () => Promise<CompiledIsland<any, any>> | CompiledIsland<any, any>;
|
|
16
|
-
|
|
17
|
-
/**
|
|
18
|
-
* Island 레지스트리 (글로벌)
|
|
19
|
-
* 주의: 로컬 Map 사용 시 번들러 인라인 문제로 별도 인스턴스 생성됨
|
|
20
|
-
* 항상 window.__MANDU_ISLANDS__를 직접 참조해야 함
|
|
12
|
+
* Window 전역 타입 확장
|
|
21
13
|
*/
|
|
22
14
|
declare global {
|
|
23
15
|
interface Window {
|
|
24
|
-
|
|
16
|
+
/** Hydrated React roots (unmount용) */
|
|
25
17
|
__MANDU_ROOTS__: Map<string, Root>;
|
|
18
|
+
/** 서버 데이터 */
|
|
19
|
+
__MANDU_DATA__?: Record<string, { serverData: unknown; timestamp: number }>;
|
|
20
|
+
/** 직렬화된 서버 데이터 (raw JSON) */
|
|
21
|
+
__MANDU_DATA_RAW__?: string;
|
|
26
22
|
}
|
|
27
23
|
}
|
|
28
24
|
|
|
29
|
-
// 글로벌 레지스트리 초기화
|
|
30
|
-
if (typeof window !== "undefined") {
|
|
31
|
-
window.__MANDU_ISLANDS__ = window.__MANDU_ISLANDS__ || new Map<string, IslandLoader>();
|
|
32
|
-
window.__MANDU_ROOTS__ = window.__MANDU_ROOTS__ || new Map<string, Root>();
|
|
33
|
-
}
|
|
34
|
-
|
|
35
25
|
/**
|
|
36
26
|
* Hydration 상태 추적
|
|
37
27
|
*/
|
|
38
|
-
interface HydrationState {
|
|
28
|
+
export interface HydrationState {
|
|
39
29
|
total: number;
|
|
40
30
|
hydrated: number;
|
|
41
31
|
failed: number;
|
|
42
32
|
pending: Set<string>;
|
|
43
33
|
}
|
|
44
34
|
|
|
45
|
-
const hydrationState: HydrationState = {
|
|
46
|
-
total: 0,
|
|
47
|
-
hydrated: 0,
|
|
48
|
-
failed: 0,
|
|
49
|
-
pending: new Set(),
|
|
50
|
-
};
|
|
51
|
-
|
|
52
|
-
/**
|
|
53
|
-
* Island 등록
|
|
54
|
-
*/
|
|
55
|
-
export function registerIsland(id: string, loader: IslandLoader): void {
|
|
56
|
-
window.__MANDU_ISLANDS__.set(id, loader);
|
|
57
|
-
}
|
|
58
|
-
|
|
59
35
|
/**
|
|
60
|
-
*
|
|
36
|
+
* Hydration 우선순위
|
|
61
37
|
*/
|
|
62
|
-
export
|
|
63
|
-
return Array.from(window.__MANDU_ISLANDS__.keys());
|
|
64
|
-
}
|
|
38
|
+
export type HydrationPriority = "immediate" | "visible" | "idle" | "interaction";
|
|
65
39
|
|
|
66
40
|
/**
|
|
67
41
|
* 서버 데이터 가져오기
|
|
@@ -71,7 +45,7 @@ export function getServerData<T = unknown>(islandId: string): T | undefined {
|
|
|
71
45
|
return undefined;
|
|
72
46
|
}
|
|
73
47
|
|
|
74
|
-
const manduData =
|
|
48
|
+
const manduData = window.__MANDU_DATA__;
|
|
75
49
|
if (!manduData) {
|
|
76
50
|
return undefined;
|
|
77
51
|
}
|
|
@@ -80,186 +54,41 @@ export function getServerData<T = unknown>(islandId: string): T | undefined {
|
|
|
80
54
|
}
|
|
81
55
|
|
|
82
56
|
/**
|
|
83
|
-
*
|
|
84
|
-
*/
|
|
85
|
-
type HydrationPriority = "immediate" | "visible" | "idle" | "interaction";
|
|
86
|
-
|
|
87
|
-
function scheduleHydration(
|
|
88
|
-
element: HTMLElement,
|
|
89
|
-
id: string,
|
|
90
|
-
priority: HydrationPriority,
|
|
91
|
-
serverData: unknown
|
|
92
|
-
): void {
|
|
93
|
-
switch (priority) {
|
|
94
|
-
case "immediate":
|
|
95
|
-
hydrateIsland(element, id, serverData);
|
|
96
|
-
break;
|
|
97
|
-
|
|
98
|
-
case "visible":
|
|
99
|
-
if ("IntersectionObserver" in window) {
|
|
100
|
-
const observer = new IntersectionObserver(
|
|
101
|
-
(entries) => {
|
|
102
|
-
if (entries[0].isIntersecting) {
|
|
103
|
-
observer.disconnect();
|
|
104
|
-
hydrateIsland(element, id, serverData);
|
|
105
|
-
}
|
|
106
|
-
},
|
|
107
|
-
{ rootMargin: "50px" }
|
|
108
|
-
);
|
|
109
|
-
observer.observe(element);
|
|
110
|
-
} else {
|
|
111
|
-
// Fallback for older browsers
|
|
112
|
-
hydrateIsland(element, id, serverData);
|
|
113
|
-
}
|
|
114
|
-
break;
|
|
115
|
-
|
|
116
|
-
case "idle":
|
|
117
|
-
if ("requestIdleCallback" in window) {
|
|
118
|
-
(window as any).requestIdleCallback(() => {
|
|
119
|
-
hydrateIsland(element, id, serverData);
|
|
120
|
-
});
|
|
121
|
-
} else {
|
|
122
|
-
// Fallback
|
|
123
|
-
setTimeout(() => hydrateIsland(element, id, serverData), 200);
|
|
124
|
-
}
|
|
125
|
-
break;
|
|
126
|
-
|
|
127
|
-
case "interaction": {
|
|
128
|
-
const hydrate = () => {
|
|
129
|
-
element.removeEventListener("mouseenter", hydrate);
|
|
130
|
-
element.removeEventListener("focusin", hydrate);
|
|
131
|
-
element.removeEventListener("touchstart", hydrate);
|
|
132
|
-
hydrateIsland(element, id, serverData);
|
|
133
|
-
};
|
|
134
|
-
element.addEventListener("mouseenter", hydrate, { once: true, passive: true });
|
|
135
|
-
element.addEventListener("focusin", hydrate, { once: true });
|
|
136
|
-
element.addEventListener("touchstart", hydrate, { once: true, passive: true });
|
|
137
|
-
break;
|
|
138
|
-
}
|
|
139
|
-
}
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
/**
|
|
143
|
-
* 단일 Island hydrate
|
|
144
|
-
*/
|
|
145
|
-
async function hydrateIsland(
|
|
146
|
-
element: HTMLElement,
|
|
147
|
-
id: string,
|
|
148
|
-
serverData: unknown
|
|
149
|
-
): Promise<void> {
|
|
150
|
-
const loader = window.__MANDU_ISLANDS__.get(id);
|
|
151
|
-
if (!loader) {
|
|
152
|
-
console.warn(`[Mandu] Island not registered: ${id}`);
|
|
153
|
-
hydrationState.failed++;
|
|
154
|
-
hydrationState.pending.delete(id);
|
|
155
|
-
return;
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
try {
|
|
159
|
-
// 로더 실행 (dynamic import 또는 직접 참조)
|
|
160
|
-
const island = await Promise.resolve(loader());
|
|
161
|
-
|
|
162
|
-
if (!island.__mandu_island) {
|
|
163
|
-
throw new Error(`[Mandu] Invalid island: ${id}`);
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
const { definition } = island;
|
|
167
|
-
|
|
168
|
-
// Island 컴포넌트 생성
|
|
169
|
-
function IslandComponent(): ReactNode {
|
|
170
|
-
const setupResult = definition.setup(serverData);
|
|
171
|
-
return definition.render(setupResult);
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
// ErrorBoundary가 있으면 감싸기
|
|
175
|
-
let content: ReactNode;
|
|
176
|
-
if (definition.errorBoundary) {
|
|
177
|
-
content = React.createElement(
|
|
178
|
-
ManduErrorBoundary,
|
|
179
|
-
{
|
|
180
|
-
fallback: (error: Error, reset: () => void) => definition.errorBoundary!(error, reset),
|
|
181
|
-
},
|
|
182
|
-
React.createElement(IslandComponent)
|
|
183
|
-
);
|
|
184
|
-
} else {
|
|
185
|
-
content = React.createElement(IslandComponent);
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
// Hydrate
|
|
189
|
-
const root = hydrateRoot(element, content);
|
|
190
|
-
window.__MANDU_ROOTS__.set(id, root);
|
|
191
|
-
|
|
192
|
-
// 상태 업데이트
|
|
193
|
-
element.setAttribute("data-mandu-hydrated", "true");
|
|
194
|
-
hydrationState.hydrated++;
|
|
195
|
-
hydrationState.pending.delete(id);
|
|
196
|
-
|
|
197
|
-
// 성능 마커
|
|
198
|
-
if (typeof performance !== "undefined" && performance.mark) {
|
|
199
|
-
performance.mark(`mandu-hydrated-${id}`);
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
// Hydration 완료 이벤트
|
|
203
|
-
element.dispatchEvent(
|
|
204
|
-
new CustomEvent("mandu:hydrated", {
|
|
205
|
-
bubbles: true,
|
|
206
|
-
detail: { id, serverData },
|
|
207
|
-
})
|
|
208
|
-
);
|
|
209
|
-
} catch (error) {
|
|
210
|
-
console.error(`[Mandu] Hydration failed for island ${id}:`, error);
|
|
211
|
-
hydrationState.failed++;
|
|
212
|
-
hydrationState.pending.delete(id);
|
|
213
|
-
|
|
214
|
-
// 에러 상태 표시
|
|
215
|
-
element.setAttribute("data-mandu-error", "true");
|
|
216
|
-
|
|
217
|
-
// 에러 이벤트
|
|
218
|
-
element.dispatchEvent(
|
|
219
|
-
new CustomEvent("mandu:hydration-error", {
|
|
220
|
-
bubbles: true,
|
|
221
|
-
detail: { id, error },
|
|
222
|
-
})
|
|
223
|
-
);
|
|
224
|
-
}
|
|
225
|
-
}
|
|
226
|
-
|
|
227
|
-
/**
|
|
228
|
-
* 모든 Island hydrate 시작
|
|
57
|
+
* Hydration 상태 조회 (DOM 기반)
|
|
229
58
|
*/
|
|
230
|
-
export
|
|
59
|
+
export function getHydrationState(): Readonly<HydrationState> {
|
|
231
60
|
if (typeof document === "undefined") {
|
|
232
|
-
return;
|
|
61
|
+
return { total: 0, hydrated: 0, failed: 0, pending: new Set() };
|
|
233
62
|
}
|
|
234
63
|
|
|
235
64
|
const islands = document.querySelectorAll<HTMLElement>("[data-mandu-island]");
|
|
236
|
-
const
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
const id =
|
|
242
|
-
if (!
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
hydrationState.pending.add(id);
|
|
248
|
-
scheduleHydration(element, id, priority, data);
|
|
249
|
-
}
|
|
250
|
-
}
|
|
65
|
+
const hydrated = document.querySelectorAll<HTMLElement>("[data-mandu-hydrated]");
|
|
66
|
+
const failed = document.querySelectorAll<HTMLElement>("[data-mandu-error]");
|
|
67
|
+
|
|
68
|
+
const pending = new Set<string>();
|
|
69
|
+
islands.forEach((el) => {
|
|
70
|
+
const id = el.getAttribute("data-mandu-island");
|
|
71
|
+
if (id && !el.hasAttribute("data-mandu-hydrated") && !el.hasAttribute("data-mandu-error")) {
|
|
72
|
+
pending.add(id);
|
|
73
|
+
}
|
|
74
|
+
});
|
|
251
75
|
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
76
|
+
return {
|
|
77
|
+
total: islands.length,
|
|
78
|
+
hydrated: hydrated.length,
|
|
79
|
+
failed: failed.length,
|
|
80
|
+
pending,
|
|
81
|
+
};
|
|
257
82
|
}
|
|
258
83
|
|
|
259
84
|
/**
|
|
260
85
|
* 특정 Island unmount
|
|
261
86
|
*/
|
|
262
87
|
export function unmountIsland(id: string): boolean {
|
|
88
|
+
if (typeof window === "undefined" || !window.__MANDU_ROOTS__) {
|
|
89
|
+
return false;
|
|
90
|
+
}
|
|
91
|
+
|
|
263
92
|
const root = window.__MANDU_ROOTS__.get(id);
|
|
264
93
|
if (!root) {
|
|
265
94
|
return false;
|
|
@@ -274,71 +103,12 @@ export function unmountIsland(id: string): boolean {
|
|
|
274
103
|
* 모든 Island unmount
|
|
275
104
|
*/
|
|
276
105
|
export function unmountAllIslands(): void {
|
|
277
|
-
|
|
278
|
-
root.unmount();
|
|
279
|
-
window.__MANDU_ROOTS__.delete(id);
|
|
280
|
-
}
|
|
281
|
-
}
|
|
282
|
-
|
|
283
|
-
/**
|
|
284
|
-
* 간단한 ErrorBoundary 컴포넌트
|
|
285
|
-
*/
|
|
286
|
-
interface ErrorBoundaryProps {
|
|
287
|
-
children: ReactNode;
|
|
288
|
-
fallback: (error: Error, reset: () => void) => ReactNode;
|
|
289
|
-
}
|
|
290
|
-
|
|
291
|
-
interface ErrorBoundaryState {
|
|
292
|
-
error: Error | null;
|
|
293
|
-
}
|
|
294
|
-
|
|
295
|
-
class ManduErrorBoundary extends React.Component<ErrorBoundaryProps, ErrorBoundaryState> {
|
|
296
|
-
constructor(props: ErrorBoundaryProps) {
|
|
297
|
-
super(props);
|
|
298
|
-
this.state = { error: null };
|
|
299
|
-
}
|
|
300
|
-
|
|
301
|
-
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
|
|
302
|
-
return { error };
|
|
303
|
-
}
|
|
304
|
-
|
|
305
|
-
componentDidCatch(error: Error, errorInfo: React.ErrorInfo): void {
|
|
306
|
-
console.error("[Mandu] Island error:", error, errorInfo);
|
|
307
|
-
}
|
|
308
|
-
|
|
309
|
-
reset = (): void => {
|
|
310
|
-
this.setState({ error: null });
|
|
311
|
-
};
|
|
312
|
-
|
|
313
|
-
render(): ReactNode {
|
|
314
|
-
if (this.state.error) {
|
|
315
|
-
return this.props.fallback(this.state.error, this.reset);
|
|
316
|
-
}
|
|
317
|
-
return this.props.children;
|
|
318
|
-
}
|
|
319
|
-
}
|
|
320
|
-
|
|
321
|
-
/**
|
|
322
|
-
* 자동 초기화 (스크립트 로드 시)
|
|
323
|
-
*/
|
|
324
|
-
export function initializeRuntime(): void {
|
|
325
|
-
if (typeof document === "undefined") {
|
|
106
|
+
if (typeof window === "undefined" || !window.__MANDU_ROOTS__) {
|
|
326
107
|
return;
|
|
327
108
|
}
|
|
328
109
|
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
hydrateIslands();
|
|
333
|
-
});
|
|
334
|
-
} else {
|
|
335
|
-
// 이미 DOM이 준비된 경우
|
|
336
|
-
hydrateIslands();
|
|
110
|
+
for (const [id, root] of window.__MANDU_ROOTS__) {
|
|
111
|
+
root.unmount();
|
|
112
|
+
window.__MANDU_ROOTS__.delete(id);
|
|
337
113
|
}
|
|
338
114
|
}
|
|
339
|
-
|
|
340
|
-
// 자동 초기화 여부 (번들 시 설정)
|
|
341
|
-
// queueMicrotask로 지연: 번들 내 Island 등록 코드가 먼저 실행되도록 함
|
|
342
|
-
if (typeof window !== "undefined" && (window as any).__MANDU_AUTO_INIT__ !== false) {
|
|
343
|
-
queueMicrotask(() => initializeRuntime());
|
|
344
|
-
}
|
package/src/runtime/ssr.ts
CHANGED
|
@@ -59,6 +59,7 @@ function generateImportMap(manifest: BundleManifest): string {
|
|
|
59
59
|
|
|
60
60
|
/**
|
|
61
61
|
* Hydration 스크립트 태그 생성
|
|
62
|
+
* v0.8.0: Island 직접 로드 제거 - Runtime이 data-mandu-src에서 dynamic import
|
|
62
63
|
*/
|
|
63
64
|
function generateHydrationScripts(
|
|
64
65
|
routeId: string,
|
|
@@ -72,15 +73,14 @@ function generateHydrationScripts(
|
|
|
72
73
|
scripts.push(importMap);
|
|
73
74
|
}
|
|
74
75
|
|
|
75
|
-
// Island 번들
|
|
76
|
-
//
|
|
76
|
+
// Island 번들 modulepreload (성능 최적화 - prefetch only)
|
|
77
|
+
// v0.8.0: <script> 태그 제거 - Runtime이 dynamic import로 로드
|
|
77
78
|
const bundle = manifest.bundles[routeId];
|
|
78
79
|
if (bundle) {
|
|
79
80
|
scripts.push(`<link rel="modulepreload" href="${bundle.js}">`);
|
|
80
|
-
scripts.push(`<script type="module" src="${bundle.js}"></script>`);
|
|
81
81
|
}
|
|
82
82
|
|
|
83
|
-
// Runtime
|
|
83
|
+
// Runtime 로드 (hydrateIslands 실행 - dynamic import 사용)
|
|
84
84
|
if (manifest.shared.runtime) {
|
|
85
85
|
scripts.push(`<script type="module" src="${manifest.shared.runtime}"></script>`);
|
|
86
86
|
}
|
|
@@ -90,13 +90,16 @@ function generateHydrationScripts(
|
|
|
90
90
|
|
|
91
91
|
/**
|
|
92
92
|
* Island 래퍼로 컨텐츠 감싸기
|
|
93
|
+
* v0.8.0: data-mandu-src 속성 추가 (Runtime이 dynamic import로 로드)
|
|
93
94
|
*/
|
|
94
95
|
export function wrapWithIsland(
|
|
95
96
|
content: string,
|
|
96
97
|
routeId: string,
|
|
97
|
-
priority: HydrationPriority = "visible"
|
|
98
|
+
priority: HydrationPriority = "visible",
|
|
99
|
+
bundleSrc?: string
|
|
98
100
|
): string {
|
|
99
|
-
|
|
101
|
+
const srcAttr = bundleSrc ? ` data-mandu-src="${bundleSrc}"` : "";
|
|
102
|
+
return `<div data-mandu-island="${routeId}"${srcAttr} data-mandu-priority="${priority}">${content}</div>`;
|
|
100
103
|
}
|
|
101
104
|
|
|
102
105
|
export function renderToHTML(element: ReactElement, options: SSROptions = {}): string {
|
|
@@ -122,7 +125,10 @@ export function renderToHTML(element: ReactElement, options: SSROptions = {}): s
|
|
|
122
125
|
hydration && hydration.strategy !== "none" && routeId && bundleManifest;
|
|
123
126
|
|
|
124
127
|
if (needsHydration) {
|
|
125
|
-
|
|
128
|
+
// v0.8.0: bundleSrc를 data-mandu-src 속성으로 전달 (Runtime이 dynamic import로 로드)
|
|
129
|
+
const bundle = bundleManifest.bundles[routeId];
|
|
130
|
+
const bundleSrc = bundle?.js;
|
|
131
|
+
content = wrapWithIsland(content, routeId, hydration.priority, bundleSrc);
|
|
126
132
|
}
|
|
127
133
|
|
|
128
134
|
// 서버 데이터 스크립트
|