Skip to content

Commit a8755ec

Browse files
authored
fix: do not clear dialog state immediately on useDialog unmount (#2584)
### 🎯 Goal When the `useDialog` hook's component unmounts, it immediately clears dialog state in an effect cleanup. However, in some situations an effect cleanup can run even if the hook's component is still mounted and effect's dependencies didn't change - e.g., when `<StrictMode />` is enabled. So it's safer to keep dialog state for a short time after cleanup runs. ### 🛠 Implementation details Instead of immediately removing dialog state, it's marked to be removed after a short timeout. Referencing the dialog again quick cancels state removal. Fixes #2583.
1 parent 56def19 commit a8755ec

File tree

7 files changed

+113
-1463
lines changed

7 files changed

+113
-1463
lines changed

examples/vite/src/main.tsx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
1+
import { StrictMode } from 'react';
12
import ReactDOM from 'react-dom/client';
23
import App from './App.tsx';
34
import './index.scss';
45

5-
ReactDOM.createRoot(document.getElementById('root')!).render(<App />);
6+
ReactDOM.createRoot(document.getElementById('root')!).render(
7+
<StrictMode>
8+
<App />
9+
</StrictMode>,
10+
);

examples/vite/yarn.lock

Lines changed: 17 additions & 1456 deletions
Large diffs are not rendered by default.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -257,7 +257,7 @@
257257
"react-dom": "^18.1.0",
258258
"react-test-renderer": "^18.1.0",
259259
"semantic-release": "^19.0.5",
260-
"stream-chat": "^8.46.1",
260+
"stream-chat": "^8.47.1",
261261
"ts-jest": "^29.1.4",
262262
"typescript": "^5.4.5"
263263
},

src/components/Dialog/DialogManager.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ export type Dialog = {
1111
id: DialogId;
1212
isOpen: boolean | undefined;
1313
open: (zIndex?: number) => void;
14+
removalTimeout: NodeJS.Timeout | undefined;
1415
remove: () => void;
1516
toggle: (closeAll?: boolean) => void;
1617
};
@@ -64,6 +65,7 @@ export class DialogManager {
6465
open: () => {
6566
this.open({ id });
6667
},
68+
removalTimeout: undefined,
6769
remove: () => {
6870
this.remove(id);
6971
},
@@ -76,6 +78,23 @@ export class DialogManager {
7678
...{ dialogsById: { ...current.dialogsById, [id]: dialog } },
7779
}));
7880
}
81+
82+
if (dialog.removalTimeout) {
83+
clearTimeout(dialog.removalTimeout);
84+
this.state.next((current) => ({
85+
...current,
86+
...{
87+
dialogsById: {
88+
...current.dialogsById,
89+
[id]: {
90+
...dialog,
91+
removalTimeout: undefined,
92+
},
93+
},
94+
},
95+
}));
96+
}
97+
7998
return dialog;
8099
}
81100

@@ -117,6 +136,10 @@ export class DialogManager {
117136
const dialog = state.dialogsById[id];
118137
if (!dialog) return;
119138

139+
if (dialog.removalTimeout) {
140+
clearTimeout(dialog.removalTimeout);
141+
}
142+
120143
this.state.next((current) => {
121144
const newDialogs = { ...current.dialogsById };
122145
delete newDialogs[id];
@@ -126,4 +149,30 @@ export class DialogManager {
126149
};
127150
});
128151
}
152+
153+
/**
154+
* Marks the dialog state as unused. If the dialog id is referenced again quickly,
155+
* the state will not be removed. Otherwise, the state will be removed after
156+
* a short timeout.
157+
*/
158+
markForRemoval(id: DialogId) {
159+
const dialog = this.state.getLatestValue().dialogsById[id];
160+
161+
if (!dialog) {
162+
return;
163+
}
164+
165+
this.state.next((current) => ({
166+
...current,
167+
dialogsById: {
168+
...current.dialogsById,
169+
[id]: {
170+
...dialog,
171+
removalTimeout: setTimeout(() => {
172+
this.remove(id);
173+
}, 16),
174+
},
175+
},
176+
}));
177+
}
129178
}

