import { useEffect, useState, type ReactNode } from "react"; import type { VariantLoader, VariantRenderOptions } from "./types.js"; /** * Minimal contract for rendering a preview variant. * Compatible with fragment variants and Storybook-adapted variants. */ export interface PreviewVariantLike { render: (options?: VariantRenderOptions) => ReactNode; loaders?: VariantLoader[]; } export interface PreviewRuntimeState { content: ReactNode | null; isLoading: boolean; error: Error | null; loadedData?: Record; } export interface PreviewRuntimeOptions { variant?: PreviewVariantLike | null; loadedData?: Record; } const EMPTY_STATE: PreviewRuntimeState = { content: null, isLoading: false, error: null, loadedData: undefined, }; function toError(error: unknown): Error { return error instanceof Error ? error : new Error(String(error)); } /** * Execute all variant loaders and merge their payloads. * `loadedData` is applied last so host-level overrides win. */ export async function executeVariantLoaders( loaders: VariantLoader[] | undefined, loadedData?: Record, ): Promise | undefined> { const hasLoaders = !!loaders && loaders.length > 0; if (!hasLoaders) { return loadedData; } const results = await Promise.all(loaders.map((loader) => loader())); const mergedFromLoaders = results.reduce>( (acc, result) => ({ ...acc, ...result }), {}, ); return loadedData ? { ...mergedFromLoaders, ...loadedData } : mergedFromLoaders; } /** * Resolve a full runtime state (loader execution + render) in one async call. * This is useful for testing and for hook/component orchestration. */ export async function resolvePreviewRuntimeState( options: PreviewRuntimeOptions, ): Promise { const { variant, loadedData } = options; if (!variant) { return EMPTY_STATE; } try { const mergedLoadedData = await executeVariantLoaders(variant.loaders, loadedData); const content = variant.render({ loadedData: mergedLoadedData }); return { content, isLoading: false, error: null, loadedData: mergedLoadedData, }; } catch (error) { return { content: null, isLoading: false, error: toError(error), loadedData: undefined, }; } } /** * Hook for rendering a preview variant with loader support. */ export function usePreviewVariantRuntime( options: PreviewRuntimeOptions, ): PreviewRuntimeState { const { variant, loadedData } = options; const [state, setState] = useState(EMPTY_STATE); useEffect(() => { let cancelled = false; if (!variant) { setState(EMPTY_STATE); return () => { cancelled = true; }; } const hasLoaders = !!variant.loaders && variant.loaders.length > 0; setState({ content: null, isLoading: hasLoaders, error: null, loadedData: undefined, }); resolvePreviewRuntimeState({ variant, loadedData }).then((nextState) => { if (!cancelled) { setState(nextState); } }); return () => { cancelled = true; }; }, [variant, loadedData]); return state; } interface PreviewVariantRuntimeProps extends PreviewRuntimeOptions { children: (state: PreviewRuntimeState) => ReactNode; } /** * Render-prop component wrapper around `usePreviewVariantRuntime`. */ export function PreviewVariantRuntime({ variant, loadedData, children, }: PreviewVariantRuntimeProps) { const state = usePreviewVariantRuntime({ variant, loadedData }); return <>{children(state)}; }