fix(web): show explicit error/retry state when example preview HTML fails to load (#863)

* fix(web): show explicit error/retry state when example preview HTML fails to load

Reporter (#860) saw the example preview modal stuck with the toolbar
buttons greyed out and only restarting the app got back to a usable
state. Lefarcen confirmed the diagnosis: when /api/skills/:id/example
fails, fetchSkillExample returns null, the modal stays at preview.loading
forever, and the share menu's disabled={!activeHtml} guard sits in the
disabled position with no recovery path.

Three changes:

1. fetchSkillExample now returns a discriminated { html } | { error }
   instead of collapsing every failure into null, so callers can tell a
   real fetch failure from a normal load.

2. PreviewView gains an optional error field. When set, PreviewModal
   renders a stacked title/body/Retry affordance instead of the
   indefinite "Loading…" placeholder. Retry re-fires onView so the
   parent can re-run its fetch.

3. ExamplesTab tracks per-skill errors alongside per-skill html, clears
   the in-flight value before each fetch, and wires onView from the
   modal into loadPreview so the Retry button actually retries.

i18n: three new keys (preview.errorTitle, preview.errorBody,
preview.retry), translated across all 17 locales. The locales-aligned
test stays green.

CSS: .ds-modal-error stacks the new content vertically inside the
existing .ds-modal-empty positioning, no other modals affected.

* fix(web): stabilize preview onView and guard parallel preview fetches

Codex caught a real bug in the round-1 fix: the inline
onView={() => loadPreview(...)} prop was recreated on every parent
render, and PreviewModal's mount effect re-fires onView whenever its
identity changes. A persistent fetch failure would update state,
recreate the prop, re-fire the effect, re-run loadPreview, and burn
through the error UI in a flash instead of waiting for a Retry click.

Pin a stable onPreviewView via a useRef-backed callback so the modal
sees a single identity for the lifetime of the panel; loadPreview is
reached through the ref, so its closure refresh on state updates no
longer leaks into the modal's effect dependencies.

While in this surface, also add lefarcen's race guard: a synchronous
inFlightRef Set so two parallel loadPreview calls (e.g. card hover
firing while the modal opens) cannot both pass the cache check before
either setState lands. The first caller adds the id pre-await; the
second sees it and exits early. try/finally clears the entry on both
success and failure paths.

Adds tests/components/preview-modal-error-state.test.tsx covering:
- error UI renders when view.error is set,
- Retry click calls onView with the active view id,
- re-rendering with the same onView identity does not re-fire the
  modal's mount effect (pins the no-auto-retry contract).

* fix(web): close Retry over the active skill id, not the modal-internal view id

mrcfps caught a real regression in round 2: PreviewModal calls
onView(activeId) where activeId is the modal-local view id ('preview'
in this component). The previous round forwarded that argument
straight into loadPreview, so the mount effect and Retry button hit
/api/skills/preview/example instead of /api/skills/{skill-id}/example.
The new error state could not actually recover.

Mirror the active skill id into a ref alongside loadPreviewRef and
have onPreviewView ignore the modal-forwarded argument, fetching the
selected skill via the ref instead. The callback identity stays
stable, so the no-auto-retry contract from round 2 still holds.

Adds tests/components/examples-tab-retry.test.tsx that mounts the
real ExamplesTab, mocks fetchSkillExample to reject, opens the
preview, clicks Retry, and asserts the second call hits the same
skill id (and explicitly never gets called with 'preview').

---------

Co-authored-by: Nagendhra <nagendhra405@gmail.com>
This commit is contained in:
Nagendhra Madishetti 2026-05-07 23:16:14 -04:00 committed by GitHub
parent 2eae7da24b
commit 655d561f38
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
24 changed files with 400 additions and 13 deletions

View file

@ -108,6 +108,16 @@ export function ExamplesTab({ skills, onUsePrompt }: Props) {
const { locale, t } = useI18n();
// Hold preview HTML per skill across re-renders so cards never re-flicker.
const [previews, setPreviews] = useState<Record<string, string | null>>({});
// Track per-skill fetch failures separately so the preview modal can show
// an actionable error / retry state instead of staying stuck at "loading".
// Issue #860.
const [previewErrors, setPreviewErrors] = useState<Record<string, string>>({});
// Synchronous in-flight set: state updates are batched, so two parallel
// loadPreview calls (e.g. card hover firing simultaneously with modal
// open) could both pass the "is anything cached?" check before either
// setState landed. The ref check happens before any await so the second
// caller sees the first one already running and exits early.
const inFlightRef = useRef<Set<string>>(new Set());
const [surfaceFilter, setSurfaceFilter] = useState<SurfaceFilter>('all');
const [modeFilter, setModeFilter] = useState<ModeFilter>('all');
const [scenarioFilter, setScenarioFilter] = useState<ScenarioFilter>('all');
@ -119,13 +129,67 @@ export function ExamplesTab({ skills, onUsePrompt }: Props) {
const loadPreview = useCallback(
async (id: string) => {
if (previews[id] !== undefined) return;
const html = await fetchSkillExample(id);
setPreviews((prev) => ({ ...prev, [id]: html }));
// Race guard: synchronous check before any state read so two parallel
// calls (hover + modal open) cannot both fall through.
if (inFlightRef.current.has(id)) return;
// Skip the fetch only when we already hold a successful html result.
// A prior error must not short-circuit a retry; a prior success can.
if (previews[id] !== undefined && previewErrors[id] === undefined) return;
inFlightRef.current.add(id);
try {
// Reset both branches before firing so a retry from the error UI
// immediately swaps to "loading" instead of flashing the old error.
setPreviewErrors((prev) => {
if (prev[id] === undefined) return prev;
const next = { ...prev };
delete next[id];
return next;
});
setPreviews((prev) => ({ ...prev, [id]: null }));
const result = await fetchSkillExample(id);
if ('html' in result) {
setPreviews((prev) => ({ ...prev, [id]: result.html }));
} else {
setPreviewErrors((prev) => ({ ...prev, [id]: result.error }));
setPreviews((prev) => {
if (prev[id] === undefined) return prev;
const next = { ...prev };
delete next[id];
return next;
});
}
} finally {
inFlightRef.current.delete(id);
}
},
[previews],
[previews, previewErrors],
);
// Keep a ref to the latest loadPreview so the onView handler passed to
// PreviewModal can have a stable identity. Without this, the inline
// `() => loadPreview(...)` arrow rebuilds on every state change and
// PreviewModal's `useEffect(() => onView?.(activeId), [activeId, onView])`
// re-fires on each render, turning a persistent fetch failure into an
// automatic retry loop that flashes past the error UI.
const loadPreviewRef = useRef(loadPreview);
useEffect(() => {
loadPreviewRef.current = loadPreview;
}, [loadPreview]);
// Mirror the active skill id into a ref so onPreviewView can fetch the
// selected skill instead of the modal's internal view id. PreviewModal
// calls onView(activeId), where activeId is the modal-local view id
// ('preview' in this component); forwarding that id straight into
// fetchSkillExample would request /api/skills/preview/example instead
// of the user's selected skill, leaving Retry unable to recover.
const activeSkillIdRef = useRef<string | null>(null);
useEffect(() => {
activeSkillIdRef.current = previewSkillId;
}, [previewSkillId]);
const onPreviewView = useCallback(() => {
const skillId = activeSkillIdRef.current;
if (skillId !== null) void loadPreviewRef.current(skillId);
}, []);
// Open the modal for a card. We always trigger a preview fetch even if
// the card hasn't been hovered yet — the modal needs the HTML.
const openPreview = useCallback(
@ -334,9 +398,15 @@ export function ExamplesTab({ skills, onUsePrompt }: Props) {
id: 'preview',
label: t('examples.previewLabel'),
html: previews[previewSkill.id],
error: previewErrors[previewSkill.id] ?? null,
deck: previewSkill.mode === 'deck',
},
]}
// Stable identity (see onPreviewView definition) so PreviewModal's
// mount-time onView effect doesn't re-fire on every state update;
// the Retry button reaches loadPreview through the same handler.
// Issue #860.
onView={onPreviewView}
exportTitleFor={() => previewSkill.name}
onClose={() => setPreviewSkillId(null)}
/>

View file

@ -6,10 +6,14 @@ import { buildSrcdoc } from '../runtime/srcdoc';
export interface PreviewView {
id: string;
label: string;
// Null means "still loading" — modal renders the loading affordance.
// Undefined means "not yet requested" — parent should react to onView and
// begin a fetch. Both states keep the iframe blank.
// Null means "still loading", undefined means "not yet requested".
// Both states keep the iframe blank. The parent should react to
// onView and begin a fetch.
html: string | null | undefined;
// When set, the modal renders an error affordance with a Retry
// button that re-fires onView for this view id, instead of sitting
// at the loading state forever. Issue #860.
error?: string | null;
// Deck previews need deck-aware srcdoc/PDF handling so slide navigation and
// print-all-slides behavior survive the sandboxed export path.
deck?: boolean;
@ -200,6 +204,7 @@ export function PreviewModal({
const activeView = views.find((v) => v.id === activeId) ?? views[0];
const activeHtml = activeView?.html ?? null;
const activeError = activeView?.error ?? null;
const activeDeck = activeView?.deck ?? false;
const srcDoc = useMemo(
() => (activeHtml ? buildSrcdoc(activeHtml, { deck: activeDeck }) : ''),
@ -377,7 +382,30 @@ export function PreviewModal({
ref={stageRef}
>
<div className="ds-modal-stage-iframe" ref={stageFrameRef}>
{activeHtml === null || activeHtml === undefined ? (
{activeError ? (
// Distinct error state so a fetch failure stops looking
// like an indefinite "Loading…". The Retry button re-fires
// onView for this view id; the caller is responsible for
// clearing the error state and re-running the fetch.
// Issue #860.
<div className="ds-modal-empty ds-modal-error">
<div className="ds-modal-error-title">
{t('preview.errorTitle')}
</div>
<div className="ds-modal-error-body">
{t('preview.errorBody')}
</div>
{onView && activeView ? (
<button
type="button"
className="ghost"
onClick={() => onView(activeView.id)}
>
{t('preview.retry')}
</button>
) : null}
</div>
) : activeHtml === null || activeHtml === undefined ? (
<div className="ds-modal-empty">
{t('preview.loading', {
label:

View file

@ -499,6 +499,9 @@ export const ar: Dict = {
'preview.fullscreen': '⤢ ملء الشاشة',
'preview.closeTitle': 'إغلاق (Esc)',
'preview.loading': 'جاري تحميل {label}...',
'preview.errorTitle': 'تعذّر تحميل هذا المثال.',
'preview.errorBody': 'فشل جلب HTML الخاص بالمثال. تأكد من تشغيل Open Design ثم أعد المحاولة.',
'preview.retry': 'إعادة المحاولة',
'preview.showSidebar': 'إظهار {label}',
'preview.hideSidebar': 'إخفاء {label}',

View file

@ -453,6 +453,9 @@ export const de: Dict = {
'preview.fullscreen': '⤢ Vollbild',
'preview.closeTitle': 'Schließen (Esc)',
'preview.loading': '{label} wird geladen…',
'preview.errorTitle': 'Beispiel konnte nicht geladen werden.',
'preview.errorBody': 'Das Beispiel-HTML konnte nicht abgerufen werden. Stelle sicher, dass Open Design läuft, und versuche es erneut.',
'preview.retry': 'Erneut versuchen',
'preview.showSidebar': '{label} einblenden',
'preview.hideSidebar': '{label} ausblenden',

View file

@ -510,6 +510,9 @@ export const en: Dict = {
'preview.fullscreen': '⤢ Fullscreen',
'preview.closeTitle': 'Close (Esc)',
'preview.loading': 'Loading {label}…',
'preview.errorTitle': 'Couldn\'t load this example.',
'preview.errorBody': 'The example HTML failed to fetch. Make sure Open Design is running and try again.',
'preview.retry': 'Retry',
'preview.showSidebar': 'Show {label}',
'preview.hideSidebar': 'Hide {label}',

View file

@ -454,6 +454,9 @@ export const esES: Dict = {
'preview.fullscreen': '⤢ Pantalla completa',
'preview.closeTitle': 'Cerrar (Esc)',
'preview.loading': 'Cargando {label}…',
'preview.errorTitle': 'No se pudo cargar este ejemplo.',
'preview.errorBody': 'No se pudo obtener el HTML del ejemplo. Asegúrate de que Open Design esté en ejecución e inténtalo de nuevo.',
'preview.retry': 'Reintentar',
'preview.showSidebar': 'Mostrar {label}',
'preview.hideSidebar': 'Ocultar {label}',

View file

@ -510,6 +510,9 @@ export const fa: Dict = {
'preview.fullscreen': '⤢ تمام صفحه',
'preview.closeTitle': 'بستن (Esc)',
'preview.loading': 'در حال بارگذاری {label}…',
'preview.errorTitle': 'بارگیری این نمونه ممکن نشد.',
'preview.errorBody': 'دریافت HTML نمونه با خطا مواجه شد. مطمئن شوید Open Design در حال اجراست و دوباره تلاش کنید.',
'preview.retry': 'تلاش دوباره',
'preview.showSidebar': 'نمایش {label}',
'preview.hideSidebar': 'پنهان کردن {label}',

View file

@ -499,6 +499,9 @@ export const fr: Dict = {
'preview.fullscreen': '⤢ Plein écran',
'preview.closeTitle': 'Fermer (Échap)',
'preview.loading': 'Chargement de {label}…',
'preview.errorTitle': 'Impossible de charger cet exemple.',
'preview.errorBody': 'Le chargement du HTML de l\'exemple a échoué. Vérifiez qu\'Open Design est en cours d\'exécution et réessayez.',
'preview.retry': 'Réessayer',
'preview.showSidebar': 'Afficher {label}',
'preview.hideSidebar': 'Masquer {label}',

View file

@ -499,6 +499,9 @@ export const hu: Dict = {
'preview.fullscreen': '⤢ Teljes képernyő',
'preview.closeTitle': 'Bezárás (Esc)',
'preview.loading': '{label} betöltése…',
'preview.errorTitle': 'A példa betöltése nem sikerült.',
'preview.errorBody': 'A példa HTML-jének letöltése meghiúsult. Győződj meg róla, hogy az Open Design fut, majd próbáld újra.',
'preview.retry': 'Újra',
'preview.showSidebar': '{label} megjelenítése',
'preview.hideSidebar': '{label} elrejtése',

View file

@ -499,6 +499,9 @@ export const id: Dict = {
'preview.fullscreen': 'Layar penuh',
'preview.closeTitle': 'Tutup pratinjau',
'preview.loading': 'Memuat {label}...',
'preview.errorTitle': 'Tidak dapat memuat contoh ini.',
'preview.errorBody': 'Pengambilan HTML contoh gagal. Pastikan Open Design berjalan, lalu coba lagi.',
'preview.retry': 'Coba lagi',
'preview.showSidebar': 'Tampilkan {label}',
'preview.hideSidebar': 'Sembunyikan {label}',

View file

@ -452,6 +452,9 @@ export const ja: Dict = {
'preview.fullscreen': '⤢ フルスクリーン',
'preview.closeTitle': '閉じる (Esc)',
'preview.loading': '{label} を読み込み中…',
'preview.errorTitle': 'この例を読み込めませんでした。',
'preview.errorBody': '例の HTML を取得できませんでした。Open Design が起動していることを確認して再試行してください。',
'preview.retry': '再試行',
'preview.showSidebar': '{label} を表示',
'preview.hideSidebar': '{label} を非表示',

View file

@ -499,6 +499,9 @@ export const ko: Dict = {
'preview.fullscreen': '⤢ 전체 화면',
'preview.closeTitle': '닫기 (Esc)',
'preview.loading': '{label} 불러오는 중…',
'preview.errorTitle': '이 예제를 불러오지 못했습니다.',
'preview.errorBody': '예제 HTML을 가져오지 못했습니다. Open Design이 실행 중인지 확인하고 다시 시도하세요.',
'preview.retry': '다시 시도',
'preview.showSidebar': '{label} 표시',
'preview.hideSidebar': '{label} 숨기기',

View file

@ -499,6 +499,9 @@ export const pl: Dict = {
'preview.fullscreen': '⤢ Pełny ekran',
'preview.closeTitle': 'Zamknij (Esc)',
'preview.loading': 'Ładowanie {label}…',
'preview.errorTitle': 'Nie udało się załadować tego przykładu.',
'preview.errorBody': 'Nie udało się pobrać kodu HTML przykładu. Upewnij się, że Open Design jest uruchomiony, i spróbuj ponownie.',
'preview.retry': 'Spróbuj ponownie',
'preview.showSidebar': 'Pokaż {label}',
'preview.hideSidebar': 'Ukryj {label}',

View file

@ -509,6 +509,9 @@ export const ptBR: Dict = {
'preview.fullscreen': '⤢ Tela cheia',
'preview.closeTitle': 'Fechar (Esc)',
'preview.loading': 'Carregando {label}…',
'preview.errorTitle': 'Não foi possível carregar este exemplo.',
'preview.errorBody': 'A obtenção do HTML do exemplo falhou. Verifique se o Open Design está em execução e tente novamente.',
'preview.retry': 'Tentar novamente',
'preview.showSidebar': 'Mostrar {label}',
'preview.hideSidebar': 'Ocultar {label}',

View file

@ -509,6 +509,9 @@ export const ru: Dict = {
'preview.fullscreen': '⤢ Полноэкранный',
'preview.closeTitle': 'Закрыть (Esc)',
'preview.loading': 'Загрузка {label}…',
'preview.errorTitle': 'Не удалось загрузить этот пример.',
'preview.errorBody': 'Не удалось получить HTML примера. Убедитесь, что Open Design запущен, и повторите попытку.',
'preview.retry': 'Повторить',
'preview.showSidebar': 'Показать {label}',
'preview.hideSidebar': 'Скрыть {label}',

View file

@ -492,6 +492,9 @@ export const tr: Dict = {
'preview.fullscreen': '⤢ Tam ekran',
'preview.closeTitle': 'Kapat (Esc)',
'preview.loading': '{label} yükleniyor…',
'preview.errorTitle': 'Bu örnek yüklenemedi.',
'preview.errorBody': 'Örnek HTML\'i alınamadı. Open Design\'ın çalıştığından emin olup tekrar deneyin.',
'preview.retry': 'Tekrar dene',
'preview.showSidebar': '{label} göster',
'preview.hideSidebar': '{label} gizle',

View file

@ -510,6 +510,9 @@ export const uk: Dict = {
'preview.fullscreen': '⤢ Повноекранний режим',
'preview.closeTitle': 'Закрити (Esc)',
'preview.loading': 'Завантаження {label}…',
'preview.errorTitle': 'Не вдалося завантажити цей приклад.',
'preview.errorBody': 'Не вдалося отримати HTML прикладу. Переконайтеся, що Open Design запущено, і повторіть спробу.',
'preview.retry': 'Повторити',
'preview.showSidebar': 'Показати {label}',
'preview.hideSidebar': 'Приховати {label}',

View file

@ -501,6 +501,9 @@ export const zhCN: Dict = {
'preview.fullscreen': '⤢ 全屏',
'preview.closeTitle': '关闭Esc',
'preview.loading': '正在加载{label}…',
'preview.errorTitle': '无法加载此示例。',
'preview.errorBody': '示例 HTML 加载失败。请确认 Open Design 正在运行后重试。',
'preview.retry': '重试',
'preview.showSidebar': '展开{label}',
'preview.hideSidebar': '收起{label}',

View file

@ -501,6 +501,9 @@ export const zhTW: Dict = {
'preview.fullscreen': '⤢ 全螢幕',
'preview.closeTitle': '關閉Esc',
'preview.loading': '正在載入{label}…',
'preview.errorTitle': '無法載入此範例。',
'preview.errorBody': '範例 HTML 載入失敗。請確認 Open Design 正在執行後重試。',
'preview.retry': '重試',
'preview.showSidebar': '展開{label}',
'preview.hideSidebar': '收合{label}',

View file

@ -568,6 +568,9 @@ export interface Dict {
'preview.fullscreen': string;
'preview.closeTitle': string;
'preview.loading': string;
'preview.errorTitle': string;
'preview.errorBody': string;
'preview.retry': string;
'preview.showSidebar': string;
'preview.hideSidebar': string;

View file

@ -8205,6 +8205,27 @@ button.ghost.mcp-copy-btn:hover:not(:disabled) {
color: var(--text-muted);
font-size: 13px;
}
/* Error state extends .ds-modal-empty: stacks a title, body, and Retry
button instead of a single line, so a fetch failure no longer leaves
the modal stuck at "Loading…" with disabled toolbar buttons and no
recovery path. Issue #860. */
.ds-modal-error {
flex-direction: column;
gap: 10px;
padding: 0 24px;
text-align: center;
}
.ds-modal-error-title {
color: var(--text);
font-size: 14px;
font-weight: 600;
}
.ds-modal-error-body {
color: var(--text-muted);
font-size: 13px;
max-width: 48ch;
line-height: 1.5;
}
.ds-modal-actions .ghost.is-active {
background: var(--accent-tint);
color: var(--accent);

View file

@ -377,13 +377,25 @@ export async function fetchAppVersionInfo(): Promise<AppVersionInfo | null> {
}
}
export async function fetchSkillExample(id: string): Promise<string | null> {
export type SkillExampleResult =
| { html: string }
| { error: string };
// Returns a discriminated result so callers can distinguish a real
// failure (network error, daemon unreachable, non-2xx) from a normal
// load. Previously this collapsed every failure into `null`, which
// left the example preview modal stuck at its loading state with no
// recovery affordance. Issue #860.
export async function fetchSkillExample(id: string): Promise<SkillExampleResult> {
try {
const resp = await fetch(`/api/skills/${encodeURIComponent(id)}/example`);
if (!resp.ok) return null;
return await resp.text();
} catch {
return null;
if (!resp.ok) {
return { error: `HTTP ${resp.status}` };
}
return { html: await resp.text() };
} catch (err) {
const message = err instanceof Error ? err.message : 'network error';
return { error: message };
}
}

View file

@ -0,0 +1,84 @@
// @vitest-environment jsdom
import {
act,
cleanup,
fireEvent,
render,
screen,
} from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { SkillSummary } from '../../src/types';
// Regression coverage for nexu-io/open-design#860 (round 3): the modal's
// onView fires with the modal-internal view id ('preview'), not the
// active skill id. The Retry path must close over the selected skill so
// re-fires hit /api/skills/{skill-id}/example, not /api/skills/preview/example.
vi.mock('../../src/providers/registry', () => ({
fetchSkillExample: vi.fn(),
}));
import { fetchSkillExample } from '../../src/providers/registry';
import { ExamplesTab } from '../../src/components/ExamplesTab';
const mockedFetch = fetchSkillExample as unknown as ReturnType<typeof vi.fn>;
const sampleSkill: SkillSummary = {
id: 'live-dashboard',
name: 'Live Dashboard',
description: 'A team dashboard live artifact.',
triggers: ['dashboard'],
mode: 'prototype',
previewType: 'web',
designSystemRequired: false,
defaultFor: [],
upstream: null,
hasBody: true,
examplePrompt: 'Build me a Notion-style team dashboard.',
};
async function flushPromises() {
await act(async () => {
await Promise.resolve();
});
}
describe('ExamplesTab preview retry path (#860)', () => {
beforeEach(() => {
mockedFetch.mockReset();
});
afterEach(() => {
cleanup();
});
it('Retry refetches the active skill, not the modal-internal view id', async () => {
mockedFetch.mockResolvedValue({ error: 'simulated failure' });
render(<ExamplesTab skills={[sampleSkill]} onUsePrompt={() => {}} />);
// Open the preview modal for the sample skill.
const openButtons = screen.getAllByText(/open preview/i);
fireEvent.click(openButtons[0]!);
// Initial fetch on mount.
await flushPromises();
expect(mockedFetch).toHaveBeenCalledTimes(1);
expect(mockedFetch).toHaveBeenLastCalledWith('live-dashboard');
// Error UI replaces the loading placeholder.
expect(screen.getByText("Couldn't load this example.")).toBeTruthy();
const retry = screen.getByRole('button', { name: /retry/i });
// Retry must hit the same skill id, NOT 'preview' (the modal view id).
fireEvent.click(retry);
await flushPromises();
expect(mockedFetch).toHaveBeenCalledTimes(2);
expect(mockedFetch).toHaveBeenLastCalledWith('live-dashboard');
// Defensive: a regression that wires the modal view id back into the
// fetcher would call with 'preview' here.
expect(mockedFetch).not.toHaveBeenCalledWith('preview');
});
});

View file

@ -0,0 +1,118 @@
// @vitest-environment jsdom
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { PreviewModal } from '../../src/components/PreviewModal';
// Regression coverage for nexu-io/open-design#860: when the example HTML
// fetch fails, the modal must render an explicit error/retry affordance
// instead of staying stuck at "Loading…" with the share menu disabled
// and no recovery path.
const baseProps = {
title: 'Example',
exportTitleFor: (id: string) => id,
};
describe('PreviewModal error state', () => {
afterEach(() => {
cleanup();
});
it('renders the error UI when the active view carries an error', () => {
render(
<PreviewModal
{...baseProps}
views={[
{
id: 'preview',
label: 'Preview',
html: undefined,
error: 'simulated failure',
},
]}
onView={() => {}}
onClose={() => {}}
/>,
);
expect(
screen.getByText("Couldn't load this example."),
).toBeTruthy();
expect(
screen.getByRole('button', { name: /retry/i }),
).toBeTruthy();
// Loading copy must NOT show alongside the error state.
expect(screen.queryByText(/loading/i)).toBeNull();
});
it('fires onView when Retry is clicked so the parent can re-run the fetch', () => {
const onView = vi.fn();
render(
<PreviewModal
{...baseProps}
views={[
{
id: 'preview',
label: 'Preview',
html: undefined,
error: 'simulated failure',
},
]}
onView={onView}
onClose={() => {}}
/>,
);
// Mount fires onView once with the initial activeId; clear the spy
// so the assertion targets only the Retry click.
onView.mockClear();
fireEvent.click(screen.getByRole('button', { name: /retry/i }));
expect(onView).toHaveBeenCalledTimes(1);
expect(onView).toHaveBeenCalledWith('preview');
});
it('does not re-fire onView on re-render when the same callback identity is passed', () => {
// Codex P2 regression: an inline `onView={() => loadPreview(...)}` was
// recreated on every parent render, and PreviewModal's mount effect
// re-fired onView on identity change, turning a persistent error into
// an automatic retry loop. The fix in ExamplesTab is to pass a
// stable-identity callback; this test pins that contract on the
// modal side by asserting that re-rendering with the same onView
// reference does not re-fire it.
const onView = vi.fn();
const { rerender } = render(
<PreviewModal
{...baseProps}
views={[
{
id: 'preview',
label: 'Preview',
html: undefined,
error: 'simulated failure',
},
]}
onView={onView}
onClose={() => {}}
/>,
);
expect(onView).toHaveBeenCalledTimes(1);
rerender(
<PreviewModal
{...baseProps}
views={[
{
id: 'preview',
label: 'Preview',
html: undefined,
error: 'simulated failure',
},
]}
onView={onView}
onClose={() => {}}
/>,
);
expect(onView).toHaveBeenCalledTimes(1);
});
});