@mandujs/core 0.54.18 → 0.54.19

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.
@@ -0,0 +1,567 @@
1
+ import React, { Component, useEffect, useState, type ReactNode } from "react";
2
+ import { createRoot, hydrateRoot, type Root } from "react-dom/client";
3
+ import { deserializeProps } from "./props-serialization";
4
+
5
+ const DEFAULT_HYDRATION_PRIORITY = "visible";
6
+
7
+ interface ManduDataRecord {
8
+ serverData: unknown;
9
+ timestamp?: number;
10
+ }
11
+
12
+ interface ManduIslandDefinition {
13
+ setup(serverData: Record<string, unknown>): Record<string, unknown>;
14
+ render(props: Record<string, unknown>): ReactNode;
15
+ errorBoundary?: (error: Error | null, reset: () => void) => ReactNode;
16
+ loading?: () => ReactNode;
17
+ }
18
+
19
+ interface ManduIslandModule {
20
+ default?: unknown;
21
+ [key: string]: unknown;
22
+ }
23
+
24
+ interface ManduIslandExport {
25
+ __mandu_island: true;
26
+ definition: ManduIslandDefinition;
27
+ }
28
+
29
+ interface RuntimeDevtoolsHook {
30
+ emit(event: unknown): void;
31
+ }
32
+
33
+ type ManduDataStore = Record<string, ManduDataRecord>;
34
+
35
+ const hydratedRoots = window.__MANDU_ROOTS__ ?? (window.__MANDU_ROOTS__ = new Map<string, Root>());
36
+ const warnedBoundaryPropFallbacks = new Set<string>();
37
+
38
+ function asRecord(value: unknown): Record<string, unknown> {
39
+ return value && typeof value === "object" && !Array.isArray(value)
40
+ ? value as Record<string, unknown>
41
+ : {};
42
+ }
43
+
44
+ function readManduData(): ManduDataStore {
45
+ if (window.__MANDU_DATA__) return window.__MANDU_DATA__ as ManduDataStore;
46
+
47
+ const raw = window.__MANDU_DATA_RAW__ || document.getElementById("__MANDU_DATA__")?.textContent;
48
+ if (!raw) {
49
+ window.__MANDU_DATA__ = {};
50
+ return window.__MANDU_DATA__ as ManduDataStore;
51
+ }
52
+
53
+ try {
54
+ window.__MANDU_DATA__ = deserializeProps(raw) as ManduDataStore;
55
+ } catch (error) {
56
+ console.warn("[Mandu] Failed to parse server data:", error);
57
+ window.__MANDU_DATA__ = {};
58
+ }
59
+
60
+ return window.__MANDU_DATA__ as ManduDataStore;
61
+ }
62
+
63
+ const getServerData = (id: string, element: Element | null): Record<string, unknown> => {
64
+ const data = readManduData();
65
+ if (data[id] && Object.prototype.hasOwnProperty.call(data[id], "serverData")) {
66
+ return asRecord(data[id].serverData);
67
+ }
68
+ const routeId = element?.getAttribute?.("data-mandu-route-id");
69
+ if (
70
+ routeId &&
71
+ data[routeId] &&
72
+ Object.prototype.hasOwnProperty.call(data[routeId], "serverData")
73
+ ) {
74
+ return asRecord(data[routeId].serverData);
75
+ }
76
+ return {};
77
+ };
78
+
79
+ function findPropsScript(id: string): HTMLScriptElement | null {
80
+ const scripts = document.querySelectorAll("script[data-mandu-props]");
81
+ for (const script of scripts) {
82
+ if (script.getAttribute("data-mandu-props") === id) {
83
+ return script as HTMLScriptElement;
84
+ }
85
+ }
86
+ return null;
87
+ }
88
+
89
+ function parsePropsScript(id: string): Record<string, unknown> | null {
90
+ const script = findPropsScript(id);
91
+ if (!script || !script.textContent) return null;
92
+ try {
93
+ return deserializeProps(script.textContent);
94
+ } catch (error) {
95
+ console.warn("[Mandu] Failed to parse data-mandu-props for island " + id + ":", error);
96
+ return null;
97
+ }
98
+ }
99
+
100
+ function readDataProps(element: Element): Record<string, unknown> | null {
101
+ const propsEl = element.hasAttribute("data-props")
102
+ ? element
103
+ : element.querySelector("[data-props]");
104
+ if (!propsEl) return null;
105
+ try {
106
+ return deserializeProps(propsEl.getAttribute("data-props") || "{}");
107
+ } catch (error) {
108
+ console.warn("[Mandu] Failed to parse data-props fallback:", error);
109
+ return null;
110
+ }
111
+ }
112
+
113
+ function getIslandProps(id: string, element: Element): Record<string, unknown> {
114
+ const inlineProps = parsePropsScript(id);
115
+ if (inlineProps) return inlineProps;
116
+
117
+ const dataProps = readDataProps(element);
118
+ if (dataProps) return dataProps;
119
+
120
+ const boundaryId = element?.getAttribute?.("data-mandu-boundary-id");
121
+ if (boundaryId && !warnedBoundaryPropFallbacks.has(id)) {
122
+ warnedBoundaryPropFallbacks.add(id);
123
+ console.warn(
124
+ "[Mandu] Missing boundary-local props for transformed client boundary " +
125
+ boundaryId +
126
+ "; falling back to route server data.",
127
+ );
128
+ }
129
+
130
+ return getServerData(id, element);
131
+ }
132
+
133
+ function resolveIslandExport(module: ManduIslandModule, element: Element): unknown {
134
+ const exportName = element.getAttribute("data-mandu-client-export");
135
+ if (exportName) {
136
+ if (exportName === "default" && module.default) return module.default;
137
+ if (exportName !== "default" && module[exportName]) return module[exportName];
138
+ console.warn('[Mandu] Client boundary export "' + exportName + '" was not found; falling back to default export.');
139
+ }
140
+ return module.default;
141
+ }
142
+
143
+ interface IslandErrorBoundaryProps {
144
+ islandId: string;
145
+ errorBoundary?: (error: Error | null, reset: () => void) => ReactNode;
146
+ children: ReactNode;
147
+ }
148
+
149
+ interface IslandErrorBoundaryState {
150
+ hasError: boolean;
151
+ error: Error | null;
152
+ }
153
+
154
+ class IslandErrorBoundary extends Component<IslandErrorBoundaryProps, IslandErrorBoundaryState> {
155
+ state: IslandErrorBoundaryState = { hasError: false, error: null };
156
+
157
+ static getDerivedStateFromError(error: Error): IslandErrorBoundaryState {
158
+ return { hasError: true, error };
159
+ }
160
+
161
+ componentDidCatch(error: Error, errorInfo: unknown): void {
162
+ console.error("[Mandu] Island error:", this.props.islandId, error, errorInfo);
163
+ }
164
+
165
+ reset = (): void => {
166
+ this.setState({ hasError: false, error: null });
167
+ };
168
+
169
+ render(): ReactNode {
170
+ if (this.state.hasError) {
171
+ if (this.props.errorBoundary) {
172
+ return this.props.errorBoundary(this.state.error, this.reset);
173
+ }
174
+ return React.createElement("div", {
175
+ className: "mandu-island-error",
176
+ style: {
177
+ padding: "16px",
178
+ background: "#fef2f2",
179
+ border: "1px solid #fecaca",
180
+ borderRadius: "8px",
181
+ color: "#dc2626",
182
+ },
183
+ }, [
184
+ React.createElement("strong", { key: "title" }, "Hydration error"),
185
+ React.createElement("p", { key: "msg", style: { margin: "8px 0", fontSize: "14px" } },
186
+ this.state.error?.message || "Unknown error",
187
+ ),
188
+ React.createElement("button", {
189
+ key: "btn",
190
+ onClick: this.reset,
191
+ style: {
192
+ padding: "6px 12px",
193
+ background: "#dc2626",
194
+ color: "white",
195
+ border: "none",
196
+ borderRadius: "4px",
197
+ cursor: "pointer",
198
+ },
199
+ }, "Retry"),
200
+ ]);
201
+ }
202
+ return this.props.children;
203
+ }
204
+ }
205
+
206
+ function IslandLoadingWrapper({
207
+ children,
208
+ loading,
209
+ isReady,
210
+ }: {
211
+ children: ReactNode;
212
+ loading?: () => ReactNode;
213
+ isReady: boolean;
214
+ }): ReactNode {
215
+ if (!isReady && loading) {
216
+ return loading();
217
+ }
218
+ return children;
219
+ }
220
+
221
+ function resolveHydrationTarget(element: Element): Element {
222
+ if (!(element instanceof HTMLElement)) {
223
+ return element;
224
+ }
225
+
226
+ if (getComputedStyle(element).display !== "contents") {
227
+ return element;
228
+ }
229
+
230
+ const queue = Array.from(element.children);
231
+ while (queue.length > 0) {
232
+ const candidate = queue.shift();
233
+ if (candidate instanceof HTMLElement) {
234
+ return candidate;
235
+ }
236
+ if (candidate) {
237
+ queue.push(...candidate.children);
238
+ }
239
+ }
240
+
241
+ return element.parentElement || element;
242
+ }
243
+
244
+ function hasHydratableMarkup(element: Element): boolean {
245
+ for (const node of element.childNodes) {
246
+ if (node.nodeType === Node.ELEMENT_NODE) {
247
+ return true;
248
+ }
249
+
250
+ if (node.nodeType === Node.TEXT_NODE && node.textContent && node.textContent.trim() !== "") {
251
+ return true;
252
+ }
253
+ }
254
+
255
+ return false;
256
+ }
257
+
258
+ function shouldHydrateCompiledIsland(element: Element): boolean {
259
+ return (
260
+ element.getAttribute("data-mandu-loading") !== "true" &&
261
+ hasHydratableMarkup(element)
262
+ );
263
+ }
264
+
265
+ function createHydrationOptions(element: Element, id: string, mode: string) {
266
+ return {
267
+ onRecoverableError(error: unknown): void {
268
+ element.setAttribute("data-mandu-recoverable-error", "true");
269
+ console.warn("[Mandu] Recoverable hydration error:", id, mode, error);
270
+ element.dispatchEvent(new CustomEvent("mandu:recoverable-hydration-error", {
271
+ bubbles: true,
272
+ detail: {
273
+ id,
274
+ mode,
275
+ error: error instanceof Error ? error.message : String(error),
276
+ },
277
+ }));
278
+ },
279
+ };
280
+ }
281
+
282
+ function priorityToHydrateStrategy(priority: string): string {
283
+ return priority === "immediate" ? "load" : priority;
284
+ }
285
+
286
+ function scheduleHydration(element: HTMLElement, src: string, strategy: string): void {
287
+ let nextStrategy = strategy || "load";
288
+ if (nextStrategy === "immediate") nextStrategy = "load";
289
+
290
+ if (nextStrategy.startsWith("media(") && nextStrategy.endsWith(")")) {
291
+ const query = nextStrategy.slice("media(".length, -1).trim();
292
+ if (!query || !window.matchMedia) {
293
+ void loadAndHydrate(element, src);
294
+ return;
295
+ }
296
+ const mql = window.matchMedia(query);
297
+ if (mql.matches) {
298
+ void loadAndHydrate(element, src);
299
+ return;
300
+ }
301
+ const onChange = (event: MediaQueryListEvent): void => {
302
+ if (!event.matches) return;
303
+ if (mql.removeEventListener) {
304
+ mql.removeEventListener("change", onChange);
305
+ } else {
306
+ mql.removeListener(onChange);
307
+ }
308
+ void loadAndHydrate(element, src);
309
+ };
310
+ if (mql.addEventListener) {
311
+ mql.addEventListener("change", onChange);
312
+ } else {
313
+ mql.addListener(onChange);
314
+ }
315
+ return;
316
+ }
317
+
318
+ switch (nextStrategy) {
319
+ case "load":
320
+ case "immediate":
321
+ void loadAndHydrate(element, src);
322
+ break;
323
+
324
+ case "visible":
325
+ if ("IntersectionObserver" in window) {
326
+ const observer = new IntersectionObserver((entries) => {
327
+ if (entries[0]?.isIntersecting) {
328
+ observer.disconnect();
329
+ void loadAndHydrate(element, src);
330
+ }
331
+ }, { rootMargin: "200px" });
332
+ const target = resolveHydrationTarget(element);
333
+ observer.observe(target);
334
+ } else {
335
+ void loadAndHydrate(element, src);
336
+ }
337
+ break;
338
+
339
+ case "idle":
340
+ if ("requestIdleCallback" in window) {
341
+ requestIdleCallback(() => void loadAndHydrate(element, src));
342
+ } else {
343
+ setTimeout(() => void loadAndHydrate(element, src), 200);
344
+ }
345
+ break;
346
+
347
+ case "interaction": {
348
+ const target = resolveHydrationTarget(element);
349
+ const hydrate = (): void => {
350
+ target.removeEventListener("touchstart", hydrate);
351
+ target.removeEventListener("click", hydrate);
352
+ target.removeEventListener("keydown", hydrate);
353
+ void loadAndHydrate(element, src);
354
+ };
355
+ target.addEventListener("touchstart", hydrate, { once: true, passive: true });
356
+ target.addEventListener("click", hydrate, { once: true });
357
+ target.addEventListener("keydown", hydrate, { once: true });
358
+ break;
359
+ }
360
+
361
+ default:
362
+ console.warn('[Mandu] Unknown hydrate strategy "' + nextStrategy + '", falling back to load.');
363
+ void loadAndHydrate(element, src);
364
+ }
365
+ }
366
+
367
+ function isManduIslandExport(value: unknown): value is ManduIslandExport {
368
+ return !!value &&
369
+ typeof value === "object" &&
370
+ (value as { __mandu_island?: unknown }).__mandu_island === true &&
371
+ "definition" in value;
372
+ }
373
+
374
+ async function loadAndHydrate(element: HTMLElement, src: string): Promise<void> {
375
+ const id = element.getAttribute("data-mandu-island");
376
+ if (!id) {
377
+ return;
378
+ }
379
+ const islandId = id;
380
+
381
+ if (
382
+ hydratedRoots.has(islandId) ||
383
+ element.hasAttribute("data-mandu-hydrated") ||
384
+ element.getAttribute("data-mandu-hydrating") === "true"
385
+ ) {
386
+ return;
387
+ }
388
+
389
+ element.setAttribute("data-mandu-hydrating", "true");
390
+
391
+ try {
392
+ const module = await import(src) as ManduIslandModule;
393
+ const island = resolveIslandExport(module, element);
394
+ const data = getIslandProps(islandId, element);
395
+
396
+ if (isManduIslandExport(island)) {
397
+ const { definition } = island;
398
+ const shouldHydrate = shouldHydrateCompiledIsland(element);
399
+ const renderMode = shouldHydrate ? "hydrate" : "mount";
400
+
401
+ function IslandComponent({ initialReady }: { initialReady: boolean }): ReactNode {
402
+ const [isReady, setIsReady] = useState(initialReady);
403
+
404
+ useEffect(() => {
405
+ setIsReady(true);
406
+ }, []);
407
+
408
+ const setupResult = definition.setup(data);
409
+ const content = definition.render(setupResult);
410
+ const wrappedContent = definition.loading
411
+ ? React.createElement(IslandLoadingWrapper, {
412
+ loading: definition.loading,
413
+ isReady,
414
+ children: content,
415
+ })
416
+ : content;
417
+
418
+ return React.createElement(IslandErrorBoundary, {
419
+ islandId,
420
+ errorBoundary: definition.errorBoundary,
421
+ children: wrappedContent,
422
+ });
423
+ }
424
+
425
+ const root = shouldHydrate
426
+ ? hydrateRoot(
427
+ element,
428
+ React.createElement(IslandComponent, { initialReady: true }),
429
+ createHydrationOptions(element, islandId, renderMode),
430
+ )
431
+ : createRoot(element);
432
+
433
+ if (!shouldHydrate) {
434
+ root.render(React.createElement(IslandComponent, { initialReady: false }));
435
+ }
436
+
437
+ markHydrated(element, islandId, data, root, renderMode);
438
+
439
+ const devtoolsHook = (window as Window & {
440
+ __MANDU_DEVTOOLS_HOOK__?: RuntimeDevtoolsHook;
441
+ }).__MANDU_DEVTOOLS_HOOK__;
442
+ if (devtoolsHook) {
443
+ const hydrateTime = performance.now ? performance.now() : Date.now();
444
+ devtoolsHook.emit({
445
+ type: "island:register",
446
+ timestamp: Date.now(),
447
+ data: {
448
+ id: islandId,
449
+ name: islandId,
450
+ strategy: element.getAttribute("data-mandu-priority") || DEFAULT_HYDRATION_PRIORITY,
451
+ status: "hydrated",
452
+ renderMode,
453
+ hydrateStartTime: hydrateTime - 10,
454
+ hydrateEndTime: hydrateTime,
455
+ propsSize: JSON.stringify(data).length,
456
+ },
457
+ });
458
+ }
459
+
460
+ console.log("[Mandu] Hydrated:", islandId, "(" + renderMode + ")");
461
+ } else if (typeof island === "function" || React.isValidElement(island)) {
462
+ console.warn("[Mandu] Plain component hydration:", islandId);
463
+ const shouldHydrate = hasHydratableMarkup(element);
464
+ const renderMode = shouldHydrate ? "hydrate" : "mount";
465
+ const ComponentOrElement = island as React.ComponentType<Record<string, unknown>> | React.ReactElement;
466
+
467
+ const root = shouldHydrate
468
+ ? (typeof ComponentOrElement === "function"
469
+ ? hydrateRoot(
470
+ element,
471
+ React.createElement(ComponentOrElement, data),
472
+ createHydrationOptions(element, islandId, renderMode),
473
+ )
474
+ : hydrateRoot(element, ComponentOrElement, createHydrationOptions(element, islandId, renderMode)))
475
+ : createRoot(element);
476
+
477
+ if (!shouldHydrate) {
478
+ root.render(
479
+ typeof ComponentOrElement === "function"
480
+ ? React.createElement(ComponentOrElement, data)
481
+ : ComponentOrElement,
482
+ );
483
+ }
484
+
485
+ markHydrated(element, islandId, data, root, renderMode);
486
+ console.log("[Mandu] Plain component hydrated:", islandId, "(" + renderMode + ")");
487
+ } else {
488
+ throw new Error("[Mandu] Invalid module: expected Mandu island or React component: " + islandId);
489
+ }
490
+ } catch (error) {
491
+ console.error("[Mandu] Hydration failed for", islandId, error);
492
+ element.setAttribute("data-mandu-error", "true");
493
+
494
+ element.dispatchEvent(new CustomEvent("mandu:hydration-error", {
495
+ bubbles: true,
496
+ detail: { id: islandId, error: error instanceof Error ? error.message : String(error) },
497
+ }));
498
+ } finally {
499
+ element.removeAttribute("data-mandu-hydrating");
500
+ }
501
+ }
502
+
503
+ function markHydrated(
504
+ element: HTMLElement,
505
+ id: string,
506
+ data: Record<string, unknown>,
507
+ root: Root,
508
+ renderMode: string,
509
+ ): void {
510
+ hydratedRoots.set(id, root);
511
+ element.setAttribute("data-mandu-render-mode", renderMode);
512
+ element.setAttribute("data-mandu-hydrated", "true");
513
+
514
+ if (performance.mark) {
515
+ performance.mark("mandu-hydrated-" + id);
516
+ }
517
+
518
+ element.dispatchEvent(new CustomEvent("mandu:hydrated", {
519
+ bubbles: true,
520
+ detail: { id, data, mode: renderMode },
521
+ }));
522
+ }
523
+
524
+ function hydrateIslands(): void {
525
+ const islands = document.querySelectorAll("[data-mandu-island]");
526
+ const seenIds = new Set<string>();
527
+
528
+ for (const el of islands) {
529
+ if (!(el instanceof HTMLElement)) continue;
530
+
531
+ const id = el.getAttribute("data-mandu-island");
532
+ const src = el.getAttribute("data-mandu-src");
533
+ const priority = el.getAttribute("data-mandu-priority") || DEFAULT_HYDRATION_PRIORITY;
534
+ const hydrateStrategy = el.getAttribute("data-hydrate") || priorityToHydrateStrategy(priority);
535
+
536
+ if (!id || !src) {
537
+ console.warn("[Mandu] Island missing id or src:", el);
538
+ continue;
539
+ }
540
+
541
+ if (seenIds.has(id)) {
542
+ console.warn("[Mandu] Duplicate island id detected:", id, "- skipping");
543
+ continue;
544
+ }
545
+ seenIds.add(id);
546
+
547
+ scheduleHydration(el, src, hydrateStrategy);
548
+ }
549
+ }
550
+
551
+ function unmountIsland(id: string): boolean {
552
+ const root = hydratedRoots.get(id);
553
+ if (root) {
554
+ root.unmount();
555
+ hydratedRoots.delete(id);
556
+ return true;
557
+ }
558
+ return false;
559
+ }
560
+
561
+ if (document.readyState === "loading") {
562
+ document.addEventListener("DOMContentLoaded", hydrateIslands);
563
+ } else {
564
+ hydrateIslands();
565
+ }
566
+
567
+ export { hydrateIslands, hydratedRoots, unmountIsland };
@@ -3,7 +3,7 @@
3
3
  * v0.8.0: Dynamic Import 기반 아키텍처
4
4
  *
5
5
  * 이 파일은 타입 정의와 유틸리티 함수를 제공합니다.
6
- * 실제 Hydration Runtime은 bundler/build.ts의 generateRuntimeSource()에서 생성됩니다.
6
+ * 실제 Hydration Runtime은 client/runtime-entry.ts에서 번들링됩니다.
7
7
  */
8
8
 
9
9
  import { getHydratedRoots, getServerData as getGlobalServerData } from "./window-state";