Skip to content

Commit 8a4e773

Browse files
authored
Update polling and timeout message handling | CTT-188 (#335)
* fix(pollers): stop polling if an explicit error code is available on the member * fix(polling): move distinctUntilChanged behavior that blocked correct handling of polls * fix(pollcount): deprecate pollingCount in favor of real timers * fix(nodda): wesockets detect CONNECTED before IMPAIRED is set This change allows a proper detection of NO DDA flows in verification mode even when websockets are insanely fast in their detection * refactor: apply some code review suggestions * Revert "fix(nodda): wesockets detect CONNECTED before IMPAIRED is set" This reverts commit e3b16a8.
1 parent c158c76 commit 8a4e773

8 files changed

Lines changed: 308 additions & 84 deletions

File tree

src/hooks/__tests__/usePollMember-test.tsx

Lines changed: 63 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,6 @@ describe('usePollMember', () => {
8888
expect(apiValue.loadJob).toHaveBeenCalledWith(connectedMember.most_recent_job_guid)
8989
expect(states[0]).toMatchObject({
9090
isError: false,
91-
pollingCount: 1,
9291
currentResponse: {
9392
member: connectedMember,
9493
job: JOB_DATA,
@@ -128,7 +127,6 @@ describe('usePollMember', () => {
128127

129128
expect(states[0]).toMatchObject({
130129
isError: true,
131-
pollingCount: 1,
132130
pollingIsDone: false,
133131
})
134132

@@ -314,7 +312,7 @@ describe('usePollMember', () => {
314312
subscription.unsubscribe()
315313
})
316314

317-
it('should increment pollingCount on each poll', async () => {
315+
it('should emit sequential states on each poll', async () => {
318316
const member1 = { ...member.member, guid: 'MBR-1', most_recent_job_guid: 'JOB-1' }
319317
const member2 = { ...member.member, guid: 'MBR-2', most_recent_job_guid: 'JOB-2' }
320318

@@ -349,8 +347,14 @@ describe('usePollMember', () => {
349347
{ timeout: 3500 },
350348
)
351349

352-
expect(states[0].pollingCount).toBe(1)
353-
expect(states[1].pollingCount).toBe(2)
350+
expect(states[0].currentResponse).toEqual({
351+
member: member1,
352+
job: { ...JOB_DATA, guid: 'JOB-1' },
353+
})
354+
expect(states[1].currentResponse).toEqual({
355+
member: member2,
356+
job: { ...JOB_DATA, guid: 'JOB-2' },
357+
})
354358

355359
subscription.unsubscribe()
356360
}, 10000)
@@ -871,4 +875,58 @@ describe('usePollMember', () => {
871875

872876
subscription.unsubscribe()
873877
})
878+
879+
it('should eventually stop polling for an oauth member in an error state that was never aggregating', async () => {
880+
/**
881+
* This tests a specific bug where distinctUntilChanged in the transport layer
882+
* blocks the second identical poll from reaching the scan accumulator.
883+
*
884+
* handlePollingResponse uses isNotAggregatingAtAll:
885+
* previousMember.is_being_aggregated === false && polledMember.is_being_aggregated === false
886+
*
887+
* Without the fix, the second poll is blocked, previousResponse stays as {} (DEFAULT),
888+
* isNotAggregatingAtAll is never true, and the OAuth member polls forever.
889+
*/
890+
const oauthMember = {
891+
...member.member,
892+
connection_status: ReadableStatuses.PREVENTED,
893+
is_being_aggregated: false,
894+
is_oauth: true,
895+
}
896+
897+
const apiValue = {
898+
loadMemberByGuid: vi.fn().mockResolvedValue(oauthMember),
899+
loadJob: vi.fn().mockResolvedValue(JOB_DATA),
900+
}
901+
902+
const preloadedState = {
903+
experimentalFeatures: {
904+
memberPollingMilliseconds: 100,
905+
},
906+
}
907+
908+
const { result } = renderHook(() => usePollMember(), {
909+
wrapper: createWrapper(apiValue, preloadedState),
910+
})
911+
912+
const pollMember = result.current
913+
const states: PollingState[] = []
914+
915+
const subscription = pollMember('MBR-123').subscribe((state: PollingState) => {
916+
states.push(state)
917+
})
918+
919+
await waitFor(
920+
() => {
921+
expect(states.some((s) => s.pollingIsDone === true)).toBe(true)
922+
},
923+
{ timeout: 1000 },
924+
)
925+
926+
expect(states.find((s) => s.pollingIsDone === true)?.userMessage).toBe(
927+
CONNECTING_MESSAGES.ERROR,
928+
)
929+
930+
subscription.unsubscribe()
931+
})
874932
})

src/hooks/usePollMember.tsx

Lines changed: 31 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,15 +5,15 @@ import { useWebSocket } from 'src/context/WebSocketContext'
55
import { useSelector } from 'react-redux'
66
import { getExperimentalFeatures } from 'src/redux/reducers/experimentalFeaturesSlice'
77

8-
import { scan } from 'rxjs/operators'
8+
import { scan, distinctUntilChanged } from 'rxjs/operators'
9+
import _isEqual from 'lodash/isEqual'
910
import {
1011
createMemberUpdateTransport,
1112
MemberUpdate,
1213
} from 'src/utilities/transport/MemberUpdateTransport'
1314

1415
export interface PollingState {
1516
isError: boolean
16-
pollingCount: number
1717
currentResponse?: MemberUpdate | Record<string, never>
1818
previousResponse?: MemberUpdate | Record<string, never>
1919
pollingIsDone: boolean
@@ -61,8 +61,6 @@ export function usePollMember() {
6161
const pollingState: PollingState = {
6262
// only track if the most recent poll was an error
6363
isError,
64-
// always increase polling count
65-
pollingCount: acc.pollingCount + 1,
6664
// dont update previous response if this is an error
6765
previousResponse: isError ? acc.previousResponse : acc.currentResponse,
6866
// dont update current response if this is an error
@@ -93,6 +91,35 @@ export function usePollMember() {
9391
},
9492
{ ...DEFAULT_POLLING_STATE } as PollingState,
9593
),
94+
// Deduplicate consecutive identical polling states to prevent unnecessary re-renders.
95+
// This must live here — after the scan — so the scan always sees every update
96+
// and can correctly track previousResponse/currentResponse transitions. Placing
97+
// distinctUntilChanged earlier (in the transport) caused the scan to miss
98+
// identical consecutive polls, breaking isNotAggregatingAtAll detection in
99+
// handlePollingResponse and causing OAuth members to poll indefinitely.
100+
distinctUntilChanged((prev: PollingState, curr: PollingState) => {
101+
if (prev.isError !== curr.isError) return false
102+
if (prev.pollingIsDone !== curr.pollingIsDone) return false
103+
if (prev.userMessage !== curr.userMessage) return false
104+
if (prev.initialDataReady !== curr.initialDataReady) return false
105+
106+
const prevMember = (prev.currentResponse as MemberUpdate)?.member
107+
const currMember = (curr.currentResponse as MemberUpdate)?.member
108+
const prevJob = (prev.currentResponse as MemberUpdate)?.job
109+
const currJob = (curr.currentResponse as MemberUpdate)?.job
110+
111+
// Return true to *prevent* emitting the event
112+
// Return false to emit the event
113+
return (
114+
prevMember?.connection_status === currMember?.connection_status &&
115+
_isEqual(prevMember?.mfa, currMember?.mfa) &&
116+
prevMember?.is_being_aggregated === currMember?.is_being_aggregated &&
117+
prevMember?.most_recent_job_detail_code === currMember?.most_recent_job_detail_code &&
118+
prevMember?.error?.error_code === currMember?.error?.error_code &&
119+
prevJob?.guid === currJob?.guid &&
120+
prevJob?.async_account_data_ready === currJob?.async_account_data_ready
121+
)
122+
}),
96123
)
97124
}
98125

src/utilities/__tests__/pollers-test.js

Lines changed: 41 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,12 @@ import {
55
} from 'src/utilities/pollers'
66
import { ErrorStatuses, ProcessingStatuses, ReadableStatuses } from 'src/const/Statuses'
77

8+
// CHALLENGED is intentionally excluded from error assertions because
9+
// handlePollingResponse routes it through the MFA-specific branch and message.
10+
const nonChallengedErrorStatuses = ErrorStatuses.filter(
11+
(status) => status !== ReadableStatuses.CHALLENGED,
12+
)
13+
814
describe('handlePollingResponse', () => {
915
test('it should stop polling and update the message', () => {
1016
testStatus(ReadableStatuses.CHALLENGED, true, CONNECTING_MESSAGES.MFA)
@@ -62,16 +68,13 @@ describe('handlePollingResponse', () => {
6268

6369
describe('Error states', () => {
6470
it('should stop polling and show a message', () => {
65-
ErrorStatuses.forEach((status) => {
66-
// CHALLENGED state is an error state, but has specific logic
67-
if (status !== ReadableStatuses.CHALLENGED) {
68-
testStatus(status, true, CONNECTING_MESSAGES.ERROR)
69-
}
71+
nonChallengedErrorStatuses.forEach((status) => {
72+
testStatus(status, true, CONNECTING_MESSAGES.ERROR)
7073
})
7174
})
7275

7376
it('should wait for aggregation to be done for error states', () => {
74-
ErrorStatuses.forEach((status) => {
77+
nonChallengedErrorStatuses.forEach((status) => {
7578
const pollingState = {
7679
...DEFAULT_POLLING_STATE,
7780
currentResponse: {
@@ -82,13 +85,10 @@ describe('handlePollingResponse', () => {
8285
},
8386
}
8487

85-
// CHALLENGED state is an error state, but has specific logic
86-
if (status !== ReadableStatuses.CHALLENGED) {
87-
const [stopPolling, message] = handlePollingResponse(pollingState)
88+
const [stopPolling, message] = handlePollingResponse(pollingState)
8889

89-
expect(stopPolling).toEqual(false)
90-
expect(message).toEqual(CONNECTING_MESSAGES.VERIFYING)
91-
}
90+
expect(stopPolling).toEqual(false)
91+
expect(message).toEqual(CONNECTING_MESSAGES.VERIFYING)
9292
})
9393
})
9494

@@ -118,9 +118,9 @@ describe('handlePollingResponse', () => {
118118
})
119119
})
120120

121-
describe('OAuth status', () => {
121+
describe('Terminal error code handling', () => {
122122
it('should keep polling and show the OAuth message if in error, but not finished agging', () => {
123-
ErrorStatuses.forEach((status) => {
123+
nonChallengedErrorStatuses.forEach((status) => {
124124
const pollingState = {
125125
...DEFAULT_POLLING_STATE,
126126
currentResponse: {
@@ -132,17 +132,15 @@ describe('handlePollingResponse', () => {
132132
},
133133
}
134134

135-
if (status !== ReadableStatuses.CHALLENGED) {
136-
const [stopPolling, message] = handlePollingResponse(pollingState)
135+
const [stopPolling, message] = handlePollingResponse(pollingState)
137136

138-
expect(message).toEqual(CONNECTING_MESSAGES.OAUTH)
139-
expect(stopPolling).toEqual(false)
140-
}
137+
expect(message).toEqual(CONNECTING_MESSAGES.OAUTH)
138+
expect(stopPolling).toEqual(false)
141139
})
142140
})
143141

144142
it('should go to error view if we are done aggregating', () => {
145-
ErrorStatuses.forEach((status) => {
143+
nonChallengedErrorStatuses.forEach((status) => {
146144
const pollingState = {
147145
...DEFAULT_POLLING_STATE,
148146
currentResponse: {
@@ -161,12 +159,31 @@ describe('handlePollingResponse', () => {
161159
},
162160
}
163161

164-
if (status !== ReadableStatuses.CHALLENGED) {
165-
const [stopPolling, message] = handlePollingResponse(pollingState)
162+
const [stopPolling, message] = handlePollingResponse(pollingState)
166163

167-
expect(message).toEqual(CONNECTING_MESSAGES.ERROR)
168-
expect(stopPolling).toEqual(true)
164+
expect(message).toEqual(CONNECTING_MESSAGES.ERROR)
165+
expect(stopPolling).toEqual(true)
166+
})
167+
})
168+
169+
it('should stop polling when a terminal error code is present, oauth is true, and there is no previous response', () => {
170+
nonChallengedErrorStatuses.forEach((status) => {
171+
const pollingState = {
172+
...DEFAULT_POLLING_STATE,
173+
currentResponse: {
174+
member: {
175+
connection_status: status,
176+
is_being_aggregated: false,
177+
is_oauth: true,
178+
error: { error_code: 'ANY_ERROR_CODE' },
179+
},
180+
},
169181
}
182+
183+
const [stopPolling, message] = handlePollingResponse(pollingState)
184+
185+
expect(message).toEqual(CONNECTING_MESSAGES.ERROR)
186+
expect(stopPolling).toEqual(true)
170187
})
171188
})
172189
})

src/utilities/pollers.js

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,6 @@ export const CONNECTING_MESSAGES = {
1818

1919
export const DEFAULT_POLLING_STATE = {
2020
isError: false, // whether or not the last poll was an error
21-
pollingCount: 0, // used to count how many times we have polled
2221
previousResponse: {}, // previous response from last poll
2322
currentResponse: {}, // current response
2423
pollingIsDone: false, // whether or not we should stop polling
@@ -65,11 +64,22 @@ export function handlePollingResponse(pollingState) {
6564
return [false, CONNECTING_MESSAGES.VERIFYING]
6665
}
6766

68-
// if we aren't aggregating whatsoever and in an error state, stop polling
67+
// if we aren't aggregating whatsoever and in an error state, stop polling,
68+
// even if we don't have an explicit error code.
6969
if (isNotAggregatingAtAll && ErrorStatuses.includes(polledMember.connection_status)) {
7070
return [true, CONNECTING_MESSAGES.ERROR]
7171
}
7272

73+
// if we aren't aggregating, are in an error state, and have an explicit error code already,
74+
// stop polling and show the error message.
75+
if (
76+
polledMember.is_being_aggregated === false &&
77+
ErrorStatuses.includes(polledMember.connection_status) &&
78+
Boolean(polledMember.error?.error_code)
79+
) {
80+
return [true, CONNECTING_MESSAGES.ERROR]
81+
}
82+
7383
/**
7484
* If this is an OAuth member, we could be stuck 'connecting' forever if the
7585
* user bails out of the authentication process, leaving the member in the
@@ -112,8 +122,6 @@ export function pollOauthState(oauthStateGuid, api) {
112122
return {
113123
// only track if the most recent poll was an error
114124
isError,
115-
// always increase polling count
116-
pollingCount: acc.pollingCount + 1,
117125
// dont update previous response if this is an error
118126
previousResponse: isError ? acc.previousResponse : acc.currentResponse,
119127
// dont update current response if this is an error

src/utilities/transport/MemberUpdateTransport.ts

Lines changed: 2 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,5 @@
11
import { Observable, defer, interval, of, merge } from 'rxjs'
2-
import {
3-
catchError,
4-
map,
5-
mergeMap,
6-
exhaustMap,
7-
filter,
8-
distinctUntilChanged,
9-
scan,
10-
} from 'rxjs/operators'
11-
import _isEqual from 'lodash/isEqual'
2+
import { catchError, map, mergeMap, exhaustMap, filter, scan } from 'rxjs/operators'
123
import type { ApiContextTypes } from 'src/context/ApiContext'
134
import { WebSocketConnection } from 'src/context/WebSocketContext'
145

@@ -83,26 +74,5 @@ export function createMemberUpdateTransport(
8374
transport$ = merge(polling$, socket$)
8475
}
8576

86-
return transport$.pipe(
87-
distinctUntilChanged((prev, curr) => {
88-
// Don't deduplicate errors
89-
if (prev instanceof Error || curr instanceof Error) return false
90-
91-
const prevMember = prev.member
92-
const currMember = curr.member
93-
94-
// Compare the relevant fields to determine if we should emit an update
95-
// Return true to *prevent* emitting the event
96-
// Return false to emit the event
97-
return (
98-
prevMember?.connection_status === currMember?.connection_status &&
99-
_isEqual(prevMember?.mfa, currMember?.mfa) &&
100-
prev.job?.guid === curr.job?.guid &&
101-
prev.job?.async_account_data_ready === curr.job?.async_account_data_ready &&
102-
prevMember?.is_being_aggregated === currMember?.is_being_aggregated &&
103-
prevMember?.most_recent_job_detail_code === currMember?.most_recent_job_detail_code &&
104-
prevMember?.error?.error_code === currMember?.error?.error_code
105-
)
106-
}),
107-
)
77+
return transport$
10878
}

0 commit comments

Comments
 (0)