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)); }