// @vitest-environment jsdom // Race condition regression tests for the prevSourceBeforeReloadRef approach // introduced in commit 65fe10d1a (PR #4652). // // Three distinct races are covered: // // Race 1 — double-click failure: // The fallback ref is overwritten with null on the second click (because // source is null after the first setSource(null) call), so when the first // fetch fails there is nothing left to restore. The user sees a blank // preview instead of their last good HTML. // // Race 2 — file-switch contamination + loading state: // The ref is not scoped to the current file, so when the user switches to a // new file while a reload fetch is in flight, a failed fetch for the new // file restores the *previous* file's HTML into the new preview. // Additionally, the sourceEverLoadedRef sentinel stays true after a file // switch when a reload snapshot is present, so the new file B never shows // the loading skeleton while its fetch is in flight — it shows a blank // iframe instead. // // Race 3 — stale snapshot leaked by identity-mismatched fetch: // When the user switches to file B while a reload fetch is in flight, // B's fetch returns null. The identity check (snap.fileName='A', // file.name='B') fails and the restore-branch returns without clearing // prevSourceBeforeReloadRef. The old snapshot stays armed, so navigating // back to file A on a later normal failed load (no Reload click) wrongly // restores the old A snapshot instead of showing the loading indicator. // // Race P3 (Codex P2 line 7377) — Manual Edit save silently fails during Reload: // reloadHtmlPreview calls setSource(null) unconditionally. The [source] // effect then writes null into sourceRef.current. If the user makes or // saves a manual edit before the reload fetch resolves, applyManualEdit // hits sourceRef.current == null and returns false without calling the file // API — a silent, invisible failure even though the preview is still // interactive (manualEditFrozenSource still shows the old HTML). import type { ComponentProps } from 'react'; import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'; import { afterEach, describe, expect, it, vi } from 'vitest'; import type { ProjectFile } from '../../src/types'; import { emptyManualEditStyles, type ManualEditTarget } from '../../src/edit-mode/types'; // --------------------------------------------------------------------------- // ManualEditPanel mock — captures the props FileViewer passes so tests can // invoke onApplyPatch / onStyleChange without needing real iframe interaction. // The mock renders a lightweight sentinel so no real panel DOM is created. // --------------------------------------------------------------------------- const panelState = vi.hoisted(() => ({ props: null as ComponentProps | null, })); vi.mock('../../src/components/ManualEditPanel', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, ManualEditPanel: ( props: ComponentProps, ) => { panelState.props = props; return
; }, }; }); import { FileViewer } from '../../src/components/FileViewer'; afterEach(() => { cleanup(); panelState.props = null; vi.restoreAllMocks(); vi.unstubAllGlobals(); }); // --------------------------------------------------------------------------- // Shared fixture helpers // --------------------------------------------------------------------------- // A plain HTML file (not a deck) that supports Manual Edit (has data-od-id // elements). The manifest marks it as an 'html' artifact so the component // uses the HTML preview path without needing deck logic. function manualEditFile(): ProjectFile { return { name: 'preview.html', path: 'preview.html', type: 'file', size: 1024, mtime: 1710000000, kind: 'html', mime: 'text/html', artifactManifest: { version: 1, kind: 'html', title: 'Preview', entry: 'preview.html', renderer: 'html', exports: ['html'], }, }; } // Minimal target shape that the component accepts via od-edit-select. function heroTarget(): ManualEditTarget { return { id: 'hero', kind: 'text', label: 'Hero', tagName: 'h1', className: '', text: 'Hello', rect: { x: 0, y: 0, width: 120, height: 40 }, fields: { text: 'Hello' }, attributes: { 'data-od-id': 'hero' }, styles: emptyManualEditStyles(), isLayoutContainer: false, outerHtml: '

Hello

', }; } // Pins the inspector to a target by dispatching the od-edit-select message // that the edit-mode iframe bridge would normally send. Returns the iframe // element so callers can inspect it or use it for further messages. async function selectManualEditTarget(target = heroTarget()): Promise { const frame = await waitFor(() => { const node = screen.getByTestId('artifact-preview-frame') as HTMLIFrameElement; if (!node.contentWindow) throw new Error('Preview frame not ready'); return node; }); act(() => { window.dispatchEvent( new MessageEvent('message', { data: { type: 'od-edit-select', target }, source: frame.contentWindow, }), ); }); await waitFor(() => expect(panelState.props).not.toBeNull()); return frame; } // Minimal deck file fixture that forces the srcDoc render path. function deckFile(overrides: Partial = {}): ProjectFile { return { name: 'deck.html', path: 'deck.html', type: 'file', size: 1024, mtime: 1710000000, kind: 'html', mime: 'text/html', artifactManifest: { version: 1, kind: 'deck', title: 'Deck', entry: 'deck.html', renderer: 'deck-html', exports: ['html'], }, ...overrides, }; } // Embeds a label inside a minimal deck HTML body so the srcdoc can be // searched for it. function deckHtml(label: string): string { return `

${label}

