Skip to content

Commit ce97b91

Browse files
authored
fix(app): fix infinitely re-rendering/never rendering firmware success toasts (#14981)
Closes RQA-2588 There are two issues with rendering firmware update toasts. First, the toasts depend on a request id stored as state within the ModuleCard, but ModuleCards most often unrender during the firmware update process, so this state is lost. This causes the toast never to render. The second issue is that sometimes, given the timing of the polling for attached modules, the module is always attached during the firmware update, thereby causing the module card not to unrender. When this happens, the useEffect hook responsible for making the success toast has conditional logic that is always true after an update, causing the toast to render infinitely. The solution to is to lift the request id state out of the module card itself and then abstract away the storage/retrieval via a utility hook, which is utilized by all parent components of ModuleCard. Also, the shouldRenderToast logic should be calculated only on the initial render.
1 parent cfefcbc commit ce97b91

File tree

7 files changed

+143
-32
lines changed

7 files changed

+143
-32
lines changed

app/src/assets/localization/en/device_details.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@
5858
"firmware_update_needed": "Instrument firmware update needed. Start the update on the robot's touchscreen.",
5959
"firmware_update_available": "Firmware update available.",
6060
"firmware_update_failed": "Failed to update module firmware",
61-
"firmware_update_installation_successful": "Installation successful",
61+
"firmware_updated_successfully": "Firmware updated successfully",
6262
"firmware_update_occurring": "Firmware update in progress...",
6363
"fixture": "Fixture",
6464
"have_not_run_description": "After you run some protocols, they will appear here.",

app/src/organisms/Devices/InstrumentsAndModules.tsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ import { PipetteCard } from './PipetteCard'
3333
import { FlexPipetteCard } from './PipetteCard/FlexPipetteCard'
3434
import { GripperCard } from '../GripperCard'
3535
import { useIsEstopNotDisengaged } from '../../resources/devices/hooks/useIsEstopNotDisengaged'
36+
import { useModuleApiRequests } from '../ModuleCard/utils'
3637

3738
import type {
3839
BadGripper,
@@ -62,6 +63,7 @@ export function InstrumentsAndModules({
6263
const currentRunId = useCurrentRunId()
6364
const { isRunTerminal, isRunRunning } = useRunStatuses()
6465
const isEstopNotDisengaged = useIsEstopNotDisengaged(robotName)
66+
const [getLatestRequestId, handleModuleApiRequests] = useModuleApiRequests()
6567

6668
const { data: attachedInstruments } = useInstrumentsQuery({
6769
refetchInterval: EQUIPMENT_POLL_MS,
@@ -218,6 +220,8 @@ export function InstrumentsAndModules({
218220
attachPipetteRequired={attachPipetteRequired}
219221
calibratePipetteRequired={calibratePipetteRequired}
220222
updatePipetteFWRequired={updatePipetteFWRequired}
223+
latestRequestId={getLatestRequestId(module.serialNumber)}
224+
handleModuleApiRequests={handleModuleApiRequests}
221225
/>
222226
))}
223227
</Flex>
@@ -267,6 +271,8 @@ export function InstrumentsAndModules({
267271
attachPipetteRequired={attachPipetteRequired}
268272
calibratePipetteRequired={calibratePipetteRequired}
269273
updatePipetteFWRequired={updatePipetteFWRequired}
274+
latestRequestId={getLatestRequestId(module.serialNumber)}
275+
handleModuleApiRequests={handleModuleApiRequests}
270276
/>
271277
))}
272278
</Flex>

app/src/organisms/Devices/ProtocolRun/ProtocolRunModuleControls.tsx

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ import {
1010
} from '@opentrons/components'
1111
import { ModuleCard } from '../../ModuleCard'
1212
import { useModuleRenderInfoForProtocolById } from '../hooks'
13+
import { useModuleApiRequests } from '../../ModuleCard/utils'
14+
1315
import type { BadPipette, PipetteData } from '@opentrons/api-client'
1416

1517
interface PipetteStatus {
@@ -77,6 +79,7 @@ export const ProtocolRunModuleControls = ({
7779
calibratePipetteRequired,
7880
updatePipetteFWRequired,
7981
} = usePipetteIsReady()
82+
const [getLatestRequestId, handleModuleApiRequests] = useModuleApiRequests()
8083

8184
const moduleRenderInfoForProtocolById = useModuleRenderInfoForProtocolById(
8285
runId,
@@ -120,6 +123,10 @@ export const ProtocolRunModuleControls = ({
120123
attachPipetteRequired={attachPipetteRequired}
121124
calibratePipetteRequired={calibratePipetteRequired}
122125
updatePipetteFWRequired={updatePipetteFWRequired}
126+
latestRequestId={getLatestRequestId(
127+
module.attachedModuleMatch.serialNumber
128+
)}
129+
handleModuleApiRequests={handleModuleApiRequests}
123130
/>
124131
) : null
125132
)}
@@ -141,6 +148,10 @@ export const ProtocolRunModuleControls = ({
141148
attachPipetteRequired={attachPipetteRequired}
142149
calibratePipetteRequired={calibratePipetteRequired}
143150
updatePipetteFWRequired={updatePipetteFWRequired}
151+
latestRequestId={getLatestRequestId(
152+
module.attachedModuleMatch.serialNumber
153+
)}
154+
handleModuleApiRequests={handleModuleApiRequests}
144155
/>
145156
) : null
146157
)}

app/src/organisms/ModuleCard/__tests__/ModuleCard.test.tsx

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,6 @@ import {
2424
getRequestById,
2525
PENDING,
2626
SUCCESS,
27-
useDispatchApiRequest,
2827
} from '../../../redux/robot-api'
2928
import { useCurrentRunStatus } from '../../RunTimeControl/hooks'
3029
import { useToaster } from '../../ToasterOven'
@@ -43,7 +42,7 @@ import type {
4342
MagneticModule,
4443
ThermocyclerModule,
4544
} from '../../../redux/modules/types'
46-
import type { DispatchApiRequestType } from '../../../redux/robot-api'
45+
import type { Mock } from 'vitest'
4746

4847
vi.mock('../ErrorInfo')
4948
vi.mock('../MagneticModuleData')
@@ -182,32 +181,33 @@ const mockMakeSnackbar = vi.fn()
182181
const mockMakeToast = vi.fn()
183182
const mockEatToast = vi.fn()
184183

184+
const MOCK_LATEST_REQUEST_ID = '1234'
185+
185186
const render = (props: React.ComponentProps<typeof ModuleCard>) => {
186187
return renderWithProviders(<ModuleCard {...props} />, {
187188
i18nInstance: i18n,
188189
})[0]
189190
}
190191

191192
describe('ModuleCard', () => {
192-
let dispatchApiRequest: DispatchApiRequestType
193193
let props: React.ComponentProps<typeof ModuleCard>
194+
let mockHandleModuleApiRequests: Mock
194195

195196
beforeEach(() => {
197+
mockHandleModuleApiRequests = vi.fn()
198+
196199
props = {
197200
module: mockMagneticModule,
198201
robotName: mockRobot.name,
199202
isLoadedInRun: false,
200203
attachPipetteRequired: false,
201204
calibratePipetteRequired: false,
202205
updatePipetteFWRequired: false,
206+
handleModuleApiRequests: mockHandleModuleApiRequests,
207+
latestRequestId: MOCK_LATEST_REQUEST_ID,
203208
}
204209

205-
dispatchApiRequest = vi.fn()
206210
vi.mocked(ErrorInfo).mockReturnValue(null)
207-
vi.mocked(useDispatchApiRequest).mockReturnValue([
208-
dispatchApiRequest,
209-
['id'],
210-
])
211211
vi.mocked(MagneticModuleData).mockReturnValue(
212212
<div>Mock Magnetic Module Data</div>
213213
)

app/src/organisms/ModuleCard/__tests__/utils.test.ts

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
import { describe, expect, it } from 'vitest'
1+
import { describe, expect, it, vi, beforeEach } from 'vitest'
2+
import { renderHook, act } from '@testing-library/react'
23

34
import {
45
mockHeaterShaker,
@@ -9,7 +10,10 @@ import {
910
mockThermocycler,
1011
mockThermocyclerGen2,
1112
} from '../../../redux/modules/__fixtures__'
12-
import { getModuleCardImage } from '../utils'
13+
import { getModuleCardImage, useModuleApiRequests } from '../utils'
14+
import { useDispatchApiRequest } from '../../../redux/robot-api'
15+
16+
vi.mock('../../../redux/robot-api')
1317

1418
const mockThermocyclerGen2ClosedLid = {
1519
id: 'thermocycler_id2',
@@ -83,3 +87,29 @@ describe('getModuleCardImage', () => {
8387
)
8488
})
8589
})
90+
91+
const updateModuleAction = { meta: { requestId: '12345' } }
92+
const MOCK_ROBOT_NAME = 'MOCK_ROBOT'
93+
const MOCK_SERIAL_NUMBER = '1234'
94+
const mockDispatchApiRequest = () => updateModuleAction
95+
96+
describe('useModuleApiRequests', () => {
97+
beforeEach(() => {
98+
vi.mocked(useDispatchApiRequest).mockReturnValue([
99+
mockDispatchApiRequest,
100+
] as any)
101+
})
102+
103+
it('should dispatch an API request and update requestIdsBySerial on handleModuleApiRequests', () => {
104+
const { result } = renderHook(() => useModuleApiRequests())
105+
106+
act(() => {
107+
result.current[1](MOCK_ROBOT_NAME, MOCK_SERIAL_NUMBER)
108+
})
109+
110+
expect(result.current[0](MOCK_SERIAL_NUMBER)).toEqual(
111+
updateModuleAction.meta.requestId
112+
)
113+
expect(result.current[0]('NON_EXISTENT_SERIAL')).toBeNull()
114+
})
115+
})

app/src/organisms/ModuleCard/index.tsx

Lines changed: 23 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
import * as React from 'react'
22
import { Trans, useTranslation } from 'react-i18next'
33
import { useDispatch, useSelector } from 'react-redux'
4-
import last from 'lodash/last'
54
import { useHistory } from 'react-router-dom'
65

76
import {
@@ -31,9 +30,7 @@ import {
3130
import { RUN_STATUS_FINISHING, RUN_STATUS_RUNNING } from '@opentrons/api-client'
3231

3332
import { OverflowBtn } from '../../atoms/MenuList/OverflowBtn'
34-
import { updateModule } from '../../redux/modules'
3533
import {
36-
useDispatchApiRequest,
3734
getRequestById,
3835
PENDING,
3936
FAILURE,
@@ -85,6 +82,8 @@ interface ModuleCardProps {
8582
attachPipetteRequired: boolean
8683
calibratePipetteRequired: boolean
8784
updatePipetteFWRequired: boolean
85+
latestRequestId: string | null
86+
handleModuleApiRequests: (robotName: string, serialNumber: string) => void
8887
runId?: string
8988
slotName?: string
9089
}
@@ -100,6 +99,8 @@ export const ModuleCard = (props: ModuleCardProps): JSX.Element | null => {
10099
attachPipetteRequired,
101100
calibratePipetteRequired,
102101
updatePipetteFWRequired,
102+
latestRequestId,
103+
handleModuleApiRequests,
103104
} = props
104105
const dispatch = useDispatch<Dispatch>()
105106
const {
@@ -115,13 +116,12 @@ export const ModuleCard = (props: ModuleCardProps): JSX.Element | null => {
115116
const [hasSecondary, setHasSecondary] = React.useState(false)
116117
const [showAboutModule, setShowAboutModule] = React.useState(false)
117118
const [showTestShake, setShowTestShake] = React.useState(false)
118-
const [showHSWizard, setShowHSWizard] = React.useState<boolean>(false)
119-
const [showFWBanner, setShowFWBanner] = React.useState<boolean>(true)
120-
const [showCalModal, setShowCalModal] = React.useState<boolean>(false)
119+
const [showHSWizard, setShowHSWizard] = React.useState(false)
120+
const [showFWBanner, setShowFWBanner] = React.useState(true)
121+
const [showCalModal, setShowCalModal] = React.useState(false)
121122

122123
const [targetProps, tooltipProps] = useHoverTooltip()
123124
const history = useHistory()
124-
const [dispatchApiRequest, requestIds] = useDispatchApiRequest()
125125
const runStatus = useCurrentRunStatus({
126126
onSettled: data => {
127127
if (data == null) {
@@ -138,29 +138,31 @@ export const ModuleCard = (props: ModuleCardProps): JSX.Element | null => {
138138
(!attachPipetteRequired ?? false) &&
139139
(!calibratePipetteRequired ?? false) &&
140140
(!updatePipetteFWRequired ?? false)
141-
const latestRequestId = last(requestIds)
141+
142142
const latestRequest = useSelector<State, RequestState | null>(state =>
143-
latestRequestId ? getRequestById(state, latestRequestId) : null
143+
latestRequestId != null ? getRequestById(state, latestRequestId) : null
144144
)
145-
const isEstopNotDisengaged = useIsEstopNotDisengaged(robotName)
146145

147-
const handleCloseErrorModal = (): void => {
148-
if (latestRequestId != null) {
149-
dispatch(dismissRequest(latestRequestId))
150-
}
146+
const hasUpdated =
147+
!module.hasAvailableUpdate && latestRequest?.status === SUCCESS
148+
const [showFirmwareToast, setShowFirmwareToast] = React.useState(hasUpdated)
149+
const { makeToast } = useToaster()
150+
if (showFirmwareToast) {
151+
makeToast(t('firmware_updated_successfully'), SUCCESS_TOAST)
152+
setShowFirmwareToast(false)
151153
}
152154

153155
const handleFirmwareUpdateClick = (): void => {
154-
robotName &&
155-
dispatchApiRequest(updateModule(robotName, module.serialNumber))
156+
robotName && handleModuleApiRequests(robotName, module.serialNumber)
156157
}
157158

158-
const { makeToast } = useToaster()
159-
React.useEffect(() => {
160-
if (!module.hasAvailableUpdate && latestRequest?.status === SUCCESS) {
161-
makeToast(t('firmware_update_installation_successful'), SUCCESS_TOAST)
159+
const isEstopNotDisengaged = useIsEstopNotDisengaged(robotName)
160+
161+
const handleCloseErrorModal = (): void => {
162+
if (latestRequestId != null) {
163+
dispatch(dismissRequest(latestRequestId))
162164
}
163-
}, [module.hasAvailableUpdate, latestRequest?.status, makeToast, t])
165+
}
164166

165167
const isPending = latestRequest?.status === PENDING
166168
const hotToTouch: IconProps = { name: 'ot-hot-to-touch' }

app/src/organisms/ModuleCard/utils.ts

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,17 @@
1+
import * as React from 'react'
2+
import last from 'lodash/last'
3+
4+
import { useDispatchApiRequest } from '../../redux/robot-api'
5+
import { updateModule } from '../../redux/modules'
6+
17
import magneticModule from '../../assets/images/magnetic_module_gen_2_transparent.png'
28
import temperatureModule from '../../assets/images/temp_deck_gen_2_transparent.png'
39
import thermoModuleGen1Closed from '../../assets/images/thermocycler_closed.png'
410
import thermoModuleGen1Opened from '../../assets/images/thermocycler_open_transparent.png'
511
import heaterShakerModule from '../../assets/images/heater_shaker_module_transparent.png'
612
import thermoModuleGen2Closed from '../../assets/images/thermocycler_gen_2_closed.png'
713
import thermoModuleGen2Opened from '../../assets/images/thermocycler_gen_2_opened.png'
14+
815
import type { AttachedModule } from '../../redux/modules/types'
916

1017
export function getModuleCardImage(attachedModule: AttachedModule): string {
@@ -35,3 +42,58 @@ export function getModuleCardImage(attachedModule: AttachedModule): string {
3542
return 'unknown module model, this is an error'
3643
}
3744
}
45+
46+
type RequestIdsBySerialNumber = Record<string, string[]>
47+
type HandleModuleApiRequestsType = (robotName: string, moduleId: string) => void
48+
type GetLatestRequestIdType = (moduleId: string) => string | null
49+
50+
export function useModuleApiRequests(): [
51+
GetLatestRequestIdType,
52+
HandleModuleApiRequestsType
53+
] {
54+
const [dispatchApiRequest] = useDispatchApiRequest()
55+
const [
56+
requestIdsBySerial,
57+
setRequestIdsBySerial,
58+
] = React.useState<RequestIdsBySerialNumber>({})
59+
60+
const handleModuleApiRequests = (
61+
robotName: string,
62+
serialNumber: string
63+
): void => {
64+
const action = dispatchApiRequest(updateModule(robotName, serialNumber))
65+
const { requestId } = action.meta
66+
67+
if (requestId != null) {
68+
if (serialNumber in requestIdsBySerial) {
69+
setRequestIdsBySerial((prevState: RequestIdsBySerialNumber) => {
70+
const existingRequestIds = prevState[serialNumber] || []
71+
return {
72+
...prevState,
73+
[serialNumber]: [...existingRequestIds, requestId],
74+
}
75+
})
76+
} else {
77+
setRequestIdsBySerial(prevState => {
78+
return {
79+
...prevState,
80+
[serialNumber]: [requestId],
81+
}
82+
})
83+
}
84+
}
85+
}
86+
87+
const getLatestRequestId = React.useCallback(
88+
(serialNumber: string): string | null => {
89+
if (serialNumber in requestIdsBySerial) {
90+
return last(requestIdsBySerial[serialNumber]) ?? null
91+
} else {
92+
return null
93+
}
94+
},
95+
[requestIdsBySerial]
96+
)
97+
98+
return [getLatestRequestId, handleModuleApiRequests]
99+
}

0 commit comments

Comments
 (0)