Skip to content

Commit 536b60c

Browse files
authored
Merge pull request #2623 from intersective/2.4.y.z/CORE-8002/pulsecheck-twice
[CORE-8002] fix: prevent duplicate pulse check popups
2 parents 35590c3 + 09dd550 commit 536b60c

2 files changed

Lines changed: 87 additions & 4 deletions

File tree

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
# CORE-8002: Pulse check popup displayed twice
2+
3+
## issue summary
4+
In a moderated assessment flow (`feedback available` -> mark review as read -> continue), learners could receive the same pulse check popup twice:
5+
1. first popup after feedback/read + review rating flow
6+
2. second popup after navigating to Home
7+
8+
## root cause
9+
`fastFeedbackOpening` was cleared too early in `FastFeedbackService.pullFastFeedback()`.
10+
11+
- the lock was set before opening the modal
12+
- it was reset in `finalize()` right after modal creation/enqueue (not after modal dismissal)
13+
- during this gap, another pulse check check (for example Home `updateDashboard()`) could run and queue/open the same pulse check again
14+
15+
## fix applied
16+
Updated `projects/v3/src/app/services/fast-feedback.service.ts`:
17+
- moved `retry` before `switchMap` so retries only re-run the API fetch, never the modal open
18+
- replaced `catchError` with `finalize` on the modal observable so the lock is released on success, error, and unsubscribe
19+
20+
### operator ordering change
21+
22+
```
23+
before (retry after switchMap — retried modal opens):
24+
fetch() → switchMap(modal()) → retry(3)
25+
26+
after (retry before switchMap — only retries fetch):
27+
fetch() → retry(3) → switchMap(modal())
28+
```
29+
30+
`retry` re-subscribes to everything **above** it in the pipe. placing it before `switchMap` means a fetch error retries the fetch, but a modal error passes through without re-triggering the entire pipeline.
31+
32+
## lock state diagram
33+
34+
the `fastFeedbackOpening` storage flag acts as a mutex to prevent concurrent modal opens:
35+
36+
```
37+
pullFastFeedback() called
38+
39+
40+
┌───────────────┐
41+
│ lock = true? │──── YES ──→ skip modal, return of(res)
42+
└───────┬───────┘
43+
│ NO
44+
45+
set lock = TRUE ← acquire
46+
47+
48+
open modal popup
49+
50+
┌───────┴────────┐
51+
│ │
52+
success error/dismiss
53+
│ │
54+
▼ ▼
55+
finalize() finalize() ← both paths release
56+
│ │
57+
▼ ▼
58+
set lock = FALSE ← release
59+
```
60+
61+
`finalize` runs when the observable completes, errors, or is unsubscribed — covering all exit paths:
62+
- user submits pulse check → observable completes → lock released
63+
- modal throws error → observable errors → lock released
64+
- user navigates away (component destroyed) → subscription unsubscribed → lock released
65+
66+
## why this is safe
67+
- preserves existing behavior for successful pulse check display
68+
- prevents duplicate modal opens from concurrent trigger paths
69+
- `finalize` guarantees lock release on every exit path — no deadlock possible
70+
71+
## reference trigger paths observed
72+
- review rating close path may request pulse check (`ReviewRatingComponent.fastFeedbackOrRedirect`)
73+
- Home dashboard load also requests pulse check (`HomePage.updateDashboard`)
74+
- assessment submission path (`AssessmentService._afterSubmit`)
75+
- review submission path (`ReviewDesktopPage`)
76+
- manual user click on traffic light (`TrafficLightGroupComponent.navigateToPulseCheck`)
77+
78+
the first two can fire back-to-back in the reported reproduce steps.
79+
80+
## verification checklist
81+
- reproduce original scenario with moderated assessment feedback-read flow
82+
- confirm only one pulse check popup is shown
83+
- confirm pulse check can still be shown later when a genuinely new pulse check is available

projects/v3/src/app/services/fast-feedback.service.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,10 @@ export class FastFeedbackService {
9393
closable: false,
9494
}): Observable<any> {
9595
return this._getFastFeedback(options.skipChecking, options.type).pipe(
96+
retry({
97+
count: 3,
98+
delay: 1000
99+
}),
96100
switchMap((res) => {
97101
try {
98102
// don't open it again if there's one opening
@@ -151,10 +155,6 @@ export class FastFeedbackService {
151155
});
152156
}
153157
}),
154-
retry({
155-
count: 3,
156-
delay: 1000
157-
})
158158
);
159159
}
160160

0 commit comments

Comments
 (0)