`; } const RAW_URL_PREFIX = '/api/projects/project-1/raw/'; // Returns the srcdoc iframe rendered for the deck (srcDoc) path. function srcDocFrame(): HTMLIFrameElement { return screen.getByTestId('artifact-preview-frame') as HTMLIFrameElement; } // A controllable fetch stub — callers resolve or reject each queued request // individually by calling the returned handle. type FetchHandle = { resolve: (html: string | null, status?: number) => void; }; function deferredFetch(): { handle: FetchHandle; stub: typeof fetch } { let resolveRequest!: (resp: Response) => void; const handle: FetchHandle = { resolve: (html, status = 200) => { resolveRequest( html !== null ? new Response(html, { status }) : new Response('', { status: 500 }), ); }, }; const stub = vi.fn(async (input: string | URL | Request) => { const url = typeof input === 'string' ? input : input instanceof Request ? input.url : String(input); if (url.startsWith(RAW_URL_PREFIX)) { return new Promise((ok) => { resolveRequest = ok; }); } return new Response('', { status: 404 }); }); return { handle, stub }; } // Fetches that resolve immediately with a fixed response. function fetchReturning(html: string) { return vi.fn(async (input: string | URL | Request) => { const url = typeof input === 'string' ? input : input instanceof Request ? input.url : String(input); if (url.startsWith(RAW_URL_PREFIX)) { return new Response(html, { status: 200 }); } return new Response('', { status: 404 }); }); } describe('FileViewer srcDoc reload — prevSourceBeforeReloadRef race conditions', () => { // --------------------------------------------------------------------------- // Race 1: double-click wipes the fallback ref before the first fetch fails // --------------------------------------------------------------------------- it('restores the last good preview when a second Reload click fires before the first fetch resolves and that first fetch fails', async () => { // Step 1: initial render — file content "V1" lands in the iframe srcdoc. const v1 = deckHtml('version-one'); vi.stubGlobal('fetch', fetchReturning(v1)); render( , ); await waitFor(() => { expect(srcDocFrame().getAttribute('srcDoc')).toContain('version-one'); }); // Step 2: first Reload click — source goes null, fetch1 is in flight. const { handle: fetch1Handle, stub: fetch1Stub } = deferredFetch(); vi.stubGlobal('fetch', fetch1Stub); act(() => { fireEvent.click(screen.getByRole('button', { name: /reload preview/i })); }); // At this point source === null, so the ref (if re-captured unconditionally // on every reload) would be overwritten with null on the next click. // Step 3: second Reload click fires BEFORE fetch1 resolves. // source is still null here (fetch1 has not resolved yet). act(() => { fireEvent.click(screen.getByRole('button', { name: /reload preview/i })); }); // Step 4: resolve fetch1 with a non-2xx to trigger the restore path. await act(async () => { fetch1Handle.resolve(null, 500); await Promise.resolve(); }); // Also drain any queued microtasks from the second click's pending fetch. await act(async () => { await Promise.resolve(); }); // --- KEY ASSERTION --- // The iframe must still display "version-one". On the buggy branch the // ref was overwritten with null by the second click (source was null when // the second click captured it), so the restore path has nothing to set // and the preview goes blank. await waitFor(() => { const srcdoc = srcDocFrame().getAttribute('srcDoc') ?? ''; expect(srcdoc).toContain('version-one'); }); }); // --------------------------------------------------------------------------- // Race 2: file-switch shows loading state, not blank iframe or old HTML // --------------------------------------------------------------------------- it('shows the loading indicator for File B (not a blank iframe or File A HTML) when the user switches files while a reload fetch is in flight and File B source is unavailable', async () => { // Build two distinct deck files rooted at different raw URLs so fetch // mocks can distinguish them. const fileA = deckFile({ name: 'deck-a.html', path: 'deck-a.html' }); const fileB = deckFile({ name: 'deck-b.html', path: 'deck-b.html' }); const RAW_A = `/api/projects/project-1/raw/deck-a.html`; const RAW_B = `/api/projects/project-1/raw/deck-b.html`; const htmlA = deckHtml('FILE-A-HTML'); const htmlB = deckHtml('file-b-html'); // Step 1: mount with File A and wait for it to load. vi.stubGlobal( 'fetch', vi.fn(async (input: string | URL | Request) => { const url = typeof input === 'string' ? input : input instanceof Request ? input.url : String(input); if (url.startsWith(RAW_A)) return new Response(htmlA, { status: 200 }); return new Response('', { status: 404 }); }), ); const { rerender } = render( , ); await waitFor(() => { expect(srcDocFrame().getAttribute('srcDoc')).toContain('FILE-A-HTML'); }); // Step 2: click Reload on File A — ref captures "FILE-A-HTML", source goes // null, fetch for File A is now in flight (deferred). let resolveFileAFetch!: (resp: Response) => void; vi.stubGlobal( 'fetch', vi.fn(async (input: string | URL | Request) => { const url = typeof input === 'string' ? input : input instanceof Request ? input.url : String(input); if (url.startsWith(RAW_A)) { return new Promise((ok) => { resolveFileAFetch = ok; }); } return new Response('', { status: 404 }); }), ); act(() => { fireEvent.click(screen.getByRole('button', { name: /reload preview/i })); }); // Step 3: switch to File B BEFORE the File A fetch resolves. // A fresh fetch for File B immediately returns null (non-2xx) to trigger // whatever fallback logic exists. vi.stubGlobal( 'fetch', vi.fn(async (input: string | URL | Request) => { const url = typeof input === 'string' ? input : input instanceof Request ? input.url : String(input); if (url.startsWith(RAW_B)) { // Initial load for File B also fails so we can observe the srcdoc // state without a successful fetch masking the contamination. return new Response('', { status: 500 }); } return new Response('', { status: 404 }); }), ); act(() => { rerender( , ); }); // Step 4: drain microtasks so React processes the file-switch re-render // and any pending fetch callbacks. await act(async () => { await Promise.resolve(); }); // --- KEY ASSERTIONS --- // // 1. File B's source is null and has never been loaded, so the loading // skeleton must be visible. On the buggy branch sourceEverLoadedRef // is still true from File A (the if-guard at ~5436 skips the reset // when a reload snapshot is present), so the component skips the // skeleton and renders the iframe instead — with File A's HTML if the // restore-branch fires, or a blank srcdoc otherwise. expect(screen.getByRole('status', { name: 'Loading…' })).toBeInTheDocument(); // 2. The srcdoc iframe itself must NOT be present while the loading state // is active — a blank