fix: initial global spinner randomly hanging until 10s timeout

This commit is contained in:
Xiaohan-Tian
2026-05-14 19:33:13 -07:00
parent c7a1630862
commit 6460d4ffa0
2 changed files with 55 additions and 12 deletions
+43 -4
View File
@@ -2,6 +2,8 @@ import { act, render, screen } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
let mockState = { isPreparingPlayback: false }; let mockState = { isPreparingPlayback: false };
let mockActiveLoadCount = 0;
let loadingListener: ((evt: { type: 'start' | 'end'; instrument: string }) => void) | null = null;
vi.mock('./stores/projectStore', () => ({ vi.mock('./stores/projectStore', () => ({
useProjectStore: (selector?: (state: typeof mockState) => unknown) => ( useProjectStore: (selector?: (state: typeof mockState) => unknown) => (
@@ -21,12 +23,21 @@ vi.mock('./components/ChatBox', () => ({ default: () => null }));
vi.mock('./components/KGOnePanel', () => ({ default: () => null })); vi.mock('./components/KGOnePanel', () => ({ default: () => null }));
vi.mock('./components/EventListPanel', () => ({ default: () => null })); vi.mock('./components/EventListPanel', () => ({ default: () => null }));
vi.mock('./components/settings', () => ({ SettingsPanel: () => null })); vi.mock('./components/settings', () => ({ SettingsPanel: () => null }));
vi.mock('./util/dialogUtil', () => ({
showAlert: vi.fn().mockResolvedValue(undefined),
}));
vi.mock('./core/audio-interface/KGToneBuffersPool', () => ({ vi.mock('./core/audio-interface/KGToneBuffersPool', () => ({
KGToneBuffersPool: { KGToneBuffersPool: {
instance: () => ({ instance: () => ({
getActiveLoadCount: () => 0, getActiveLoadCount: () => mockActiveLoadCount,
addLoadingListener: () => undefined, addLoadingListener: (listener: (evt: { type: 'start' | 'end'; instrument: string }) => void) => {
removeLoadingListener: () => undefined, loadingListener = listener;
},
removeLoadingListener: (listener: (evt: { type: 'start' | 'end'; instrument: string }) => void) => {
if (loadingListener === listener) {
loadingListener = null;
}
},
}), }),
}, },
})); }));
@@ -47,12 +58,14 @@ vi.mock('./core/KGCore', () => ({
}, },
})); }));
import { PlaybackPreparationOverlayContainer } from './App'; import { GlobalLoadingOverlayContainer, PlaybackPreparationOverlayContainer } from './App';
describe('PlaybackPreparationOverlayContainer', () => { describe('PlaybackPreparationOverlayContainer', () => {
beforeEach(() => { beforeEach(() => {
vi.useFakeTimers(); vi.useFakeTimers();
mockState = { isPreparingPlayback: false }; mockState = { isPreparingPlayback: false };
mockActiveLoadCount = 0;
loadingListener = null;
}); });
afterEach(() => { afterEach(() => {
@@ -112,3 +125,29 @@ describe('PlaybackPreparationOverlayContainer', () => {
expect(screen.queryByText('Preparing playback...')).not.toBeInTheDocument(); expect(screen.queryByText('Preparing playback...')).not.toBeInTheDocument();
}); });
}); });
describe('GlobalLoadingOverlayContainer', () => {
beforeEach(() => {
vi.useFakeTimers();
mockActiveLoadCount = 0;
loadingListener = null;
});
afterEach(() => {
vi.useRealTimers();
});
it('syncs to the pool active count instead of relying on incremental listener math', () => {
mockActiveLoadCount = 2;
render(<GlobalLoadingOverlayContainer />);
expect(screen.getByText('Loading ... (2)')).toBeInTheDocument();
mockActiveLoadCount = 0;
act(() => {
loadingListener?.({ type: 'end', instrument: 'woodblock' });
});
expect(screen.queryByText(/Loading \.\.\./)).not.toBeInTheDocument();
});
});
+12 -8
View File
@@ -195,19 +195,20 @@ function App() {
export default App; export default App;
// Local component to subscribe to pool events and manage a counter // Local component to subscribe to pool events and manage a counter
const GlobalLoadingOverlayContainer: React.FC = () => { export const GlobalLoadingOverlayContainer: React.FC = () => {
const [loadingCount, setLoadingCount] = useState<number>(() => KGToneBuffersPool.instance().getActiveLoadCount()); const [loadingCount, setLoadingCount] = useState<number>(() => KGToneBuffersPool.instance().getActiveLoadCount());
const [overdue, setOverdue] = useState<boolean>(false); const [overdue, setOverdue] = useState<boolean>(false);
const timeoutRef = useRef<number | null>(null); const timeoutRef = useRef<number | null>(null);
useEffectReact(() => { useEffectReact(() => {
const pool = KGToneBuffersPool.instance(); const pool = KGToneBuffersPool.instance();
const listener = (evt: { type: 'start' | 'end'; instrument: string }) => { const syncLoadingCount = () => {
setLoadingCount(prev => { setLoadingCount(pool.getActiveLoadCount());
if (evt.type === 'start') return prev + 1;
return Math.max(0, prev - 1);
});
}; };
const listener = (_evt: { type: 'start' | 'end'; instrument: string }) => {
syncLoadingCount();
};
syncLoadingCount();
pool.addLoadingListener(listener); pool.addLoadingListener(listener);
return () => { return () => {
pool.removeLoadingListener(listener); pool.removeLoadingListener(listener);
@@ -219,8 +220,11 @@ const GlobalLoadingOverlayContainer: React.FC = () => {
// When loading starts, start a 30s timer if not already overdue/timed // When loading starts, start a 30s timer if not already overdue/timed
if (loadingCount > 0 && !overdue && timeoutRef.current === null) { if (loadingCount > 0 && !overdue && timeoutRef.current === null) {
timeoutRef.current = window.setTimeout(async () => { timeoutRef.current = window.setTimeout(async () => {
// Only trigger if still loading const activeLoadCount = KGToneBuffersPool.instance().getActiveLoadCount();
if (loadingCount > 0) { setLoadingCount(activeLoadCount);
// Only trigger if still loading according to the source of truth
if (activeLoadCount > 0) {
setOverdue(true); setOverdue(true);
await showAlert( await showAlert(
'Loading resources is taking longer than expected and may have partially failed. If you notice any playback issues, please refresh the page to retry downloading the audio files.' 'Loading resources is taking longer than expected and may have partially failed. If you notice any playback issues, please refresh the page to retry downloading the audio files.'