@fragments-sdk/cli 0.7.13 → 0.7.15
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/dist/bin.js +7 -7
- package/dist/{chunk-CRTN6BIW.js → chunk-QLTLLQBI.js} +2 -2
- package/dist/{chunk-TQOGBAOZ.js → chunk-WLXFE6XW.js} +91 -2
- package/dist/chunk-WLXFE6XW.js.map +1 -0
- package/dist/core/index.d.ts +44 -3
- package/dist/core/index.js +11 -3
- package/dist/{defineFragment-C6PFzZyo.d.ts → defineFragment-BI9KoPrs.d.ts} +1 -1
- package/dist/{generate-ZPERYZLF.js → generate-ICIPKCKV.js} +2 -2
- package/dist/index.d.ts +2 -2
- package/dist/index.js +2 -2
- package/dist/{init-GID2DXB3.js → init-V42FFMUJ.js} +3 -3
- package/dist/mcp-bin.js +2 -2
- package/dist/{scan-BSMLGBX4.js → scan-X3DI2X5G.js} +2 -2
- package/dist/{service-QACVPR37.js → service-JEWWTSKI.js} +2 -2
- package/dist/{static-viewer-2RQD5QLR.js → static-viewer-JIWCYKVK.js} +2 -2
- package/dist/{tokens-A3BZIQPB.js → tokens-K2AGUUOJ.js} +2 -2
- package/dist/{viewer-CNLZQUFO.js → viewer-7I4WGVU3.js} +60 -12
- package/dist/viewer-7I4WGVU3.js.map +1 -0
- package/package.json +1 -1
- package/src/core/__tests__/preview-runtime.test.tsx +111 -0
- package/src/core/index.ts +13 -0
- package/src/core/preview-runtime.tsx +144 -0
- package/src/viewer/components/App.tsx +6 -8
- package/src/viewer/components/FragmentRenderer.tsx +61 -0
- package/src/viewer/components/IsolatedPreviewFrame.tsx +10 -8
- package/src/viewer/components/PreviewFrameHost.tsx +27 -60
- package/src/viewer/components/SkeletonLoader.tsx +114 -125
- package/src/viewer/components/ThemeProvider.tsx +24 -78
- package/src/viewer/components/VariantMatrix.tsx +3 -3
- package/src/viewer/entry.tsx +26 -2
- package/src/viewer/index.html +1 -1
- package/src/viewer/public/favicon.ico +0 -0
- package/src/viewer/server.ts +1 -0
- package/src/viewer/vendor/shared/src/DocsHeaderBar.tsx +19 -0
- package/src/viewer/vendor/shared/src/DocsPageAsideHost.tsx +1 -1
- package/src/viewer/vendor/shared/src/DocsSearchCommand.tsx +69 -104
- package/src/viewer/vite-plugin.ts +58 -1
- package/dist/chunk-TQOGBAOZ.js.map +0 -1
- package/dist/viewer-CNLZQUFO.js.map +0 -1
- package/src/viewer/components/StoryRenderer.tsx +0 -121
- /package/dist/{chunk-CRTN6BIW.js.map → chunk-QLTLLQBI.js.map} +0 -0
- /package/dist/{generate-ZPERYZLF.js.map → generate-ICIPKCKV.js.map} +0 -0
- /package/dist/{init-GID2DXB3.js.map → init-V42FFMUJ.js.map} +0 -0
- /package/dist/{scan-BSMLGBX4.js.map → scan-X3DI2X5G.js.map} +0 -0
- /package/dist/{service-QACVPR37.js.map → service-JEWWTSKI.js.map} +0 -0
- /package/dist/{static-viewer-2RQD5QLR.js.map → static-viewer-JIWCYKVK.js.map} +0 -0
- /package/dist/{tokens-A3BZIQPB.js.map → tokens-K2AGUUOJ.js.map} +0 -0
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { useEffect, useState, type ReactNode } from "react";
|
|
2
|
+
import type { VariantLoader, VariantRenderOptions } from "./types.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Minimal contract for rendering a preview variant.
|
|
6
|
+
* Compatible with fragment variants and Storybook-adapted variants.
|
|
7
|
+
*/
|
|
8
|
+
export interface PreviewVariantLike {
|
|
9
|
+
render: (options?: VariantRenderOptions) => ReactNode;
|
|
10
|
+
loaders?: VariantLoader[];
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface PreviewRuntimeState {
|
|
14
|
+
content: ReactNode | null;
|
|
15
|
+
isLoading: boolean;
|
|
16
|
+
error: Error | null;
|
|
17
|
+
loadedData?: Record<string, unknown>;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface PreviewRuntimeOptions {
|
|
21
|
+
variant?: PreviewVariantLike | null;
|
|
22
|
+
loadedData?: Record<string, unknown>;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const EMPTY_STATE: PreviewRuntimeState = {
|
|
26
|
+
content: null,
|
|
27
|
+
isLoading: false,
|
|
28
|
+
error: null,
|
|
29
|
+
loadedData: undefined,
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
function toError(error: unknown): Error {
|
|
33
|
+
return error instanceof Error ? error : new Error(String(error));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Execute all variant loaders and merge their payloads.
|
|
38
|
+
* `loadedData` is applied last so host-level overrides win.
|
|
39
|
+
*/
|
|
40
|
+
export async function executeVariantLoaders(
|
|
41
|
+
loaders: VariantLoader[] | undefined,
|
|
42
|
+
loadedData?: Record<string, unknown>,
|
|
43
|
+
): Promise<Record<string, unknown> | undefined> {
|
|
44
|
+
const hasLoaders = !!loaders && loaders.length > 0;
|
|
45
|
+
if (!hasLoaders) {
|
|
46
|
+
return loadedData;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const results = await Promise.all(loaders.map((loader) => loader()));
|
|
50
|
+
const mergedFromLoaders = results.reduce<Record<string, unknown>>(
|
|
51
|
+
(acc, result) => ({ ...acc, ...result }),
|
|
52
|
+
{},
|
|
53
|
+
);
|
|
54
|
+
|
|
55
|
+
return loadedData ? { ...mergedFromLoaders, ...loadedData } : mergedFromLoaders;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Resolve a full runtime state (loader execution + render) in one async call.
|
|
60
|
+
* This is useful for testing and for hook/component orchestration.
|
|
61
|
+
*/
|
|
62
|
+
export async function resolvePreviewRuntimeState(
|
|
63
|
+
options: PreviewRuntimeOptions,
|
|
64
|
+
): Promise<PreviewRuntimeState> {
|
|
65
|
+
const { variant, loadedData } = options;
|
|
66
|
+
if (!variant) {
|
|
67
|
+
return EMPTY_STATE;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
try {
|
|
71
|
+
const mergedLoadedData = await executeVariantLoaders(variant.loaders, loadedData);
|
|
72
|
+
const content = variant.render({ loadedData: mergedLoadedData });
|
|
73
|
+
return {
|
|
74
|
+
content,
|
|
75
|
+
isLoading: false,
|
|
76
|
+
error: null,
|
|
77
|
+
loadedData: mergedLoadedData,
|
|
78
|
+
};
|
|
79
|
+
} catch (error) {
|
|
80
|
+
return {
|
|
81
|
+
content: null,
|
|
82
|
+
isLoading: false,
|
|
83
|
+
error: toError(error),
|
|
84
|
+
loadedData: undefined,
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Hook for rendering a preview variant with loader support.
|
|
91
|
+
*/
|
|
92
|
+
export function usePreviewVariantRuntime(
|
|
93
|
+
options: PreviewRuntimeOptions,
|
|
94
|
+
): PreviewRuntimeState {
|
|
95
|
+
const { variant, loadedData } = options;
|
|
96
|
+
const [state, setState] = useState<PreviewRuntimeState>(EMPTY_STATE);
|
|
97
|
+
|
|
98
|
+
useEffect(() => {
|
|
99
|
+
let cancelled = false;
|
|
100
|
+
|
|
101
|
+
if (!variant) {
|
|
102
|
+
setState(EMPTY_STATE);
|
|
103
|
+
return () => {
|
|
104
|
+
cancelled = true;
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const hasLoaders = !!variant.loaders && variant.loaders.length > 0;
|
|
109
|
+
setState({
|
|
110
|
+
content: null,
|
|
111
|
+
isLoading: hasLoaders,
|
|
112
|
+
error: null,
|
|
113
|
+
loadedData: undefined,
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
resolvePreviewRuntimeState({ variant, loadedData }).then((nextState) => {
|
|
117
|
+
if (!cancelled) {
|
|
118
|
+
setState(nextState);
|
|
119
|
+
}
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
return () => {
|
|
123
|
+
cancelled = true;
|
|
124
|
+
};
|
|
125
|
+
}, [variant, loadedData]);
|
|
126
|
+
|
|
127
|
+
return state;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
interface PreviewVariantRuntimeProps extends PreviewRuntimeOptions {
|
|
131
|
+
children: (state: PreviewRuntimeState) => ReactNode;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Render-prop component wrapper around `usePreviewVariantRuntime`.
|
|
136
|
+
*/
|
|
137
|
+
export function PreviewVariantRuntime({
|
|
138
|
+
variant,
|
|
139
|
+
loadedData,
|
|
140
|
+
children,
|
|
141
|
+
}: PreviewVariantRuntimeProps) {
|
|
142
|
+
const state = usePreviewVariantRuntime({ variant, loadedData });
|
|
143
|
+
return <>{children(state)}</>;
|
|
144
|
+
}
|
|
@@ -20,20 +20,18 @@ import { PreviewToolbar } from "./PreviewToolbar.js";
|
|
|
20
20
|
import { PreviewArea } from "./PreviewArea.js";
|
|
21
21
|
import { BottomPanel } from "./BottomPanel.js";
|
|
22
22
|
import { IsolatedRender } from "./IsolatedRender.js";
|
|
23
|
-
import {
|
|
23
|
+
import { FragmentRenderer, LoaderIndicator } from "./FragmentRenderer.js";
|
|
24
24
|
import { HealthDashboard } from "./HealthDashboard.js";
|
|
25
25
|
import { useAllFigmaUrls } from "./FigmaEmbed.js";
|
|
26
26
|
import { ActionCapture } from "./ActionCapture.js";
|
|
27
27
|
|
|
28
28
|
// Fragments UI
|
|
29
|
-
import { Header, Stack, Text, Separator, Tooltip, Button, EmptyState, Box, Alert, Input, ThemeToggle } from "@fragments-sdk/ui";
|
|
29
|
+
import { Header, Stack, Text, Separator, Tooltip, Button, EmptyState, Box, Alert, Input, ThemeToggle, FragmentsLogo } from "@fragments-sdk/ui";
|
|
30
30
|
import { DeviceMobile, GridFour, Rows } from "@phosphor-icons/react";
|
|
31
31
|
|
|
32
32
|
// Icons
|
|
33
33
|
import { EmptyIcon, FigmaIcon, CompareIcon } from "./Icons.js";
|
|
34
34
|
|
|
35
|
-
// Logo
|
|
36
|
-
import { fragmentsLogo } from "../assets/fragments-logo.js";
|
|
37
35
|
|
|
38
36
|
function GitHubIcon() {
|
|
39
37
|
return (
|
|
@@ -342,14 +340,14 @@ export function App({ fragments }: AppProps) {
|
|
|
342
340
|
|
|
343
341
|
return (
|
|
344
342
|
<ActionCapture onAction={useActionsRef.current.logAction}>
|
|
345
|
-
<
|
|
343
|
+
<FragmentRenderer variant={variant}>
|
|
346
344
|
{(content, isLoading, error) => {
|
|
347
345
|
if (isLoading) return <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', padding: '32px' }}><LoaderIndicator /></div>;
|
|
348
346
|
if (error) return <EmptyVariantMessage reason={`Error: ${error.message}`} variantName={variant.name} hint="Check the console for the full error stack trace." />;
|
|
349
347
|
if (content === null || content === undefined) return <EmptyVariantMessage reason="render() returned null or undefined" variantName={variant.name} hint="The variant's render function didn't return any JSX." />;
|
|
350
348
|
return content;
|
|
351
349
|
}}
|
|
352
|
-
</
|
|
350
|
+
</FragmentRenderer>
|
|
353
351
|
</ActionCapture>
|
|
354
352
|
);
|
|
355
353
|
}, []);
|
|
@@ -605,7 +603,7 @@ function ViewerHeader({ showHealth, searchQuery, onSearchChange, searchInputRef
|
|
|
605
603
|
<Header.Trigger />
|
|
606
604
|
<Header.Brand>
|
|
607
605
|
<Stack direction="row" gap="sm" align="center">
|
|
608
|
-
<
|
|
606
|
+
<FragmentsLogo size={20} />
|
|
609
607
|
<Text weight="medium" size="sm">{BRAND.name}</Text>
|
|
610
608
|
<Text size="xs" color="tertiary">{showHealth ? 'health dashboard' : 'preview'}</Text>
|
|
611
609
|
</Stack>
|
|
@@ -761,7 +759,7 @@ function TopToolbar({
|
|
|
761
759
|
<Header.Trigger />
|
|
762
760
|
<Header.Brand>
|
|
763
761
|
<Stack direction="row" align="center" gap="sm">
|
|
764
|
-
<
|
|
762
|
+
<FragmentsLogo size={20} />
|
|
765
763
|
<Text weight="medium" size="sm">{fragment.fragment.meta.name}</Text>
|
|
766
764
|
<Text size="xs" color="tertiary">{fragment.fragment.meta.category}</Text>
|
|
767
765
|
</Stack>
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
import {
|
|
3
|
+
PreviewVariantRuntime,
|
|
4
|
+
type FragmentVariant,
|
|
5
|
+
} from "../../core/index.js";
|
|
6
|
+
|
|
7
|
+
interface FragmentRendererProps {
|
|
8
|
+
/** The variant to render */
|
|
9
|
+
variant: FragmentVariant;
|
|
10
|
+
/** Children render function - receives rendered content */
|
|
11
|
+
children: (content: ReactNode | null, isLoading: boolean, error: Error | null) => ReactNode;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Component that handles async loader execution before rendering a fragment variant.
|
|
16
|
+
*
|
|
17
|
+
* If the variant has loaders:
|
|
18
|
+
* 1. Shows loading state while loaders execute
|
|
19
|
+
* 2. Merges all loaded data
|
|
20
|
+
* 3. Passes loaded data to render function
|
|
21
|
+
*
|
|
22
|
+
* If no loaders, renders immediately.
|
|
23
|
+
*/
|
|
24
|
+
export function FragmentRenderer({ variant, children }: FragmentRendererProps) {
|
|
25
|
+
return (
|
|
26
|
+
<PreviewVariantRuntime variant={variant}>
|
|
27
|
+
{({ content, isLoading, error }) => children(content, isLoading, error)}
|
|
28
|
+
</PreviewVariantRuntime>
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Loading indicator for fragment variants with loaders
|
|
34
|
+
*/
|
|
35
|
+
export function LoaderIndicator() {
|
|
36
|
+
return (
|
|
37
|
+
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', color: 'var(--text-secondary)', fontSize: '14px' }}>
|
|
38
|
+
<svg
|
|
39
|
+
style={{ animation: 'spin 1s linear infinite', width: '16px', height: '16px' }}
|
|
40
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
41
|
+
fill="none"
|
|
42
|
+
viewBox="0 0 24 24"
|
|
43
|
+
>
|
|
44
|
+
<circle
|
|
45
|
+
style={{ opacity: 0.25 }}
|
|
46
|
+
cx="12"
|
|
47
|
+
cy="12"
|
|
48
|
+
r="10"
|
|
49
|
+
stroke="currentColor"
|
|
50
|
+
strokeWidth="4"
|
|
51
|
+
/>
|
|
52
|
+
<path
|
|
53
|
+
style={{ opacity: 0.75 }}
|
|
54
|
+
fill="currentColor"
|
|
55
|
+
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
|
56
|
+
/>
|
|
57
|
+
</svg>
|
|
58
|
+
<span>Running loaders...</span>
|
|
59
|
+
</div>
|
|
60
|
+
);
|
|
61
|
+
}
|
|
@@ -70,9 +70,9 @@ export const IsolatedPreviewFrame = memo(function IsolatedPreviewFrame({
|
|
|
70
70
|
const [frameError, setFrameError] = useState<string | null>(null);
|
|
71
71
|
const [retryCount, setRetryCount] = useState(0);
|
|
72
72
|
const [iframeKey, setIframeKey] = useState(0);
|
|
73
|
+
const [hasRenderedOnce, setHasRenderedOnce] = useState(false);
|
|
73
74
|
const { isReady, isRendering, lastError, contentSize, render, setTheme, clearError } = usePreviewBridge(iframeRef);
|
|
74
75
|
const lastRenderRef = useRef<string>('');
|
|
75
|
-
const isFirstLoad = useRef(true);
|
|
76
76
|
const [isHovered, setIsHovered] = useState(false);
|
|
77
77
|
|
|
78
78
|
// Build the preview URL
|
|
@@ -82,7 +82,6 @@ export const IsolatedPreviewFrame = memo(function IsolatedPreviewFrame({
|
|
|
82
82
|
const handleLoad = useCallback(() => {
|
|
83
83
|
setIsLoading(false);
|
|
84
84
|
setFrameError(null);
|
|
85
|
-
isFirstLoad.current = false;
|
|
86
85
|
}, []);
|
|
87
86
|
|
|
88
87
|
// Handle iframe error
|
|
@@ -100,6 +99,7 @@ export const IsolatedPreviewFrame = memo(function IsolatedPreviewFrame({
|
|
|
100
99
|
clearError();
|
|
101
100
|
setRetryCount(c => c + 1);
|
|
102
101
|
setIsLoading(true);
|
|
102
|
+
setHasRenderedOnce(false);
|
|
103
103
|
lastRenderRef.current = ''; // Force re-render
|
|
104
104
|
setIframeKey(k => k + 1); // Force iframe reload
|
|
105
105
|
}, [retryCount, clearError]);
|
|
@@ -125,6 +125,7 @@ export const IsolatedPreviewFrame = memo(function IsolatedPreviewFrame({
|
|
|
125
125
|
// Report content size changes
|
|
126
126
|
useEffect(() => {
|
|
127
127
|
if (contentSize) {
|
|
128
|
+
setHasRenderedOnce(true);
|
|
128
129
|
onContentSize?.(contentSize);
|
|
129
130
|
}
|
|
130
131
|
}, [contentSize, onContentSize]);
|
|
@@ -142,11 +143,12 @@ export const IsolatedPreviewFrame = memo(function IsolatedPreviewFrame({
|
|
|
142
143
|
const frameHeight = typeof height === 'number' ? `${height}px` : height;
|
|
143
144
|
const frameMinHeight = typeof minHeight === 'number' ? `${minHeight}px` : minHeight;
|
|
144
145
|
|
|
145
|
-
//
|
|
146
|
-
//
|
|
147
|
-
|
|
148
|
-
const
|
|
149
|
-
const
|
|
146
|
+
// Keep first-load skeleton visible until the first successful render signal.
|
|
147
|
+
// After first render, preserve content visibility and show spinner overlays
|
|
148
|
+
// for later renders to avoid white/blank flashes.
|
|
149
|
+
const showSkeleton = !hasRenderedOnce && !frameError;
|
|
150
|
+
const showSpinner = hasRenderedOnce && !frameError && (isLoading || isRendering);
|
|
151
|
+
const showContent = !frameError && (hasRenderedOnce || (isReady && !isRendering));
|
|
150
152
|
|
|
151
153
|
return (
|
|
152
154
|
<div
|
|
@@ -259,7 +261,7 @@ export const IsolatedPreviewFrame = memo(function IsolatedPreviewFrame({
|
|
|
259
261
|
border: 'none',
|
|
260
262
|
display: 'block',
|
|
261
263
|
background: 'transparent',
|
|
262
|
-
transition: 'opacity
|
|
264
|
+
transition: 'opacity 120ms ease',
|
|
263
265
|
opacity: showContent ? 1 : 0,
|
|
264
266
|
}}
|
|
265
267
|
// Security attributes
|
|
@@ -8,19 +8,15 @@
|
|
|
8
8
|
* 4. Reports render status back to parent
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
-
import { useState, useEffect, useRef
|
|
11
|
+
import { useState, useEffect, useRef } from 'react';
|
|
12
|
+
import {
|
|
13
|
+
usePreviewVariantRuntime,
|
|
14
|
+
type FragmentVariant,
|
|
15
|
+
} from '../../core/index.js';
|
|
12
16
|
import { useFrameBridge } from '../hooks/usePreviewBridge.js';
|
|
13
17
|
|
|
14
18
|
// Types for fragment data
|
|
15
|
-
interface
|
|
16
|
-
name: string;
|
|
17
|
-
render: (options?: { loadedData?: Record<string, unknown> }) => ReactNode;
|
|
18
|
-
loaders?: Array<() => Promise<Record<string, unknown>>>;
|
|
19
|
-
props?: Record<string, unknown>;
|
|
20
|
-
hasPlayFunction?: boolean;
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
interface FragmentDefinition {
|
|
19
|
+
interface PreviewFragmentDefinition {
|
|
24
20
|
meta: {
|
|
25
21
|
name: string;
|
|
26
22
|
description?: string;
|
|
@@ -31,7 +27,7 @@ interface FragmentDefinition {
|
|
|
31
27
|
|
|
32
28
|
interface FragmentItem {
|
|
33
29
|
path: string;
|
|
34
|
-
fragment:
|
|
30
|
+
fragment: PreviewFragmentDefinition;
|
|
35
31
|
}
|
|
36
32
|
|
|
37
33
|
// Cached fragments
|
|
@@ -79,13 +75,13 @@ function findFragmentByPath(fragments: FragmentItem[], path: string): FragmentIt
|
|
|
79
75
|
/**
|
|
80
76
|
* Find a variant by name within a fragment
|
|
81
77
|
*/
|
|
82
|
-
function findVariant(fragment:
|
|
78
|
+
function findVariant(fragment: PreviewFragmentDefinition, variantName: string): FragmentVariant | undefined {
|
|
83
79
|
return fragment.variants?.find(v => v.name === variantName);
|
|
84
80
|
}
|
|
85
81
|
|
|
86
82
|
type PreviewMode = 'centered' | 'full-bleed';
|
|
87
83
|
|
|
88
|
-
function resolvePreviewMode(fragment:
|
|
84
|
+
function resolvePreviewMode(fragment: PreviewFragmentDefinition): PreviewMode {
|
|
89
85
|
const name = fragment.meta.name.toLowerCase();
|
|
90
86
|
const category = (fragment.meta.category || '').toLowerCase();
|
|
91
87
|
|
|
@@ -142,65 +138,36 @@ function VariantRenderer({
|
|
|
142
138
|
onRendered: (width: number, height: number) => void;
|
|
143
139
|
onError: (message: string, stack?: string) => void;
|
|
144
140
|
}) {
|
|
145
|
-
const
|
|
146
|
-
|
|
147
|
-
|
|
141
|
+
const { content, isLoading, error } = usePreviewVariantRuntime({
|
|
142
|
+
variant,
|
|
143
|
+
loadedData: props,
|
|
144
|
+
});
|
|
148
145
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
149
146
|
const hasReported = useRef(false);
|
|
147
|
+
const lastReportedError = useRef<string | null>(null);
|
|
150
148
|
|
|
151
149
|
useEffect(() => {
|
|
152
150
|
hasReported.current = false;
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
if (!hasLoaders) {
|
|
159
|
-
// No loaders - render immediately
|
|
160
|
-
try {
|
|
161
|
-
const rendered = variant.render({ loadedData: props });
|
|
162
|
-
setContent(rendered);
|
|
163
|
-
} catch (err) {
|
|
164
|
-
const errorObj = err instanceof Error ? err : new Error(String(err));
|
|
165
|
-
setError({ message: errorObj.message, stack: errorObj.stack });
|
|
166
|
-
onError(errorObj.message, errorObj.stack);
|
|
167
|
-
}
|
|
151
|
+
}, [variant, props, mode]);
|
|
152
|
+
|
|
153
|
+
useEffect(() => {
|
|
154
|
+
if (!error) {
|
|
155
|
+
lastReportedError.current = null;
|
|
168
156
|
return;
|
|
169
157
|
}
|
|
170
158
|
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
async function executeLoaders() {
|
|
176
|
-
try {
|
|
177
|
-
const results = await Promise.all(variant.loaders!.map(loader => loader()));
|
|
178
|
-
if (cancelled) return;
|
|
179
|
-
|
|
180
|
-
const merged = results.reduce((acc, result) => ({ ...acc, ...result }), {});
|
|
181
|
-
const rendered = variant.render({ loadedData: { ...merged, ...props } });
|
|
182
|
-
|
|
183
|
-
setContent(rendered);
|
|
184
|
-
setIsLoading(false);
|
|
185
|
-
} catch (err) {
|
|
186
|
-
if (cancelled) return;
|
|
187
|
-
const errorObj = err instanceof Error ? err : new Error(String(err));
|
|
188
|
-
setError({ message: errorObj.message, stack: errorObj.stack });
|
|
189
|
-
setIsLoading(false);
|
|
190
|
-
onError(errorObj.message, errorObj.stack);
|
|
191
|
-
}
|
|
159
|
+
const signature = `${error.message}:${error.stack ?? ''}`;
|
|
160
|
+
if (lastReportedError.current === signature) {
|
|
161
|
+
return;
|
|
192
162
|
}
|
|
193
163
|
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
cancelled = true;
|
|
198
|
-
};
|
|
199
|
-
}, [variant, props, onError]);
|
|
164
|
+
lastReportedError.current = signature;
|
|
165
|
+
onError(error.message, error.stack);
|
|
166
|
+
}, [error, onError]);
|
|
200
167
|
|
|
201
168
|
// Report rendered size after content renders
|
|
202
169
|
useEffect(() => {
|
|
203
|
-
if (!content || hasReported.current) return;
|
|
170
|
+
if (!content || hasReported.current || isLoading || error) return;
|
|
204
171
|
|
|
205
172
|
// Wait for next frame to ensure DOM has updated
|
|
206
173
|
requestAnimationFrame(() => {
|
|
@@ -210,7 +177,7 @@ function VariantRenderer({
|
|
|
210
177
|
onRendered(rect.width, rect.height);
|
|
211
178
|
}
|
|
212
179
|
});
|
|
213
|
-
}, [content, onRendered]);
|
|
180
|
+
}, [content, error, isLoading, onRendered]);
|
|
214
181
|
|
|
215
182
|
if (isLoading) {
|
|
216
183
|
return <LoadingIndicator />;
|