fix(D6): clean up strategy editor timers

This commit is contained in:
Saravana Achu Mac 2026-05-04 17:50:38 -07:00
parent d36c2bae5e
commit 2089b9aa16
4 changed files with 115 additions and 11 deletions

View File

@ -32,6 +32,7 @@ describe('CodeStrategyEditor save behavior', () => {
}); });
afterEach(() => { afterEach(() => {
vi.useRealTimers();
setItemSpy.mockRestore(); setItemSpy.mockRestore();
}); });
@ -71,4 +72,18 @@ describe('CodeStrategyEditor save behavior', () => {
expect(await screen.findByText('Profile save failed')).toBeInTheDocument(); expect(await screen.findByText('Profile save failed')).toBeInTheDocument();
}); });
it('clears the saved status timeout when unmounted', async () => {
const clearTimeoutSpy = vi.spyOn(globalThis, 'clearTimeout');
const user = userEvent.setup();
const { unmount } = render(<CodeStrategyEditor symbol="NVDA" />);
await user.click(screen.getByRole('button', { name: /^save$/i }));
await waitFor(() => expect(screen.getByRole('button', { name: /saved!/i })).toBeInTheDocument());
unmount();
expect(clearTimeoutSpy).toHaveBeenCalled();
clearTimeoutSpy.mockRestore();
});
}); });

View File

@ -2,7 +2,7 @@
* Monaco-based code strategy editor. * Monaco-based code strategy editor.
* Users write a JS strategy function; "Run Backtest" posts it to /api/backtest. * Users write a JS strategy function; "Run Backtest" posts it to /api/backtest.
*/ */
import { useState } from 'react'; import { useCallback, useEffect, useRef, useState } from 'react';
import Editor from '@monaco-editor/react'; import Editor from '@monaco-editor/react';
import { Play, Save, Copy, RotateCcw } from 'lucide-react'; import { Play, Save, Copy, RotateCcw } from 'lucide-react';
import { getPlatformAccessToken } from '../../lib/authSession'; import { getPlatformAccessToken } from '../../lib/authSession';
@ -66,6 +66,23 @@ export function CodeStrategyEditor({
const [result, setResult] = useState<BacktestResult | null>(null); const [result, setResult] = useState<BacktestResult | null>(null);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [saved, setSaved] = useState(false); const [saved, setSaved] = useState(false);
const mountedRef = useRef(true);
const savedResetTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const clearSavedResetTimeout = useCallback(() => {
if (savedResetTimeoutRef.current) {
clearTimeout(savedResetTimeoutRef.current);
savedResetTimeoutRef.current = null;
}
}, []);
useEffect(() => {
mountedRef.current = true;
return () => {
mountedRef.current = false;
clearSavedResetTimeout();
};
}, [clearSavedResetTimeout]);
const handleRunBacktest = async () => { const handleRunBacktest = async () => {
setRunning(true); setRunning(true);
@ -97,11 +114,17 @@ export function CodeStrategyEditor({
throw new Error(data?.error ?? `Backtest failed (${res.status})`); throw new Error(data?.error ?? `Backtest failed (${res.status})`);
} }
// Backend may wrap results in { success, results } or return them flat. // Backend may wrap results in { success, results } or return them flat.
setResult(data?.results ?? data); if (mountedRef.current) {
setResult(data?.results ?? data);
}
} catch (err: any) { } catch (err: any) {
setError(err?.message ?? 'Backtest failed'); if (mountedRef.current) {
setError(err?.message ?? 'Backtest failed');
}
} finally { } finally {
setRunning(false); if (mountedRef.current) {
setRunning(false);
}
} }
}; };
@ -122,12 +145,24 @@ export function CodeStrategyEditor({
code, code,
}, },
}); });
if (!mountedRef.current) return;
setSaved(true); setSaved(true);
clearSavedResetTimeout();
savedResetTimeoutRef.current = setTimeout(() => {
if (mountedRef.current) {
setSaved(false);
}
savedResetTimeoutRef.current = null;
}, 3000);
} catch (err: any) { } catch (err: any) {
setSaved(false); if (mountedRef.current) {
setError(err?.message ?? 'Failed to save strategy'); setSaved(false);
setError(err?.message ?? 'Failed to save strategy');
}
} finally { } finally {
setSaving(false); if (mountedRef.current) {
setSaving(false);
}
} }
}; };

View File

@ -0,0 +1,26 @@
// @vitest-environment jsdom
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { VisualRuleBuilder } from './VisualRuleBuilder';
describe('VisualRuleBuilder save behavior', () => {
afterEach(() => {
vi.useRealTimers();
});
it('clears the saved message timeout when unmounted', async () => {
const clearTimeoutSpy = vi.spyOn(globalThis, 'clearTimeout');
const user = userEvent.setup();
const onSave = vi.fn().mockResolvedValue(undefined);
const { unmount } = render(<VisualRuleBuilder symbol="AAPL" onSave={onSave} />);
await user.click(screen.getByRole('button', { name: /save strategy/i }));
await waitFor(() => expect(screen.getByText('Strategy saved!')).toBeInTheDocument());
unmount();
expect(clearTimeoutSpy).toHaveBeenCalled();
clearTimeoutSpy.mockRestore();
});
});

View File

@ -2,7 +2,7 @@
* Visual drag-and-drop strategy rule builder using @dnd-kit. * Visual drag-and-drop strategy rule builder using @dnd-kit.
* Lets users compose IF/THEN trading rules without writing code. * Lets users compose IF/THEN trading rules without writing code.
*/ */
import { useState, useCallback } from 'react'; import { useState, useCallback, useEffect, useRef } from 'react';
import { import {
DndContext, DndContext,
closestCenter, closestCenter,
@ -209,6 +209,23 @@ export function VisualRuleBuilder({ symbol, onSave, onBacktest }: Props) {
const [name, setName] = useState('My Strategy'); const [name, setName] = useState('My Strategy');
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [savedMsg, setSavedMsg] = useState(''); const [savedMsg, setSavedMsg] = useState('');
const mountedRef = useRef(true);
const savedMessageTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const clearSavedMessageTimeout = useCallback(() => {
if (savedMessageTimeoutRef.current) {
clearTimeout(savedMessageTimeoutRef.current);
savedMessageTimeoutRef.current = null;
}
}, []);
useEffect(() => {
mountedRef.current = true;
return () => {
mountedRef.current = false;
clearSavedMessageTimeout();
};
}, [clearSavedMessageTimeout]);
const sensors = useSensors(useSensor(PointerSensor, { const sensors = useSensors(useSensor(PointerSensor, {
activationConstraint: { distance: 5 }, activationConstraint: { distance: 5 },
@ -238,12 +255,23 @@ export function VisualRuleBuilder({ symbol, onSave, onBacktest }: Props) {
setSaving(true); setSaving(true);
try { try {
await onSave(name.trim(), rules); await onSave(name.trim(), rules);
if (!mountedRef.current) return;
setSavedMsg('Strategy saved!'); setSavedMsg('Strategy saved!');
setTimeout(() => setSavedMsg(''), 3000); clearSavedMessageTimeout();
savedMessageTimeoutRef.current = setTimeout(() => {
if (mountedRef.current) {
setSavedMsg('');
}
savedMessageTimeoutRef.current = null;
}, 3000);
} catch { } catch {
setSavedMsg('Save failed — try again'); if (mountedRef.current) {
setSavedMsg('Save failed — try again');
}
} finally { } finally {
setSaving(false); if (mountedRef.current) {
setSaving(false);
}
} }
}; };