/** * Mandu Island - Client Slot API ๐Ÿ๏ธ * Hydration์„ ์œ„ํ•œ ํด๋ผ์ด์–ธํŠธ ์‚ฌ์ด๋“œ ์ปดํฌ๋„ŒํŠธ ์ •์˜ */ import type { ReactNode } from "react"; import { serializeProps } from "./serialize"; import { getServerData as getGlobalServerData } from "./window-state"; /** * Island ์ •์˜ ํƒ€์ž… * @template TServerData - SSR์—์„œ ์ „๋‹ฌ๋ฐ›๋Š” ์„œ๋ฒ„ ๋ฐ์ดํ„ฐ ํƒ€์ž… * @template TSetupResult - setup ํ•จ์ˆ˜๊ฐ€ ๋ฐ˜ํ™˜ํ•˜๋Š” ๊ฒฐ๊ณผ ํƒ€์ž… */ export interface IslandDefinition { /** * Setup Phase * - ์„œ๋ฒ„ ๋ฐ์ดํ„ฐ๋ฅผ ๋ฐ›์•„ ํด๋ผ์ด์–ธํŠธ ์ƒํƒœ ์ดˆ๊ธฐํ™” * - React hooks ์‚ฌ์šฉ ๊ฐ€๋Šฅ * - ๋ฐ˜ํ™˜๊ฐ’์ด render ํ•จ์ˆ˜์— ์ „๋‹ฌ๋จ */ setup: (serverData: TServerData) => TSetupResult; /** * Render Phase * - setup์—์„œ ๋ฐ˜ํ™˜๋œ ๊ฐ’์„ props๋กœ ๋ฐ›์Œ * - ์ˆœ์ˆ˜ ๋ Œ๋”๋ง ๋กœ์ง๋งŒ ํฌํ•จ */ render: (props: TSetupResult) => ReactNode; /** * Optional: ์—๋Ÿฌ ๋ฐœ์ƒ ์‹œ ํ‘œ์‹œํ•  fallback UI */ errorBoundary?: (error: Error, reset: () => void) => ReactNode; /** * Optional: ๋กœ๋”ฉ ์ค‘ ํ‘œ์‹œํ•  UI (progressive hydration์šฉ) */ loading?: () => ReactNode; } /** * Island ์ปดํฌ๋„ŒํŠธ์˜ ๋ฉ”ํƒ€๋ฐ์ดํ„ฐ */ export interface IslandMetadata { /** Island ๊ณ ์œ  ์‹๋ณ„์ž */ id: string; /** SSR ๋ฐ์ดํ„ฐ ํ‚ค */ dataKey: string; /** Hydration ์šฐ์„ ์ˆœ์œ„ */ priority: "immediate" | "visible" | "idle" | "interaction"; } /** * ์ปดํŒŒ์ผ๋œ Island ์ปดํฌ๋„ŒํŠธ ํƒ€์ž…. * * An island is a **page-level client bundle**, not an inline JSX element. * The runtime representation is a React component **whose body unconditionally * throws**, decorated with the `definition` + `__mandu_island` marker so the * build pipeline can still recognise it. * * Why callable? A plain `{ definition, __mandu_island }` object rendered as * `` produced React's opaque *"Element type is invalid... got: * object"* error. Making it a function lets React invoke it like any other * component, which triggers our clear diagnostic below โ€” users immediately * learn to switch to `partial()` for embedded client regions. */ export type CompiledIsland = (() => never) & { /** Island ์ •์˜ */ definition: IslandDefinition; /** Island ๋ฉ”ํƒ€๋ฐ์ดํ„ฐ (๋นŒ๋“œ ์‹œ ์ฃผ์ž…) */ __mandu_island: true; /** Island ID (๋นŒ๋“œ ์‹œ ์ฃผ์ž…) */ __mandu_island_id?: string; }; /** * Island ์ปดํฌ๋„ŒํŠธ ์ƒ์„ฑ * * @example * ```typescript * // spec/slots/todos.client.ts * import { Mandu } from "@mandujs/core/client"; * import { useState, useCallback } from "react"; * * interface TodosData { * todos: Todo[]; * user: User | null; * } * * export default Mandu.island({ * setup: (serverData) => { * const [todos, setTodos] = useState(serverData.todos); * const addTodo = useCallback(async (text: string) => { * // ... * }, []); * return { todos, addTodo, user: serverData.user }; * }, * render: ({ todos, addTodo, user }) => ( *
* {user && Hello, {user.name}!} * *
* ) * }); * ``` */ export function island( definition: IslandDefinition ): CompiledIsland { // Validate definition if (typeof definition.setup !== "function") { throw new Error("[Mandu Island] setup must be a function"); } if (typeof definition.render !== "function") { throw new Error("[Mandu Island] render must be a function"); } // Function body: reached only when a server page (or any caller) tries to // render the island as an inline React element โ€” ``. Islands // are not inline elements; they are page-level client bundles. Throw a // clear message pointing at `partial()` (the right API for embedded // client regions) instead of React's generic "Element type is invalid". const IslandElement = (() => { throw new Error( "[Mandu Island] Islands are page-level client bundles โ€” they cannot " + "be rendered as inline JSX elements. For an embedded client region " + "inside a server page, use `partial()` instead (it returns a " + "renderable component). See " + "https://mandujs.com/docs/architect/client-rendering", ); }) as CompiledIsland; IslandElement.definition = definition; IslandElement.__mandu_island = true; return IslandElement; } /** * Island์—์„œ ์‚ฌ์šฉํ•  ์ˆ˜ ์žˆ๋Š” ํ—ฌํผ ํ›…๋“ค */ /** * SSR ๋ฐ์ดํ„ฐ์— ์•ˆ์ „ํ•˜๊ฒŒ ์ ‘๊ทผํ•˜๋Š” ํ›… * ์„œ๋ฒ„ ๋ฐ์ดํ„ฐ๊ฐ€ ์—†๋Š” ๊ฒฝ์šฐ fallback ๋ฐ˜ํ™˜ */ export function useServerData(key: string, fallback: T): T { if (typeof window === "undefined") return fallback; const data = getGlobalServerData(key); return data === undefined ? fallback : data; } /** * Hydration ์ƒํƒœ๋ฅผ ์ถ”์ ํ•˜๋Š” ํ›… */ export function useHydrated(): boolean { if (typeof window === "undefined") { return false; } return true; } /** * Island ๊ฐ„ ํ†ต์‹ ์„ ์œ„ํ•œ ์ด๋ฒคํŠธ ํ›… ๋ฐ˜ํ™˜ ํƒ€์ž… */ export interface IslandEventHandle { /** ์ด๋ฒคํŠธ ๋ฐœ์†ก ํ•จ์ˆ˜ */ emit: (data: T) => void; /** ์ด๋ฒคํŠธ ๋ฆฌ์Šค๋„ˆ ํ•ด์ œ ํ•จ์ˆ˜ (cleanup) */ cleanup: () => void; } /** * Island ๊ฐ„ ํ†ต์‹ ์„ ์œ„ํ•œ ์ด๋ฒคํŠธ ํ›… * * @example * ```typescript * // Island A * const { emit, cleanup } = useIslandEvent<{ count: number }>( * 'counter-update', * (data) => console.log('Received:', data.count) * ); * * // ์ด๋ฒคํŠธ ๋ฐœ์†ก * emit({ count: 42 }); * * // ์ปดํฌ๋„ŒํŠธ ์–ธ๋งˆ์šดํŠธ ์‹œ cleanup * useEffect(() => cleanup, []); * ``` * * @deprecated ์ƒˆ๋กœ์šด API๋Š” { emit, cleanup } ๊ฐ์ฒด๋ฅผ ๋ฐ˜ํ™˜ํ•ฉ๋‹ˆ๋‹ค. * ํ•˜์œ„ ํ˜ธํ™˜์„ฑ์„ ์œ„ํ•ด emit ํ•จ์ˆ˜์— cleanup ์†์„ฑ๋„ ์ถ”๊ฐ€๋ฉ๋‹ˆ๋‹ค. */ export function useIslandEvent( eventName: string, handler: (data: T) => void ): IslandEventHandle["emit"] & IslandEventHandle { if (typeof window === "undefined") { const noop = (() => {}) as unknown as IslandEventHandle["emit"] & IslandEventHandle; noop.emit = noop; noop.cleanup = () => {}; return noop; } // ์ด๋ฒคํŠธ ๋ฆฌ์Šค๋„ˆ ๋“ฑ๋ก const customEventName = `mandu:island:${eventName}`; const listener = (event: CustomEvent) => { handler(event.detail); }; window.addEventListener(customEventName, listener as EventListener); // cleanup ํ•จ์ˆ˜ const cleanup = () => { window.removeEventListener(customEventName, listener as EventListener); }; // ์ด๋ฒคํŠธ ๋ฐœ์†ก ํ•จ์ˆ˜ const emit = (data: T) => { window.dispatchEvent(new CustomEvent(customEventName, { detail: data })); }; // ํ•˜์œ„ ํ˜ธํ™˜์„ฑ: emit ํ•จ์ˆ˜์— cleanup ์†์„ฑ ์ถ”๊ฐ€ const result = emit as IslandEventHandle["emit"] & IslandEventHandle; result.emit = emit; result.cleanup = cleanup; return result; } /** * ๊ธฐ์กด React ์ปดํฌ๋„ŒํŠธ๋ฅผ Island๋กœ ๋ž˜ํ•‘ * * @example * ```typescript * // ๊ธฐ์กด React ์ปดํฌ๋„ŒํŠธ * import DatePicker from 'react-datepicker'; * * // Island๋กœ ๋ž˜ํ•‘ (serverData๊ฐ€ ๊ทธ๋Œ€๋กœ props๋กœ ์ „๋‹ฌ๋จ) * export default Mandu.wrapComponent(DatePicker); * * // ๋˜๋Š” props ๋ณ€ํ™˜์ด ํ•„์š”ํ•œ ๊ฒฝ์šฐ * export default Mandu.wrapComponent(DatePicker, { * transformProps: (serverData) => ({ * selected: new Date(serverData.selectedDate), * onChange: (date) => console.log(date), * }) * }); * ``` */ export interface WrapComponentOptions { /** ์„œ๋ฒ„ ๋ฐ์ดํ„ฐ๋ฅผ ์ปดํฌ๋„ŒํŠธ props๋กœ ๋ณ€ํ™˜ */ transformProps?: (serverData: TServerData) => TProps; /** ์—๋Ÿฌ ์‹œ ํ‘œ์‹œํ•  UI */ errorBoundary?: (error: Error, reset: () => void) => ReactNode; /** ๋กœ๋”ฉ ์ค‘ ํ‘œ์‹œํ•  UI */ loading?: () => ReactNode; } export function wrapComponent>( Component: React.ComponentType, options?: WrapComponentOptions ): CompiledIsland; export function wrapComponent( Component: React.ComponentType, options: WrapComponentOptions & { transformProps: (serverData: TServerData) => TProps } ): CompiledIsland; export function wrapComponent( Component: React.ComponentType, options?: WrapComponentOptions ): CompiledIsland { const { transformProps, errorBoundary, loading } = options || {}; return island({ setup: (serverData: TServerData) => { return transformProps ? transformProps(serverData) : (serverData as unknown as TProps); }, render: (props: TProps) => { // React.createElement๋ฅผ ์‚ฌ์šฉํ•˜์—ฌ Component ๋ Œ๋”๋ง const React = require("react"); return React.createElement(Component, props); }, errorBoundary, loading, }); } /** * API ํ˜ธ์ถœ ํ—ฌํผ */ export interface FetchOptions extends Omit { body?: unknown; } export async function fetchApi( url: string, options: FetchOptions = {} ): Promise { const { body, headers = {}, ...rest } = options; const response = await fetch(url, { headers: { "Content-Type": "application/json", ...headers, }, body: body ? JSON.stringify(body) : undefined, ...rest, }); if (!response.ok) { const error = await response.json().catch(() => ({ message: response.statusText })); throw new Error(error.message || `API Error: ${response.status}`); } return response.json(); } // ========== Client Partials/Slots API ========== /** * Partial Island ์„ค์ • * ํŽ˜์ด์ง€ ๋‚ด ํŠน์ • ๋ถ€๋ถ„๋งŒ ํ•˜์ด๋“œ๋ ˆ์ด์…˜ํ•  ๋•Œ ์‚ฌ์šฉ */ export interface PartialConfig { /** Partial ๊ณ ์œ  ID */ id: string; /** ํ•˜์ด๋“œ๋ ˆ์ด์…˜ ์šฐ์„ ์ˆœ์œ„ */ priority?: "immediate" | "visible" | "idle" | "interaction"; /** ๋ถ€๋ชจ Island ID (์ค‘์ฒฉ ์‹œ) */ parentId?: string; } /** * Partial Island ์ •์˜ ํƒ€์ž… */ export interface PartialDefinition { /** Partial ๊ณ ์œ  ID. ๊ธฐ๋ณธ๊ฐ’์€ component displayName/name ์ž…๋‹ˆ๋‹ค. */ id?: string; /** Partial ์ปดํฌ๋„ŒํŠธ */ component: React.ComponentType; /** ์ดˆ๊ธฐ props (SSR์—์„œ ์ „๋‹ฌ) */ initialProps?: TProps; /** ํ•˜์ด๋“œ๋ ˆ์ด์…˜ ์šฐ์„ ์ˆœ์œ„ */ priority?: "immediate" | "visible" | "idle" | "interaction"; /** ๋ช…์‹œ์  ๋ฒˆ๋“ค URL. ๊ธฐ๋ณธ๊ฐ’์€ `/.mandu/client/{id}.partial.js` ์ž…๋‹ˆ๋‹ค. */ src?: string; /** ์—๋Ÿฌ ์‹œ ํ‘œ์‹œํ•  UI */ errorBoundary?: (error: Error, reset: () => void) => ReactNode; /** ๋กœ๋”ฉ ์ค‘ ํ‘œ์‹œํ•  UI */ loading?: () => ReactNode; } /** * ์ปดํŒŒ์ผ๋œ Partial */ export interface CompiledPartial { /** Partial ์ •์˜ */ definition: PartialDefinition; /** Mandu Partial ๋งˆ์ปค */ __mandu_partial: true; /** Partial ID */ __mandu_partial_id?: string; } function normalizePartialId(id: string): string { return id .trim() .replace(/[^A-Za-z0-9_-]/g, "-") .replace(/^-+|-+$/g, "") || "partial"; } function priorityToHydrate(priority: NonNullable["priority"]>): string { return priority === "immediate" ? "load" : priority; } /** * Partial Island ์ƒ์„ฑ * ํŽ˜์ด์ง€ ๋‚ด ํŠน์ • ๋ถ€๋ถ„๋งŒ ํ•˜์ด๋“œ๋ ˆ์ด์…˜ * * @example * ```typescript * // ๊ฒ€์ƒ‰ ๋ฐ”๋งŒ ๋ณ„๋„ Island๋กœ ๋ถ„๋ฆฌ * const SearchBarPartial = partial({ * component: SearchBar, * priority: 'interaction', // ์‚ฌ์šฉ์ž ์ƒํ˜ธ์ž‘์šฉ ์‹œ ํ•˜์ด๋“œ๋ ˆ์ด์…˜ * }); * * // ์‚ฌ์šฉ * function Header() { * return ( *
* * *
* ); * } * ``` */ export function partial>( definition: PartialDefinition ): CompiledPartial & { Render: React.ComponentType; } { if (!definition.component) { throw new Error("[Mandu Partial] component is required"); } const partialId = normalizePartialId( definition.id || definition.component.displayName || definition.component.name || "partial", ); const normalizedDefinition: PartialDefinition = { ...definition, id: partialId, }; const compiled: CompiledPartial = { definition: normalizedDefinition, __mandu_partial: true, __mandu_partial_id: partialId, }; // Render ์ปดํฌ๋„ŒํŠธ ์ƒ์„ฑ const React = require("react"); const RenderComponent: React.FC = (props) => { const renderProps = Object.keys(props).length > 0 ? props : (normalizedDefinition.initialProps ?? props); const priority = normalizedDefinition.priority ?? "visible"; const bundleSrc = normalizedDefinition.src ?? `/.mandu/client/${partialId}.partial.js`; return React.createElement( "div", { "data-mandu-island": partialId, "data-mandu-partial": partialId, "data-mandu-src": bundleSrc, "data-mandu-priority": priority, "data-hydrate": priorityToHydrate(priority), "data-props": serializeProps(renderProps), style: { display: "contents" }, }, React.createElement(normalizedDefinition.component, renderProps), ); }; return Object.assign(compiled, { Render: RenderComponent }); } /** * Slot ์ •์˜ - ์„œ๋ฒ„์—์„œ ๋ Œ๋”๋ง๋˜๊ณ  ํด๋ผ์ด์–ธํŠธ์—์„œ ํ•˜์ด๋“œ๋ ˆ์ด์…˜๋˜๋Š” ์˜์—ญ */ export interface SlotDefinition { /** ์Šฌ๋กฏ ID */ id: string; /** ๋ฐ์ดํ„ฐ ๋กœ๋” (์„œ๋ฒ„์—์„œ ์‹คํ–‰) */ loader?: () => Promise; /** ๋ฐ์ดํ„ฐ๋ฅผ props๋กœ ๋ณ€ํ™˜ */ transform?: (data: TData) => TProps; /** ๋ Œ๋”๋ง ์ปดํฌ๋„ŒํŠธ */ component: React.ComponentType; /** ํ•˜์ด๋“œ๋ ˆ์ด์…˜ ์šฐ์„ ์ˆœ์œ„ */ priority?: "immediate" | "visible" | "idle" | "interaction"; /** ๋กœ๋”ฉ UI */ loading?: () => ReactNode; /** ์—๋Ÿฌ UI */ errorBoundary?: (error: Error, reset: () => void) => ReactNode; } /** * ์ปดํŒŒ์ผ๋œ Slot */ export interface CompiledSlot { definition: SlotDefinition; __mandu_slot: true; __mandu_slot_id: string; } /** * Client Slot ์ƒ์„ฑ * ์„œ๋ฒ„ ๋ฐ์ดํ„ฐ๋ฅผ ๋ฐ›์•„ ํด๋ผ์ด์–ธํŠธ์—์„œ ํ•˜์ด๋“œ๋ ˆ์ด์…˜๋˜๋Š” ์ปดํฌ๋„ŒํŠธ * * @example * ```typescript * // ๋Œ“๊ธ€ ์˜์—ญ์„ ๋ณ„๋„ ์Šฌ๋กฏ์œผ๋กœ ๋ถ„๋ฆฌ * const CommentsSlot = slot({ * id: 'comments', * loader: async () => fetchComments(postId), * transform: (data) => ({ comments: data.items }), * component: CommentList, * priority: 'visible', * loading: () => , * }); * ``` */ export function slot>( definition: SlotDefinition ): CompiledSlot { if (!definition.id) { throw new Error("[Mandu Slot] id is required"); } if (!definition.component) { throw new Error("[Mandu Slot] component is required"); } return { definition, __mandu_slot: true, __mandu_slot_id: definition.id, }; } /** * ์—ฌ๋Ÿฌ Partial์„ ๊ทธ๋ฃน์œผ๋กœ ๊ด€๋ฆฌ */ export interface PartialGroup { /** ๊ทธ๋ฃน์— Partial ์ถ”๊ฐ€ */ add: (id: string, partial: CompiledPartial) => void; /** Partial ์กฐํšŒ */ get: (id: string) => CompiledPartial | undefined; /** ๋ชจ๋“  Partial ID ๋ชฉ๋ก */ ids: () => string[]; /** ํŠน์ • Partial ํ•˜์ด๋“œ๋ ˆ์ด์…˜ ํŠธ๋ฆฌ๊ฑฐ */ hydrate: (id: string) => Promise; /** ๋ชจ๋“  Partial ํ•˜์ด๋“œ๋ ˆ์ด์…˜ */ hydrateAll: () => Promise; } /** * Partial ๊ทธ๋ฃน ์ƒ์„ฑ * * @example * ```typescript * const dashboardPartials = createPartialGroup(); * * dashboardPartials.add('chart', ChartPartial); * dashboardPartials.add('table', TablePartial); * * // ํŠน์ • ๋ถ€๋ถ„๋งŒ ํ•˜์ด๋“œ๋ ˆ์ด์…˜ * await dashboardPartials.hydrate('chart'); * ``` */ export function createPartialGroup(): PartialGroup { // React `ComponentType

` is contravariant on `P`, so a heterogeneous // component map genuinely needs `any` here โ€” any narrower bag type would // reject concrete `CompiledPartial<{userId: string}>` entries at `.set()`. // oxlint-disable-next-line no-explicit-any -- heterogeneous React component storage const partials = new Map>(); return { add: (id, partial) => { partial.__mandu_partial_id = id; partials.set(id, partial); }, get: (id) => partials.get(id), ids: () => Array.from(partials.keys()), hydrate: async (id) => { if (typeof window === "undefined") return; const element = document.querySelector(`[data-mandu-partial="${id}"]`); if (element) { element.dispatchEvent( new CustomEvent("mandu:hydrate-partial", { bubbles: true, detail: { id } }) ); } }, hydrateAll: async () => { if (typeof window === "undefined") return; const elements = document.querySelectorAll("[data-mandu-partial]"); for (const el of elements) { const id = el.getAttribute("data-mandu-partial"); if (id) { el.dispatchEvent( new CustomEvent("mandu:hydrate-partial", { bubbles: true, detail: { id } }) ); } } }, }; }