From 8d6862fe30b8da2ab8b9ed90c0f0ff0ae08829bd Mon Sep 17 00:00:00 2001 From: Logan Rasmussen Date: Thu, 5 Feb 2026 11:05:59 -0700 Subject: [PATCH 1/4] feat: add ability to set custom polling interval timings --- src/redux/reducers/experimentalFeaturesSlice.ts | 3 +++ src/utilities/pollers.js | 11 +++++++++-- src/views/connecting/Connecting.js | 11 +++++++++-- typings/connectProps.d.ts | 1 + 4 files changed, 22 insertions(+), 4 deletions(-) diff --git a/src/redux/reducers/experimentalFeaturesSlice.ts b/src/redux/reducers/experimentalFeaturesSlice.ts index 7f608fddd0..5b957ff583 100644 --- a/src/redux/reducers/experimentalFeaturesSlice.ts +++ b/src/redux/reducers/experimentalFeaturesSlice.ts @@ -4,11 +4,13 @@ import { RootState } from 'src/redux/Store' type ExperimentalFeaturesSlice = { optOutOfEarlyUserRelease?: boolean unavailableInstitutions?: { guid: string; name: string }[] + memberPollingMilliseconds?: number } export const initialState: ExperimentalFeaturesSlice = { optOutOfEarlyUserRelease: false, unavailableInstitutions: [], + memberPollingMilliseconds: undefined, } const experimentalFeaturesSlice = createSlice({ @@ -18,6 +20,7 @@ const experimentalFeaturesSlice = createSlice({ loadExperimentalFeatures(state, action) { state.unavailableInstitutions = action.payload?.unavailableInstitutions || [] state.optOutOfEarlyUserRelease = action.payload?.optOutOfEarlyUserRelease || false + state.memberPollingMilliseconds = action.payload?.memberPollingMilliseconds || undefined }, }, }) diff --git a/src/utilities/pollers.js b/src/utilities/pollers.js index 97fd8a449d..5436f9a189 100644 --- a/src/utilities/pollers.js +++ b/src/utilities/pollers.js @@ -26,8 +26,15 @@ export const DEFAULT_POLLING_STATE = { initialDataReady: false, // whether the initial data ready event has been sent } -export function pollMember(memberGuid, api, clientLocale, optOutOfEarlyUserRelease = false) { - return interval(3000).pipe( +export function pollMember( + memberGuid, + api, + clientLocale, + optOutOfEarlyUserRelease = false, + memberPollingMilliseconds = undefined, +) { + const pollingInterval = memberPollingMilliseconds || 3000 + return interval(pollingInterval).pipe( switchMap(() => // Poll the currentMember. Catch errors but don't handle it here // the scan will handle it below diff --git a/src/views/connecting/Connecting.js b/src/views/connecting/Connecting.js index 974e8a8312..83ec7f05eb 100644 --- a/src/views/connecting/Connecting.js +++ b/src/views/connecting/Connecting.js @@ -61,7 +61,8 @@ export const Connecting = (props) => { } = props const selectedInstitution = useSelector(getSelectedInstitution) - const { optOutOfEarlyUserRelease } = useSelector(getExperimentalFeatures) + const { optOutOfEarlyUserRelease, memberPollingMilliseconds } = + useSelector(getExperimentalFeatures) const sendAnalyticsEvent = useAnalyticsEvent() const clientLocale = useMemo(() => { return document.querySelector('html')?.getAttribute('lang') || 'en' @@ -283,7 +284,13 @@ export const Connecting = (props) => { }) .pipe( concatMap((member) => - pollMember(member.guid, api, clientLocale, optOutOfEarlyUserRelease).pipe( + pollMember( + member.guid, + api, + clientLocale, + optOutOfEarlyUserRelease, + memberPollingMilliseconds, + ).pipe( tap((pollingState) => handleMemberPoll(pollingState)), filter((pollingState) => pollingState.pollingIsDone), pluck('currentResponse'), diff --git a/typings/connectProps.d.ts b/typings/connectProps.d.ts index c53dabf7bc..661e269fb8 100644 --- a/typings/connectProps.d.ts +++ b/typings/connectProps.d.ts @@ -39,6 +39,7 @@ interface ConnectProps { experimentalFeatures?: null | { unavailableInstitutions?: { guid: string; name: string }[] optOutOfEarlyUserRelease?: boolean + memberPollingMilliseconds?: boolean } } interface ClientConfigType { From d43ce39b510cf6ac4f8f97bb5676dc2b88e6782b Mon Sep 17 00:00:00 2001 From: Logan Rasmussen Date: Fri, 6 Feb 2026 15:01:06 -0700 Subject: [PATCH 2/4] fix: prevent network race conditions by using exhaustMap instead of switchMap --- src/utilities/pollers.js | 20 +++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/src/utilities/pollers.js b/src/utilities/pollers.js index 5436f9a189..4b2b24641f 100644 --- a/src/utilities/pollers.js +++ b/src/utilities/pollers.js @@ -1,5 +1,5 @@ import { defer, interval, of } from 'rxjs' -import { catchError, scan, switchMap, filter, map, mergeMap } from 'rxjs/operators' +import { catchError, scan, filter, map, mergeMap, exhaustMap } from 'rxjs/operators' import { ErrorStatuses, ProcessingStatuses, ReadableStatuses } from 'src/const/Statuses' @@ -35,7 +35,14 @@ export function pollMember( ) { const pollingInterval = memberPollingMilliseconds || 3000 return interval(pollingInterval).pipe( - switchMap(() => + /** + * used to be switchMap + * exhaustMap ignores new emissions from the source while the current inner observable is still active. + * + * This ensures that we do not start a new poll request until the previous one has completed, + * preventing overlapping requests and potential race conditions. + */ + exhaustMap(() => // Poll the currentMember. Catch errors but don't handle it here // the scan will handle it below defer(() => api.loadMemberByGuid(memberGuid, clientLocale)).pipe( @@ -154,7 +161,14 @@ export function handlePollingResponse(pollingState) { */ export function pollOauthState(oauthStateGuid, api) { return interval(1000).pipe( - switchMap(() => + /** + * used to be switchMap + * exhaustMap ignores new emissions from the source while the current inner observable is still active. + * + * This ensures that we do not start a new poll request until the previous one has completed, + * preventing overlapping requests and potential race conditions. + */ + exhaustMap(() => // Poll the oauthstate. Catch errors but don't handle it here // the scan will handle it below defer(() => api.loadOAuthState(oauthStateGuid)).pipe(catchError((error) => of(error))), From 5c8ae00ae70d807758107442d1da2252f03b554b Mon Sep 17 00:00:00 2001 From: Logan Rasmussen Date: Fri, 6 Feb 2026 15:07:23 -0700 Subject: [PATCH 3/4] fix: memberPollingMilliseconds is a number not a boolean --- typings/connectProps.d.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/typings/connectProps.d.ts b/typings/connectProps.d.ts index 661e269fb8..ad4a4fa6a5 100644 --- a/typings/connectProps.d.ts +++ b/typings/connectProps.d.ts @@ -39,7 +39,7 @@ interface ConnectProps { experimentalFeatures?: null | { unavailableInstitutions?: { guid: string; name: string }[] optOutOfEarlyUserRelease?: boolean - memberPollingMilliseconds?: boolean + memberPollingMilliseconds?: number } } interface ClientConfigType { From 63b656d055431da9949ef1215add30eb1d44c5fc Mon Sep 17 00:00:00 2001 From: Logan Rasmussen Date: Mon, 9 Feb 2026 14:50:30 -0700 Subject: [PATCH 4/4] move pollMember to a hook and add tests --- src/hooks/__tests__/usePollMember-test.tsx | 644 +++++++++++++++++++++ src/hooks/usePollMember.tsx | 85 +++ src/utilities/__tests__/pollers-test.js | 230 -------- src/utilities/pollers.js | 70 +-- src/views/connecting/Connecting.js | 16 +- 5 files changed, 735 insertions(+), 310 deletions(-) create mode 100644 src/hooks/__tests__/usePollMember-test.tsx create mode 100644 src/hooks/usePollMember.tsx diff --git a/src/hooks/__tests__/usePollMember-test.tsx b/src/hooks/__tests__/usePollMember-test.tsx new file mode 100644 index 0000000000..df85432d29 --- /dev/null +++ b/src/hooks/__tests__/usePollMember-test.tsx @@ -0,0 +1,644 @@ +import React from 'react' +import { renderHook, waitFor } from '@testing-library/react' +import { vi } from 'vitest' +import { usePollMember } from 'src/hooks/usePollMember' +import { ApiProvider } from 'src/context/ApiContext' +import { Provider } from 'react-redux' +import { createReduxStore } from 'src/redux/Store' +import { member, JOB_DATA } from 'src/services/mockedData' +import { ReadableStatuses } from 'src/const/Statuses' +import { CONNECTING_MESSAGES } from 'src/utilities/pollers' +import { take } from 'rxjs/operators' + +interface PollingState { + isError: boolean + pollingCount: number + currentResponse?: unknown + pollingIsDone: boolean + userMessage?: string + initialDataReady?: boolean +} + +interface ApiValue { + loadMemberByGuid?: (guid: string, locale: string) => Promise + loadJob?: (jobGuid: string) => Promise +} + +interface PreloadedState { + experimentalFeatures?: { + optOutOfEarlyUserRelease?: boolean + memberPollingMilliseconds?: number + } +} + +const createWrapper = (apiValue: ApiValue, preloadedState?: PreloadedState) => { + const store = createReduxStore(preloadedState) + const Wrapper = ({ children }: { children: React.ReactNode }) => ( + + {/* eslint-disable-next-line @typescript-eslint/no-explicit-any */} + {children} + + ) + Wrapper.displayName = 'TestWrapper' + return Wrapper +} + +describe('usePollMember', () => { + beforeEach(() => { + document.documentElement.setAttribute('lang', 'en') + }) + + it('should return a pollMember function', () => { + const apiValue = { + loadMemberByGuid: vi.fn().mockResolvedValue(member.member), + loadJob: vi.fn().mockResolvedValue(JOB_DATA), + } + + const { result } = renderHook(() => usePollMember(), { + wrapper: createWrapper(apiValue), + }) + + expect(result.current).toBeInstanceOf(Function) + }) + + it('should poll successfully and emit polling states with member and job data', async () => { + const connectedMember = { + ...member.member, + connection_status: ReadableStatuses.CONNECTED, + is_being_aggregated: false, + } + + const apiValue = { + loadMemberByGuid: vi.fn().mockResolvedValue(connectedMember), + loadJob: vi.fn().mockResolvedValue(JOB_DATA), + } + + const { result } = renderHook(() => usePollMember(), { + wrapper: createWrapper(apiValue), + }) + + const pollMember = result.current + const states: PollingState[] = [] + + const subscription = pollMember('MBR-123') + .pipe(take(1)) + .subscribe((state: PollingState) => { + states.push(state) + }) + + await waitFor( + () => { + expect(states.length).toBeGreaterThan(0) + }, + { timeout: 4000 }, + ) + + expect(apiValue.loadMemberByGuid).toHaveBeenCalledWith('MBR-123', 'en') + expect(apiValue.loadJob).toHaveBeenCalledWith(connectedMember.most_recent_job_guid) + expect(states[0]).toMatchObject({ + isError: false, + pollingCount: 1, + currentResponse: { + member: connectedMember, + job: JOB_DATA, + }, + pollingIsDone: true, + userMessage: CONNECTING_MESSAGES.FINISHING, + }) + + subscription.unsubscribe() + }) + + it('should handle API context not being available', async () => { + const apiValue = { + loadMemberByGuid: undefined, + loadJob: vi.fn().mockResolvedValue(JOB_DATA), + } + + const { result } = renderHook(() => usePollMember(), { + wrapper: createWrapper(apiValue), + }) + + const pollMember = result.current + const states: PollingState[] = [] + + const subscription = pollMember('MBR-123') + .pipe(take(1)) + .subscribe((state: PollingState) => { + states.push(state) + }) + + await waitFor( + () => { + expect(states.length).toBeGreaterThan(0) + }, + { timeout: 4000 }, + ) + + expect(states[0]).toMatchObject({ + isError: true, + pollingCount: 1, + pollingIsDone: false, + }) + + subscription.unsubscribe() + }) + + it('should handle loadMemberByGuid errors gracefully', async () => { + const apiValue = { + loadMemberByGuid: vi.fn().mockRejectedValue(new Error('Network error')), + loadJob: vi.fn().mockResolvedValue(JOB_DATA), + } + + const { result } = renderHook(() => usePollMember(), { + wrapper: createWrapper(apiValue), + }) + + const pollMember = result.current + const states: PollingState[] = [] + + const subscription = pollMember('MBR-123') + .pipe(take(1)) + .subscribe((state: PollingState) => { + states.push(state) + }) + + await waitFor( + () => { + expect(states.length).toBeGreaterThan(0) + }, + { timeout: 4000 }, + ) + + expect(states[0].isError).toBe(true) + expect(states[0].pollingIsDone).toBe(false) + + subscription.unsubscribe() + }) + + it('should handle loadJob errors gracefully', async () => { + const apiValue = { + loadMemberByGuid: vi.fn().mockResolvedValue(member.member), + loadJob: vi.fn().mockRejectedValue(new Error('Job loading failed')), + } + + const { result } = renderHook(() => usePollMember(), { + wrapper: createWrapper(apiValue), + }) + + const pollMember = result.current + const states: PollingState[] = [] + + const subscription = pollMember('MBR-123') + .pipe(take(1)) + .subscribe((state: PollingState) => { + states.push(state) + }) + + await waitFor( + () => { + expect(states.length).toBeGreaterThan(0) + }, + { timeout: 4000 }, + ) + + expect(states[0].isError).toBe(true) + + subscription.unsubscribe() + }) + + it('should set initialDataReady when async_account_data_ready is true', async () => { + const jobWithAsyncData = { + ...JOB_DATA, + async_account_data_ready: true, + } + + const memberWithJob = { + ...member.member, + is_being_aggregated: false, + connection_status: ReadableStatuses.CONNECTED, + } + + const apiValue = { + loadMemberByGuid: vi.fn().mockResolvedValue(memberWithJob), + loadJob: vi.fn().mockResolvedValue(jobWithAsyncData), + } + + const { result } = renderHook(() => usePollMember(), { + wrapper: createWrapper(apiValue), + }) + + const pollMember = result.current + const states: PollingState[] = [] + + const subscription = pollMember('MBR-123') + .pipe(take(1)) + .subscribe((state: PollingState) => { + states.push(state) + }) + + await waitFor( + () => { + expect(states.length).toBeGreaterThan(0) + }, + { timeout: 4000 }, + ) + + expect(states[0].initialDataReady).toBe(true) + expect(states[0].pollingIsDone).toBe(true) + expect(states[0].userMessage).toBe(CONNECTING_MESSAGES.FINISHING) + + subscription.unsubscribe() + }) + + it('should not set initialDataReady when optOutOfEarlyUserRelease is true', async () => { + const jobWithAsyncData = { + ...JOB_DATA, + async_account_data_ready: true, + } + + const apiValue = { + loadMemberByGuid: vi.fn().mockResolvedValue(member.member), + loadJob: vi.fn().mockResolvedValue(jobWithAsyncData), + } + + const preloadedState = { + experimentalFeatures: { + optOutOfEarlyUserRelease: true, + memberPollingMilliseconds: 3000, + }, + } + + const { result } = renderHook(() => usePollMember(), { + wrapper: createWrapper(apiValue, preloadedState), + }) + + const pollMember = result.current + const states: PollingState[] = [] + + const subscription = pollMember('MBR-123') + .pipe(take(1)) + .subscribe((state: PollingState) => { + states.push(state) + }) + + await waitFor( + () => { + expect(states.length).toBeGreaterThan(0) + }, + { timeout: 4000 }, + ) + + expect(states[0].initialDataReady).toBe(false) + + subscription.unsubscribe() + }) + + it('should use custom polling interval when provided', async () => { + const apiValue = { + loadMemberByGuid: vi.fn().mockResolvedValue(member.member), + loadJob: vi.fn().mockResolvedValue(JOB_DATA), + } + + const preloadedState = { + experimentalFeatures: { + memberPollingMilliseconds: 500, + }, + } + + const { result } = renderHook(() => usePollMember(), { + wrapper: createWrapper(apiValue, preloadedState), + }) + + const pollMember = result.current + const subscription = pollMember('MBR-123').subscribe() + + await waitFor( + () => { + expect(apiValue.loadMemberByGuid).toHaveBeenCalled() + }, + { timeout: 1000 }, + ) + + subscription.unsubscribe() + }) + + it('should increment pollingCount on each poll', async () => { + const apiValue = { + loadMemberByGuid: vi.fn().mockResolvedValue(member.member), + loadJob: vi.fn().mockResolvedValue(JOB_DATA), + } + + const preloadedState = { + experimentalFeatures: { + memberPollingMilliseconds: 1000, + }, + } + + const { result } = renderHook(() => usePollMember(), { + wrapper: createWrapper(apiValue, preloadedState), + }) + + const pollMember = result.current + const states: PollingState[] = [] + + const subscription = pollMember('MBR-123') + .pipe(take(2)) + .subscribe((state: PollingState) => { + states.push(state) + }) + + await waitFor( + () => { + expect(states.length).toBeGreaterThanOrEqual(2) + }, + { timeout: 3500 }, + ) + + expect(states[0].pollingCount).toBe(1) + expect(states[1].pollingCount).toBe(2) + + subscription.unsubscribe() + }, 10000) + + it('should show CHALLENGED status message when member is challenged', async () => { + const challengedMember = { + ...member.member, + connection_status: ReadableStatuses.CHALLENGED, + } + + const apiValue = { + loadMemberByGuid: vi.fn().mockResolvedValue(challengedMember), + loadJob: vi.fn().mockResolvedValue(JOB_DATA), + } + + const { result } = renderHook(() => usePollMember(), { + wrapper: createWrapper(apiValue), + }) + + const pollMember = result.current + const states: PollingState[] = [] + + const subscription = pollMember('MBR-123') + .pipe(take(1)) + .subscribe((state: PollingState) => { + states.push(state) + }) + + await waitFor( + () => { + expect(states.length).toBeGreaterThan(0) + }, + { timeout: 4000 }, + ) + + expect(states[0].userMessage).toBe(CONNECTING_MESSAGES.MFA) + expect(states[0].pollingIsDone).toBe(true) + + subscription.unsubscribe() + }) + + it('should continue polling when member is still being aggregated', async () => { + const aggregatingMember = { + ...member.member, + connection_status: ReadableStatuses.CONNECTED, + is_being_aggregated: true, + } + + const apiValue = { + loadMemberByGuid: vi.fn().mockResolvedValue(aggregatingMember), + loadJob: vi.fn().mockResolvedValue(JOB_DATA), + } + + const { result } = renderHook(() => usePollMember(), { + wrapper: createWrapper(apiValue), + }) + + const pollMember = result.current + const states: PollingState[] = [] + + const subscription = pollMember('MBR-123') + .pipe(take(1)) + .subscribe((state: PollingState) => { + states.push(state) + }) + + await waitFor( + () => { + expect(states.length).toBeGreaterThan(0) + }, + { timeout: 4000 }, + ) + + expect(states[0].userMessage).toBe(CONNECTING_MESSAGES.SYNCING) + expect(states[0].pollingIsDone).toBe(false) + + subscription.unsubscribe() + }) + + it('should use client locale from html lang attribute', async () => { + document.documentElement.setAttribute('lang', 'es') + + const apiValue = { + loadMemberByGuid: vi.fn().mockResolvedValue(member.member), + loadJob: vi.fn().mockResolvedValue(JOB_DATA), + } + + const { result } = renderHook(() => usePollMember(), { + wrapper: createWrapper(apiValue), + }) + + const pollMember = result.current + const subscription = pollMember('MBR-123').pipe(take(1)).subscribe() + + await waitFor( + () => { + expect(apiValue.loadMemberByGuid).toHaveBeenCalledWith('MBR-123', 'es') + }, + { timeout: 4000 }, + ) + + subscription.unsubscribe() + }) + + it('should only set initialDataReady once, even on subsequent polls', async () => { + const jobWithAsyncData = { + ...JOB_DATA, + async_account_data_ready: true, + } + + const memberWithJob = { + ...member.member, + is_being_aggregated: false, + connection_status: ReadableStatuses.CONNECTED, + } + + const apiValue = { + loadMemberByGuid: vi.fn().mockResolvedValue(memberWithJob), + loadJob: vi.fn().mockResolvedValue(jobWithAsyncData), + } + + const preloadedState = { + experimentalFeatures: { + memberPollingMilliseconds: 800, + }, + } + + const { result } = renderHook(() => usePollMember(), { + wrapper: createWrapper(apiValue, preloadedState), + }) + + const pollMember = result.current + const states: PollingState[] = [] + + const subscription = pollMember('MBR-123') + .pipe(take(3)) + .subscribe((state: PollingState) => { + states.push(state) + }) + + await waitFor( + () => { + expect(states.length).toBeGreaterThanOrEqual(3) + }, + { timeout: 3500 }, + ) + + expect(states[0].initialDataReady).toBe(true) + expect(states[1].initialDataReady).toBe(true) + expect(states[2].initialDataReady).toBe(true) + + subscription.unsubscribe() + }, 10000) + + it('should not set initialDataReady when async_account_data_ready is false', async () => { + const jobWithoutAsyncData = { + ...JOB_DATA, + async_account_data_ready: false, + } + + const memberWithJob = { + ...member.member, + is_being_aggregated: false, + connection_status: ReadableStatuses.CONNECTED, + } + + const apiValue = { + loadMemberByGuid: vi.fn().mockResolvedValue(memberWithJob), + loadJob: vi.fn().mockResolvedValue(jobWithoutAsyncData), + } + + const { result } = renderHook(() => usePollMember(), { + wrapper: createWrapper(apiValue), + }) + + const pollMember = result.current + const states: PollingState[] = [] + + const subscription = pollMember('MBR-123') + .pipe(take(1)) + .subscribe((state: PollingState) => { + states.push(state) + }) + + await waitFor( + () => { + expect(states.length).toBeGreaterThan(0) + }, + { timeout: 4000 }, + ) + + expect(states[0].initialDataReady).toBe(false) + + subscription.unsubscribe() + }) + + it('should not set initialDataReady when there is an error', async () => { + const apiValue = { + loadMemberByGuid: vi.fn().mockRejectedValue(new Error('API Error')), + loadJob: vi.fn().mockResolvedValue(JOB_DATA), + } + + const { result } = renderHook(() => usePollMember(), { + wrapper: createWrapper(apiValue), + }) + + const pollMember = result.current + const states: PollingState[] = [] + + const subscription = pollMember('MBR-123') + .pipe(take(1)) + .subscribe((state: PollingState) => { + states.push(state) + }) + + await waitFor( + () => { + expect(states.length).toBeGreaterThan(0) + }, + { timeout: 4000 }, + ) + + expect(states[0].isError).toBe(true) + expect(states[0].initialDataReady).toBe(false) + + subscription.unsubscribe() + }) + + it('should set initialDataReady when async_account_data_ready becomes true after being false', async () => { + const jobWithoutAsyncData = { + ...JOB_DATA, + async_account_data_ready: false, + } + + const jobWithAsyncData = { + ...JOB_DATA, + async_account_data_ready: true, + } + + const memberWithJob = { + ...member.member, + is_being_aggregated: false, + connection_status: ReadableStatuses.CONNECTED, + } + + const apiValue = { + loadMemberByGuid: vi.fn().mockResolvedValue(memberWithJob), + loadJob: vi + .fn() + .mockResolvedValueOnce(jobWithoutAsyncData) + .mockResolvedValue(jobWithAsyncData), + } + + const preloadedState = { + experimentalFeatures: { + memberPollingMilliseconds: 1000, + }, + } + + const { result } = renderHook(() => usePollMember(), { + wrapper: createWrapper(apiValue, preloadedState), + }) + + const pollMember = result.current + const states: PollingState[] = [] + + const subscription = pollMember('MBR-123') + .pipe(take(2)) + .subscribe((state: PollingState) => { + states.push(state) + }) + + await waitFor( + () => { + expect(states.length).toBeGreaterThanOrEqual(2) + }, + { timeout: 3500 }, + ) + + expect(states[0].initialDataReady).toBe(false) + expect(states[1].initialDataReady).toBe(true) + + subscription.unsubscribe() + }, 10000) +}) diff --git a/src/hooks/usePollMember.tsx b/src/hooks/usePollMember.tsx new file mode 100644 index 0000000000..d3fbb4bc96 --- /dev/null +++ b/src/hooks/usePollMember.tsx @@ -0,0 +1,85 @@ +import { useMemo } from 'react' +import { DEFAULT_POLLING_STATE, handlePollingResponse } from 'src/utilities/pollers' +import { useApi } from 'src/context/ApiContext' +import { useSelector } from 'react-redux' +import { getExperimentalFeatures } from 'src/redux/reducers/experimentalFeaturesSlice' + +import { defer, interval, of } from 'rxjs' +import { catchError, scan, map, mergeMap, exhaustMap } from 'rxjs/operators' + +export function usePollMember() { + const { api } = useApi() + + const clientLocale = useMemo(() => { + return document.querySelector('html')?.getAttribute('lang') || 'en' + }, [document.querySelector('html')?.getAttribute('lang')]) + + const { optOutOfEarlyUserRelease, memberPollingMilliseconds } = + useSelector(getExperimentalFeatures) + + const pollingInterval = memberPollingMilliseconds || 3000 + + const pollMember = (memberGuid: string) => { + return interval(pollingInterval).pipe( + /** + * used to be switchMap + * exhaustMap ignores new emissions from the source while the current inner observable is still active. + * + * This ensures that we do not start a new poll request until the previous one has completed, + * preventing overlapping requests and potential race conditions. + */ + exhaustMap(() => + // Poll the currentMember. Catch errors but don't handle it here + // the scan will handle it below + // @ts-expect-error: cannot invoke a method that might be undefined + defer(() => api.loadMemberByGuid(memberGuid, clientLocale)).pipe( + mergeMap((member) => + defer(() => api.loadJob(member.most_recent_job_guid as string)).pipe( + map((job) => ({ member, job })), + ), + ), + catchError((error) => of(error)), + ), + ), + scan( + (acc, response) => { + const isError = response instanceof Error + + const pollingState = { + // only track if the most recent poll was an error + isError, + // always increase polling count + pollingCount: acc.pollingCount + 1, + // dont update previous response if this is an error + previousResponse: isError ? acc.previousResponse : acc.currentResponse, + // dont update current response if this is an error + currentResponse: isError ? acc.currentResponse : response, + // preserve the initialDataReadySent flag + initialDataReady: acc.initialDataReady, + } + + if ( + !isError && + !acc.initialDataReady && + response?.job?.async_account_data_ready && + !optOutOfEarlyUserRelease + ) { + pollingState.initialDataReady = true + } + + const [shouldStopPolling, messageKey] = handlePollingResponse(pollingState) + + return { + ...pollingState, + // we should keep polling based on the member + pollingIsDone: isError ? false : shouldStopPolling, + userMessage: messageKey, + } + }, + { ...DEFAULT_POLLING_STATE }, + ), + ) + } + + return pollMember +} diff --git a/src/utilities/__tests__/pollers-test.js b/src/utilities/__tests__/pollers-test.js index 4f489bbb12..3e49c953f5 100644 --- a/src/utilities/__tests__/pollers-test.js +++ b/src/utilities/__tests__/pollers-test.js @@ -2,12 +2,8 @@ import { handlePollingResponse, DEFAULT_POLLING_STATE, CONNECTING_MESSAGES, - pollMember, } from 'src/utilities/pollers' import { ErrorStatuses, ProcessingStatuses, ReadableStatuses } from 'src/const/Statuses' -import { member, JOB_DATA } from 'src/services/mockedData' -import { of } from 'rxjs' -import { take } from 'rxjs/operators' describe('handlePollingResponse', () => { test('it should stop polling and update the message', () => { @@ -187,229 +183,3 @@ function testStatus(status, shouldStopPolling, expectedMessage) { expect(message).toEqual(expectedMessage) expect(stopPolling).toEqual(shouldStopPolling) } - -describe('pollMember', () => { - let mockApi - const memberGuid = member.member.guid - const clientLocale = 'en-US' - - const createMockJob = (asyncDataReady = false) => ({ - ...JOB_DATA, - async_account_data_ready: asyncDataReady, - }) - - const createMockMember = (overrides = {}) => ({ - ...member.member, - ...overrides, - }) - - beforeEach(() => { - vi.useFakeTimers() - mockApi = { - loadMemberByGuid: vi.fn(), - loadJob: vi.fn(), - } - }) - - afterEach(() => { - vi.useRealTimers() - vi.clearAllMocks() - }) - - describe('initial data ready functionality', () => { - it('should set initialDataReady flag when async_account_data_ready becomes true', async () => { - const mockMember = createMockMember({ - connection_status: ReadableStatuses.CONNECTED, - is_being_aggregated: false, - }) - const mockJob = createMockJob(true) - - mockApi.loadMemberByGuid.mockReturnValue(of(mockMember)) - mockApi.loadJob.mockReturnValue(of(mockJob)) - - const resultPromise = new Promise((resolve, reject) => { - const subscription = pollMember(memberGuid, mockApi, clientLocale) - .pipe(take(1)) - .subscribe({ - next: (result) => { - subscription.unsubscribe() - resolve(result) - }, - error: (error) => { - subscription.unsubscribe() - reject(error) - }, - }) - - // Advance timers to trigger the interval - vi.advanceTimersByTime(3000) - }) - - const result = await resultPromise - expect(result.initialDataReady).toBe(true) - }) - - it('should NOT set initialDataReady flag when async_account_data_ready becomes true and optOutOfEarlyUserRelease is true', async () => { - const mockMember = createMockMember({ - connection_status: ReadableStatuses.SYNCING, - is_being_aggregated: false, - }) - const mockJob = createMockJob(true) - - mockApi.loadMemberByGuid.mockReturnValue(of(mockMember)) - mockApi.loadJob.mockReturnValue(of(mockJob)) - - const resultPromise = new Promise((resolve, reject) => { - const subscription = pollMember(memberGuid, mockApi, clientLocale, true) - .pipe(take(1)) - .subscribe({ - next: (result) => { - subscription.unsubscribe() - resolve(result) - }, - error: (error) => { - subscription.unsubscribe() - reject(error) - }, - }) - - // Advance timers to trigger the interval - vi.advanceTimersByTime(3000) - }) - - const result = await resultPromise - expect(result.initialDataReady).toBe(false) - expect(result.pollingIsDone).toBe(false) - }) - - it('should only set initialDataReady once, even on subsequent polls', async () => { - const mockMember = createMockMember({ - connection_status: ReadableStatuses.CONNECTED, - is_being_aggregated: false, - }) - const mockJob = createMockJob(true) - - mockApi.loadMemberByGuid.mockReturnValue(of(mockMember)) - mockApi.loadJob.mockReturnValue(of(mockJob)) - - const resultPromise = new Promise((resolve) => { - const results = [] - const subscription = pollMember(memberGuid, mockApi, clientLocale) - .pipe(take(3)) - .subscribe({ - next: (result) => { - results.push(result) - }, - complete: () => { - subscription.unsubscribe() - resolve(results) - }, - }) - - // Advance timers to trigger multiple polls - vi.advanceTimersByTime(3000) // First poll - vi.advanceTimersByTime(3000) // Second poll - vi.advanceTimersByTime(3000) // Third poll - }) - - const results = await resultPromise - expect(results[0].initialDataReady).toBe(true) - // Subsequent polls should maintain the flag - expect(results[1].initialDataReady).toBe(true) - expect(results[2].initialDataReady).toBe(true) - }) - - it('should not set initialDataReady when async_account_data_ready is false', async () => { - const mockMember = createMockMember({ - connection_status: ReadableStatuses.CONNECTED, - is_being_aggregated: false, - }) - const mockJob = createMockJob(false) - - mockApi.loadMemberByGuid.mockReturnValue(of(mockMember)) - mockApi.loadJob.mockReturnValue(of(mockJob)) - - const resultPromise = new Promise((resolve, reject) => { - const subscription = pollMember(memberGuid, mockApi, clientLocale) - .pipe(take(1)) - .subscribe({ - next: (result) => { - subscription.unsubscribe() - resolve(result) - }, - error: (error) => { - subscription.unsubscribe() - reject(error) - }, - }) - - vi.advanceTimersByTime(3000) - }) - - const result = await resultPromise - expect(result.initialDataReady).toBe(false) - }) - - it('should not set initialDataReady when there is an error', async () => { - const error = new Error('API Error') - mockApi.loadMemberByGuid.mockReturnValue(of(error)) - - const resultPromise = new Promise((resolve, reject) => { - const subscription = pollMember(memberGuid, mockApi, clientLocale) - .pipe(take(1)) - .subscribe({ - next: (result) => { - subscription.unsubscribe() - resolve(result) - }, - error: (error) => { - subscription.unsubscribe() - reject(error) - }, - }) - - vi.advanceTimersByTime(3000) - }) - - const result = await resultPromise - expect(result.isError).toBe(true) - expect(result.initialDataReady).toBe(false) - }) - - it('should set initialDataReady when async_account_data_ready becomes true after being false', async () => { - const mockMember = createMockMember({ - connection_status: ReadableStatuses.CONNECTED, - is_being_aggregated: false, - }) - const mockJobFalse = createMockJob(false) - const mockJobTrue = createMockJob(true) - - mockApi.loadMemberByGuid.mockReturnValue(of(mockMember)) - mockApi.loadJob.mockReturnValueOnce(of(mockJobFalse)).mockReturnValueOnce(of(mockJobTrue)) - - const resultPromise = new Promise((resolve) => { - const results = [] - const subscription = pollMember(memberGuid, mockApi, clientLocale) - .pipe(take(2)) - .subscribe({ - next: (result) => { - results.push(result) - }, - complete: () => { - subscription.unsubscribe() - resolve(results) - }, - }) - - // Advance timers to trigger multiple polls - vi.advanceTimersByTime(3000) - vi.advanceTimersByTime(3000) - }) - - const results = await resultPromise - expect(results[0].initialDataReady).toBe(false) - // Second poll should set the flag - expect(results[1].initialDataReady).toBe(true) - }) - }) -}) diff --git a/src/utilities/pollers.js b/src/utilities/pollers.js index 4b2b24641f..87889e1803 100644 --- a/src/utilities/pollers.js +++ b/src/utilities/pollers.js @@ -1,5 +1,5 @@ import { defer, interval, of } from 'rxjs' -import { catchError, scan, filter, map, mergeMap, exhaustMap } from 'rxjs/operators' +import { catchError, scan, filter, exhaustMap } from 'rxjs/operators' import { ErrorStatuses, ProcessingStatuses, ReadableStatuses } from 'src/const/Statuses' @@ -26,74 +26,6 @@ export const DEFAULT_POLLING_STATE = { initialDataReady: false, // whether the initial data ready event has been sent } -export function pollMember( - memberGuid, - api, - clientLocale, - optOutOfEarlyUserRelease = false, - memberPollingMilliseconds = undefined, -) { - const pollingInterval = memberPollingMilliseconds || 3000 - return interval(pollingInterval).pipe( - /** - * used to be switchMap - * exhaustMap ignores new emissions from the source while the current inner observable is still active. - * - * This ensures that we do not start a new poll request until the previous one has completed, - * preventing overlapping requests and potential race conditions. - */ - exhaustMap(() => - // Poll the currentMember. Catch errors but don't handle it here - // the scan will handle it below - defer(() => api.loadMemberByGuid(memberGuid, clientLocale)).pipe( - mergeMap((member) => - defer(() => api.loadJob(member.most_recent_job_guid)).pipe( - map((job) => ({ member, job })), - ), - ), - catchError((error) => of(error)), - ), - ), - scan( - (acc, response) => { - const isError = response instanceof Error - - const pollingState = { - // only track if the most recent poll was an error - isError, - // always increase polling count - pollingCount: acc.pollingCount + 1, - // dont update previous response if this is an error - previousResponse: isError ? acc.previousResponse : acc.currentResponse, - // dont update current response if this is an error - currentResponse: isError ? acc.currentResponse : response, - // preserve the initialDataReadySent flag - initialDataReady: acc.initialDataReady, - } - - if ( - !isError && - !acc.initialDataReady && - response?.job?.async_account_data_ready && - !optOutOfEarlyUserRelease - ) { - pollingState.initialDataReady = true - } - - const [shouldStopPolling, messageKey] = handlePollingResponse(pollingState) - - return { - ...pollingState, - // we should keep polling based on the member - pollingIsDone: isError ? false : shouldStopPolling, - userMessage: messageKey, - } - }, - { ...DEFAULT_POLLING_STATE }, - ), - ) -} - export function handlePollingResponse(pollingState) { const polledMember = pollingState.currentResponse?.member || {} const previousMember = pollingState.previousResponse?.member || {} diff --git a/src/views/connecting/Connecting.js b/src/views/connecting/Connecting.js index 83ec7f05eb..6c60e7ba8e 100644 --- a/src/views/connecting/Connecting.js +++ b/src/views/connecting/Connecting.js @@ -19,7 +19,7 @@ import { useTokens } from '@kyper/tokenprovider' import { SlideDown } from 'src/components/SlideDown' import { getDelay } from 'src/utilities/getDelay' -import { pollMember, CONNECTING_MESSAGES } from 'src/utilities/pollers' +import { CONNECTING_MESSAGES } from 'src/utilities/pollers' import { STEPS } from 'src/const/Connect' import { ProgressBar } from 'src/views/connecting/progress/ProgressBar' import * as JobSchedule from 'src/utilities/JobSchedule' @@ -48,7 +48,7 @@ import { POST_MESSAGES } from 'src/const/postMessages' import { AnalyticContext } from 'src/Connect' import { PostMessageContext } from 'src/ConnectWidget' import { Stack } from '@mui/material' -import { getExperimentalFeatures } from 'src/redux/reducers/experimentalFeaturesSlice' +import { usePollMember } from 'src/hooks/usePollMember' export const Connecting = (props) => { const { @@ -61,8 +61,6 @@ export const Connecting = (props) => { } = props const selectedInstitution = useSelector(getSelectedInstitution) - const { optOutOfEarlyUserRelease, memberPollingMilliseconds } = - useSelector(getExperimentalFeatures) const sendAnalyticsEvent = useAnalyticsEvent() const clientLocale = useMemo(() => { return document.querySelector('html')?.getAttribute('lang') || 'en' @@ -89,6 +87,8 @@ export const Connecting = (props) => { const [timedOut, setTimedOut] = useState(false) const [connectingError, setConnectingError] = useState(null) + const pollMember = usePollMember() + const activeJob = JobSchedule.getActiveJob(jobSchedule) const needsToInitializeJobSchedule = jobSchedule.isInitialized === false @@ -284,13 +284,7 @@ export const Connecting = (props) => { }) .pipe( concatMap((member) => - pollMember( - member.guid, - api, - clientLocale, - optOutOfEarlyUserRelease, - memberPollingMilliseconds, - ).pipe( + pollMember(member.guid).pipe( tap((pollingState) => handleMemberPoll(pollingState)), filter((pollingState) => pollingState.pollingIsDone), pluck('currentResponse'),