fix(D6): clean up strategy editor timers
This commit is contained in:
parent
d36c2bae5e
commit
2089b9aa16
@ -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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
26
web/src/components/strategy/VisualRuleBuilder.dom.test.tsx
Normal file
26
web/src/components/strategy/VisualRuleBuilder.dom.test.tsx
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user