@@ -202,13 +229,38 @@ function ScenariosContent() {
}
return (
-
+ <>
+
+ {!!refreshError && (
+
+ {refreshError}
+
+
+
+ )}
+ >
);
}
diff --git a/apps/studio/src/components/svm/scenario-editor.tsx b/apps/studio/src/components/svm/scenario-editor.tsx
index 58548ad..1a6c5e4 100644
--- a/apps/studio/src/components/svm/scenario-editor.tsx
+++ b/apps/studio/src/components/svm/scenario-editor.tsx
@@ -354,6 +354,8 @@ export default function ScenarioEditor({
console.error('Failed to sync scenario with backend:', response.status, errorText);
} else {
logger.log('Scenario synced with backend successfully');
+ // Fire after the PATCH resolves so a refetch reads the saved state.
+ window.dispatchEvent(new Event('scenarioUpdated'));
}
} catch (error) {
console.error('Error syncing scenario with backend:', error);
@@ -365,7 +367,6 @@ export default function ScenarioEditor({
};
syncWithBackend();
- window.dispatchEvent(new Event('scenarioUpdated'));
} else {
isFirstSlotsChangeRef.current = false;
}
diff --git a/apps/studio/src/components/svm/scenarios-bento.test.tsx b/apps/studio/src/components/svm/scenarios-bento.test.tsx
index dbafa41..b33298b 100644
--- a/apps/studio/src/components/svm/scenarios-bento.test.tsx
+++ b/apps/studio/src/components/svm/scenarios-bento.test.tsx
@@ -1,14 +1,22 @@
import { renderWithConfig } from '@/test-utils';
-import { screen } from '@testing-library/react';
+import { act, fireEvent, screen, waitFor } from '@testing-library/react';
import type { ReactNode } from 'react';
-import { describe, expect, it, vi } from 'vitest';
-import type { ScenarioBentoItem } from './scenarios-bento.types';
+import { afterEach, describe, expect, it, vi } from 'vitest';
import ScenariosBento from './scenarios-bento';
+import type { ScenarioBentoItem } from './scenarios-bento.types';
const editorMounts = vi.hoisted(function createEditorMountSpy() {
return vi.fn();
});
+const toastError = vi.hoisted(function createToastErrorSpy() {
+ return vi.fn();
+});
+
+vi.mock('sonner', function mockSonner() {
+ return { toast: { error: toastError } };
+});
+
vi.mock('next/dynamic', async function mockNextDynamic() {
const React = await import('react');
@@ -74,16 +82,28 @@ vi.mock('./generic-bento', function mockGenericBentoModule() {
items: ScenarioBentoItem[];
initialSelectedId?: string;
initialTab?: string;
+ renderDetailHeader: (item: ScenarioBentoItem) => ReactNode;
renderDetailContent: (item: ScenarioBentoItem, activeTab: string) => ReactNode;
}
- function GenericBentoMock({ items, initialSelectedId, initialTab, renderDetailContent }: GenericBentoMockProps) {
+ function GenericBentoMock({
+ items,
+ initialSelectedId,
+ initialTab,
+ renderDetailHeader,
+ renderDetailContent,
+ }: GenericBentoMockProps) {
function isSelectedItem(item: ScenarioBentoItem) {
return item.id === initialSelectedId;
}
const selectedItem = items.find(isSelectedItem);
- return selectedItem ? renderDetailContent(selectedItem, initialTab ?? 'editor') : null;
+ return selectedItem ? (
+ <>
+ {renderDetailHeader(selectedItem)}
+ {renderDetailContent(selectedItem, initialTab ?? 'editor')}
+ >
+ ) : null;
}
return { default: GenericBentoMock };
@@ -104,6 +124,20 @@ const scenarios = [
},
];
+function createPendingResponse() {
+ let resolve!: (response: Response) => void;
+ const promise = new Promise
(function captureResolve(promiseResolve) {
+ resolve = promiseResolve;
+ });
+
+ return { promise, resolve };
+}
+
+afterEach(function restoreGlobals() {
+ vi.unstubAllGlobals();
+ toastError.mockClear();
+});
+
describe('ScenariosBento', function scenariosBentoTests() {
it('remounts the scenario editor when the selected scenario changes', function remountsEditorOnScenarioChange() {
const { rerender } = renderWithConfig(
@@ -119,4 +153,57 @@ describe('ScenariosBento', function scenariosBentoTests() {
expect(editorMounts).toHaveBeenNthCalledWith(1, 'scenario-a');
expect(editorMounts).toHaveBeenNthCalledWith(2, 'scenario-b');
});
+
+ it('serializes overlapping edits and keeps failed changes out of the UI', async function serializesFailedEdits() {
+ const firstResponse = createPendingResponse();
+ const secondResponse = createPendingResponse();
+ const fetchMock = vi.fn().mockReturnValueOnce(firstResponse.promise).mockReturnValueOnce(secondResponse.promise);
+ vi.stubGlobal('fetch', fetchMock);
+
+ renderWithConfig();
+
+ fireEvent.click(screen.getByText('Scenario A'));
+ fireEvent.change(screen.getByRole('textbox'), { target: { value: 'Failed name' } });
+ fireEvent.keyDown(screen.getByRole('textbox'), { key: 'Enter' });
+
+ fireEvent.click(screen.getByText('First scenario'));
+ fireEvent.change(screen.getByRole('textbox'), { target: { value: 'Failed description' } });
+ fireEvent.keyDown(screen.getByRole('textbox'), { key: 'Enter' });
+
+ expect(screen.getByText('Failed name')).toBeInTheDocument();
+ expect(screen.getByText('Failed description')).toBeInTheDocument();
+
+ await waitFor(function waitsForFirstUpdate() {
+ expect(fetchMock).toHaveBeenCalledTimes(1);
+ });
+
+ await act(async function failFirstUpdate() {
+ firstResponse.resolve({ ok: false, status: 500 } as Response);
+ await firstResponse.promise;
+ });
+
+ await waitFor(function waitsForSecondUpdate() {
+ expect(fetchMock).toHaveBeenCalledTimes(2);
+ });
+
+ const secondRequest = fetchMock.mock.calls[1]?.[1] as RequestInit;
+ const secondPayload = JSON.parse(secondRequest.body as string);
+ expect(secondPayload.name).toBe('Scenario A');
+ expect(secondPayload.description).toBe('Failed description');
+
+ await act(async function failSecondUpdate() {
+ secondResponse.resolve({ ok: false, status: 500 } as Response);
+ await secondResponse.promise;
+ });
+
+ expect(screen.getByText('Scenario A')).toBeInTheDocument();
+ expect(screen.getByText('First scenario')).toBeInTheDocument();
+ expect(screen.queryByText('Failed name')).not.toBeInTheDocument();
+ expect(screen.queryByText('Failed description')).not.toBeInTheDocument();
+ expect(toastError).toHaveBeenCalledTimes(2);
+ expect(toastError).toHaveBeenLastCalledWith(
+ "Couldn't save the scenario changes.",
+ expect.objectContaining({ action: expect.objectContaining({ label: 'Retry' }) })
+ );
+ });
});
diff --git a/apps/studio/src/components/svm/scenarios-bento.tsx b/apps/studio/src/components/svm/scenarios-bento.tsx
index 47c8461..7400de5 100644
--- a/apps/studio/src/components/svm/scenarios-bento.tsx
+++ b/apps/studio/src/components/svm/scenarios-bento.tsx
@@ -9,6 +9,7 @@ import {
serializeScenarioJson,
} from '@/lib/scenarios-api';
import type { Scenario } from '@/lib/scenarios-data';
+import { reinsertScenario } from '@/lib/scenarios-list-ops';
import { PencilIcon, PlusIcon, SparklesIcon, TrashIcon } from '@heroicons/react/24/solid';
import { logger } from '@surfpool/shared';
import {
@@ -25,6 +26,7 @@ import {
import dynamic from 'next/dynamic';
import { useRouter } from 'next/navigation';
import { useEffect, useMemo, useRef, useState } from 'react';
+import { toast } from 'sonner';
import AIHeader from './ai-header';
import DraftField from './draft-field';
import GenericBento from './generic-bento';
@@ -56,6 +58,12 @@ export default function ScenariosBento({
const [isDetailPaneOpen, setIsDetailPaneOpen] = useState(false);
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [scenarioToDelete, setScenarioToDelete] = useState<{ id: string; onClose?: () => void } | null>(null);
+ const [importError, setImportError] = useState(null);
+
+ // REFS
+ const importInputRef = useRef(null);
+ const scenariosRef = useRef(scenarios);
+ const scenarioUpdateQueuesRef = useRef(new Map>());
// Sync scenarios when initialScenarios changes
useEffect(() => {
@@ -68,6 +76,11 @@ export default function ScenariosBento({
setScenarios(initialScenarios);
}, [initialScenarios]);
+ // Keep imperative retry reads synchronized with the rendered list.
+ useEffect(() => {
+ scenariosRef.current = scenarios;
+ }, [scenarios]);
+
// Notify parent when detail pane state changes
useEffect(() => {
onDetailPaneChange?.(isDetailPaneOpen);
@@ -88,9 +101,6 @@ export default function ScenariosBento({
};
// Import scenario from a downloaded file
- const importInputRef = useRef(null);
- const [importError, setImportError] = useState(null);
-
const handleImportScenario = async (file: File) => {
setImportError(null);
const result = scenarioImportPayload(await file.text(), crypto.randomUUID());
@@ -155,41 +165,79 @@ export default function ScenariosBento({
};
// Update scenario
- const handleUpdateScenario = async (id: string, updates: Partial) => {
- const scenario = scenarios.find((s) => s.id === id);
+ const handleUpdateScenario = (id: string, updates: Partial) => {
+ const scenario = scenariosRef.current.find((item) => item.id === id);
if (!scenario) return;
- const updatedScenario = { ...scenario, ...updates, updated_at: new Date().toISOString() };
+ async function persistQueuedUpdate(previousScenario: Scenario | undefined) {
+ if (!previousScenario) return undefined;
- // Optimistic update
- setScenarios(scenarios.map((s) => (s.id === id ? updatedScenario : s)));
+ const updatedScenario = { ...previousScenario, ...updates, updated_at: new Date().toISOString() };
- try {
- const response = await fetch(`${studioUrl}/v1/scenarios/${id}`, {
- method: 'PATCH',
- headers: { 'Content-Type': 'application/json' },
- body: serializeScenarioJson(buildUpdatePayload(updatedScenario)),
- });
+ try {
+ const response = await fetch(`${studioUrl}/v1/scenarios/${id}`, {
+ method: 'PATCH',
+ headers: { 'Content-Type': 'application/json' },
+ body: serializeScenarioJson(buildUpdatePayload(updatedScenario)),
+ });
- if (!response.ok) {
- throw new Error(`Failed to update scenario: ${response.status}`);
+ if (!response.ok) {
+ throw new Error(`Failed to update scenario: ${response.status}`);
+ }
+
+ logger.log('Scenario updated successfully:', id);
+
+ if (!isDetailPaneOpen && onRefresh) {
+ onRefresh();
+ }
+
+ return updatedScenario;
+ } catch (error) {
+ console.error('Error updating scenario:', error);
+
+ function retryUpdate() {
+ handleUpdateScenario(id, updates);
+ }
+
+ toast.error("Couldn't save the scenario changes.", {
+ action: { label: 'Retry', onClick: retryUpdate },
+ });
+ return previousScenario;
}
+ }
- logger.log('Scenario updated successfully:', id);
+ const previousUpdate = scenarioUpdateQueuesRef.current.get(id) ?? Promise.resolve(scenario);
+ const queuedUpdate = previousUpdate.then(persistQueuedUpdate);
- if (!isDetailPaneOpen && onRefresh) {
- onRefresh();
+ function reconcileCompletedUpdate(persistedScenario: Scenario | undefined) {
+ if (scenarioUpdateQueuesRef.current.get(id) === queuedUpdate) {
+ scenarioUpdateQueuesRef.current.delete(id);
+ if (persistedScenario) {
+ setScenarios((current) => current.map((item) => (item.id === id ? persistedScenario : item)));
+ }
}
- } catch (error) {
- console.error('Error updating scenario:', error);
- // Revert optimistic update
- setScenarios(scenarios.map((s) => (s.id === id ? scenario : s)));
}
+
+ setScenarios((current) =>
+ current.map((item) => (item.id === id ? { ...item, ...updates, updated_at: new Date().toISOString() } : item))
+ );
+ scenarioUpdateQueuesRef.current.set(id, queuedUpdate);
+ void queuedUpdate.then(reconcileCompletedUpdate);
};
// Delete scenario
const handleDeleteScenario = async (id: string) => {
+ const index = scenarios.findIndex((s) => s.id === id);
+ const deleted = scenarios[index];
+ if (!deleted) return;
+
+ // Drop the card immediately so it doesn't linger until the background
+ // refetch resolves; restore it if the delete fails.
+ setScenarios((prev) => prev.filter((s) => s.id !== id));
+ let scenarioToRestore = deleted;
+
try {
+ scenarioToRestore = (await scenarioUpdateQueuesRef.current.get(id)) ?? deleted;
const response = await fetch(`${studioUrl}/v1/scenarios/${id}`, {
method: 'DELETE',
});
@@ -202,6 +250,8 @@ export default function ScenariosBento({
onRefresh?.();
} catch (error) {
console.error('Error deleting scenario:', error);
+ // Restore against the current list without overwriting a newer same-ID item.
+ setScenarios((prev) => reinsertScenario(prev, scenarioToRestore, index));
}
};
diff --git a/apps/studio/src/lib/scenarios-list-ops.test.ts b/apps/studio/src/lib/scenarios-list-ops.test.ts
new file mode 100644
index 0000000..9dd7c54
--- /dev/null
+++ b/apps/studio/src/lib/scenarios-list-ops.test.ts
@@ -0,0 +1,49 @@
+import type { Scenario } from '@/lib/scenarios-data';
+import { reinsertScenario } from '@/lib/scenarios-list-ops';
+import { describe, expect, it } from 'vitest';
+
+const s = (id: string): Scenario => ({ id, name: id });
+
+describe('reinsertScenario', () => {
+ it('restores the item at its original index when nothing else changed', () => {
+ const a = s('a');
+ const result = reinsertScenario([s('b')], a, 0);
+ expect(result.map((x) => x.id)).toEqual(['a', 'b']);
+ });
+
+ it('preserves a concurrent create that landed while the delete was in flight', () => {
+ // Deleted A from [A, B]; C was created concurrently, so current is [B, C].
+ const a = s('a');
+ const result = reinsertScenario([s('b'), s('c')], a, 0);
+ expect(result.map((x) => x.id)).toEqual(['a', 'b', 'c']);
+ });
+
+ it('does not resurrect a scenario removed concurrently', () => {
+ // Deleted A from [A, B]; B was also deleted concurrently, so current is [C].
+ const a = s('a');
+ const result = reinsertScenario([s('c')], a, 0);
+ expect(result.map((x) => x.id)).toEqual(['a', 'c']);
+ expect(result.some((x) => x.id === 'b')).toBe(false);
+ });
+
+ it('is a no-op if the item is already present', () => {
+ const a = s('a');
+ const current = [a, s('b')];
+ expect(reinsertScenario(current, a, 0)).toBe(current);
+ });
+
+ it('preserves a newer version of the item after a failed delete', () => {
+ const deleted = { ...s('a'), name: 'stale' };
+ const current = [{ ...s('a'), name: 'updated' }, s('b')];
+
+ expect(reinsertScenario(current, deleted, 0)).toBe(current);
+ expect(current[0].name).toBe('updated');
+ });
+
+ it('clamps the index when the current list is shorter than the original', () => {
+ const a = s('a');
+ // Original index 3, but concurrent deletes shrank the list to one item.
+ const result = reinsertScenario([s('c')], a, 3);
+ expect(result.map((x) => x.id)).toEqual(['c', 'a']);
+ });
+});
diff --git a/apps/studio/src/lib/scenarios-list-ops.ts b/apps/studio/src/lib/scenarios-list-ops.ts
new file mode 100644
index 0000000..b08b826
--- /dev/null
+++ b/apps/studio/src/lib/scenarios-list-ops.ts
@@ -0,0 +1,13 @@
+import type { Scenario } from '@/lib/scenarios-data';
+
+// Reinsert a scenario that was optimistically removed back into the *current*
+// list. The list may have changed while the delete request was in flight (a
+// create or another delete landed), so we operate on the current array rather
+// than a stale snapshot: concurrent additions survive and a concurrently
+// removed item is not resurrected. The item is placed back at its original
+// index, clamped to the current length.
+export function reinsertScenario(current: Scenario[], scenario: Scenario, index: number): Scenario[] {
+ if (current.some((s) => s.id === scenario.id)) return current;
+ const at = Math.max(0, Math.min(index, current.length));
+ return [...current.slice(0, at), scenario, ...current.slice(at)];
+}