Files
nexu-io--open-design/apps/web/tests/components/FileViewer.srcdoc-reload-races.test.tsx
wehub-resource-sync 070959e133
landing-page-staging / Deploy landing page to staging (push) Has been skipped
landing-page-ci / Validate landing page (push) Failing after 4s
visual-baseline / Capture visual baselines (push) Has been cancelled
bake-plugin-previews / Bake plugin previews (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:00:47 +08:00

1080 lines
41 KiB
TypeScript

// @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<typeof import('../../src/components/ManualEditPanel').ManualEditPanel> | null,
}));
vi.mock('../../src/components/ManualEditPanel', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../src/components/ManualEditPanel')>();
return {
...actual,
ManualEditPanel: (
props: ComponentProps<typeof actual.ManualEditPanel>,
) => {
panelState.props = props;
return <div data-testid="mock-manual-edit-panel" />;
},
};
});
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: '<h1 data-od-id="hero">Hello</h1>',
};
}
// 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<HTMLIFrameElement> {
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> = {}): 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 `<html><body><section class="slide"><h1>${label}</h1></section></body></html>`;
}
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<Response>((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(
<FileViewer
projectId="project-1"
projectKind="prototype"
file={deckFile()}
isDeck
/>,
);
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(
<FileViewer
projectId="project-1"
projectKind="prototype"
file={fileA}
isDeck
/>,
);
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<Response>((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(
<FileViewer
projectId="project-1"
projectKind="prototype"
file={fileB}
isDeck
/>,
);
});
// 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 <iframe srcDoc=""> is not an acceptable stand-in
// for the loading indicator.
expect(screen.queryByTestId('artifact-preview-frame')).toBeNull();
// 3. The loading indicator text must not contain File A's HTML content.
expect(screen.queryByText(/FILE-A-HTML/)).toBeNull();
// Resolve the stale File A fetch last (it should be discarded, not applied
// to the now-File-B viewer).
if (resolveFileAFetch) {
act(() => {
resolveFileAFetch(new Response(htmlA, { status: 200 }));
});
await act(async () => {
await Promise.resolve();
});
// After the stale resolution, File A's HTML must still not appear in
// any rendered element.
expect(screen.queryByText(/FILE-A-HTML/)).toBeNull();
}
});
// ---------------------------------------------------------------------------
// Race 4 (P2): routing decision must not flip to URL-load during the
// source=null window opened by a Reload click on an artifact whose srcDoc
// path is driven by a source-derived predicate (htmlNeedsSandboxShim).
// ---------------------------------------------------------------------------
it('keeps the srcDoc iframe active (does not flip to URL-load) while source is null after a Reload click on an htmlNeedsSandboxShim artifact', async () => {
// An HTML file that uses localStorage — triggers htmlNeedsSandboxShim and
// therefore forces the srcDoc path (forceInline=true). No .slide class so
// looksLikeDeck stays false and isDeck is passed as false; the ONLY reason
// this file takes the srcDoc path is the sandbox-shim predicate.
const shimFile: ProjectFile = {
name: 'app.html',
path: 'app.html',
type: 'file',
size: 512,
mtime: 1710000001,
kind: 'html',
mime: 'text/html',
};
const shimHtml =
'<html><body><script>localStorage.setItem("key","val");</script><p>loaded</p></body></html>';
// Step 1: mount with shimHtml — source is non-null, needsSandboxShim=true,
// forceInline=true, useUrlLoadPreview=false → srcDoc iframe is active.
vi.stubGlobal('fetch', fetchReturning(shimHtml));
render(
<FileViewer
projectId="project-1"
projectKind="prototype"
file={shimFile}
isDeck={false}
/>,
);
// Wait for the source fetch to resolve so the component has non-null source.
await waitFor(() => {
// The srcDoc iframe is active: its testid is 'artifact-preview-frame'
// (not 'artifact-preview-frame-srcdoc') when useUrlLoadPreview=false.
const frame = screen.queryByTestId('artifact-preview-frame');
expect(frame).not.toBeNull();
expect((frame as HTMLIFrameElement).getAttribute('data-od-render-mode')).toBe('srcdoc');
});
// Step 2: click Reload — source goes null synchronously. Without the fix,
// needsSandboxShim(null)=false → forceInline flips → useUrlLoadPreview
// becomes true → the URL-load iframe hijacks testid 'artifact-preview-frame'
// and the srcDoc iframe is renamed to 'artifact-preview-frame-srcdoc'.
const { handle: reloadHandle, stub: reloadStub } = deferredFetch();
vi.stubGlobal('fetch', reloadStub);
act(() => {
fireEvent.click(screen.getByRole('button', { name: /reload preview/i }));
});
// Step 3: assert BEFORE the fetch resolves — this is the reload window
// where the source=null race can expose the bug.
//
// The srcDoc iframe must remain the active iframe: its testid stays
// 'artifact-preview-frame' and its data-od-render-mode is 'srcdoc'.
// If the routing flipped, a URL-load iframe would have taken the
// 'artifact-preview-frame' testid and data-od-active='true' instead.
const activeFrame = screen.getByTestId('artifact-preview-frame');
expect(activeFrame.getAttribute('data-od-render-mode')).toBe('srcdoc');
expect(activeFrame.getAttribute('data-od-active')).toBe('true');
// Confirm the URL-load iframe is NOT the active one (it would carry
// data-od-render-mode='url-load' and data-od-active='true' on a buggy build).
const urlLoadFrame = screen.queryByTestId('artifact-preview-frame-url-load');
if (urlLoadFrame) {
expect(urlLoadFrame.getAttribute('data-od-active')).toBe('false');
}
// Drain the deferred fetch so it doesn't leak into subsequent tests.
await act(async () => {
reloadHandle.resolve(shimHtml);
await Promise.resolve();
});
});
// ---------------------------------------------------------------------------
// Race 3: stale snapshot must not survive an identity-mismatched fetch
// ---------------------------------------------------------------------------
it('does not restore the reload snapshot on a later normal failed load after an identity-mismatched fetch left the ref un-cleared', async () => {
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('V1-CONTENT');
// Step 1: mount with File A and wait for "V1-CONTENT" to appear.
vi.stubGlobal('fetch', fetchReturning(htmlA));
const { rerender } = render(
<FileViewer
projectId="project-1"
projectKind="prototype"
file={fileA}
isDeck
/>,
);
await waitFor(() => {
expect(srcDocFrame().getAttribute('srcDoc')).toContain('V1-CONTENT');
});
// Step 2: click Reload on File A.
// prevSourceBeforeReloadRef is now { source: htmlA, projectId, fileName: 'deck-a.html' }.
// source goes null; fetch for A is in flight (deferred).
let resolveFileAReloadFetch!: (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<Response>((ok) => {
resolveFileAReloadFetch = ok;
});
}
return new Response('', { status: 404 });
}),
);
act(() => {
fireEvent.click(screen.getByRole('button', { name: /reload preview/i }));
});
// Step 3: switch to File B while the File A reload fetch is still in
// flight. File B's fetch returns null (500) immediately, triggering the
// identity-mismatch branch. On the buggy implementation the mismatch
// branch returns early WITHOUT clearing prevSourceBeforeReloadRef, leaving
// the stale snapshot armed.
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)) {
return new Response('', { status: 500 });
}
return new Response('', { status: 404 });
}),
);
act(() => {
rerender(
<FileViewer
projectId="project-1"
projectKind="prototype"
file={fileB}
isDeck
/>,
);
});
await act(async () => {
await Promise.resolve();
});
// Step 4: switch back to File A. File A's fetch returns null (500) — a
// normal failed load, NOT a Reload click. On the buggy implementation
// prevSourceBeforeReloadRef is still non-null (leaked from step 2),
// the identity check passes (projectId + fileName both match A again),
// and the snapshot "V1-CONTENT" is incorrectly restored into the srcdoc.
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('', { status: 500 });
}
return new Response('', { status: 404 });
}),
);
act(() => {
rerender(
<FileViewer
projectId="project-1"
projectKind="prototype"
file={fileA}
isDeck
/>,
);
});
await act(async () => {
await Promise.resolve();
});
// --- KEY ASSERTION ---
// The stale snapshot must NOT be restored. A normal failed load with no
// active Reload click must show the loading indicator, not old content.
// On the buggy branch the leaked ref satisfies the identity check and
// setSource(snap.source) is called, surfacing "V1-CONTENT" instead of the
// skeleton.
expect(screen.getByRole('status', { name: 'Loading…' })).toBeInTheDocument();
expect(screen.queryByTestId('artifact-preview-frame')).toBeNull();
// Also confirm the stale HTML is not present anywhere in the rendered DOM.
expect(screen.queryByText(/V1-CONTENT/)).toBeNull();
// Drain the in-flight File A reload fetch so it doesn't leak into other
// tests (it was started in step 2 and never resolved).
if (resolveFileAReloadFetch) {
act(() => {
resolveFileAReloadFetch(new Response('', { status: 500 }));
});
await act(async () => {
await Promise.resolve();
});
}
});
// ---------------------------------------------------------------------------
// Annotation-freeze stale: Reload while Draw mode is active must update the
// visible iframe to the freshly-fetched content, not stay frozen on the
// snapshot captured at mode entry.
//
// Sequence:
// 1. Mount deck, wait for "V1-HTML" to load in the srcdoc iframe.
// 2. Click the Mark (Draw) button — drawOverlayOpen=true →
// annotationFreezeActive=true → annotationFrozenSource="V1-HTML".
// 3. Click Reload — source cleared, fetch deferred (in flight).
// 4. Resolve fetch with "V2-NEW-HTML".
// 5. Assert: while still in Draw mode the srcdoc iframe shows "V2-NEW-HTML",
// NOT "V1-HTML". On the current buggy branch previewSource is selected
// from annotationFrozenSource (line 5352 of FileViewer.tsx) whenever
// annotationFreezeActive && annotationFrozenSource !== null, so the reload
// lands in `source` / `livePreviewSource` but is never forwarded to the
// iframe because annotationFrozenSource still holds the stale V1 snapshot.
// ---------------------------------------------------------------------------
it('updates the srcdoc iframe to the reloaded content when Draw mode is active while Reload resolves', async () => {
const v1 = deckHtml('V1-HTML');
const v2 = deckHtml('V2-NEW-HTML');
// Step 1: initial render — "V1-HTML" lands in the iframe srcdoc.
vi.stubGlobal('fetch', fetchReturning(v1));
render(
<FileViewer
projectId="project-1"
projectKind="prototype"
file={deckFile()}
isDeck
/>,
);
await waitFor(() => {
expect(srcDocFrame().getAttribute('srcDoc')).toContain('V1-HTML');
});
// Step 2: enter Draw (Mark) mode — drawOverlayOpen becomes true, which
// sets annotationFreezeActive=true and captures annotationFrozenSource="V1".
act(() => {
fireEvent.click(screen.getByRole('button', { name: /^mark$/i }));
});
// Step 3: click Reload while Draw mode is active.
// source goes null; the deferred fetch is now in flight.
const { handle: reloadHandle, stub: reloadStub } = deferredFetch();
vi.stubGlobal('fetch', reloadStub);
act(() => {
fireEvent.click(screen.getByRole('button', { name: /reload preview/i }));
});
// Step 4: resolve fetch with V2 content.
await act(async () => {
reloadHandle.resolve(v2);
await Promise.resolve();
});
// Step 5: KEY ASSERTION — the active srcdoc iframe must show V2 content.
// On the buggy branch previewSource is pinned to annotationFrozenSource
// ("V1-HTML") while annotationFreezeActive is true, so the iframe never
// receives the new source even though `source` state was updated.
await waitFor(() => {
const srcdoc = srcDocFrame().getAttribute('srcDoc') ?? '';
expect(srcdoc).toContain('V2-NEW-HTML');
});
// Confirm the stale snapshot is not what the user sees.
expect(srcDocFrame().getAttribute('srcDoc') ?? '').not.toContain('V1-HTML');
});
// ---------------------------------------------------------------------------
// Race P2 (Codex P2 #5): canceled-fetch ref leak
//
// Sequence:
// 1. Mount file A ("A-V1"), wait for it to load.
// 2. Click Reload on A — prevSourceBeforeReloadRef captures {A, A-V1},
// source goes null, A-reload fetch is in flight (deferred).
// 3. Switch to file B — A's in-flight fetch is canceled (cancelled=true);
// the .then() callback returns early without touching the ref, so
// prevSourceBeforeReloadRef remains armed with the A-V1 snapshot.
// 4. Switch back to A before B's fetch resolves (B fetch also deferred) —
// B's fetch is also canceled (cancelled=true), ref still armed.
// 5. A's new fetch returns null (500) — a normal failed load, NOT a
// Reload click. On the buggy build the identity check passes
// (snap.fileName='deck-a.html' === file.name='deck-a.html') and
// setSource(snap.source) restores "A-V1" from the stale snapshot.
//
// The fix: clear prevSourceBeforeReloadRef in the [projectId, file.name]
// effect (the per-file reset that also clears sourceEverLoadedRef), so the
// ref cannot outlive a file-identity change regardless of fetch-cancel order.
// ---------------------------------------------------------------------------
it('does not restore the reload snapshot on a normal failed load after B fetch was canceled and the ref was never cleared by a callback', async () => {
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('A-V1-CONTENT');
// Step 1: mount with File A and wait for "A-V1-CONTENT" to appear.
vi.stubGlobal('fetch', fetchReturning(htmlA));
const { rerender } = render(
<FileViewer
projectId="project-1"
projectKind="prototype"
file={fileA}
isDeck
/>,
);
await waitFor(() => {
expect(srcDocFrame().getAttribute('srcDoc')).toContain('A-V1-CONTENT');
});
// Step 2: click Reload on File A.
// prevSourceBeforeReloadRef now = { source: htmlA, projectId, fileName: 'deck-a.html' }.
// source goes null; A-reload fetch deferred (in flight, never resolved).
let resolveAReloadFetch!: (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<Response>((ok) => {
resolveAReloadFetch = ok;
});
}
return new Response('', { status: 404 });
}),
);
act(() => {
fireEvent.click(screen.getByRole('button', { name: /reload preview/i }));
});
// Step 3: switch to file B while A-reload fetch is still in flight.
// B's fetch is also deferred — it will never resolve in this test.
// Switching files sets cancelled=true for A's fetch; the .then() callback
// skips all branches (returns at "if (cancelled) return") so the ref stays
// armed. The [projectId, file.name] effect runs and resets
// sourceEverLoadedRef (and lastGoodSourceForRoutingRef) — but on the buggy
// build it does NOT clear prevSourceBeforeReloadRef.
let resolveBFetch!: (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_B)) {
return new Promise<Response>((ok) => {
resolveBFetch = ok;
});
}
return new Response('', { status: 404 });
}),
);
act(() => {
rerender(
<FileViewer
projectId="project-1"
projectKind="prototype"
file={fileB}
isDeck
/>,
);
});
await act(async () => {
await Promise.resolve();
});
// Step 4: switch back to File A before B's fetch resolves.
// B's fetch is canceled (cancelled=true for B's effect cleanup).
// A's new fetch returns null (500) immediately — this is a NORMAL failed
// load, not a Reload-triggered fetch. The Reload button was NOT clicked.
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('', { status: 500 });
}
return new Response('', { status: 404 });
}),
);
act(() => {
rerender(
<FileViewer
projectId="project-1"
projectKind="prototype"
file={fileA}
isDeck
/>,
);
});
await act(async () => {
await Promise.resolve();
});
// --- KEY ASSERTION ---
// No Reload click happened for this A load. prevSourceBeforeReloadRef
// must have been invalidated when the file identity changed (A→B step 3
// or B→A step 4). A normal 500 response must NOT restore "A-V1-CONTENT".
//
// On the buggy build: the ref was left armed by the canceled-fetch path
// (B's .then() never ran), the identity check passes (A===A), and
// setSource(snap.source) wrongly surfaces "A-V1-CONTENT" in the srcdoc.
expect(screen.getByRole('status', { name: 'Loading…' })).toBeInTheDocument();
expect(screen.queryByTestId('artifact-preview-frame')).toBeNull();
expect(screen.queryByText(/A-V1-CONTENT/)).toBeNull();
// Drain deferred fetches so they don't leak into subsequent tests.
if (resolveAReloadFetch) {
act(() => {
resolveAReloadFetch(new Response('', { status: 500 }));
});
await act(async () => {
await Promise.resolve();
});
}
if (resolveBFetch) {
act(() => {
resolveBFetch(new Response('', { status: 500 }));
});
await act(async () => {
await Promise.resolve();
});
}
});
// ---------------------------------------------------------------------------
// Race P3 (Codex P2 line 7377): Manual Edit save silently fails during Reload
//
// Sequence:
// 1. Mount preview.html, wait for source V1 to load into the iframe.
// 2. Enter Manual Edit mode — manualEditFrozenSource captures V1,
// sourceRef.current = V1.
// 3. Select a target via od-edit-select postMessage (opens the panel so
// onApplyPatch is reachable).
// 4. Click Reload — setSource(null) fires. The [source] effect then sets
// sourceRef.current = null. The reload fetch is deferred (in flight).
// 5. BEFORE the fetch resolves: call panelState.props!.onApplyPatch(…).
// On the buggy build applyManualEdit returns false immediately because
// sourceRef.current == null — no file-write API call is made.
//
// Fix: either skip the setSource(null) when manualEditFrozenSource !== null,
// or keep sourceRef.current pointed at the last-good source across the
// reload window so applyManualEdit can still operate.
//
// Observable assertion: the file-write API is called, confirming the edit
// was not silently dropped.
// ---------------------------------------------------------------------------
it('saves a Manual Edit patch made during the Reload window instead of silently discarding it', async () => {
// Include localStorage to trigger htmlNeedsSandboxShim → forceInline=true
// → srcDoc render path even before Manual Edit mode is entered. This lets
// the test confirm srcdoc content in Step 1 and verify the iframe is active.
const initialSource =
'<!doctype html><html><body>' +
'<script>localStorage.setItem("k","v");</script>' +
'<h1 data-od-id="hero">Hello</h1>' +
'</body></html>';
const savedSources: string[] = [];
let resolveReloadFetch!: (resp: Response) => void;
let reloadFetchStarted = false;
// Initial fetch returns V1 immediately. Subsequent fetches (reload) are
// deferred so the test can act while source is null.
const fetchMock = vi.fn(async (input: string | URL | Request, init?: RequestInit) => {
const url =
typeof input === 'string'
? input
: input instanceof Request
? input.url
: String(input);
// File-write (save) POST — record and return success.
if (url.includes('/api/projects/project-1/files') && init?.method === 'POST') {
const payload = JSON.parse(String(init.body)) as { content: string };
savedSources.push(payload.content);
return new Response(
JSON.stringify({ file: manualEditFile() }),
{ status: 200, headers: { 'Content-Type': 'application/json' } },
);
}
// Deployments — not relevant but FileViewer fetches this on mount.
if (url.includes('/api/projects/project-1/deployments')) {
return new Response(
JSON.stringify({ deployments: [] }),
{ status: 200, headers: { 'Content-Type': 'application/json' } },
);
}
// Raw source GET — the same URL is used by three distinct callers:
// (a) Initial load effect: cacheBust=<mtime>-<reloadKey>-<filesRefreshKey>
// → resolve immediately before reloadFetchStarted.
// (b) Reload effect (after Reload click): cacheBust=<mtime>-<N>-<N>
// → deferred so the test can assert inside the reload window.
// (c) confirmManualEditHistorySource: cacheBust=<Date.now() timestamp>
// → pure 13-digit integer, no dashes; resolve immediately with
// initialSource so applyManualEdit can proceed without blocking on
// the deferred reload fetch (avoids test deadlock after revert of
// the sourceMatchesFrozen shortcut, PR #4652 8th-pass review).
if (url.includes('/api/projects/project-1/raw/preview.html')) {
if (!reloadFetchStarted) {
// (a) Initial load: resolve immediately.
return new Response(initialSource, { status: 200 });
}
// Distinguish reload-effect fetch (cacheBust contains dashes, e.g.
// "1710000000-1-0") from confirm-source fetch (cacheBust is a pure
// integer timestamp, e.g. "1750800000000").
const cacheBustParam = new URL(url, 'http://x').searchParams.get('cacheBust') ?? '';
const isConfirmSourceFetch = /^\d+$/.test(cacheBustParam);
if (isConfirmSourceFetch) {
// (c) confirmManualEditHistorySource: resolve immediately so it can
// return true and let applyManualEdit proceed to the file-write call.
return new Response(initialSource, { status: 200 });
}
// (b) Reload fetch: deferred.
return new Promise<Response>((ok) => {
resolveReloadFetch = ok;
});
}
return new Response('{}', {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
});
vi.stubGlobal('fetch', fetchMock);
// Step 1: mount and wait for source V1 to land in the preview.
render(
<FileViewer
projectId="project-1"
projectKind="prototype"
file={manualEditFile()}
/>,
);
await waitFor(() => {
const frame = screen.getByTestId('artifact-preview-frame') as HTMLIFrameElement;
expect(frame.srcdoc).toContain('Hello');
});
// Step 2: enter Manual Edit mode — manualEditFrozenSource captures V1.
act(() => {
fireEvent.click(screen.getByTestId('manual-edit-mode-toggle'));
});
// Step 3: select a target so the panel (and onApplyPatch) is available.
await selectManualEditTarget();
// Step 4: arm the deferred reload fetch, then click Reload.
// reloadHtmlPreview skips setSource(null) when manualEditFrozenSource !== null
// (PR #4652 Codex P2 fix, kept), so sourceRef.current stays non-null.
// The reload re-fetch is still triggered via reloadKey increment.
reloadFetchStarted = true;
act(() => {
fireEvent.click(screen.getByRole('button', { name: /reload preview/i }));
});
// Drain one microtask tick so React flushes the Reload render and any
// synchronous state updates from the click handler settle.
await act(async () => {
await Promise.resolve();
});
// Step 5: attempt to save a Manual Edit patch BEFORE the reload fetch
// resolves. applyManualEdit calls confirmManualEditHistorySource (unconditional
// after revert of the sourceMatchesFrozen shortcut), which fetches the
// persisted source. The confirm-source fetch is mocked to resolve immediately
// (it uses a pure-integer cacheBust from Date.now(), distinguishable from the
// reload-effect cacheBust which includes dashes), so applyManualEdit proceeds
// to the file-write call while the reload fetch is still deferred.
act(() => {
panelState.props!.onApplyPatch(
{ id: 'hero', kind: 'set-text', value: 'Updated text' },
'Content: Hero',
);
});
// --- KEY ASSERTION ---
// The file-write API must have been called. The pre-fix regression was
// applyManualEdit returning false at the sourceRef.current == null guard
// before reaching writeProjectTextFileDetailed (savedSources stays empty).
// The fix (reloadHtmlPreview skipping setSource(null) when Manual Edit is
// active) keeps sourceRef.current non-null throughout the reload window.
await waitFor(() => {
expect(savedSources).toHaveLength(1);
});
// The written content must contain the user's edit.
expect(savedSources[0]).toContain('Updated text');
// Drain the deferred reload fetch so it doesn't leak into subsequent tests.
await act(async () => {
resolveReloadFetch(new Response(initialSource, { status: 200 }));
await Promise.resolve();
});
});
});