@mandujs/core 0.7.6 → 0.8.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.
- package/package.json +1 -1
- package/src/bundler/build.ts +68 -65
- package/src/client/index.ts +3 -18
- package/src/client/runtime.ts +41 -270
- package/src/filling/filling.ts +19 -0
- package/src/runtime/ssr.ts +13 -7
package/package.json
CHANGED
package/src/bundler/build.ts
CHANGED
|
@@ -47,44 +47,34 @@ 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
|
-
// 항상 window.__MANDU_ISLANDS__를 직접 참조해야 함
|
|
61
|
-
window.__MANDU_ISLANDS__ = window.__MANDU_ISLANDS__ || new Map();
|
|
62
|
-
window.__MANDU_ROOTS__ = window.__MANDU_ROOTS__ || new Map();
|
|
65
|
+
// Hydrated roots 추적 (unmount용)
|
|
66
|
+
const hydratedRoots = new Map();
|
|
63
67
|
|
|
64
68
|
// 서버 데이터
|
|
65
|
-
const
|
|
66
|
-
|
|
67
|
-
/**
|
|
68
|
-
* Island 등록
|
|
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
|
-
}
|
|
69
|
+
const getServerData = (id) => (window.__MANDU_DATA__ || {})[id]?.serverData || {};
|
|
80
70
|
|
|
81
71
|
/**
|
|
82
72
|
* Hydration 스케줄러
|
|
83
73
|
*/
|
|
84
|
-
function scheduleHydration(element,
|
|
74
|
+
function scheduleHydration(element, src, priority) {
|
|
85
75
|
switch (priority) {
|
|
86
76
|
case 'immediate':
|
|
87
|
-
|
|
77
|
+
loadAndHydrate(element, src);
|
|
88
78
|
break;
|
|
89
79
|
|
|
90
80
|
case 'visible':
|
|
@@ -92,20 +82,20 @@ function scheduleHydration(element, id, priority, data) {
|
|
|
92
82
|
const observer = new IntersectionObserver((entries) => {
|
|
93
83
|
if (entries[0].isIntersecting) {
|
|
94
84
|
observer.disconnect();
|
|
95
|
-
|
|
85
|
+
loadAndHydrate(element, src);
|
|
96
86
|
}
|
|
97
87
|
}, { rootMargin: '50px' });
|
|
98
88
|
observer.observe(element);
|
|
99
89
|
} else {
|
|
100
|
-
|
|
90
|
+
loadAndHydrate(element, src);
|
|
101
91
|
}
|
|
102
92
|
break;
|
|
103
93
|
|
|
104
94
|
case 'idle':
|
|
105
95
|
if ('requestIdleCallback' in window) {
|
|
106
|
-
requestIdleCallback(() =>
|
|
96
|
+
requestIdleCallback(() => loadAndHydrate(element, src));
|
|
107
97
|
} else {
|
|
108
|
-
setTimeout(() =>
|
|
98
|
+
setTimeout(() => loadAndHydrate(element, src), 200);
|
|
109
99
|
}
|
|
110
100
|
break;
|
|
111
101
|
|
|
@@ -114,7 +104,7 @@ function scheduleHydration(element, id, priority, data) {
|
|
|
114
104
|
element.removeEventListener('mouseenter', hydrate);
|
|
115
105
|
element.removeEventListener('focusin', hydrate);
|
|
116
106
|
element.removeEventListener('touchstart', hydrate);
|
|
117
|
-
|
|
107
|
+
loadAndHydrate(element, src);
|
|
118
108
|
};
|
|
119
109
|
element.addEventListener('mouseenter', hydrate, { once: true, passive: true });
|
|
120
110
|
element.addEventListener('focusin', hydrate, { once: true });
|
|
@@ -125,26 +115,26 @@ function scheduleHydration(element, id, priority, data) {
|
|
|
125
115
|
}
|
|
126
116
|
|
|
127
117
|
/**
|
|
128
|
-
*
|
|
129
|
-
*
|
|
118
|
+
* Island 로드 및 hydrate (핵심 함수)
|
|
119
|
+
* Dynamic import로 Island 모듈 로드 후 렌더링
|
|
130
120
|
*/
|
|
131
|
-
async function
|
|
132
|
-
const
|
|
133
|
-
if (!loader) {
|
|
134
|
-
console.warn('[Mandu] Island not found:', id);
|
|
135
|
-
return;
|
|
136
|
-
}
|
|
121
|
+
async function loadAndHydrate(element, src) {
|
|
122
|
+
const id = element.getAttribute('data-mandu-island');
|
|
137
123
|
|
|
138
124
|
try {
|
|
139
|
-
|
|
125
|
+
// Dynamic import - 이 시점에 Island 모듈 로드
|
|
126
|
+
const module = await import(src);
|
|
127
|
+
const island = module.default;
|
|
140
128
|
|
|
141
|
-
// Island
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
throw new Error('[Mandu] Invalid island: ' + id);
|
|
129
|
+
// Island 유효성 검사
|
|
130
|
+
if (!island || !island.__mandu_island) {
|
|
131
|
+
throw new Error('[Mandu] Invalid island module: ' + id);
|
|
145
132
|
}
|
|
146
133
|
|
|
147
|
-
const { definition } =
|
|
134
|
+
const { definition } = island;
|
|
135
|
+
const data = getServerData(id);
|
|
136
|
+
|
|
137
|
+
// React 동적 로드
|
|
148
138
|
const { createRoot } = await import('react-dom/client');
|
|
149
139
|
const React = await import('react');
|
|
150
140
|
|
|
@@ -154,11 +144,10 @@ async function hydrateIsland(element, id, data) {
|
|
|
154
144
|
return definition.render(setupResult);
|
|
155
145
|
}
|
|
156
146
|
|
|
157
|
-
// Mount
|
|
158
|
-
// hydrateRoot 대신 createRoot 사용: Island는 SSR과 다른 컨텐츠를 렌더링할 수 있음
|
|
147
|
+
// Mount
|
|
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,34 +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
|
-
*
|
|
194
|
+
* Island unmount
|
|
201
195
|
*/
|
|
196
|
+
function unmountIsland(id) {
|
|
197
|
+
const root = hydratedRoots.get(id);
|
|
198
|
+
if (root) {
|
|
199
|
+
root.unmount();
|
|
200
|
+
hydratedRoots.delete(id);
|
|
201
|
+
return true;
|
|
202
|
+
}
|
|
203
|
+
return false;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// 자동 초기화
|
|
202
207
|
if (document.readyState === 'loading') {
|
|
203
208
|
document.addEventListener('DOMContentLoaded', hydrateIslands);
|
|
204
209
|
} else {
|
|
205
210
|
hydrateIslands();
|
|
206
211
|
}
|
|
207
212
|
|
|
208
|
-
//
|
|
209
|
-
export
|
|
210
|
-
export const getHydratedRoots = () => window.__MANDU_ROOTS__;
|
|
213
|
+
// Export for external use
|
|
214
|
+
export { hydrateIslands, unmountIsland, hydratedRoots };
|
|
211
215
|
`;
|
|
212
216
|
}
|
|
213
217
|
|
|
@@ -609,23 +613,22 @@ async function buildRouterRuntime(
|
|
|
609
613
|
}
|
|
610
614
|
|
|
611
615
|
/**
|
|
612
|
-
* Island 엔트리 래퍼 생성
|
|
613
|
-
*
|
|
616
|
+
* Island 엔트리 래퍼 생성 (v0.8.0 재설계)
|
|
617
|
+
*
|
|
618
|
+
* 설계 원칙:
|
|
619
|
+
* - 순수 export만 (부작용 없음)
|
|
620
|
+
* - Runtime이 dynamic import로 로드
|
|
621
|
+
* - 등록/초기화 코드 없음
|
|
614
622
|
*/
|
|
615
623
|
function generateIslandEntry(routeId: string, clientModulePath: string): string {
|
|
616
624
|
// Windows 경로의 백슬래시를 슬래시로 변환 (JS escape 문제 방지)
|
|
617
625
|
const normalizedPath = clientModulePath.replace(/\\/g, "/");
|
|
618
626
|
return `
|
|
619
627
|
/**
|
|
620
|
-
* Mandu Island
|
|
628
|
+
* Mandu Island: ${routeId} (Generated)
|
|
629
|
+
* Pure export - no side effects
|
|
621
630
|
*/
|
|
622
|
-
|
|
623
631
|
import island from "${normalizedPath}";
|
|
624
|
-
|
|
625
|
-
// 글로벌 레지스트리에 직접 등록 (런타임과 공유)
|
|
626
|
-
window.__MANDU_ISLANDS__ = window.__MANDU_ISLANDS__ || new Map();
|
|
627
|
-
window.__MANDU_ISLANDS__.set("${routeId}", () => island);
|
|
628
|
-
|
|
629
632
|
export default island;
|
|
630
633
|
`;
|
|
631
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,70 +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
|
-
if (typeof window !== "undefined" && (window as any).__MANDU_AUTO_INIT__ !== false) {
|
|
342
|
-
initializeRuntime();
|
|
343
|
-
}
|
package/src/filling/filling.ts
CHANGED
|
@@ -141,6 +141,9 @@ export class ManduFilling<TLoaderData = unknown> {
|
|
|
141
141
|
return this;
|
|
142
142
|
}
|
|
143
143
|
|
|
144
|
+
/**
|
|
145
|
+
* 요청 시작 훅
|
|
146
|
+
*/
|
|
144
147
|
onRequest(fn: OnRequestHandler): this {
|
|
145
148
|
this.config.lifecycle.onRequest.push({ fn, scope: "local" });
|
|
146
149
|
return this;
|
|
@@ -159,6 +162,10 @@ export class ManduFilling<TLoaderData = unknown> {
|
|
|
159
162
|
return this;
|
|
160
163
|
}
|
|
161
164
|
|
|
165
|
+
/**
|
|
166
|
+
* 바디 파싱 훅
|
|
167
|
+
* body를 읽을 때는 req.clone() 사용 권장
|
|
168
|
+
*/
|
|
162
169
|
onParse(fn: OnParseHandler): this {
|
|
163
170
|
this.config.lifecycle.onParse.push({ fn, scope: "local" });
|
|
164
171
|
return this;
|
|
@@ -184,21 +191,33 @@ export class ManduFilling<TLoaderData = unknown> {
|
|
|
184
191
|
return this.guard(fn);
|
|
185
192
|
}
|
|
186
193
|
|
|
194
|
+
/**
|
|
195
|
+
* 핸들러 후 훅
|
|
196
|
+
*/
|
|
187
197
|
afterHandle(fn: AfterHandleHandler): this {
|
|
188
198
|
this.config.lifecycle.afterHandle.push({ fn, scope: "local" });
|
|
189
199
|
return this;
|
|
190
200
|
}
|
|
191
201
|
|
|
202
|
+
/**
|
|
203
|
+
* 최종 응답 매핑 훅
|
|
204
|
+
*/
|
|
192
205
|
mapResponse(fn: MapResponseHandler): this {
|
|
193
206
|
this.config.lifecycle.mapResponse.push({ fn, scope: "local" });
|
|
194
207
|
return this;
|
|
195
208
|
}
|
|
196
209
|
|
|
210
|
+
/**
|
|
211
|
+
* 에러 핸들링 훅
|
|
212
|
+
*/
|
|
197
213
|
onError(fn: OnErrorHandler): this {
|
|
198
214
|
this.config.lifecycle.onError.push({ fn, scope: "local" });
|
|
199
215
|
return this;
|
|
200
216
|
}
|
|
201
217
|
|
|
218
|
+
/**
|
|
219
|
+
* 응답 후 훅 (비동기)
|
|
220
|
+
*/
|
|
202
221
|
afterResponse(fn: AfterResponseHandler): this {
|
|
203
222
|
this.config.lifecycle.afterResponse.push({ fn, scope: "local" });
|
|
204
223
|
return this;
|
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
|
// 서버 데이터 스크립트
|