Skip to content

Commit a00d7a6

Browse files
authored
feat(webapp): implement automatic force-reload timeout (#20582)
1 parent 068e870 commit a00d7a6

File tree

2 files changed

+191
-26
lines changed

2 files changed

+191
-26
lines changed

apps/webapp/src/script/page/components/ForceReloadModal/ForceReloadModal.test.tsx

Lines changed: 152 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -17,21 +17,24 @@
1717
*
1818
*/
1919

20+
import assert from 'node:assert';
2021
import {ReactElement} from 'react';
2122

2223
import {render} from '@testing-library/react';
2324

2425
import {usePrimaryModalState} from 'Components/Modals/PrimaryModal';
25-
import {createDeterministicWallClock} from 'src/script/clock/deterministicWallClock';
26+
import {createDeterministicWallClock, DeterministicWallClock} from 'src/script/clock/deterministicWallClock';
2627
import {MainViewModel} from 'src/script/view_model/MainViewModel';
2728
import {t} from 'Util/LocalizerUtil';
29+
import {TIME_IN_MILLIS} from 'Util/TimeUtil';
2830

2931
import {RootProvider} from '../../RootProvider';
3032
import {ForceReloadModal} from './ForceReloadModal';
3133

3234
interface ForceReloadModalTestContextValue {
3335
readonly doesApplicationNeedForceReload: boolean;
3436
readonly reloadApplication: () => void;
37+
readonly wallClock?: DeterministicWallClock;
3538
}
3639

3740
function isFeatureFlagDisabledForTest(): boolean {
@@ -65,22 +68,28 @@ function resetPrimaryModalState(): void {
6568
function createForceReloadModalTestElement(
6669
contextValue: ForceReloadModalTestContextValue,
6770
): ReactElement {
68-
const {doesApplicationNeedForceReload, reloadApplication} = contextValue;
71+
const {
72+
doesApplicationNeedForceReload,
73+
reloadApplication,
74+
wallClock = createDeterministicWallClock({initialCurrentTimestampInMilliseconds: 1_111}),
75+
} = contextValue;
6976

7077
return (
7178
<RootProvider
7279
value={{
7380
doesApplicationNeedForceReload,
7481
isFeatureFlagEnabled: isFeatureFlagDisabledForTest,
7582
mainViewModel: createMainViewModelForTest(),
76-
wallClock: createDeterministicWallClock({initialCurrentTimestampInMilliseconds: 1_111}),
83+
wallClock,
7784
}}
7885
>
7986
<ForceReloadModal reloadApplication={reloadApplication} />
8087
</RootProvider>
8188
);
8289
}
8390

91+
const forceReloadDelayInMilliseconds = TIME_IN_MILLIS.SECOND * 60;
92+
8493
describe('ForceReloadModal', () => {
8594
beforeEach(() => {
8695
resetPrimaryModalState();
@@ -94,32 +103,89 @@ describe('ForceReloadModal', () => {
94103
});
95104

96105
it('opens the modal when force reload becomes required', () => {
106+
const deterministicWallClock = createDeterministicWallClock({initialCurrentTimestampInMilliseconds: 0});
97107
const reloadApplication = jest.fn();
98-
const {rerender} = render(createForceReloadModalTestElement({doesApplicationNeedForceReload: false, reloadApplication}));
108+
const {rerender} = render(
109+
createForceReloadModalTestElement({
110+
doesApplicationNeedForceReload: false,
111+
reloadApplication,
112+
wallClock: deterministicWallClock,
113+
}),
114+
);
99115

100-
rerender(createForceReloadModalTestElement({doesApplicationNeedForceReload: true, reloadApplication}));
116+
rerender(
117+
createForceReloadModalTestElement({
118+
doesApplicationNeedForceReload: true,
119+
reloadApplication,
120+
wallClock: deterministicWallClock,
121+
}),
122+
);
101123

102124
expect(usePrimaryModalState.getState().currentModalId).not.toBeNull();
103125
expect(usePrimaryModalState.getState().queue).toHaveLength(0);
104126
});
105127

106128
it('does not open the modal repeatedly while force reload remains required', () => {
129+
const deterministicWallClock = createDeterministicWallClock({initialCurrentTimestampInMilliseconds: 0});
107130
const reloadApplication = jest.fn();
108-
const {rerender} = render(createForceReloadModalTestElement({doesApplicationNeedForceReload: true, reloadApplication}));
131+
const {rerender} = render(
132+
createForceReloadModalTestElement({
133+
doesApplicationNeedForceReload: true,
134+
reloadApplication,
135+
wallClock: deterministicWallClock,
136+
}),
137+
);
109138

110-
rerender(createForceReloadModalTestElement({doesApplicationNeedForceReload: true, reloadApplication}));
111-
rerender(createForceReloadModalTestElement({doesApplicationNeedForceReload: true, reloadApplication}));
139+
rerender(
140+
createForceReloadModalTestElement({
141+
doesApplicationNeedForceReload: true,
142+
reloadApplication,
143+
wallClock: deterministicWallClock,
144+
}),
145+
);
146+
rerender(
147+
createForceReloadModalTestElement({
148+
doesApplicationNeedForceReload: true,
149+
reloadApplication,
150+
wallClock: deterministicWallClock,
151+
}),
152+
);
112153

113154
expect(usePrimaryModalState.getState().queue).toHaveLength(0);
114155
});
115156

116157
it('opens the modal again after force reload status returns to false and then true', () => {
158+
const deterministicWallClock = createDeterministicWallClock({initialCurrentTimestampInMilliseconds: 0});
117159
const reloadApplication = jest.fn();
118-
const {rerender} = render(createForceReloadModalTestElement({doesApplicationNeedForceReload: false, reloadApplication}));
160+
const {rerender} = render(
161+
createForceReloadModalTestElement({
162+
doesApplicationNeedForceReload: false,
163+
reloadApplication,
164+
wallClock: deterministicWallClock,
165+
}),
166+
);
119167

120-
rerender(createForceReloadModalTestElement({doesApplicationNeedForceReload: true, reloadApplication}));
121-
rerender(createForceReloadModalTestElement({doesApplicationNeedForceReload: false, reloadApplication}));
122-
rerender(createForceReloadModalTestElement({doesApplicationNeedForceReload: true, reloadApplication}));
168+
rerender(
169+
createForceReloadModalTestElement({
170+
doesApplicationNeedForceReload: true,
171+
reloadApplication,
172+
wallClock: deterministicWallClock,
173+
}),
174+
);
175+
rerender(
176+
createForceReloadModalTestElement({
177+
doesApplicationNeedForceReload: false,
178+
reloadApplication,
179+
wallClock: deterministicWallClock,
180+
}),
181+
);
182+
rerender(
183+
createForceReloadModalTestElement({
184+
doesApplicationNeedForceReload: true,
185+
reloadApplication,
186+
wallClock: deterministicWallClock,
187+
}),
188+
);
123189

124190
expect(usePrimaryModalState.getState().currentModalId).not.toBeNull();
125191
expect(usePrimaryModalState.getState().queue).toHaveLength(1);
@@ -141,11 +207,82 @@ describe('ForceReloadModal', () => {
141207
currentModalContent.onBgClick();
142208
expect(usePrimaryModalState.getState().currentModalId).toBe(currentModalIdentifierBeforeBackgroundClick);
143209

144-
if (!currentModalContent.primaryAction?.action) {
145-
throw new Error('Primary reload action is missing');
146-
}
210+
assert(currentModalContent.primaryAction?.action);
211+
currentModalContent.primaryAction.action();
212+
213+
expect(reloadApplication).toHaveBeenCalledTimes(1);
214+
});
215+
216+
it('reloads automatically after 60 seconds when no user action is performed', () => {
217+
const deterministicWallClock = createDeterministicWallClock({initialCurrentTimestampInMilliseconds: 0});
218+
const reloadApplication = jest.fn();
219+
220+
render(
221+
createForceReloadModalTestElement({
222+
doesApplicationNeedForceReload: true,
223+
reloadApplication,
224+
wallClock: deterministicWallClock,
225+
}),
226+
);
227+
228+
deterministicWallClock.advanceByMilliseconds(forceReloadDelayInMilliseconds - 1);
229+
expect(reloadApplication).not.toHaveBeenCalled();
230+
231+
deterministicWallClock.advanceByMilliseconds(1);
232+
expect(reloadApplication).toHaveBeenCalledTimes(1);
233+
234+
deterministicWallClock.advanceByMilliseconds(forceReloadDelayInMilliseconds);
235+
expect(reloadApplication).toHaveBeenCalledTimes(1);
236+
});
237+
238+
it('cancels automatic reload if force reload requirement is removed before timeout', () => {
239+
const deterministicWallClock = createDeterministicWallClock({initialCurrentTimestampInMilliseconds: 0});
240+
const reloadApplication = jest.fn();
241+
const {rerender} = render(
242+
createForceReloadModalTestElement({
243+
doesApplicationNeedForceReload: false,
244+
reloadApplication,
245+
wallClock: deterministicWallClock,
246+
}),
247+
);
248+
249+
rerender(
250+
createForceReloadModalTestElement({
251+
doesApplicationNeedForceReload: true,
252+
reloadApplication,
253+
wallClock: deterministicWallClock,
254+
}),
255+
);
256+
rerender(
257+
createForceReloadModalTestElement({
258+
doesApplicationNeedForceReload: false,
259+
reloadApplication,
260+
wallClock: deterministicWallClock,
261+
}),
262+
);
263+
264+
deterministicWallClock.advanceByMilliseconds(forceReloadDelayInMilliseconds);
265+
266+
expect(reloadApplication).not.toHaveBeenCalled();
267+
});
268+
269+
it('does not trigger reload twice if the user clicks reload before timeout', () => {
270+
const deterministicWallClock = createDeterministicWallClock({initialCurrentTimestampInMilliseconds: 0});
271+
const reloadApplication = jest.fn();
272+
273+
render(
274+
createForceReloadModalTestElement({
275+
doesApplicationNeedForceReload: true,
276+
reloadApplication,
277+
wallClock: deterministicWallClock,
278+
}),
279+
);
280+
281+
const {currentModalContent} = usePrimaryModalState.getState();
147282

283+
assert(currentModalContent.primaryAction?.action);
148284
currentModalContent.primaryAction.action();
285+
deterministicWallClock.advanceByMilliseconds(forceReloadDelayInMilliseconds);
149286

150287
expect(reloadApplication).toHaveBeenCalledTimes(1);
151288
});

apps/webapp/src/script/page/components/ForceReloadModal/ForceReloadModal.tsx

Lines changed: 39 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -17,48 +17,76 @@
1717
*
1818
*/
1919

20-
import {FunctionComponent, useEffect, useRef} from 'react';
20+
import {FunctionComponent, useEffect} from 'react';
21+
22+
import {Maybe} from 'true-myth';
2123

2224
import {PrimaryModal} from 'Components/Modals/PrimaryModal';
2325
import {t} from 'Util/LocalizerUtil';
26+
import {TIME_IN_MILLIS} from 'Util/TimeUtil';
2427

2528
import {useApplicationContext} from '../../RootProvider';
2629

2730
interface ForceReloadModalProperties {
2831
readonly reloadApplication: () => void;
2932
}
3033

34+
const forceReloadDelayInMilliseconds = TIME_IN_MILLIS.SECOND * 60;
35+
3136
export const ForceReloadModal: FunctionComponent<ForceReloadModalProperties> = properties => {
3237
const {reloadApplication} = properties;
33-
const hasForceReloadModalBeenShown = useRef(false);
34-
const {doesApplicationNeedForceReload} = useApplicationContext();
38+
const {doesApplicationNeedForceReload, wallClock} = useApplicationContext();
3539

36-
useEffect(() => {
40+
useEffect((): void | (() => void) => {
3741
if (!doesApplicationNeedForceReload) {
38-
hasForceReloadModalBeenShown.current = false;
39-
return;
42+
return undefined;
4043
}
4144

42-
if (hasForceReloadModalBeenShown.current) {
43-
return;
45+
let hasApplicationReloadBeenTriggered = false;
46+
let forceReloadTimeoutIdentifier: Maybe<ReturnType<typeof globalThis.setTimeout>> = Maybe.nothing();
47+
48+
function clearScheduledForceReloadTimeout(): void {
49+
const timeoutIdentifier = forceReloadTimeoutIdentifier.unwrapOr(undefined);
50+
51+
if (timeoutIdentifier !== undefined) {
52+
wallClock.clearTimeout(timeoutIdentifier);
53+
}
54+
55+
forceReloadTimeoutIdentifier = Maybe.nothing();
4456
}
4557

46-
hasForceReloadModalBeenShown.current = true;
58+
function triggerReloadApplicationOnce(): void {
59+
if (hasApplicationReloadBeenTriggered) {
60+
return;
61+
}
62+
63+
hasApplicationReloadBeenTriggered = true;
64+
clearScheduledForceReloadTimeout();
65+
66+
reloadApplication();
67+
}
4768

4869
PrimaryModal.show(PrimaryModal.type.ACKNOWLEDGE, {
4970
hideCloseBtn: true,
5071
hideSecondary: true,
5172
preventClose: true,
5273
primaryAction: {
53-
action: reloadApplication,
74+
action: triggerReloadApplicationOnce,
5475
text: t('forceReloadModalAction'),
5576
},
5677
text: {
5778
title: t('forceReloadModalTitle'),
5879
htmlMessage: t('forceReloadModalMessage'),
5980
},
6081
});
61-
}, [doesApplicationNeedForceReload, reloadApplication]);
82+
forceReloadTimeoutIdentifier = Maybe.just(
83+
wallClock.setTimeout(triggerReloadApplicationOnce, forceReloadDelayInMilliseconds),
84+
);
85+
86+
return () => {
87+
clearScheduledForceReloadTimeout();
88+
};
89+
}, [doesApplicationNeedForceReload, reloadApplication, wallClock]);
6290

6391
return null;
6492
};

0 commit comments

Comments
 (0)