src/components/Dialog/__tests__/DialogsManager.test.js

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,15 @@ describe('DialogManager', () => {
88
const dialogManager = new DialogManager({ id });
99
expect(dialogManager.id).toBe(id);
1010
});
11+
1112
it('initiates with default options', () => {
1213
const mockedId = '12345';
1314
const spy = jest.spyOn(Date.prototype, 'getTime').mockReturnValueOnce(mockedId);
1415
const dialogManager = new DialogManager();
1516
expect(dialogManager.id).toBe(mockedId);
1617
spy.mockRestore();
1718
});
19+
1820
it('creates a new closed dialog', () => {
1921
const dialogManager = new DialogManager();
2022
expect(Object.keys(dialogManager.state.getLatestValue().dialogsById)).toHaveLength(0);
@@ -142,4 +144,33 @@ describe('DialogManager', () => {
142144
expect(dialogManager.openDialogCount).toBe(1);
143145
expect(Object.keys(dialogManager.state.getLatestValue().dialogsById)).toHaveLength(1);
144146
});
147+
148+
it('marks dialog for removal', () => {
149+
jest.useFakeTimers();
150+
151+
const dialogManager = new DialogManager();
152+
dialogManager.getOrCreate({ id: dialogId });
153+
dialogManager.open({ id: dialogId });
154+
dialogManager.markForRemoval(dialogId);
155+
156+
jest.runAllTimers();
157+
158+
expect(dialogManager.openDialogCount).toBe(0);
159+
expect(Object.keys(dialogManager.state.getLatestValue().dialogsById)).toHaveLength(0);
160+
});
161+
162+
it('cancels dialog removal if it is referenced again quickly', () => {
163+
jest.useFakeTimers();
164+
165+
const dialogManager = new DialogManager();
166+
dialogManager.getOrCreate({ id: dialogId });
167+
dialogManager.open({ id: dialogId });
168+
dialogManager.markForRemoval(dialogId);
169+
dialogManager.getOrCreate({ id: dialogId });
170+
171+
jest.runAllTimers();
172+
173+
expect(dialogManager.openDialogCount).toBe(1);
174+
expect(Object.keys(dialogManager.state.getLatestValue().dialogsById)).toHaveLength(1);
175+
});
145176
});

src/components/Dialog/hooks/useDialog.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,11 @@ export const useDialog = ({ id }: GetOrCreateDialogParams) => {
99

1010
useEffect(
1111
() => () => {
12-
dialogManager.remove(id);
12+
// Since this cleanup can run even if the component is still mounted
13+
// and dialog id is unchanged (e.g. in <StrictMode />), it's safer to
14+
// mark state as unused and only remove it after a timeout, rather than
15+
// to remove it immediately.
16+
dialogManager.markForRemoval(id);
1317
},
1418
[dialogManager, id],
1519
);

yarn.lock

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12231,10 +12231,10 @@ [email protected]:
1223112231
resolved "https://registry.yarnpkg.com/statuses/-/statuses-2.0.1.tgz#55cb000ccf1d48728bd23c685a063998cf1a1b63"
1223212232
integrity sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==
1223312233

12234-
stream-chat@^8.46.1:
12235-
version "8.46.1"
12236-
resolved "https://registry.yarnpkg.com/stream-chat/-/stream-chat-8.46.1.tgz#9624bbd4e8e357414389e4b0dcb5f0134487f679"
12237-
integrity sha512-jVg148tZDCAmZa6b31cnamiUvt58IQB4YTRIGFt3+4zp6jI2tKeHvEj24uEcVF2d7fhT1IXSuGhNfcrStqSZHQ==
12234+
stream-chat@^8.47.1:
12235+
version "8.47.1"
12236+
resolved "https://registry.yarnpkg.com/stream-chat/-/stream-chat-8.47.1.tgz#5390c87cbb1929e7ca183aa1204dae3ab38469a2"
12237+
integrity sha512-raMAGYLT4UCVluMF0TMfdPKH9OUhDjH6e1HQdJIlllAFLaA8oxtG+e/7jyuPmVodLPzYCPqOt2eBH7soAkhV/A==
1223812238
dependencies:
1223912239
"@babel/runtime" "^7.16.3"
1224012240
"@types/jsonwebtoken" "~9.0.0"

0 commit comments

Comments
 (0)