import type { ComponentType, ReactNode } from 'react';
import React, { useMemo, useEffect, useRef, useState } from 'react';
import { CompositionsAspect, ComponentComposition, Composition } from '@teambit/compositions';
import { H3, H5 } from '@teambit/design.ui.heading';
import { capitalize } from '@teambit/toolbox.string.capitalize';
import type { ComponentModel } from '@teambit/component';
import type { ComponentDescriptor } from '@teambit/component-descriptor';
import { DocsAspect } from '@teambit/docs';
import styles from './preview-placeholder.module.scss';
// ── BrowserSkeleton ─────────────────────────────────────────────────────────
function BrowserSkeleton() {
return (
);
}
// ── Prefetch helper ─────────────────────────────────────────────────────────
const prefetchedAssets = new Set();
function prefetchPreviewAssets(url: string) {
if (prefetchedAssets.has(url)) return;
prefetchedAssets.add(url);
fetch(url)
.then((res) => (res.ok ? res.json() : null))
.then((data) => {
if (!data?.files) return;
for (const file of data.files) {
if (document.querySelector(`link[href*="${file}"]`)) continue;
const link = document.createElement('link');
link.rel = 'prefetch';
link.href = file;
document.head.appendChild(link);
}
})
.catch(() => {});
}
// ── ViewportGate ────────────────────────────────────────────────────────────
// Defers iframe mounting until near the viewport. Prefetches assets ahead.
// Does NOT own skeleton state — parent handles that via onLoad.
function ViewportGate({
previewAssetsUrl,
rootMargin = '0px 0px 50% 0px',
children,
}: {
previewAssetsUrl?: string;
rootMargin?: string;
children: ReactNode;
}) {
const sentinelRef = useRef(null);
const [visible, setVisible] = useState(false);
useEffect(() => {
const el = sentinelRef.current;
if (!el) return;
// Prefetch observer — warm browser cache 4 viewports ahead
const prefetchObs = previewAssetsUrl
? new IntersectionObserver(
([e]) => {
if (e.isIntersecting) {
prefetchPreviewAssets(previewAssetsUrl);
prefetchObs!.disconnect();
}
},
{ rootMargin: '0px 0px 150% 0px' }
)
: null;
// Mount observer — mount iframe when 2 viewports away
const mountObs = new IntersectionObserver(
([e]) => {
if (e.isIntersecting) {
mountObs.disconnect();
prefetchObs?.disconnect();
if (previewAssetsUrl) prefetchPreviewAssets(previewAssetsUrl);
setVisible(true);
}
},
{ rootMargin }
);
prefetchObs?.observe(el);
mountObs.observe(el);
return () => {
prefetchObs?.disconnect();
mountObs.disconnect();
};
}, []);
return (
{visible ? children : null}
);
}
// ── PreviewPlaceholder ──────────────────────────────────────────────────────
export function getCompositions(component: ComponentDescriptor) {
const entry: any = component.get(CompositionsAspect.id);
if (!entry) return [];
const compositions = entry.data.compositions;
if (!compositions) return [];
return Composition.fromArray(compositions);
}
export function getDisplayName(component: ComponentDescriptor) {
const tokens = component.id.name.split('-').map((token) => capitalize(token));
return tokens.join(' ');
}
function getDocsProperty(component: ComponentDescriptor, name: string) {
const docs = component.get(DocsAspect.id)?.data || {};
if (!docs || !docs?.doc?.props) return undefined;
const docProps = docs.doc.props;
return docProps.find((prop) => prop.name === name);
}
export function getDescription(component: ComponentDescriptor) {
const descriptionItem = getDocsProperty(component, 'description');
if (!descriptionItem) return '';
return descriptionItem.value || '';
}
export function PreviewPlaceholder({
component,
componentDescriptor,
Container = ({ children, className }) => {children}
,
shouldShowPreview = (component?.compositions.length ?? 0) > 0 && component?.buildStatus !== 'pending',
}: {
component?: ComponentModel;
componentDescriptor?: ComponentDescriptor;
Container?: ComponentType<{ component: any; children: ReactNode; className: string }>;
shouldShowPreview?: boolean;
}) {
const compositions = component?.compositions;
const description = componentDescriptor && getDescription(componentDescriptor);
const displayName = componentDescriptor && getDisplayName(componentDescriptor);
const serverUrl = component?.server?.url;
const prevServerUrlRef = useRef(serverUrl);
const [forceRender, setForceRender] = React.useState(0);
const [previewLoaded, setPreviewLoaded] = useState(false);
useEffect(() => {
if (prevServerUrlRef.current !== serverUrl && shouldShowPreview) {
prevServerUrlRef.current = serverUrl;
setForceRender((prev) => prev + 1);
}
}, [serverUrl, shouldShowPreview]);
const selectedPreview = useMemo(() => {
if (!shouldShowPreview || !component) return undefined;
return selectDefaultComposition(component);
}, [component, shouldShowPreview, forceRender]);
if (!component || !componentDescriptor) return null;
if (!shouldShowPreview || !compositions || !compositions.length) {
return (
{component.id.scope}
{displayName}
{description}
);
}
const name = component.id.toString();
if (!serverUrl || (!shouldShowPreview && component.buildStatus === 'pending'))
return (
);
return (
setPreviewLoaded(true)}
/>
{!previewLoaded && (
)}
);
}
const PREVIEW_COMPOSITION_SUFFIX = 'Preview';
function selectDefaultComposition(component: ComponentModel) {
const { compositions } = component;
return compositions.find((x) => x.identifier.endsWith(PREVIEW_COMPOSITION_SUFFIX));
}