diff --git a/.github/workflows/audit.yml b/.github/workflows/audit.yml
deleted file mode 100644
index e81a64a2c5..0000000000
--- a/.github/workflows/audit.yml
+++ /dev/null
@@ -1,17 +0,0 @@
-name: NPM Audit
-
-on:
- pull_request:
-
-jobs:
- NPM-Audit:
- runs-on: ubuntu-latest
-
- steps:
- - uses: actions/checkout@v2
- - uses: actions/setup-node@v2
- with:
- node-version: "lts/*"
-
- - name: Run npm audit
- run: npm audit --audit-level=high
diff --git a/README.md b/README.md
index dbd64f87cd..028f18fcab 100644
--- a/README.md
+++ b/README.md
@@ -48,6 +48,8 @@ const App = () => {
| `profiles` | [`ProfilesTypes`](./typings/connectProps.d.ts) | The connect widget uses the profiles to set the initial state of the widget. [More details](./docs/PROFILES.md) | See more details |
| `userFeatures` | [`UserFeaturesType`](./typings/connectProps.d.ts) | The connect widget uses user features to determine the behavior of the widget. [More details](./docs/USER_FEATURES.md) | See more details |
| `showTooSmallDialog` | `boolean` | The connect widget can show a warning when the widget size is below the supported 320px. | `true` |
+| `webSocketConnection` | `object` | An object containing `isConnected()` function and `webSocketMessages$` observable for real-time updates. | `null` |
+| `experimentalFeatures` | `object` | An object to enable or disable experimental features like `useWebSockets: true`. | `null` |
## ApiProvider
diff --git a/package-lock.json b/package-lock.json
index 5220338274..c8dc1eb303 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -847,6 +847,7 @@
"cpu": [
"ppc64"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -863,6 +864,7 @@
"cpu": [
"arm"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -879,6 +881,7 @@
"cpu": [
"arm64"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -895,6 +898,7 @@
"cpu": [
"x64"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -911,6 +915,7 @@
"cpu": [
"arm64"
],
+ "dev": true,
"optional": true,
"os": [
"darwin"
@@ -926,6 +931,7 @@
"cpu": [
"x64"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -942,6 +948,7 @@
"cpu": [
"arm64"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -958,6 +965,7 @@
"cpu": [
"x64"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -974,6 +982,7 @@
"cpu": [
"arm"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -990,6 +999,7 @@
"cpu": [
"arm64"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1006,6 +1016,7 @@
"cpu": [
"ia32"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1022,6 +1033,7 @@
"cpu": [
"loong64"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1038,6 +1050,7 @@
"cpu": [
"mips64el"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1054,6 +1067,7 @@
"cpu": [
"ppc64"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1070,6 +1084,7 @@
"cpu": [
"riscv64"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1086,6 +1101,7 @@
"cpu": [
"s390x"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1102,6 +1118,7 @@
"cpu": [
"x64"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1118,6 +1135,7 @@
"cpu": [
"arm64"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1134,6 +1152,7 @@
"cpu": [
"x64"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1150,6 +1169,7 @@
"cpu": [
"arm64"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1166,6 +1186,7 @@
"cpu": [
"x64"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1182,6 +1203,7 @@
"cpu": [
"arm64"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1198,6 +1220,7 @@
"cpu": [
"x64"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1214,6 +1237,7 @@
"cpu": [
"arm64"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1230,6 +1254,7 @@
"cpu": [
"ia32"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1246,6 +1271,7 @@
"cpu": [
"x64"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -3234,6 +3260,7 @@
"cpu": [
"arm"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -3247,6 +3274,7 @@
"cpu": [
"arm64"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -3260,6 +3288,7 @@
"cpu": [
"arm64"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -3273,6 +3302,7 @@
"cpu": [
"x64"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -3286,6 +3316,7 @@
"cpu": [
"arm64"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -3299,6 +3330,7 @@
"cpu": [
"x64"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -3312,9 +3344,7 @@
"cpu": [
"arm"
],
- "libc": [
- "glibc"
- ],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -3328,9 +3358,7 @@
"cpu": [
"arm"
],
- "libc": [
- "musl"
- ],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -3344,9 +3372,7 @@
"cpu": [
"arm64"
],
- "libc": [
- "glibc"
- ],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -3360,9 +3386,7 @@
"cpu": [
"arm64"
],
- "libc": [
- "musl"
- ],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -3376,9 +3400,7 @@
"cpu": [
"loong64"
],
- "libc": [
- "glibc"
- ],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -3392,9 +3414,7 @@
"cpu": [
"loong64"
],
- "libc": [
- "musl"
- ],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -3408,9 +3428,7 @@
"cpu": [
"ppc64"
],
- "libc": [
- "glibc"
- ],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -3424,9 +3442,7 @@
"cpu": [
"ppc64"
],
- "libc": [
- "musl"
- ],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -3440,9 +3456,7 @@
"cpu": [
"riscv64"
],
- "libc": [
- "glibc"
- ],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -3456,9 +3470,7 @@
"cpu": [
"riscv64"
],
- "libc": [
- "musl"
- ],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -3472,9 +3484,7 @@
"cpu": [
"s390x"
],
- "libc": [
- "glibc"
- ],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -3488,9 +3498,7 @@
"cpu": [
"x64"
],
- "libc": [
- "glibc"
- ],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -3504,9 +3512,7 @@
"cpu": [
"x64"
],
- "libc": [
- "musl"
- ],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -3520,6 +3526,7 @@
"cpu": [
"x64"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -3533,6 +3540,7 @@
"cpu": [
"arm64"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -3546,6 +3554,7 @@
"cpu": [
"arm64"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -3559,6 +3568,7 @@
"cpu": [
"ia32"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -3572,6 +3582,7 @@
"cpu": [
"x64"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -3585,6 +3596,7 @@
"cpu": [
"x64"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -5536,13 +5548,14 @@
}
},
"node_modules/axios": {
- "version": "1.13.5",
- "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.5.tgz",
- "integrity": "sha512-cz4ur7Vb0xS4/KUN0tPWe44eqxrIu31me+fbang3ijiNscE129POzipJJA6zniq2C/Z6sJCjMimjS8Lc/GAs8Q==",
+ "version": "1.15.0",
+ "resolved": "https://registry.npmjs.org/axios/-/axios-1.15.0.tgz",
+ "integrity": "sha512-wWyJDlAatxk30ZJer+GeCWS209sA42X+N5jU2jy6oHTp7ufw8uzUTVFBX9+wTfAlhiJXGS0Bq7X6efruWjuK9Q==",
+ "license": "MIT",
"dependencies": {
"follow-redirects": "^1.15.11",
"form-data": "^4.0.5",
- "proxy-from-env": "^1.1.0"
+ "proxy-from-env": "^2.1.0"
}
},
"node_modules/babel-plugin-macros": {
@@ -8233,6 +8246,7 @@
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+ "dev": true,
"hasInstallScript": true,
"optional": true,
"os": [
@@ -9653,6 +9667,7 @@
"version": "2.6.1",
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz",
"integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==",
+ "dev": true,
"optional": true,
"bin": {
"jiti": "lib/jiti-cli.mjs"
@@ -13278,7 +13293,7 @@
}
},
"node_modules/npm/node_modules/tinyglobby/node_modules/picomatch": {
- "version": "4.0.4",
+ "version": "4.0.3",
"dev": true,
"inBundle": true,
"license": "MIT",
@@ -14200,9 +14215,13 @@
"dev": true
},
"node_modules/proxy-from-env": {
- "version": "1.1.0",
- "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
- "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg=="
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz",
+ "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ }
},
"node_modules/psl": {
"version": "1.15.0",
@@ -16745,10 +16764,11 @@
"integrity": "sha512-m6EXlCAMetKztO1ppBhGU1/1MR3IiEevO6ESq6rcrSQ3Q77xYSW13jkfXW88o4xMrkXJhy/U7j4wFR/twMB0Eg=="
},
"node_modules/vite": {
- "version": "7.3.1",
- "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz",
- "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==",
+ "version": "7.3.2",
+ "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.2.tgz",
+ "integrity": "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==",
"devOptional": true,
+ "license": "MIT",
"dependencies": {
"esbuild": "^0.27.0",
"fdir": "^6.5.0",
@@ -17319,7 +17339,7 @@
"version": "2.8.3",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz",
"integrity": "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==",
- "devOptional": true,
+ "dev": true,
"license": "ISC",
"bin": {
"yaml": "bin.mjs"
diff --git a/src/ConnectWidget.tsx b/src/ConnectWidget.tsx
index 389a40e163..97acf4bf03 100644
--- a/src/ConnectWidget.tsx
+++ b/src/ConnectWidget.tsx
@@ -9,6 +9,7 @@ import { initGettextLocaleData } from 'src/utilities/Personalization'
import { ConnectedTokenProvider } from 'src/ConnectedTokenProvider'
import { TooSmallDialog } from 'src/components/app/TooSmallDialog'
import { setLocalizedContent } from 'src/redux/reducers/localizedContentSlice'
+import { WebSocketProvider } from 'src/context/WebSocketContext'
import './sharedVariables.css'
interface PostMessageContextType {
@@ -27,6 +28,7 @@ export const ConnectWidget = ({
onAnalyticPageview = () => {},
postMessageEventOverrides,
showTooSmallDialog = true,
+ webSocketConnection,
...props
}: any) => {
initGettextLocaleData(props.language)
@@ -38,12 +40,14 @@ export const ConnectWidget = ({
return (
-
-
- {showTooSmallDialog && }
-
-
-
+
+
+
+ {showTooSmallDialog && }
+
+
+
+
)
diff --git a/src/__tests__/ConnectWidget-test.tsx b/src/__tests__/ConnectWidget-test.tsx
new file mode 100644
index 0000000000..1ab6d38be6
--- /dev/null
+++ b/src/__tests__/ConnectWidget-test.tsx
@@ -0,0 +1,53 @@
+import React from 'react'
+import { render } from '@testing-library/react'
+import { describe, it, expect, vi } from 'vitest'
+import { of } from 'rxjs'
+
+import { ConnectWidget } from '../ConnectWidget'
+import { useWebSocket } from '../context/WebSocketContext'
+
+vi.mock('src/Connect', () => ({
+ default: vi.fn(() => {
+ // In actual implementation, it uses Context
+ // But for the test we just want to see if it renders without crashing
+ // and correctly provides the context which we can check via useWebSocket in a child if we want
+ return
mock-connect
+ }),
+}))
+
+// A simple component to verify context
+const ContextChecker = () => {
+ const ws = useWebSocket()
+ return {ws ? 'has-ws' : 'no-ws'}
+}
+
+// We need to mock Connect to render the ContextChecker instead
+vi.mock('src/Connect', () => ({
+ default: () => ,
+}))
+
+describe('ConnectWidget', () => {
+ const defaultProps = {
+ clientConfig: {},
+ profiles: {},
+ userFeatures: {},
+ language: { locale: 'en', localizedContent: {} },
+ }
+
+ it('provides webSocketConnection to children when passed as a prop', () => {
+ const mockWS = {
+ isConnected: vi.fn().mockReturnValue(true),
+ webSocketMessages$: of({}),
+ }
+
+ const { getByTestId } = render()
+
+ expect(getByTestId('context-checker')).toHaveTextContent('has-ws')
+ })
+
+ it('does not provide webSocketConnection when not passed', () => {
+ const { getByTestId } = render()
+
+ expect(getByTestId('context-checker')).toHaveTextContent('no-ws')
+ })
+})
diff --git a/src/context/WebSocketContext.tsx b/src/context/WebSocketContext.tsx
new file mode 100644
index 0000000000..04f24ec9f3
--- /dev/null
+++ b/src/context/WebSocketContext.tsx
@@ -0,0 +1,19 @@
+/* eslint-disable @typescript-eslint/no-explicit-any */
+import React, { createContext, useContext } from 'react'
+import { Observable } from 'rxjs'
+
+export interface WebSocketConnection {
+ isConnected: () => boolean
+ webSocketMessages$: Observable
+}
+
+const WebSocketContext = createContext(undefined)
+
+export const WebSocketProvider: React.FC<{
+ value?: WebSocketConnection
+ children: React.ReactNode
+}> = ({ value, children }) => (
+ {children}
+)
+
+export const useWebSocket = () => useContext(WebSocketContext)
diff --git a/src/hooks/__tests__/usePollMember-test.tsx b/src/hooks/__tests__/usePollMember-test.tsx
index 41869a1fda..9571e2328a 100644
--- a/src/hooks/__tests__/usePollMember-test.tsx
+++ b/src/hooks/__tests__/usePollMember-test.tsx
@@ -1,21 +1,29 @@
+/* eslint-disable @typescript-eslint/no-explicit-any */
import React from 'react'
import { renderHook, waitFor } from '@testing-library/react'
import { vi } from 'vitest'
import { usePollMember, PollingState } from 'src/hooks/usePollMember'
import { ApiProvider, ApiContextTypes } from 'src/context/ApiContext'
+import { WebSocketProvider, WebSocketConnection } from 'src/context/WebSocketContext'
import { Provider } from 'react-redux'
import { createReduxStore, RootState } 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'
+import { Subject } from 'rxjs'
-const createWrapper = (apiValue: Partial, preloadedState?: Partial) => {
+const createWrapper = (
+ apiValue: Partial,
+ preloadedState?: Partial,
+ webSocketValue?: WebSocketConnection,
+) => {
const store = createReduxStore(preloadedState)
const Wrapper = ({ children }: { children: React.ReactNode }) => (
- {/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
- {children}
+
+ {children}
+
)
Wrapper.displayName = 'TestWrapper'
@@ -27,6 +35,10 @@ describe('usePollMember', () => {
document.documentElement.setAttribute('lang', 'en')
})
+ afterEach(() => {
+ vi.restoreAllMocks()
+ })
+
it('should return a pollMember function', () => {
const apiValue = {
loadMemberByGuid: vi.fn().mockResolvedValue(member.member),
@@ -303,9 +315,12 @@ describe('usePollMember', () => {
})
it('should increment pollingCount on each poll', async () => {
+ const member1 = { ...member.member, guid: 'MBR-1', most_recent_job_guid: 'JOB-1' }
+ const member2 = { ...member.member, guid: 'MBR-2', most_recent_job_guid: 'JOB-2' }
+
const apiValue = {
- loadMemberByGuid: vi.fn().mockResolvedValue(member.member),
- loadJob: vi.fn().mockResolvedValue(JOB_DATA),
+ loadMemberByGuid: vi.fn().mockResolvedValueOnce(member1).mockResolvedValue(member2),
+ loadJob: vi.fn().mockImplementation((guid) => Promise.resolve({ ...JOB_DATA, guid })),
}
const preloadedState = {
@@ -446,15 +461,23 @@ describe('usePollMember', () => {
async_account_data_ready: true,
}
- const memberWithJob = {
+ const member1 = {
...member.member,
+ guid: 'MBR-1',
+ most_recent_job_guid: 'JOB-1',
is_being_aggregated: false,
connection_status: ReadableStatuses.CONNECTED,
}
+ const member2 = { ...member1, guid: 'MBR-2', most_recent_job_guid: 'JOB-2' }
+ const member3 = { ...member1, guid: 'MBR-3', most_recent_job_guid: 'JOB-3' }
const apiValue = {
- loadMemberByGuid: vi.fn().mockResolvedValue(memberWithJob),
- loadJob: vi.fn().mockResolvedValue(jobWithAsyncData),
+ loadMemberByGuid: vi
+ .fn()
+ .mockResolvedValueOnce(member1)
+ .mockResolvedValueOnce(member2)
+ .mockResolvedValue(member3),
+ loadJob: vi.fn().mockImplementation((guid) => Promise.resolve({ ...jobWithAsyncData, guid })),
}
const preloadedState = {
@@ -622,12 +645,12 @@ describe('usePollMember', () => {
}, 10000)
it('should correctly update previousResponse and currentResponse over multiple polls', async () => {
- const member1 = { ...member.member, guid: 'MBR-1' }
- const member2 = { ...member.member, guid: 'MBR-2' }
+ const member1 = { ...member.member, guid: 'MBR-1', most_recent_job_guid: 'JOB-1' }
+ const member2 = { ...member.member, guid: 'MBR-2', most_recent_job_guid: 'JOB-2' }
const apiValue = {
loadMemberByGuid: vi.fn().mockResolvedValueOnce(member1).mockResolvedValue(member2),
- loadJob: vi.fn().mockResolvedValue(JOB_DATA),
+ loadJob: vi.fn().mockImplementation((guid) => Promise.resolve({ ...JOB_DATA, guid })),
}
const preloadedState = {
@@ -658,25 +681,34 @@ describe('usePollMember', () => {
// First poll
expect(states[0].previousResponse).toEqual({})
- expect(states[0].currentResponse).toEqual({ member: member1, job: JOB_DATA })
+ expect(states[0].currentResponse).toEqual({
+ member: member1,
+ job: { ...JOB_DATA, guid: 'JOB-1' },
+ })
// Second poll
- expect(states[1].previousResponse).toEqual({ member: member1, job: JOB_DATA })
- expect(states[1].currentResponse).toEqual({ member: member2, job: JOB_DATA })
+ expect(states[1].previousResponse).toEqual({
+ member: member1,
+ job: { ...JOB_DATA, guid: 'JOB-1' },
+ })
+ expect(states[1].currentResponse).toEqual({
+ member: member2,
+ job: { ...JOB_DATA, guid: 'JOB-2' },
+ })
subscription.unsubscribe()
}, 10000)
it('should preserve previousResponse and currentResponse when an intermediate poll fails', async () => {
- const member1 = { ...member.member, guid: 'MBR-1' }
+ const member1 = { ...member.member, guid: 'MBR-1', most_recent_job_guid: 'JOB-1' }
const apiValue = {
loadMemberByGuid: vi
.fn()
.mockResolvedValueOnce(member1)
.mockRejectedValueOnce(new Error('Intermediate Error'))
- .mockResolvedValue(member1),
- loadJob: vi.fn().mockResolvedValue(JOB_DATA),
+ .mockResolvedValue({ ...member1, guid: 'MBR-1-new', most_recent_job_guid: 'JOB-1-new' }),
+ loadJob: vi.fn().mockImplementation((guid) => Promise.resolve({ ...JOB_DATA, guid })),
}
const preloadedState = {
@@ -707,18 +739,88 @@ describe('usePollMember', () => {
// First poll: Success
expect(states[0].isError).toBe(false)
- expect(states[0].currentResponse).toEqual({ member: member1, job: JOB_DATA })
+ expect(states[0].currentResponse).toEqual({
+ member: member1,
+ job: { ...JOB_DATA, guid: 'JOB-1' },
+ })
// Second poll: Error
expect(states[1].isError).toBe(true)
expect(states[1].previousResponse).toEqual({}) // Should be preserved from acc
- expect(states[1].currentResponse).toEqual({ member: member1, job: JOB_DATA }) // Should be preserved from acc
+ expect(states[1].currentResponse).toEqual({
+ member: member1,
+ job: { ...JOB_DATA, guid: 'JOB-1' },
+ }) // Should be preserved from acc
// Third poll: Success again
expect(states[2].isError).toBe(false)
- expect(states[2].previousResponse).toEqual({ member: member1, job: JOB_DATA }) // acc.currentResponse was preserved
- expect(states[2].currentResponse).toEqual({ member: member1, job: JOB_DATA })
+ expect(states[2].previousResponse).toEqual({
+ member: member1,
+ job: { ...JOB_DATA, guid: 'JOB-1' },
+ }) // acc.currentResponse was preserved
+ expect(states[2].currentResponse).toEqual({
+ member: { ...member1, guid: 'MBR-1-new', most_recent_job_guid: 'JOB-1-new' },
+ job: { ...JOB_DATA, guid: 'JOB-1-new' },
+ })
subscription.unsubscribe()
}, 10000)
+
+ it('should receive updates from WebSockets when enabled', async () => {
+ const wsMessages$ = new Subject()
+ const mockWS = {
+ isConnected: vi.fn().mockReturnValue(true),
+ webSocketMessages$: wsMessages$.asObservable(),
+ }
+
+ const apiValue = {
+ loadMemberByGuid: vi.fn().mockResolvedValue(member.member),
+ loadJob: vi.fn().mockResolvedValue(JOB_DATA),
+ }
+
+ const preloadedState = {
+ experimentalFeatures: {
+ useWebSockets: true,
+ memberPollingMilliseconds: 10000, // Long interval to avoid poll interference
+ },
+ }
+
+ const { result } = renderHook(() => usePollMember(), {
+ wrapper: createWrapper(apiValue, preloadedState, mockWS),
+ })
+
+ const pollMember = result.current
+ const states: PollingState[] = []
+
+ const subscription = pollMember('MBR-123').subscribe((state: PollingState) => {
+ states.push(state)
+ })
+
+ // Emit from WebSocket
+ const wsMember = { guid: 'MBR-123', connection_status: 1 }
+ wsMessages$.next({ event: 'members/updated', payload: wsMember })
+
+ await waitFor(
+ () => {
+ expect(states.length).toBeGreaterThan(0)
+ },
+ { timeout: 4000 },
+ )
+
+ expect(states[0].currentResponse?.member).toEqual(wsMember)
+
+ // Emit priority data ready
+ wsMessages$.next({ event: 'members/priority_data_ready', payload: wsMember })
+
+ await waitFor(
+ () => {
+ expect(states.length).toBeGreaterThan(1)
+ },
+ { timeout: 4000 },
+ )
+
+ expect(states[1].initialDataReady).toBe(true)
+
+ subscription.unsubscribe()
+ })
})
diff --git a/src/hooks/usePollMember.tsx b/src/hooks/usePollMember.tsx
index 9c571569da..d889905eba 100644
--- a/src/hooks/usePollMember.tsx
+++ b/src/hooks/usePollMember.tsx
@@ -1,6 +1,7 @@
import { useMemo } from 'react'
import { DEFAULT_POLLING_STATE, handlePollingResponse } from 'src/utilities/pollers'
import { useApi } from 'src/context/ApiContext'
+import { useWebSocket } from 'src/context/WebSocketContext'
import { useSelector } from 'react-redux'
import { getExperimentalFeatures } from 'src/redux/reducers/experimentalFeaturesSlice'
@@ -22,12 +23,13 @@ export interface PollingState {
export function usePollMember() {
const { api } = useApi()
+ const webSocket = useWebSocket()
const clientLocale = useMemo(() => {
return document.querySelector('html')?.getAttribute('lang') || 'en'
}, [document.querySelector('html')?.getAttribute('lang')])
- const { optOutOfEarlyUserRelease, memberPollingMilliseconds } =
+ const { optOutOfEarlyUserRelease, memberPollingMilliseconds, useWebSockets } =
useSelector(getExperimentalFeatures)
const pollingInterval = memberPollingMilliseconds || 3000
@@ -46,7 +48,9 @@ export function usePollMember() {
{
pollingInterval,
clientLocale,
+ useWebSockets,
},
+ webSocket,
)
return updateStream$.pipe(
@@ -72,7 +76,6 @@ export function usePollMember() {
if (
!isError &&
!acc.initialDataReady &&
- // @ts-expect-error response might be undefined or an error
response?.job?.async_account_data_ready &&
!optOutOfEarlyUserRelease
) {
diff --git a/src/redux/reducers/__tests__/experimentalFeaturesSlice-test.ts b/src/redux/reducers/__tests__/experimentalFeaturesSlice-test.ts
new file mode 100644
index 0000000000..a5eb020f39
--- /dev/null
+++ b/src/redux/reducers/__tests__/experimentalFeaturesSlice-test.ts
@@ -0,0 +1,28 @@
+import { describe, it, expect } from 'vitest'
+import reducer, { initialState, loadExperimentalFeatures } from '../experimentalFeaturesSlice'
+
+describe('experimentalFeaturesSlice', () => {
+ it('should return the initial state', () => {
+ expect(reducer(undefined, { type: 'unknown' })).toEqual(initialState)
+ })
+
+ it('should handle loadExperimentalFeatures', () => {
+ const payload = {
+ unavailableInstitutions: [{ guid: '123', name: 'Test' }],
+ optOutOfEarlyUserRelease: true,
+ memberPollingMilliseconds: 5000,
+ useWebSockets: true,
+ }
+ const nextState = reducer(initialState, loadExperimentalFeatures(payload))
+ expect(nextState.unavailableInstitutions).toEqual(payload.unavailableInstitutions)
+ expect(nextState.optOutOfEarlyUserRelease).toBe(true)
+ expect(nextState.memberPollingMilliseconds).toBe(5000)
+ expect(nextState.useWebSockets).toBe(true)
+ })
+
+ it('should default useWebSockets to false if not provided', () => {
+ const payload = {}
+ const nextState = reducer(initialState, loadExperimentalFeatures(payload))
+ expect(nextState.useWebSockets).toBe(false)
+ })
+})
diff --git a/src/redux/reducers/experimentalFeaturesSlice.ts b/src/redux/reducers/experimentalFeaturesSlice.ts
index 5b957ff583..a1c9a063e4 100644
--- a/src/redux/reducers/experimentalFeaturesSlice.ts
+++ b/src/redux/reducers/experimentalFeaturesSlice.ts
@@ -5,12 +5,14 @@ type ExperimentalFeaturesSlice = {
optOutOfEarlyUserRelease?: boolean
unavailableInstitutions?: { guid: string; name: string }[]
memberPollingMilliseconds?: number
+ useWebSockets?: boolean
}
export const initialState: ExperimentalFeaturesSlice = {
optOutOfEarlyUserRelease: false,
unavailableInstitutions: [],
memberPollingMilliseconds: undefined,
+ useWebSockets: false,
}
const experimentalFeaturesSlice = createSlice({
@@ -21,6 +23,7 @@ const experimentalFeaturesSlice = createSlice({
state.unavailableInstitutions = action.payload?.unavailableInstitutions || []
state.optOutOfEarlyUserRelease = action.payload?.optOutOfEarlyUserRelease || false
state.memberPollingMilliseconds = action.payload?.memberPollingMilliseconds || undefined
+ state.useWebSockets = action.payload?.useWebSockets || false
},
},
})
diff --git a/src/utilities/transport/MemberUpdateTransport.ts b/src/utilities/transport/MemberUpdateTransport.ts
index e81d7fc63b..bbd793976a 100644
--- a/src/utilities/transport/MemberUpdateTransport.ts
+++ b/src/utilities/transport/MemberUpdateTransport.ts
@@ -1,6 +1,8 @@
-import { Observable, defer, interval, of } from 'rxjs'
-import { catchError, map, mergeMap, exhaustMap } from 'rxjs/operators'
+import { Observable, defer, interval, of, merge } from 'rxjs'
+import { catchError, map, mergeMap, exhaustMap, filter, distinctUntilChanged } from 'rxjs/operators'
+import _isEqual from 'lodash/isEqual'
import type { ApiContextTypes } from 'src/context/ApiContext'
+import { WebSocketConnection } from 'src/context/WebSocketContext'
type MemberUpdateApi = Required>
@@ -12,17 +14,20 @@ export interface MemberUpdate {
export interface MemberUpdateTransportOptions {
pollingInterval?: number
clientLocale?: string
+ useWebSockets?: boolean
}
export function createMemberUpdateTransport(
api: MemberUpdateApi,
memberGuid: string,
options: MemberUpdateTransportOptions = {},
+ webSocket?: WebSocketConnection,
): Observable {
const pollingInterval = options.pollingInterval || 3000
const clientLocale = options.clientLocale || 'en'
+ const useWebSockets = options.useWebSockets || false
- return interval(pollingInterval).pipe(
+ const polling$ = interval(pollingInterval).pipe(
exhaustMap(() =>
defer(() => api.loadMemberByGuid(memberGuid, clientLocale)).pipe(
mergeMap((member: MemberResponseType) =>
@@ -34,4 +39,52 @@ export function createMemberUpdateTransport(
),
),
)
+
+ let transport$: Observable = polling$
+
+ if (useWebSockets && webSocket?.webSocketMessages$ && webSocket?.isConnected()) {
+ const socket$ = webSocket.webSocketMessages$.pipe(
+ filter(
+ (msg) =>
+ (msg.event === 'members/updated' || msg.event === 'members/priority_data_ready') &&
+ msg.payload?.guid === memberGuid,
+ ),
+ map((msg) => {
+ const member = msg.payload
+ const job = {
+ guid: member?.most_recent_job_guid,
+ async_account_data_ready: msg.event === 'members/priority_data_ready' || undefined,
+ } as JobResponseType
+
+ return { member, job }
+ }),
+ // If the websocket errors out, we don't want to kill the polling stream.
+ // We just want to stop receiving messages from the socket and let polling continue.
+ catchError(() => of()),
+ )
+ transport$ = merge(polling$, socket$)
+ }
+
+ return transport$.pipe(
+ distinctUntilChanged((prev, curr) => {
+ // Don't deduplicate errors
+ if (prev instanceof Error || curr instanceof Error) return false
+
+ const prevMember = prev.member
+ const currMember = curr.member
+
+ // Compare the relevant fields to determine if we should emit an update
+ // Return true to *prevent* emitting the event
+ // Return false to emit the event
+ return (
+ prevMember?.connection_status === currMember?.connection_status &&
+ _isEqual(prevMember?.mfa, currMember?.mfa) &&
+ prev.job?.guid === curr.job?.guid &&
+ prev.job?.async_account_data_ready === curr.job?.async_account_data_ready &&
+ prevMember?.is_being_aggregated === currMember?.is_being_aggregated &&
+ prevMember?.most_recent_job_detail_code === currMember?.most_recent_job_detail_code &&
+ prevMember?.error?.error_code === currMember?.error?.error_code
+ )
+ }),
+ )
}
diff --git a/src/utilities/transport/__tests__/MemberUpdateTransport-test.ts b/src/utilities/transport/__tests__/MemberUpdateTransport-test.ts
index 32e9e59584..371f27768c 100644
--- a/src/utilities/transport/__tests__/MemberUpdateTransport-test.ts
+++ b/src/utilities/transport/__tests__/MemberUpdateTransport-test.ts
@@ -1,6 +1,11 @@
+/* eslint-disable @typescript-eslint/no-explicit-any */
import { vi } from 'vitest'
import { take } from 'rxjs/operators'
-import { createMemberUpdateTransport, MemberUpdate } from '../MemberUpdateTransport'
+import { Subject } from 'rxjs'
+import {
+ createMemberUpdateTransport,
+ MemberUpdate,
+} from 'src/utilities/transport/MemberUpdateTransport'
describe('MemberUpdateTransport', () => {
const mockMemberGuid = 'MBR-123'
@@ -47,7 +52,7 @@ describe('MemberUpdateTransport', () => {
subscription.unsubscribe()
})
- it('should continue emitting updates on each interval', async () => {
+ it('should continue emitting updates on each interval when data changes', async () => {
const transport$ = createMemberUpdateTransport(mockApi, mockMemberGuid, {
pollingInterval: 1000,
clientLocale: mockClientLocale,
@@ -59,12 +64,22 @@ describe('MemberUpdateTransport', () => {
results.push(val)
})
- // Fast-forward 3 intervals
- await vi.advanceTimersByTimeAsync(3000)
+ // Fast-forward 1 interval
+ await vi.advanceTimersByTimeAsync(1000)
+ expect(results).toHaveLength(1)
+
+ // Change the mock to return a different status
+ mockApi.loadMemberByGuid.mockResolvedValue({ ...mockMember, connection_status: 1 })
+ await vi.advanceTimersByTimeAsync(1000)
+ expect(results).toHaveLength(2)
+
+ // Change it back
+ mockApi.loadMemberByGuid.mockResolvedValue(mockMember)
+ await vi.advanceTimersByTimeAsync(1000)
+ expect(results).toHaveLength(3)
expect(mockApi.loadMemberByGuid).toHaveBeenCalledTimes(3)
expect(mockApi.loadJob).toHaveBeenCalledTimes(3)
- expect(results).toHaveLength(3)
subscription.unsubscribe()
})
@@ -134,4 +149,112 @@ describe('MemberUpdateTransport', () => {
subscription.unsubscribe()
})
+
+ it('should emit WebSocket updates immediately when enabled', async () => {
+ const wsMessages$ = new Subject()
+ const mockWS = {
+ isConnected: vi.fn().mockReturnValue(true),
+ webSocketMessages$: wsMessages$.asObservable(),
+ }
+
+ const transport$ = createMemberUpdateTransport(
+ mockApi,
+ mockMemberGuid,
+ { useWebSockets: true },
+ mockWS,
+ )
+
+ const results: (MemberUpdate | Error)[] = []
+ const subscription = transport$.subscribe((val) => {
+ results.push(val)
+ })
+
+ const wsMember = { guid: mockMemberGuid, connection_status: 1 }
+ wsMessages$.next({ event: 'members/updated', payload: wsMember })
+
+ expect(results).toHaveLength(1)
+ expect(results[0]).toEqual({
+ member: wsMember,
+ job: { async_account_data_ready: undefined, guid: undefined },
+ })
+
+ subscription.unsubscribe()
+ })
+
+ it('should signal async_account_data_ready when members/priority_data_ready is received', async () => {
+ const wsMessages$ = new Subject()
+ const mockWS = {
+ isConnected: vi.fn().mockReturnValue(true),
+ webSocketMessages$: wsMessages$.asObservable(),
+ }
+
+ const transport$ = createMemberUpdateTransport(
+ mockApi,
+ mockMemberGuid,
+ { useWebSockets: true },
+ mockWS,
+ )
+
+ const results: (MemberUpdate | Error)[] = []
+ const subscription = transport$.subscribe((val) => {
+ results.push(val)
+ })
+
+ const wsMember = { guid: mockMemberGuid, connection_status: 1 }
+ wsMessages$.next({ event: 'members/priority_data_ready', payload: wsMember })
+
+ expect(results).toHaveLength(1)
+ expect(results[0]).toEqual({
+ member: wsMember,
+ job: { async_account_data_ready: true },
+ })
+
+ subscription.unsubscribe()
+ })
+
+ it('should deduplicate identical updates from polling and WebSockets', async () => {
+ const wsMessages$ = new Subject()
+ const mockWS = {
+ isConnected: vi.fn().mockReturnValue(true),
+ webSocketMessages$: wsMessages$.asObservable(),
+ }
+
+ // Configure polling to return same data
+ const jobWithGuid = { ...mockJob, guid: 'JOB-123' }
+ mockApi.loadMemberByGuid.mockResolvedValue(mockMember)
+ mockApi.loadJob.mockResolvedValue(jobWithGuid)
+
+ const transport$ = createMemberUpdateTransport(
+ mockApi,
+ mockMemberGuid,
+ { useWebSockets: true, pollingInterval: 1000 },
+ mockWS,
+ )
+
+ const results: (MemberUpdate | Error)[] = []
+ const subscription = transport$.subscribe((val) => {
+ results.push(val)
+ })
+
+ // 1. Trigger first poll
+ await vi.advanceTimersByTimeAsync(1000)
+ expect(results).toHaveLength(1)
+
+ // 2. Emit identical data from WebSocket
+ wsMessages$.next({ event: 'members/updated', payload: mockMember })
+ expect(results).toHaveLength(1) // Still 1
+
+ // 3. Trigger second poll
+ await vi.advanceTimersByTimeAsync(1000)
+ expect(results).toHaveLength(1)
+
+ // 4. Emit a DIFFERENT update from WebSocket
+ const updatedMember = { ...mockMember, connection_status: 3 }
+ wsMessages$.next({ event: 'members/updated', payload: updatedMember })
+
+ expect(results).toHaveLength(2)
+ expect((results[1] as MemberUpdate).member?.connection_status).toBe(3)
+
+ subscription.unsubscribe()
+ })
})
diff --git a/typings/apiTypes.d.ts b/typings/apiTypes.d.ts
index 30c1e4fcc3..6919ce3f75 100644
--- a/typings/apiTypes.d.ts
+++ b/typings/apiTypes.d.ts
@@ -112,12 +112,12 @@ type MemberResponseType = {
name?: string
process_status?: number
revision?: number
+ use_cases?: [string] | null
user_guid: string
- verification_is_enabled: boolean
- oauth_window_uri?: string | null
verification_is_enabled?: boolean
+ oauth_window_uri?: string | null
tax_statement_is_enabled?: boolean
- successfully_aggreagted_at?: number
+ successfully_aggregated_at?: number
}
// Institution types
@@ -287,6 +287,7 @@ type JobResponseType = {
finished_at: number
started_at: number
updated_at: number
+ async_account_data_ready?: boolean
}
// user types
diff --git a/typings/connectProps.d.ts b/typings/connectProps.d.ts
index 65a9b06c7e..86356e91f9 100644
--- a/typings/connectProps.d.ts
+++ b/typings/connectProps.d.ts
@@ -4,6 +4,7 @@ interface ConnectWidgetPropTypes extends ConnectProps {
language?: LanguageType
onPostMessage: (event: string, data?: object) => void
showTooSmallDialog: boolean
+ webSocketConnection?: any
}
interface PostMessageEventOverrides {
@@ -36,10 +37,12 @@ interface ConnectProps {
postMessageEventOverrides?: PostMessageEventOverrides
profiles: ProfilesTypes
userFeatures?: object
+ webSocketConnection?: any
experimentalFeatures?: null | {
unavailableInstitutions?: { guid: string; name: string }[]
optOutOfEarlyUserRelease?: boolean
memberPollingMilliseconds?: number
+ useWebSockets?: boolean
}
}
interface ClientConfigType {
diff --git a/typings/mxTypes.d.ts b/typings/mxTypes.d.ts
index f3c34fe12c..eb30575362 100644
--- a/typings/mxTypes.d.ts
+++ b/typings/mxTypes.d.ts
@@ -46,7 +46,8 @@ type MemberResponseType = {
last_job_status?: number
last_update_time?: string
metadata?: { [key: string]: unknown }
- most_recent_job_detail_code?: number
+ mfa?: MfaCredentialType | object
+ most_recent_job_detail_code?: number | null
most_recent_job_guid?: string
needs_updated_credentials?: boolean
name?: string
@@ -54,7 +55,10 @@ type MemberResponseType = {
revision?: number
use_cases?: [string] | null
user_guid: string
- verification_is_enabled: boolean
+ verification_is_enabled?: boolean
+ oauth_window_uri?: string | null
+ tax_statement_is_enabled?: boolean
+ successfully_aggregated_at?: number
}
// Institution types
@@ -172,6 +176,7 @@ type JobResponseType = {
job_type: number
status: number
finished_at: number
+ async_account_data_ready?: boolean
}
// user types