From 40cd53d699098c82fec3bffefd1b3147bcbd35e7 Mon Sep 17 00:00:00 2001 From: Jay Kim <76601570+yeonkim1213@users.noreply.github.com> Date: Wed, 17 Jun 2026 21:00:43 -0600 Subject: [PATCH 1/8] Remove storage disconnected warning continue study button --- src/components/interface/AlertModal.tsx | 4 +++- src/components/interface/tests/AlertModal.spec.tsx | 1 + 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/src/components/interface/AlertModal.tsx b/src/components/interface/AlertModal.tsx index 5a1dc06f22..f09bc8086a 100644 --- a/src/components/interface/AlertModal.tsx +++ b/src/components/interface/AlertModal.tsx @@ -101,7 +101,9 @@ export function AlertModal() { )} - + diff --git a/src/components/interface/tests/AlertModal.spec.tsx b/src/components/interface/tests/AlertModal.spec.tsx index 3a2c370575..180a2f8062 100644 --- a/src/components/interface/tests/AlertModal.spec.tsx +++ b/src/components/interface/tests/AlertModal.spec.tsx @@ -91,6 +91,7 @@ describe('AlertModal', () => { expect(html).toContain('mailto:test@test.com'); expect(html).toContain('Study ID: test-study'); expect(html).toContain('Participant ID: p1'); + expect(html).toContain('Reconnect'); }); test('does not show diagnostics for regular (non-storage) alert', () => { From d24ea32a98ce1e137b6cf65e6169c04c5b62a47e Mon Sep 17 00:00:00 2001 From: Jay Kim <76601570+yeonkim1213@users.noreply.github.com> Date: Thu, 18 Jun 2026 04:34:03 -0600 Subject: [PATCH 2/8] Add reconnect logic when server connection is lost --- src/components/interface/AlertModal.tsx | 16 ++++-- .../interface/tests/AlertModal.spec.tsx | 49 +++++++++++++++++-- 2 files changed, 59 insertions(+), 6 deletions(-) diff --git a/src/components/interface/AlertModal.tsx b/src/components/interface/AlertModal.tsx index f09bc8086a..70ce8b583f 100644 --- a/src/components/interface/AlertModal.tsx +++ b/src/components/interface/AlertModal.tsx @@ -24,6 +24,16 @@ export function AlertModal() { useEffect(() => setOpened(alertModal.show), [alertModal.show]); const isStorageEngineAlert = alertModal.title === 'Failed to connect to the storage engine'; + const handleClose = useCallback(() => { + if (isStorageEngineAlert) { + return; + } + close(); + }, [close, isStorageEngineAlert]); + + const handleReconnect = useCallback(() => { + window.location.reload(); + }, []); const diagnosticsMessage = useMemo(() => { if (!opened || !isStorageEngineAlert) { @@ -55,14 +65,14 @@ export function AlertModal() { centered size={isStorageEngineAlert ? '70%' : 'lg'} withCloseButton={false} - onClose={close} + onClose={handleClose} > } - onClose={close} + onClose={isStorageEngineAlert ? undefined : close} styles={{ root: { backgroundColor: 'unset' } }} > @@ -101,7 +111,7 @@ export function AlertModal() { )} - diff --git a/src/components/interface/tests/AlertModal.spec.tsx b/src/components/interface/tests/AlertModal.spec.tsx index 180a2f8062..db03d5993e 100644 --- a/src/components/interface/tests/AlertModal.spec.tsx +++ b/src/components/interface/tests/AlertModal.spec.tsx @@ -1,13 +1,18 @@ import { ReactNode } from 'react'; +import { + cleanup, fireEvent, render, screen, +} from '@testing-library/react'; import { renderToStaticMarkup } from 'react-dom/server'; import { - beforeEach, describe, expect, test, vi, + afterEach, beforeEach, describe, expect, test, vi, } from 'vitest'; import { AlertModal } from '../AlertModal'; // ── mutable state ───────────────────────────────────────────────────────────── let mockAlertModal = { show: false, title: '', message: '' }; +let mockSetAlertModal = vi.fn(); +let mockStoreDispatch = vi.fn(); // ── mocks ───────────────────────────────────────────────────────────────────── @@ -24,8 +29,8 @@ vi.mock('../../../store/store', () => ({ language: 'en', }, }), - useStoreActions: () => ({ setAlertModal: vi.fn() }), - useStoreDispatch: () => vi.fn(), + useStoreActions: () => ({ setAlertModal: mockSetAlertModal }), + useStoreDispatch: () => mockStoreDispatch, })); vi.mock('@mantine/core', () => ({ @@ -67,6 +72,13 @@ vi.mock('@tabler/icons-react', () => ({ describe('AlertModal', () => { beforeEach(() => { mockAlertModal = { show: false, title: '', message: '' }; + mockSetAlertModal = vi.fn((payload) => payload); + mockStoreDispatch = vi.fn(); + }); + + afterEach(() => { + cleanup(); + vi.clearAllMocks(); }); test('renders nothing when alertModal.show is false', () => { @@ -106,4 +118,35 @@ describe('AlertModal', () => { const html = renderToStaticMarkup(); expect(html).toContain('Continue Study'); }); + + test('reconnect reloads the page without closing storage engine alert', () => { + const reloadSpy = vi.fn(); + const originalLocation = window.location; + Object.defineProperty(window, 'location', { + configurable: true, + value: { + href: 'https://example.com/study', + reload: reloadSpy, + }, + }); + mockAlertModal = { + show: true, + title: 'Failed to connect to the storage engine', + message: 'Connection refused', + }; + + try { + render(); + fireEvent.click(screen.getByRole('button', { name: 'Reconnect' })); + + expect(reloadSpy).toHaveBeenCalledTimes(1); + expect(mockSetAlertModal).not.toHaveBeenCalled(); + expect(mockStoreDispatch).not.toHaveBeenCalled(); + } finally { + Object.defineProperty(window, 'location', { + configurable: true, + value: originalLocation, + }); + } + }); }); From a765028af36752de433e0ebcf9cb81f7a97ddf55 Mon Sep 17 00:00:00 2001 From: Jay Kim <76601570+yeonkim1213@users.noreply.github.com> Date: Tue, 30 Jun 2026 19:28:27 -0600 Subject: [PATCH 3/8] Remove localStorage fallback when cloud storage and production mode are enabled. --- src/storage/initialize.ts | 16 ++++++++++++++-- src/storage/tests/initialize.spec.ts | 20 ++++++++++++++++++++ 2 files changed, 34 insertions(+), 2 deletions(-) diff --git a/src/storage/initialize.ts b/src/storage/initialize.ts index f692ccd417..7f2b5d0a44 100644 --- a/src/storage/initialize.ts +++ b/src/storage/initialize.ts @@ -3,6 +3,14 @@ import { FirebaseStorageEngine } from './engines/FirebaseStorageEngine'; import { SupabaseStorageEngine } from './engines/SupabaseStorageEngine'; import { StorageEngine } from './engines/types'; +async function connectStorageEngine(storageEngine: StorageEngine, storageEngineName: string) { + try { + await storageEngine.connect(); + } catch (error) { + console.warn(`Failed to connect to ${storageEngineName} storage engine`, error); + } +} + export async function initializeStorageEngine() { let storageEngine: StorageEngine | undefined; let fallback = false; @@ -11,10 +19,12 @@ export async function initializeStorageEngine() { if (storageEngineName === 'supabase') { const supabaseStorageEngine = new SupabaseStorageEngine(); - await supabaseStorageEngine.connect(); + await connectStorageEngine(supabaseStorageEngine, storageEngineName); if (supabaseStorageEngine.isConnected()) { storageEngine = supabaseStorageEngine; + } else if (import.meta.env.PROD) { + storageEngine = supabaseStorageEngine; } else { fallback = true; } @@ -22,10 +32,12 @@ export async function initializeStorageEngine() { if (storageEngineName === 'firebase') { const firebaseStorageEngine = new FirebaseStorageEngine(); - await firebaseStorageEngine.connect(); + await connectStorageEngine(firebaseStorageEngine, storageEngineName); if (firebaseStorageEngine.isConnected()) { storageEngine = firebaseStorageEngine; + } else if (import.meta.env.PROD) { + storageEngine = firebaseStorageEngine; } else { fallback = true; } diff --git a/src/storage/tests/initialize.spec.ts b/src/storage/tests/initialize.spec.ts index 41b8cc0e55..77d4454c40 100644 --- a/src/storage/tests/initialize.spec.ts +++ b/src/storage/tests/initialize.spec.ts @@ -81,6 +81,16 @@ describe('initializeStorageEngine', () => { expect(mocks.MockLocal).toHaveBeenCalledOnce(); }); + test('does not fall back to LocalStorageEngine in production when supabase fails to connect', async () => { + vi.stubEnv('PROD', true); + vi.stubEnv('VITE_STORAGE_ENGINE', 'supabase'); + mocks.mockSupabaseIsConnected.mockReturnValue(false); + const storageEngine = await initializeStorageEngine(); + expect(storageEngine).toBeInstanceOf(mocks.MockSupabase); + expect(mocks.MockSupabase).toHaveBeenCalledOnce(); + expect(mocks.MockLocal).not.toHaveBeenCalled(); + }); + test('creates FirebaseStorageEngine and connects when env is firebase', async () => { vi.stubEnv('VITE_STORAGE_ENGINE', 'firebase'); await initializeStorageEngine(); @@ -98,6 +108,16 @@ describe('initializeStorageEngine', () => { expect(mocks.MockLocal).toHaveBeenCalledOnce(); }); + test('does not fall back to LocalStorageEngine in production when firebase fails to connect', async () => { + vi.stubEnv('PROD', true); + vi.stubEnv('VITE_STORAGE_ENGINE', 'firebase'); + mocks.mockFirebaseIsConnected.mockReturnValue(false); + const storageEngine = await initializeStorageEngine(); + expect(storageEngine).toBeInstanceOf(mocks.MockFirebase); + expect(mocks.MockFirebase).toHaveBeenCalledOnce(); + expect(mocks.MockLocal).not.toHaveBeenCalled(); + }); + test('creates LocalStorageEngine when env is localStorage', async () => { vi.stubEnv('VITE_STORAGE_ENGINE', 'localStorage'); await initializeStorageEngine(); From 94dd98a32cb2fd5cc6082cdade44ed79fb31e409 Mon Sep 17 00:00:00 2001 From: Jay Kim <76601570+yeonkim1213@users.noreply.github.com> Date: Wed, 1 Jul 2026 16:07:47 -0600 Subject: [PATCH 4/8] Add localhost fallback warning modal --- src/controllers/ComponentController.tsx | 16 +++++++-- .../tests/ComponentController.spec.tsx | 36 +++++++++++++++++-- 2 files changed, 48 insertions(+), 4 deletions(-) diff --git a/src/controllers/ComponentController.tsx b/src/controllers/ComponentController.tsx index daa93e5985..5d6d86dd44 100644 --- a/src/controllers/ComponentController.tsx +++ b/src/controllers/ComponentController.tsx @@ -84,10 +84,22 @@ export function ComponentController() { const storeDispatch = useStoreDispatch(); const { setAlertModal } = useStoreActions(); useEffect(() => { - if (storageEngine?.getEngine() !== import.meta.env.VITE_STORAGE_ENGINE) { + const configuredStorageEngine = import.meta.env.VITE_STORAGE_ENGINE; + const activeStorageEngine = storageEngine?.getEngine(); + if (!configuredStorageEngine || activeStorageEngine === configuredStorageEngine) { + return; + } + + if (activeStorageEngine === 'localStorage' && !import.meta.env.PROD) { + storeDispatch(setAlertModal({ + show: true, + message: `There was an issue connecting to the ${configuredStorageEngine} database, so this development build is using localStorage instead. Study data will not be saved to cloud storage.`, + title: 'Using localStorage fallback', + })); + } else { storeDispatch(setAlertModal({ show: true, - message: `There was an issue connecting to the ${import.meta.env.VITE_STORAGE_ENGINE} database. This could be caused by a network issue or your adblocker. If you are using an adblocker, please disable it for this website and refresh.`, + message: `There was an issue connecting to the ${configuredStorageEngine} database. This could be caused by a network issue or your adblocker. If you are using an adblocker, please disable it for this website and refresh.`, title: 'Failed to connect to the storage engine', })); } diff --git a/src/controllers/tests/ComponentController.spec.tsx b/src/controllers/tests/ComponentController.spec.tsx index d0cbd3e0ee..4e5ee90728 100644 --- a/src/controllers/tests/ComponentController.spec.tsx +++ b/src/controllers/tests/ComponentController.spec.tsx @@ -245,7 +245,10 @@ vi.mock('@visdesignlab/upset2-react', () => ({ Upset: () => null, })); -afterEach(() => vi.restoreAllMocks()); +afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllEnvs(); +}); // ── typed fixtures ──────────────────────────────────────────────────────────── @@ -554,11 +557,40 @@ describe('ComponentController — effect coverage (render-based)', () => { ); }); - test('setAlertModal dispatched when engine does not match env', async () => { + test('shows localStorage fallback warning when cloud engine falls back in development', async () => { + vi.stubEnv('VITE_STORAGE_ENGINE', 'firebase'); + vi.stubEnv('PROD', false); const mockDispatch = vi.fn(); vi.mocked(useStoreDispatch).mockReturnValue(mockDispatch); + vi.mocked(useStorageEngine).mockReturnValue({ + storageEngine: makeStorageEngine({ getEngine: vi.fn<() => 'localStorage'>(() => 'localStorage') }), + setStorageEngine: vi.fn(), + }); render(); await waitFor(() => expect(mockDispatch).toHaveBeenCalled()); + expect(mockStoreActions.setAlertModal).toHaveBeenCalledWith({ + show: true, + message: 'There was an issue connecting to the firebase database, so this development build is using localStorage instead. Study data will not be saved to cloud storage.', + title: 'Using localStorage fallback', + }); + }); + + test('shows storage disconnected alert when cloud engine falls back in production', async () => { + vi.stubEnv('VITE_STORAGE_ENGINE', 'firebase'); + vi.stubEnv('PROD', true); + const mockDispatch = vi.fn(); + vi.mocked(useStoreDispatch).mockReturnValue(mockDispatch); + vi.mocked(useStorageEngine).mockReturnValue({ + storageEngine: makeStorageEngine({ getEngine: vi.fn<() => 'localStorage'>(() => 'localStorage') }), + setStorageEngine: vi.fn(), + }); + render(); + await waitFor(() => expect(mockDispatch).toHaveBeenCalled()); + expect(mockStoreActions.setAlertModal).toHaveBeenCalledWith({ + show: true, + message: 'There was an issue connecting to the firebase database. This could be caused by a network issue or your adblocker. If you are using an adblocker, please disable it for this website and refresh.', + title: 'Failed to connect to the storage engine', + }); }); test('isAnalysis=true returns early from block effect', async () => { From e303e8d88f8fa0ddff9da0f73be6f205d6536e06 Mon Sep 17 00:00:00 2001 From: Jay Kim <76601570+yeonkim1213@users.noreply.github.com> Date: Thu, 9 Jul 2026 06:55:01 -0600 Subject: [PATCH 5/8] Update storage connection warning message --- src/components/interface/AppHeader.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/interface/AppHeader.tsx b/src/components/interface/AppHeader.tsx index 9ab3f7998c..f388da590a 100644 --- a/src/components/interface/AppHeader.tsx +++ b/src/components/interface/AppHeader.tsx @@ -189,7 +189,7 @@ export function AppHeader({ developmentModeEnabled, dataCollectionEnabled }: { d const timeoutId = window.setTimeout(() => { storeDispatch(setAlertModal({ show: true, - message: 'You may be behind a firewall blocking access, or the server collecting data may be down. Study data will not be saved. If you\'re taking the study you will not be compensated for your efforts. You are welcome to look around.', + message: 'You may be behind a firewall blocking access, or the server collecting data may be down. Study data will not be saved. If you\'re taking the study you will not be compensated for your efforts.', title: 'Failed to connect to the storage engine', })); setFirstMount(false); From 2810911d257cbbd1668c4ffc28f328fef48bfffb Mon Sep 17 00:00:00 2001 From: Jay Kim <76601570+yeonkim1213@users.noreply.github.com> Date: Thu, 9 Jul 2026 07:44:42 -0600 Subject: [PATCH 6/8] Show blocking modal when saving an answer fails mid-study --- src/components/interface/AlertModal.tsx | 2 +- .../interface/tests/AlertModal.spec.tsx | 12 +++++ src/store/hooks/tests/useNextStep.spec.tsx | 46 +++++++++++++++++-- src/store/hooks/useNextStep.ts | 26 ++++------- 4 files changed, 65 insertions(+), 21 deletions(-) diff --git a/src/components/interface/AlertModal.tsx b/src/components/interface/AlertModal.tsx index 70ce8b583f..def2a1f168 100644 --- a/src/components/interface/AlertModal.tsx +++ b/src/components/interface/AlertModal.tsx @@ -23,7 +23,7 @@ export function AlertModal() { const close = useCallback(() => storeDispatch(setAlertModal({ ...alertModal, show: false, title: '' })), [alertModal, setAlertModal, storeDispatch]); useEffect(() => setOpened(alertModal.show), [alertModal.show]); - const isStorageEngineAlert = alertModal.title === 'Failed to connect to the storage engine'; + const isStorageEngineAlert = alertModal.title === 'Failed to connect to the storage engine' || alertModal.title === 'Failed to Save Response'; const handleClose = useCallback(() => { if (isStorageEngineAlert) { return; diff --git a/src/components/interface/tests/AlertModal.spec.tsx b/src/components/interface/tests/AlertModal.spec.tsx index db03d5993e..adca48c467 100644 --- a/src/components/interface/tests/AlertModal.spec.tsx +++ b/src/components/interface/tests/AlertModal.spec.tsx @@ -106,6 +106,18 @@ describe('AlertModal', () => { expect(html).toContain('Reconnect'); }); + test('treats mid-study save failure alert as a blocking storage alert', () => { + mockAlertModal = { + show: true, + title: 'Failed to Save Response', + message: 'Your response could not be saved', + }; + const html = renderToStaticMarkup(); + expect(html).toContain('Reconnect'); + expect(html).not.toContain('Continue Study'); + expect(html).toContain('Study ID: test-study'); + }); + test('does not show diagnostics for regular (non-storage) alert', () => { mockAlertModal = { show: true, title: 'Generic Error', message: 'Test' }; const html = renderToStaticMarkup(); diff --git a/src/store/hooks/tests/useNextStep.spec.tsx b/src/store/hooks/tests/useNextStep.spec.tsx index 7dcff33e1c..50e19ce9a9 100644 --- a/src/store/hooks/tests/useNextStep.spec.tsx +++ b/src/store/hooks/tests/useNextStep.spec.tsx @@ -18,6 +18,7 @@ const mockSetReactiveAnswers = vi.fn((payload) => ({ type: 'setReactiveAnswers', const mockSetMatrixAnswersCheckbox = vi.fn((payload) => ({ type: 'setMatrixAnswersCheckbox', payload })); const mockSetMatrixAnswersRadio = vi.fn((payload) => ({ type: 'setMatrixAnswersRadio', payload })); const mockSetRankingAnswers = vi.fn((payload) => ({ type: 'setRankingAnswers', payload })); +const mockSetAlertModal = vi.fn((payload) => ({ type: 'setAlertModal', payload })); let mockStoredAnswer: { answer: Record; @@ -83,6 +84,7 @@ vi.mock('../../store', () => ({ setMatrixAnswersRadio: mockSetMatrixAnswersRadio, setMatrixAnswersCheckbox: mockSetMatrixAnswersCheckbox, setRankingAnswers: mockSetRankingAnswers, + setAlertModal: mockSetAlertModal, }), useStoreDispatch: () => mockDispatch, useAreResponsesValid: () => true, @@ -148,6 +150,7 @@ describe('useNextStep', () => { mockSetMatrixAnswersCheckbox.mockClear(); mockSetMatrixAnswersRadio.mockClear(); mockSetRankingAnswers.mockClear(); + mockSetAlertModal.mockClear(); mockDispatch.mockClear(); mockAnswers = {}; mockTrialValidation = { @@ -206,7 +209,7 @@ describe('useNextStep', () => { vi.restoreAllMocks(); }); - test('continues locally and shows an error when persistence fails', async () => { + test('continues locally and shows the blocking storage modal when persistence fails', async () => { mockSaveAnswers .mockRejectedValueOnce(new Error('write failed')) .mockResolvedValueOnce(undefined); @@ -224,11 +227,12 @@ describe('useNextStep', () => { responseSubmitAttempted: true, })); expect(mockNavigate).toHaveBeenCalledTimes(1); - expect(mockShowNotification).toHaveBeenCalledWith({ + expect(mockSetAlertModal).toHaveBeenCalledWith({ + show: true, + message: 'Your response could not be saved because the connection to the server was interrupted. Please check your internet connection, then click Reconnect to try again.', title: 'Failed to Save Response', - message: 'Your response could not be saved. Please check your connection and try again.', - color: 'red', }); + expect(mockShowNotification).not.toHaveBeenCalled(); expect(mockStoredAnswer.endTime).toBeGreaterThan(-1); await capturedGoToNextStep?.(); @@ -239,6 +243,40 @@ describe('useNextStep', () => { expect(mockNavigate).toHaveBeenCalledTimes(2); }); + test('shows the blocking storage modal when provenance persistence fails', async () => { + mockSaveAnswers.mockResolvedValueOnce(undefined); + mockSaveProvenance.mockRejectedValueOnce(new Error('provenance write failed')); + mockTrialValidation = { + intro_0: { + ...(mockTrialValidation.intro_0 as Record), + provenanceGraph: { nodes: {} }, + }, + }; + + renderToStaticMarkup(); + + await capturedGoToNextStep?.(); + await Promise.resolve(); + + expect(mockSaveProvenance).toHaveBeenCalledTimes(1); + expect(mockSetAlertModal).toHaveBeenCalledWith(expect.objectContaining({ + show: true, + title: 'Failed to Save Response', + })); + }); + + test('does not show the storage modal when persistence succeeds', async () => { + mockSaveAnswers.mockResolvedValueOnce(undefined); + + renderToStaticMarkup(); + + await capturedGoToNextStep?.(); + await Promise.resolve(); + + expect(mockSaveAnswers).toHaveBeenCalledTimes(1); + expect(mockSetAlertModal).not.toHaveBeenCalled(); + }); + test('does not disable next when stimulus validation fails', () => { renderToStaticMarkup(); diff --git a/src/store/hooks/useNextStep.ts b/src/store/hooks/useNextStep.ts index b71d70b075..b243d1cc10 100644 --- a/src/store/hooks/useNextStep.ts +++ b/src/store/hooks/useNextStep.ts @@ -42,7 +42,7 @@ export function useNextStep() { const storeDispatch = useStoreDispatch(); const { - saveTrialAnswer, setReactiveAnswers, setMatrixAnswersRadio, setMatrixAnswersCheckbox, setRankingAnswers, + saveTrialAnswer, setReactiveAnswers, setMatrixAnswersRadio, setMatrixAnswersCheckbox, setRankingAnswers, setAlertModal, } = useStoreActions(); const { storageEngine } = useStorageEngine(); @@ -95,23 +95,17 @@ export function useNextStep() { const answersToPersist = { ...answers, [identifier]: toSave }; if (storageEngine) { - storageEngine.saveAnswers(answersToPersist).catch((error) => { + const onSaveFailure = (error: unknown) => { console.error('Failed to save participant response data', error); - showNotification({ + storeDispatch(setAlertModal({ + show: true, + message: 'Your response could not be saved because the connection to the server was interrupted. Please check your internet connection, then click Reconnect to try again.', title: 'Failed to Save Response', - message: 'Your response could not be saved. Please check your connection and try again.', - color: 'red', - }); - }); + })); + }; + storageEngine.saveAnswers(answersToPersist).catch(onSaveFailure); if (provenanceGraph) { - storageEngine.saveProvenance(provenanceGraph, identifier).catch((error) => { - console.error('Failed to save participant response data', error); - showNotification({ - title: 'Failed to Save Response', - message: 'Your response could not be saved. Please check your connection and try again.', - color: 'red', - }); - }); + storageEngine.saveProvenance(provenanceGraph, identifier).catch(onSaveFailure); } } @@ -176,7 +170,7 @@ export function useNextStep() { color: 'red', }); } - }, [currentStep, trialValidation, identifier, storedAnswer, windowEvents, dataCollectionEnabled, clickedPrevious, sequence, answers, startTime, funcIndex, storeDispatch, saveTrialAnswer, storageEngine, setReactiveAnswers, setMatrixAnswersCheckbox, setMatrixAnswersRadio, setRankingAnswers, studyConfig, participantSequence, navigate, studyId, responseSubmitAttempted]); + }, [currentStep, trialValidation, identifier, storedAnswer, windowEvents, dataCollectionEnabled, clickedPrevious, sequence, answers, startTime, funcIndex, storeDispatch, saveTrialAnswer, storageEngine, setReactiveAnswers, setMatrixAnswersCheckbox, setMatrixAnswersRadio, setRankingAnswers, setAlertModal, studyConfig, participantSequence, navigate, studyId, responseSubmitAttempted]); return { isNextDisabled, From c27abadf9ff48b56b63954957cd4577dcbff8416 Mon Sep 17 00:00:00 2001 From: Jay Kim <76601570+yeonkim1213@users.noreply.github.com> Date: Thu, 16 Jul 2026 03:17:47 -0600 Subject: [PATCH 7/8] Await and flush participant data writes before advancing steps --- src/store/hooks/tests/useNextStep.spec.ts | 41 +++++++++--------- src/store/hooks/tests/useNextStep.spec.tsx | 49 +++++++++++++++------- src/store/hooks/useNextStep.ts | 15 ++++--- 3 files changed, 65 insertions(+), 40 deletions(-) diff --git a/src/store/hooks/tests/useNextStep.spec.ts b/src/store/hooks/tests/useNextStep.spec.ts index 74017152de..b355ef2204 100644 --- a/src/store/hooks/tests/useNextStep.spec.ts +++ b/src/store/hooks/tests/useNextStep.spec.ts @@ -30,6 +30,7 @@ let mockIsAnalysis = false; let mockStoredAnswer = defaultStoredAnswer; let mockSaveAnswers = vi.fn(); let mockSaveProvenance = vi.fn(); +let mockFlushPendingParticipantData = vi.fn(); let mockDispatch = vi.fn(); let mockSaveTrialAnswer = vi.fn((payload) => ({ type: 'saveTrialAnswer', payload })); const mockSetReactiveAnswers = vi.fn((payload) => ({ type: 'setReactiveAnswers', payload })); @@ -76,6 +77,7 @@ vi.mock('../../../storage/storageEngineHooks', () => ({ storageEngine: { saveAnswers: mockSaveAnswers, saveProvenance: mockSaveProvenance, + flushPendingParticipantData: mockFlushPendingParticipantData, }, }), })); @@ -241,6 +243,7 @@ describe('useNextStep', () => { mockStoredAnswer = defaultStoredAnswer; mockSaveAnswers = vi.fn().mockResolvedValue(undefined); mockSaveProvenance = vi.fn().mockResolvedValue(undefined); + mockFlushPendingParticipantData = vi.fn().mockResolvedValue(undefined); mockDispatch = vi.fn(); mockSaveTrialAnswer = vi.fn((payload) => ({ type: 'saveTrialAnswer', payload })); mockSetReactiveAnswers.mockClear(); @@ -272,42 +275,42 @@ describe('useNextStep', () => { expect(result.current.isNextDisabled).toBe(false); }); - test('goToNextStep navigates to next step', () => { + test('goToNextStep navigates to next step', async () => { const { result } = renderHook(() => useNextStep()); - act(() => { result.current.goToNextStep(); }); + await act(async () => { await result.current.goToNextStep(); }); expect(mockNavigate).toHaveBeenCalledWith('/test-study/1'); }); - test('goToNextStep dispatches saveTrialAnswer and clears form state', () => { + test('goToNextStep dispatches saveTrialAnswer and clears form state', async () => { mockStoredAnswer = defaultStoredAnswer; const { result } = renderHook(() => useNextStep()); - act(() => { result.current.goToNextStep(); }); + await act(async () => { await result.current.goToNextStep(); }); expect(mockDispatch).toHaveBeenCalled(); expect(mockSaveTrialAnswer).toHaveBeenCalled(); }); - test('goToNextStep saves answers to storage engine', () => { + test('goToNextStep saves answers to storage engine', async () => { const { result } = renderHook(() => useNextStep()); - act(() => { result.current.goToNextStep(); }); + await act(async () => { await result.current.goToNextStep(); }); expect(mockSaveAnswers).toHaveBeenCalled(); }); - test('goToNextStep does nothing when currentStep is not a number', () => { + test('goToNextStep does nothing when currentStep is not a number', async () => { mockCurrentStep = 'reviewer-0'; const { result } = renderHook(() => useNextStep()); - act(() => { result.current.goToNextStep(); }); + await act(async () => { await result.current.goToNextStep(); }); expect(mockNavigate).not.toHaveBeenCalled(); }); - test('goToNextStep(false) marks answer as timed out', () => { + test('goToNextStep(false) marks answer as timed out', async () => { const { result } = renderHook(() => useNextStep()); - act(() => { result.current.goToNextStep(false); }); + await act(async () => { await result.current.goToNextStep(false); }); const savedPayload = mockSaveTrialAnswer.mock.calls[0][0] as Record; expect(savedPayload.timedOut).toBe(true); expect(savedPayload.answer).toEqual({}); }); - test('goToNextStep collects answers from trialValidation', () => { + test('goToNextStep collects answers from trialValidation', async () => { mockTrialValidation = { trial1_0: { stimulus: { valid: true, values: { q1: 'Blue' } }, @@ -320,30 +323,30 @@ describe('useNextStep', () => { }, }; const { result } = renderHook(() => useNextStep()); - act(() => { result.current.goToNextStep(); }); + await act(async () => { await result.current.goToNextStep(); }); const savedPayload = mockSaveTrialAnswer.mock.calls[0][0] as Record; expect(savedPayload.answer).toEqual({ q1: 'Blue', q2: 'Cat' }); }); - test('goToNextStep navigates with funcIndex increment when funcIndex is set', () => { + test('goToNextStep navigates with funcIndex increment when funcIndex is set', async () => { mockFuncIndex = '0'; const { result } = renderHook(() => useNextStep()); - act(() => { result.current.goToNextStep(); }); + await act(async () => { await result.current.goToNextStep(); }); expect(mockNavigate).toHaveBeenCalledWith('/test-study/0/1'); }); - test('goToNextStep skips saving when data collection is disabled', () => { + test('goToNextStep skips saving when data collection is disabled', async () => { mockModes = { dataCollectionEnabled: false, developmentModeEnabled: false, dataSharingEnabled: false }; const { result } = renderHook(() => useNextStep()); - act(() => { result.current.goToNextStep(); }); + await act(async () => { await result.current.goToNextStep(); }); expect(mockSaveTrialAnswer).not.toHaveBeenCalled(); expect(mockNavigate).toHaveBeenCalled(); }); - test('goToNextStep skips saving when answer already has endTime > -1', () => { + test('goToNextStep skips saving when answer already has endTime > -1', async () => { mockStoredAnswer = { endTime: 100 } as StoredAnswer; const { result } = renderHook(() => useNextStep()); - act(() => { result.current.goToNextStep(); }); + await act(async () => { await result.current.goToNextStep(); }); expect(mockSaveTrialAnswer).not.toHaveBeenCalled(); expect(mockNavigate).toHaveBeenCalled(); }); @@ -382,7 +385,7 @@ describe('useNextStep', () => { mockAnswers = {}; const { result } = renderHook(() => useNextStep()); - act(() => { result.current.goToNextStep(); }); + await act(async () => { await result.current.goToNextStep(); }); expect(mockNavigate).toHaveBeenCalledWith('/test-study/2'); }); }); diff --git a/src/store/hooks/tests/useNextStep.spec.tsx b/src/store/hooks/tests/useNextStep.spec.tsx index 50e19ce9a9..e6e6b227bc 100644 --- a/src/store/hooks/tests/useNextStep.spec.tsx +++ b/src/store/hooks/tests/useNextStep.spec.tsx @@ -13,6 +13,7 @@ const mockNavigate = vi.fn(); const mockShowNotification = vi.fn(); const mockSaveAnswers = vi.fn(); const mockSaveProvenance = vi.fn(() => Promise.resolve()); +const mockFlushPendingParticipantData = vi.fn(() => Promise.resolve()); const mockSaveTrialAnswer = vi.fn((payload) => ({ type: 'saveTrialAnswer', payload })); const mockSetReactiveAnswers = vi.fn((payload) => ({ type: 'setReactiveAnswers', payload })); const mockSetMatrixAnswersCheckbox = vi.fn((payload) => ({ type: 'setMatrixAnswersCheckbox', payload })); @@ -39,7 +40,7 @@ let mockStoredAnswer: { }; let mockAnswers: Record; -let capturedGoToNextStep: ((collectData?: boolean) => void) | undefined; +let capturedGoToNextStep: ((collectData?: boolean) => Promise) | undefined; let mockSequence: { id: string; orderPath: string; @@ -102,6 +103,7 @@ vi.mock('../../../storage/storageEngineHooks', () => ({ storageEngine: { saveAnswers: mockSaveAnswers, saveProvenance: mockSaveProvenance, + flushPendingParticipantData: mockFlushPendingParticipantData, }, }), })); @@ -145,6 +147,7 @@ describe('useNextStep', () => { mockShowNotification.mockReset(); mockSaveAnswers.mockReset(); mockSaveProvenance.mockClear(); + mockFlushPendingParticipantData.mockClear(); mockSaveTrialAnswer.mockClear(); mockSetReactiveAnswers.mockClear(); mockSetMatrixAnswersCheckbox.mockClear(); @@ -209,7 +212,7 @@ describe('useNextStep', () => { vi.restoreAllMocks(); }); - test('continues locally and shows the blocking storage modal when persistence fails', async () => { + test('blocks navigation and shows the storage modal when saveAnswers fails, then retries successfully', async () => { mockSaveAnswers .mockRejectedValueOnce(new Error('write failed')) .mockResolvedValueOnce(undefined); @@ -219,31 +222,45 @@ describe('useNextStep', () => { expect(capturedGoToNextStep).toBeDefined(); await capturedGoToNextStep?.(); - await Promise.resolve(); expect(mockSaveAnswers).toHaveBeenCalledTimes(1); - expect(mockSaveTrialAnswer).toHaveBeenCalledTimes(1); - expect(mockSaveTrialAnswer).toHaveBeenCalledWith(expect.objectContaining({ - responseSubmitAttempted: true, - })); - expect(mockNavigate).toHaveBeenCalledTimes(1); + expect(mockSaveTrialAnswer).not.toHaveBeenCalled(); + expect(mockNavigate).not.toHaveBeenCalled(); expect(mockSetAlertModal).toHaveBeenCalledWith({ show: true, message: 'Your response could not be saved because the connection to the server was interrupted. Please check your internet connection, then click Reconnect to try again.', title: 'Failed to Save Response', }); expect(mockShowNotification).not.toHaveBeenCalled(); - expect(mockStoredAnswer.endTime).toBeGreaterThan(-1); + expect(mockStoredAnswer.endTime).toBe(-1); await capturedGoToNextStep?.(); - expect(mockSaveAnswers).toHaveBeenCalledTimes(1); + expect(mockSaveAnswers).toHaveBeenCalledTimes(2); expect(mockSaveTrialAnswer).toHaveBeenCalledTimes(1); expect(mockStoredAnswer.endTime).toBeGreaterThan(-1); - expect(mockNavigate).toHaveBeenCalledTimes(2); + expect(mockNavigate).toHaveBeenCalledTimes(1); + }); + + test('blocks navigation when the flushed queued write fails', async () => { + mockSaveAnswers.mockResolvedValueOnce(undefined); + mockFlushPendingParticipantData.mockRejectedValueOnce(new Error('queued write failed')); + + renderToStaticMarkup(); + + await capturedGoToNextStep?.(); + + expect(mockSaveAnswers).toHaveBeenCalledTimes(1); + expect(mockFlushPendingParticipantData).toHaveBeenCalledTimes(1); + expect(mockSaveTrialAnswer).not.toHaveBeenCalled(); + expect(mockNavigate).not.toHaveBeenCalled(); + expect(mockSetAlertModal).toHaveBeenCalledWith(expect.objectContaining({ + show: true, + title: 'Failed to Save Response', + })); }); - test('shows the blocking storage modal when provenance persistence fails', async () => { + test('blocks navigation and shows the storage modal when provenance persistence fails', async () => { mockSaveAnswers.mockResolvedValueOnce(undefined); mockSaveProvenance.mockRejectedValueOnce(new Error('provenance write failed')); mockTrialValidation = { @@ -256,24 +273,26 @@ describe('useNextStep', () => { renderToStaticMarkup(); await capturedGoToNextStep?.(); - await Promise.resolve(); expect(mockSaveProvenance).toHaveBeenCalledTimes(1); + expect(mockSaveTrialAnswer).not.toHaveBeenCalled(); + expect(mockNavigate).not.toHaveBeenCalled(); expect(mockSetAlertModal).toHaveBeenCalledWith(expect.objectContaining({ show: true, title: 'Failed to Save Response', })); }); - test('does not show the storage modal when persistence succeeds', async () => { + test('flushes the queued write and navigates when persistence succeeds', async () => { mockSaveAnswers.mockResolvedValueOnce(undefined); renderToStaticMarkup(); await capturedGoToNextStep?.(); - await Promise.resolve(); expect(mockSaveAnswers).toHaveBeenCalledTimes(1); + expect(mockFlushPendingParticipantData).toHaveBeenCalledTimes(1); + expect(mockNavigate).toHaveBeenCalledTimes(1); expect(mockSetAlertModal).not.toHaveBeenCalled(); }); diff --git a/src/store/hooks/useNextStep.ts b/src/store/hooks/useNextStep.ts index b243d1cc10..b947206064 100644 --- a/src/store/hooks/useNextStep.ts +++ b/src/store/hooks/useNextStep.ts @@ -62,7 +62,7 @@ export function useNextStep() { const startTime = useMemo(() => Date.now(), [funcIndex, currentStep]); const windowEvents = useWindowEvents(); - const goToNextStep = useCallback((collectData = true) => { + const goToNextStep = useCallback(async (collectData = true) => { try { if (typeof currentStep !== 'number') { return; @@ -95,17 +95,20 @@ export function useNextStep() { const answersToPersist = { ...answers, [identifier]: toSave }; if (storageEngine) { - const onSaveFailure = (error: unknown) => { + try { + await storageEngine.saveAnswers(answersToPersist); + if (provenanceGraph) { + await storageEngine.saveProvenance(provenanceGraph, identifier); + } + await storageEngine.flushPendingParticipantData(); + } catch (error) { console.error('Failed to save participant response data', error); storeDispatch(setAlertModal({ show: true, message: 'Your response could not be saved because the connection to the server was interrupted. Please check your internet connection, then click Reconnect to try again.', title: 'Failed to Save Response', })); - }; - storageEngine.saveAnswers(answersToPersist).catch(onSaveFailure); - if (provenanceGraph) { - storageEngine.saveProvenance(provenanceGraph, identifier).catch(onSaveFailure); + return; } } From 5bfe2f335b95eb1b78f7b9ce7269db8242c504aa Mon Sep 17 00:00:00 2001 From: Jay Kim <76601570+yeonkim1213@users.noreply.github.com> Date: Fri, 17 Jul 2026 12:45:40 -0600 Subject: [PATCH 8/8] Add showing modal when answer save fails --- src/components/StepRenderer.tsx | 19 ++++++- src/components/tests/StepRenderer.spec.tsx | 56 +++++++++++++++++++- src/storage/engines/types.ts | 17 ++++++- src/storage/tests/highLevel.spec.ts | 47 +++++++++++++++++ src/store/hooks/tests/useNextStep.spec.tsx | 59 ++++++---------------- src/store/hooks/useNextStep.ts | 16 +++--- 6 files changed, 157 insertions(+), 57 deletions(-) diff --git a/src/components/StepRenderer.tsx b/src/components/StepRenderer.tsx index da694b3fd4..cca76a3e11 100644 --- a/src/components/StepRenderer.tsx +++ b/src/components/StepRenderer.tsx @@ -27,13 +27,15 @@ import { ScreenRecordingRejection } from './interface/ScreenRecordingRejection'; import { ReplayContext, useReplay } from '../store/hooks/useReplay'; import { DeviceWarning } from './interface/DeviceWarning'; import { handleBeforeUnload, shouldConfirmTabClose } from '../utils/closeTabConfirmation'; +import { useStorageEngine } from '../storage/storageEngineHooks'; const STUDY_BROWSER_WIDTH = 360; export function StepRenderer() { const windowEvents = useRef([]); const dispatch = useStoreDispatch(); - const { toggleStudyBrowser } = useStoreActions(); + const { toggleStudyBrowser, setAlertModal } = useStoreActions(); + const { storageEngine } = useStorageEngine(); const isAnalysis = useIsAnalysis(); const studyConfig = useStudyConfig(); @@ -58,6 +60,21 @@ export function StepRenderer() { const analysisHasScreenRecording = useStoreSelector((state) => state.analysisHasScreenRecording); const analysisCanPlayScreenRecording = useStoreSelector((state) => state.analysisCanPlayScreenRecording); + useEffect(() => { + if (!storageEngine) { + return undefined; + } + + return storageEngine.subscribeToParticipantDataWriteErrors((error) => { + console.error('Failed to save participant response data', error); + dispatch(setAlertModal({ + show: true, + message: 'Your response could not be saved because the connection to the server was interrupted. Please check your internet connection, then click Reconnect to try again.', + title: 'Failed to Save Response', + })); + }); + }, [dispatch, setAlertModal, storageEngine]); + // Attach event listeners useEffect(() => { // Focus diff --git a/src/components/tests/StepRenderer.spec.tsx b/src/components/tests/StepRenderer.spec.tsx index d027d07881..d9308c0744 100644 --- a/src/components/tests/StepRenderer.spec.tsx +++ b/src/components/tests/StepRenderer.spec.tsx @@ -2,12 +2,17 @@ import { ReactNode } from 'react'; import { render, act } from '@testing-library/react'; import { describe, expect, test, vi, + beforeEach, afterEach, } from 'vitest'; import { StepRenderer } from '../StepRenderer'; import { shouldConfirmTabClose } from '../../utils/closeTabConfirmation'; // ── mocks ───────────────────────────────────────────────────────────────────── +const mockDispatch = vi.fn(); +const mockSetAlertModal = vi.fn((payload) => ({ type: 'setAlertModal', payload })); +const mockSubscribeToParticipantDataWriteErrors = vi.fn(); + vi.mock('../interface/AppAside', () => ({ AppAside: () =>
, })); @@ -97,8 +102,19 @@ vi.mock('../../store/store', () => ({ analysisHasScreenRecording: false, analysisCanPlayScreenRecording: false, }), - useStoreDispatch: () => vi.fn(), - useStoreActions: () => ({ toggleStudyBrowser: vi.fn() }), + useStoreDispatch: () => mockDispatch, + useStoreActions: () => ({ + toggleStudyBrowser: vi.fn(), + setAlertModal: mockSetAlertModal, + }), +})); + +vi.mock('../../storage/storageEngineHooks', () => ({ + useStorageEngine: () => ({ + storageEngine: { + subscribeToParticipantDataWriteErrors: mockSubscribeToParticipantDataWriteErrors, + }, + }), })); vi.mock('../../routes/utils', () => ({ @@ -153,6 +169,42 @@ vi.mock('lodash.debounce', () => ({ // ── tests ───────────────────────────────────────────────────────────────────── describe('StepRenderer', () => { + beforeEach(() => { + mockDispatch.mockClear(); + mockSetAlertModal.mockClear(); + mockSubscribeToParticipantDataWriteErrors.mockReset(); + vi.mocked(shouldConfirmTabClose).mockReturnValue(false); + vi.spyOn(console, 'error').mockImplementation(() => {}); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + test('shows the blocking storage modal when a queued participant data write fails', async () => { + let onParticipantDataWriteError: ((error: Error) => void) | undefined; + const unsubscribe = vi.fn(); + mockSubscribeToParticipantDataWriteErrors.mockImplementation((callback) => { + onParticipantDataWriteError = callback; + return unsubscribe; + }); + + const { unmount } = await act(async () => render()); + act(() => { + onParticipantDataWriteError?.(new Error('write failed')); + }); + + expect(mockSetAlertModal).toHaveBeenCalledWith({ + show: true, + message: 'Your response could not be saved because the connection to the server was interrupted. Please check your internet connection, then click Reconnect to try again.', + title: 'Failed to Save Response', + }); + expect(mockDispatch).toHaveBeenCalledWith(expect.objectContaining({ type: 'setAlertModal' })); + + unmount(); + expect(unsubscribe).toHaveBeenCalledTimes(1); + }); + test('renders the app shell', async () => { const { getByTestId } = await act(async () => render()); expect(getByTestId('app-shell')).toBeDefined(); diff --git a/src/storage/engines/types.ts b/src/storage/engines/types.ts index ff1beac793..0a2cfcfd00 100644 --- a/src/storage/engines/types.ts +++ b/src/storage/engines/types.ts @@ -180,6 +180,8 @@ export abstract class StorageEngine { private participantDataWriteError: Error | null = null; + private participantDataWriteErrorListeners = new Set<(error: Error) => void>(); + private pendingAssetUploads = new Map>(); private pendingAssetOperations = new Set>(); @@ -205,6 +207,14 @@ export abstract class StorageEngine { return this.cloudEngine; } + subscribeToParticipantDataWriteErrors(callback: (error: Error) => void) { + this.participantDataWriteErrorListeners.add(callback); + + return () => { + this.participantDataWriteErrorListeners.delete(callback); + }; + } + protected shouldDeferInitialParticipantDataPersistence() { return false; } @@ -438,7 +448,12 @@ export abstract class StorageEngine { } private recordParticipantDataWriteError(error: unknown) { - this.participantDataWriteError = normalizeError(error); + const normalizedError = normalizeError(error); + this.participantDataWriteError = normalizedError; + + this.participantDataWriteErrorListeners.forEach((listener) => { + listener(normalizedError); + }); } private consumeParticipantDataWriteError() { diff --git a/src/storage/tests/highLevel.spec.ts b/src/storage/tests/highLevel.spec.ts index ab13b9d35b..71dc6a20e5 100644 --- a/src/storage/tests/highLevel.spec.ts +++ b/src/storage/tests/highLevel.spec.ts @@ -233,6 +233,33 @@ class DeferredInitialWriteLocalStorageEngine extends DelayedLocalStorageEngine { } } +class FailingParticipantDataLocalStorageEngine extends LocalStorageEngine { + private failNextParticipantDataWrite = false; + + constructor(testing: boolean = false) { + super(testing); + this.participantDataWriteDelayMs = 0; + } + + failNextWrite() { + this.failNextParticipantDataWrite = true; + } + + protected override async _pushToStorage( + prefix: string, + type: T, + objectToUpload: StorageObject, + ) { + const isParticipantDataWrite = type === 'participantData' && prefix.startsWith('participants/'); + if (isParticipantDataWrite && this.failNextParticipantDataWrite) { + this.failNextParticipantDataWrite = false; + throw new Error('Participant data upload failed'); + } + + return super._pushToStorage(prefix, type, objectToUpload); + } +} + describe.each([ { TestEngine: LocalStorageEngine }, ])('describe object $TestEngine', ({ TestEngine }) => { @@ -815,6 +842,26 @@ describe.each([ expect(participantData?.answers).toEqual(secondAnswers); }); + test('notifies subscribers when a queued participant data write fails', async () => { + storageEngine = new FailingParticipantDataLocalStorageEngine(true); + await storageEngine.connect(); + await storageEngine.initializeStudyDb(studyId); + await storageEngine.initializeParticipantSession({}, configSimple, participantMetadata); + + const onWriteError = vi.fn(); + storageEngine.subscribeToParticipantDataWriteErrors(onWriteError); + + (storageEngine as FailingParticipantDataLocalStorageEngine).failNextWrite(); + await storageEngine.saveAnswers({ + intro_0: makeStoredAnswer('intro_0', 100), + }); + + await expect(storageEngine.flushPendingParticipantData()).rejects.toThrow('Participant data upload failed'); + expect(onWriteError).toHaveBeenCalledWith(expect.objectContaining({ + message: 'Participant data upload failed', + })); + }); + test('saveAnswers strips inline provenance and stores it as a task asset', async () => { const participantSession = await storageEngine.initializeParticipantSession({}, configSimple, participantMetadata); const identifier = 'intro_0'; diff --git a/src/store/hooks/tests/useNextStep.spec.tsx b/src/store/hooks/tests/useNextStep.spec.tsx index 71de0250e2..4f54b60e3c 100644 --- a/src/store/hooks/tests/useNextStep.spec.tsx +++ b/src/store/hooks/tests/useNextStep.spec.tsx @@ -13,7 +13,6 @@ const mockNavigate = vi.fn(); const mockShowNotification = vi.fn(); const mockSaveAnswers = vi.fn(); const mockSaveProvenance = vi.fn(() => Promise.resolve()); -const mockFlushPendingParticipantData = vi.fn(() => Promise.resolve()); const mockSaveTrialAnswer = vi.fn((payload) => ({ type: 'saveTrialAnswer', payload })); const mockSetReactiveAnswers = vi.fn((payload) => ({ type: 'setReactiveAnswers', payload })); const mockSetMatrixAnswersCheckbox = vi.fn((payload) => ({ type: 'setMatrixAnswersCheckbox', payload })); @@ -40,7 +39,7 @@ let mockStoredAnswer: { }; let mockAnswers: Record; -let capturedGoToNextStep: ((collectData?: boolean) => Promise) | undefined; +let capturedGoToNextStep: ((collectData?: boolean) => void) | undefined; let mockSequence: { id: string; orderPath: string; @@ -104,7 +103,6 @@ vi.mock('../../../storage/storageEngineHooks', () => ({ storageEngine: { saveAnswers: mockSaveAnswers, saveProvenance: mockSaveProvenance, - flushPendingParticipantData: mockFlushPendingParticipantData, }, }), })); @@ -146,9 +144,8 @@ describe('useNextStep', () => { vi.spyOn(console, 'error').mockImplementation(() => {}); mockNavigate.mockReset(); mockShowNotification.mockReset(); - mockSaveAnswers.mockReset(); + mockSaveAnswers.mockReset().mockResolvedValue(undefined); mockSaveProvenance.mockClear(); - mockFlushPendingParticipantData.mockClear(); mockSaveTrialAnswer.mockClear(); mockSetReactiveAnswers.mockClear(); mockSetMatrixAnswersCheckbox.mockClear(); @@ -213,55 +210,29 @@ describe('useNextStep', () => { vi.restoreAllMocks(); }); - test('blocks navigation and shows the storage modal when saveAnswers fails, then retries successfully', async () => { - mockSaveAnswers - .mockRejectedValueOnce(new Error('write failed')) - .mockResolvedValueOnce(undefined); + test('shows the storage modal after a direct answer persistence failure', async () => { + mockSaveAnswers.mockRejectedValueOnce(new Error('write failed')); renderToStaticMarkup(); expect(capturedGoToNextStep).toBeDefined(); - await capturedGoToNextStep?.(); + capturedGoToNextStep?.(); + await Promise.resolve(); expect(mockSaveAnswers).toHaveBeenCalledTimes(1); - expect(mockSaveTrialAnswer).not.toHaveBeenCalled(); - expect(mockNavigate).not.toHaveBeenCalled(); + expect(mockSaveTrialAnswer).toHaveBeenCalledTimes(1); + expect(mockNavigate).toHaveBeenCalledTimes(1); expect(mockSetAlertModal).toHaveBeenCalledWith({ show: true, message: 'Your response could not be saved because the connection to the server was interrupted. Please check your internet connection, then click Reconnect to try again.', title: 'Failed to Save Response', }); expect(mockShowNotification).not.toHaveBeenCalled(); - expect(mockStoredAnswer.endTime).toBe(-1); - - await capturedGoToNextStep?.(); - - expect(mockSaveAnswers).toHaveBeenCalledTimes(2); - expect(mockSaveTrialAnswer).toHaveBeenCalledTimes(1); expect(mockStoredAnswer.endTime).toBeGreaterThan(-1); - expect(mockNavigate).toHaveBeenCalledTimes(1); }); - test('blocks navigation when the flushed queued write fails', async () => { - mockSaveAnswers.mockResolvedValueOnce(undefined); - mockFlushPendingParticipantData.mockRejectedValueOnce(new Error('queued write failed')); - - renderToStaticMarkup(); - - await capturedGoToNextStep?.(); - - expect(mockSaveAnswers).toHaveBeenCalledTimes(1); - expect(mockFlushPendingParticipantData).toHaveBeenCalledTimes(1); - expect(mockSaveTrialAnswer).not.toHaveBeenCalled(); - expect(mockNavigate).not.toHaveBeenCalled(); - expect(mockSetAlertModal).toHaveBeenCalledWith(expect.objectContaining({ - show: true, - title: 'Failed to Save Response', - })); - }); - - test('blocks navigation and shows the storage modal when provenance persistence fails', async () => { + test('shows the storage modal after a provenance persistence failure', async () => { mockSaveAnswers.mockResolvedValueOnce(undefined); mockSaveProvenance.mockRejectedValueOnce(new Error('provenance write failed')); mockTrialValidation = { @@ -273,26 +244,26 @@ describe('useNextStep', () => { renderToStaticMarkup(); - await capturedGoToNextStep?.(); + capturedGoToNextStep?.(); + await Promise.resolve(); expect(mockSaveProvenance).toHaveBeenCalledTimes(1); - expect(mockSaveTrialAnswer).not.toHaveBeenCalled(); - expect(mockNavigate).not.toHaveBeenCalled(); + expect(mockSaveTrialAnswer).toHaveBeenCalledTimes(1); + expect(mockNavigate).toHaveBeenCalledTimes(1); expect(mockSetAlertModal).toHaveBeenCalledWith(expect.objectContaining({ show: true, title: 'Failed to Save Response', })); }); - test('flushes the queued write and navigates when persistence succeeds', async () => { + test('schedules the queued write and navigates without flushing it', async () => { mockSaveAnswers.mockResolvedValueOnce(undefined); renderToStaticMarkup(); - await capturedGoToNextStep?.(); + capturedGoToNextStep?.(); expect(mockSaveAnswers).toHaveBeenCalledTimes(1); - expect(mockFlushPendingParticipantData).toHaveBeenCalledTimes(1); expect(mockNavigate).toHaveBeenCalledTimes(1); expect(mockSetAlertModal).not.toHaveBeenCalled(); }); diff --git a/src/store/hooks/useNextStep.ts b/src/store/hooks/useNextStep.ts index 24b4008f77..c9e686e538 100644 --- a/src/store/hooks/useNextStep.ts +++ b/src/store/hooks/useNextStep.ts @@ -63,7 +63,7 @@ export function useNextStep() { const startTime = useMemo(() => Date.now(), [funcIndex, currentStep]); const windowEvents = useWindowEvents(); - const goToNextStep = useCallback(async (collectData = true) => { + const goToNextStep = useCallback((collectData = true) => { try { if (typeof currentStep !== 'number') { return; @@ -92,20 +92,18 @@ export function useNextStep() { const answersToPersist = { ...answers, [identifier]: toSave }; if (storageEngine) { - try { - await storageEngine.saveAnswers(answersToPersist); - if (provenanceGraph) { - await storageEngine.saveProvenance(provenanceGraph, identifier); - } - await storageEngine.flushPendingParticipantData(); - } catch (error) { + const onSaveFailure = (error: unknown) => { console.error('Failed to save participant response data', error); storeDispatch(setAlertModal({ show: true, message: 'Your response could not be saved because the connection to the server was interrupted. Please check your internet connection, then click Reconnect to try again.', title: 'Failed to Save Response', })); - return; + }; + + storageEngine.saveAnswers(answersToPersist).catch(onSaveFailure); + if (provenanceGraph) { + storageEngine.saveProvenance(provenanceGraph, identifier).catch(onSaveFailure); } }