Skip to content

Commit 623fc12

Browse files
authored
fix: abstract member polling into swappable transport layer (#304)
1 parent 58f4107 commit 623fc12

File tree

5 files changed

+378
-51
lines changed

5 files changed

+378
-51
lines changed

GEMINI.md

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
# Connect Widget AI Context
2+
3+
## Project Overview
4+
5+
`@mxenabled/connect-widget` is a UI-only library for the Connect Widget, built with React and TypeScript. It provides the visual components and state management for the widget but relies on an external API and configuration provided by the consuming application to function.
6+
7+
The library uses `ConnectWidget` as the main entry point and `ApiProvider` to inject the necessary API callbacks.
8+
9+
**Core Technologies:**
10+
11+
- **UI Framework:** React
12+
- **Language:** TypeScript
13+
- **State Management:** Redux Toolkit
14+
- **Build Tool:** Vite
15+
- **Testing:** Vitest
16+
- **(Old. Do not introduce more usage) Component Library:** Kyper UI (`@kyper/*`)
17+
18+
## Building and Running
19+
20+
The project relies on standard npm scripts for development, building, and testing:
21+
22+
- **Install Dependencies:** `npm install`
23+
- **Development Build (Watch Mode):** `npm run dev`
24+
- **Production Build:** `npm run build`
25+
- **Run Tests:** `npm run test`
26+
- **Watch Tests:** `npm run watch`
27+
- **Lint Code:** `npm run lint`
28+
- **Link locally:** Use `npm link` in the root and then `npm link @mxenabled/connect-widget` in the consuming application to test local changes.
29+
30+
## Development Conventions
31+
32+
This repository has strict architectural, styling, and testing standards defined in the Architecture Decision Records (`architectureDecisionRecords/`). All new code must conform to these ADRs.
33+
34+
### Architecture & Design
35+
36+
- **Architecture Decision Records (ADRs):** Any significant technical choices should be documented as an ADR. Pull requests with new code that does not adhere to the agreed-upon ADRs will not be approved (exceptions for urgent hotfixes).
37+
- **Styling:** The project uses **CSS Modules** for styling. Do not use Tailwind CSS or other global CSS frameworks unless specifically working on existing legacy code that hasn't been migrated.
38+
39+
### Testing
40+
41+
- **Frameworks:** Use **Vitest** for unit and integration testing. **MSW (Mock Service Worker)** is the standard for API mocking in tests. **Cypress** is the standard for end-to-end tests.
42+
- **Philosophy:** Prefer integration tests over unit tests. Mock as little as possible. The primary goal of testing is to ensure that the frontend works properly with the backend and to provide confidence to deploy without manual testing.
43+
44+
### Git & Version Control
45+
46+
- **Conventional Commits:** All commit messages must follow the Conventional Commits specification to trigger semantic versioning releases properly.
47+
- You can use `npx cz` (Commitizen) to launch interactive prompts for formatting your commit message.
48+
- `fix:` triggers a PATCH bump
49+
- `feat:` triggers a MINOR bump
50+
- `BREAKING CHANGE:` footer triggers a MAJOR bump.
51+
52+
## Project Structure Highlights
53+
54+
- `src/`: Contains all the source code for the widget.
55+
- `components/`: React components.
56+
- `redux/`: Redux actions, reducers, selectors, and store configuration.
57+
- `context/`: React context providers (like `ApiContext`).
58+
- `hooks/`: Custom React hooks.
59+
- `views/`: Higher-level view components.
60+
- `docs/`: Extensive documentation on analytics, API requirements, client config, and user features.
61+
- `architectureDecisionRecords/`: Documentation of core engineering decisions (ADRs).

src/hooks/__tests__/usePollMember-test.tsx

Lines changed: 105 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,37 +1,16 @@
11
import React from 'react'
22
import { renderHook, waitFor } from '@testing-library/react'
33
import { vi } from 'vitest'
4-
import { usePollMember } from 'src/hooks/usePollMember'
5-
import { ApiProvider } from 'src/context/ApiContext'
4+
import { usePollMember, PollingState } from 'src/hooks/usePollMember'
5+
import { ApiProvider, ApiContextTypes } from 'src/context/ApiContext'
66
import { Provider } from 'react-redux'
7-
import { createReduxStore } from 'src/redux/Store'
7+
import { createReduxStore, RootState } from 'src/redux/Store'
88
import { member, JOB_DATA } from 'src/services/mockedData'
99
import { ReadableStatuses } from 'src/const/Statuses'
1010
import { CONNECTING_MESSAGES } from 'src/utilities/pollers'
1111
import { take } from 'rxjs/operators'
1212

13-
interface PollingState {
14-
isError: boolean
15-
pollingCount: number
16-
currentResponse?: unknown
17-
pollingIsDone: boolean
18-
userMessage?: string
19-
initialDataReady?: boolean
20-
}
21-
22-
interface ApiValue {
23-
loadMemberByGuid?: (guid: string, locale: string) => Promise<unknown>
24-
loadJob?: (jobGuid: string) => Promise<unknown>
25-
}
26-
27-
interface PreloadedState {
28-
experimentalFeatures?: {
29-
optOutOfEarlyUserRelease?: boolean
30-
memberPollingMilliseconds?: number
31-
}
32-
}
33-
34-
const createWrapper = (apiValue: ApiValue, preloadedState?: PreloadedState) => {
13+
const createWrapper = (apiValue: Partial<ApiContextTypes>, preloadedState?: Partial<RootState>) => {
3514
const store = createReduxStore(preloadedState)
3615
const Wrapper = ({ children }: { children: React.ReactNode }) => (
3716
<Provider store={store}>
@@ -641,4 +620,105 @@ describe('usePollMember', () => {
641620

642621
subscription.unsubscribe()
643622
}, 10000)
623+
624+
it('should correctly update previousResponse and currentResponse over multiple polls', async () => {
625+
const member1 = { ...member.member, guid: 'MBR-1' }
626+
const member2 = { ...member.member, guid: 'MBR-2' }
627+
628+
const apiValue = {
629+
loadMemberByGuid: vi.fn().mockResolvedValueOnce(member1).mockResolvedValue(member2),
630+
loadJob: vi.fn().mockResolvedValue(JOB_DATA),
631+
}
632+
633+
const preloadedState = {
634+
experimentalFeatures: {
635+
memberPollingMilliseconds: 1000,
636+
},
637+
}
638+
639+
const { result } = renderHook(() => usePollMember(), {
640+
wrapper: createWrapper(apiValue, preloadedState),
641+
})
642+
643+
const pollMember = result.current
644+
const states: PollingState[] = []
645+
646+
const subscription = pollMember('MBR-123')
647+
.pipe(take(2))
648+
.subscribe((state: PollingState) => {
649+
states.push(state)
650+
})
651+
652+
await waitFor(
653+
() => {
654+
expect(states.length).toBeGreaterThanOrEqual(2)
655+
},
656+
{ timeout: 3500 },
657+
)
658+
659+
// First poll
660+
expect(states[0].previousResponse).toEqual({})
661+
expect(states[0].currentResponse).toEqual({ member: member1, job: JOB_DATA })
662+
663+
// Second poll
664+
expect(states[1].previousResponse).toEqual({ member: member1, job: JOB_DATA })
665+
expect(states[1].currentResponse).toEqual({ member: member2, job: JOB_DATA })
666+
667+
subscription.unsubscribe()
668+
}, 10000)
669+
670+
it('should preserve previousResponse and currentResponse when an intermediate poll fails', async () => {
671+
const member1 = { ...member.member, guid: 'MBR-1' }
672+
673+
const apiValue = {
674+
loadMemberByGuid: vi
675+
.fn()
676+
.mockResolvedValueOnce(member1)
677+
.mockRejectedValueOnce(new Error('Intermediate Error'))
678+
.mockResolvedValue(member1),
679+
loadJob: vi.fn().mockResolvedValue(JOB_DATA),
680+
}
681+
682+
const preloadedState = {
683+
experimentalFeatures: {
684+
memberPollingMilliseconds: 1000,
685+
},
686+
}
687+
688+
const { result } = renderHook(() => usePollMember(), {
689+
wrapper: createWrapper(apiValue, preloadedState),
690+
})
691+
692+
const pollMember = result.current
693+
const states: PollingState[] = []
694+
695+
const subscription = pollMember('MBR-123')
696+
.pipe(take(3))
697+
.subscribe((state: PollingState) => {
698+
states.push(state)
699+
})
700+
701+
await waitFor(
702+
() => {
703+
expect(states.length).toBeGreaterThanOrEqual(3)
704+
},
705+
{ timeout: 5000 },
706+
)
707+
708+
// First poll: Success
709+
expect(states[0].isError).toBe(false)
710+
expect(states[0].currentResponse).toEqual({ member: member1, job: JOB_DATA })
711+
712+
// Second poll: Error
713+
expect(states[1].isError).toBe(true)
714+
expect(states[1].previousResponse).toEqual({}) // Should be preserved from acc
715+
expect(states[1].currentResponse).toEqual({ member: member1, job: JOB_DATA }) // Should be preserved from acc
716+
717+
// Third poll: Success again
718+
expect(states[2].isError).toBe(false)
719+
expect(states[2].previousResponse).toEqual({ member: member1, job: JOB_DATA }) // acc.currentResponse was preserved
720+
expect(states[2].currentResponse).toEqual({ member: member1, job: JOB_DATA })
721+
722+
subscription.unsubscribe()
723+
}, 10000)
644724
})

src/hooks/usePollMember.tsx

Lines changed: 38 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,21 @@ import { useApi } from 'src/context/ApiContext'
44
import { useSelector } from 'react-redux'
55
import { getExperimentalFeatures } from 'src/redux/reducers/experimentalFeaturesSlice'
66

7-
import { defer, interval, of } from 'rxjs'
8-
import { catchError, scan, map, mergeMap, exhaustMap } from 'rxjs/operators'
7+
import { scan } from 'rxjs/operators'
8+
import {
9+
createMemberUpdateTransport,
10+
MemberUpdate,
11+
} from 'src/utilities/transport/MemberUpdateTransport'
12+
13+
export interface PollingState {
14+
isError: boolean
15+
pollingCount: number
16+
currentResponse?: MemberUpdate | Record<string, never>
17+
previousResponse?: MemberUpdate | Record<string, never>
18+
pollingIsDone: boolean
19+
userMessage?: string
20+
initialDataReady?: boolean
21+
}
922

1023
export function usePollMember() {
1124
const { api } = useApi()
@@ -20,32 +33,28 @@ export function usePollMember() {
2033
const pollingInterval = memberPollingMilliseconds || 3000
2134

2235
const pollMember = (memberGuid: string) => {
23-
return interval(pollingInterval).pipe(
24-
/**
25-
* used to be switchMap
26-
* exhaustMap ignores new emissions from the source while the current inner observable is still active.
27-
*
28-
* This ensures that we do not start a new poll request until the previous one has completed,
29-
* preventing overlapping requests and potential race conditions.
30-
*/
31-
exhaustMap(() =>
32-
// Poll the currentMember. Catch errors but don't handle it here
33-
// the scan will handle it below
34-
// @ts-expect-error: cannot invoke a method that might be undefined
35-
defer(() => api.loadMemberByGuid(memberGuid, clientLocale)).pipe(
36-
mergeMap((member) =>
37-
defer(() => api.loadJob(member.most_recent_job_guid as string)).pipe(
38-
map((job) => ({ member, job })),
39-
),
40-
),
41-
catchError((error) => of(error)),
42-
),
43-
),
36+
const loadMemberByGuid =
37+
api.loadMemberByGuid ||
38+
(() => Promise.reject(new Error('api.loadMemberByGuid is required for member polling')))
39+
40+
const updateStream$ = createMemberUpdateTransport(
41+
{
42+
loadMemberByGuid,
43+
loadJob: api.loadJob,
44+
},
45+
memberGuid,
46+
{
47+
pollingInterval,
48+
clientLocale,
49+
},
50+
)
51+
52+
return updateStream$.pipe(
4453
scan(
45-
(acc, response) => {
54+
(acc: PollingState, response) => {
4655
const isError = response instanceof Error
4756

48-
const pollingState = {
57+
const pollingState: PollingState = {
4958
// only track if the most recent poll was an error
5059
isError,
5160
// always increase polling count
@@ -56,11 +65,14 @@ export function usePollMember() {
5665
currentResponse: isError ? acc.currentResponse : response,
5766
// preserve the initialDataReadySent flag
5867
initialDataReady: acc.initialDataReady,
68+
pollingIsDone: false,
69+
userMessage: acc.userMessage,
5970
}
6071

6172
if (
6273
!isError &&
6374
!acc.initialDataReady &&
75+
// @ts-expect-error response might be undefined or an error
6476
response?.job?.async_account_data_ready &&
6577
!optOutOfEarlyUserRelease
6678
) {
@@ -76,7 +88,7 @@ export function usePollMember() {
7688
userMessage: messageKey,
7789
}
7890
},
79-
{ ...DEFAULT_POLLING_STATE },
91+
{ ...DEFAULT_POLLING_STATE } as PollingState,
8092
),
8193
)
8294
}
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import { Observable, defer, interval, of } from 'rxjs'
2+
import { catchError, map, mergeMap, exhaustMap } from 'rxjs/operators'
3+
import type { ApiContextTypes } from 'src/context/ApiContext'
4+
5+
type MemberUpdateApi = Required<Pick<ApiContextTypes, 'loadMemberByGuid' | 'loadJob'>>
6+
7+
export interface MemberUpdate {
8+
member?: MemberResponseType
9+
job?: JobResponseType
10+
}
11+
12+
export interface MemberUpdateTransportOptions {
13+
pollingInterval?: number
14+
clientLocale?: string
15+
}
16+
17+
export function createMemberUpdateTransport(
18+
api: MemberUpdateApi,
19+
memberGuid: string,
20+
options: MemberUpdateTransportOptions = {},
21+
): Observable<MemberUpdate | Error> {
22+
const pollingInterval = options.pollingInterval || 3000
23+
const clientLocale = options.clientLocale || 'en'
24+
25+
return interval(pollingInterval).pipe(
26+
exhaustMap(() =>
27+
defer(() => api.loadMemberByGuid(memberGuid, clientLocale)).pipe(
28+
mergeMap((member: MemberResponseType) =>
29+
defer(() =>
30+
api.loadJob((member as { most_recent_job_guid: string }).most_recent_job_guid),
31+
).pipe(map((job: JobResponseType) => ({ member, job }))),
32+
),
33+
catchError((error) => of(error)),
34+
),
35+
),
36+
)
37+
}

0 commit comments

Comments
 (0)