Skip to content

Commit 1bd538f

Browse files
LFDanLusnowystingerktaborsdevongovett
authored
Refactor logic to prevent breaking changes in useToastState (#6270)
* Refactor logic to prevent breaking changes in useToastState * added more tests and fixing a bug they caught * Fixed the other bug found by new tests * fix toast visual insertion so it inserts at the bottom * remove uneeded key and handle case where a user just calls remove * tentative change to readd exiting to toasts that are shifted out of the visibleToast list due to a higher priority toast being added * fix so that a toast that is removed but not closed doesnt get removed from the queue this mimics the exitAnimation call to close that RSP toast does * add test to make sure order of toast doesnt change on close --------- Co-authored-by: Robert Snow <rsnow@adobe.com> Co-authored-by: Kyle Taborski <ktabors@yahoo.com> Co-authored-by: Devon Govett <devongovett@gmail.com>
1 parent 04df93f commit 1bd538f

File tree

3 files changed

+235
-25
lines changed

3 files changed

+235
-25
lines changed

packages/@react-spectrum/toast/src/toastContainer.css

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,14 +29,14 @@
2929

3030
&[data-position=top] {
3131
top: 0;
32-
flex-direction: column;
32+
flex-direction: column-reverse;
3333
--slide-from: translateY(-100%);
3434
--slide-to: translateY(0);
3535
}
3636

3737
&[data-position=bottom] {
3838
bottom: 0;
39-
flex-direction: column-reverse;
39+
flex-direction: column;
4040
--slide-from: translateY(100%);
4141
--slide-to: translateY(0);
4242
}

packages/@react-stately/toast/src/useToastState.ts

Lines changed: 20 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -134,19 +134,15 @@ export class ToastQueue<T> {
134134
}
135135
}
136136

137-
if (toast.priority) {
138-
this.queue.splice(low, 0, toast);
139-
} else {
140-
this.queue.unshift(toast);
141-
}
137+
this.queue.splice(low, 0, toast);
142138

143139
toast.animation = low < this.maxVisibleToasts ? 'entering' : 'queued';
144140
let i = this.maxVisibleToasts;
145141
while (i < this.queue.length) {
146142
this.queue[i++].animation = 'queued';
147143
}
148144

149-
this.updateVisibleToasts();
145+
this.updateVisibleToasts({action: 'add'});
150146
return toastKey;
151147
}
152148

@@ -161,27 +157,32 @@ export class ToastQueue<T> {
161157
this.queue.splice(index, 1);
162158
}
163159

164-
this.updateVisibleToasts(index);
160+
this.updateVisibleToasts({action: 'close', key});
165161
}
166162

167163
/** Removes a toast from the visible toasts after an exiting animation. */
168164
remove(key: string) {
169-
this.visibleToasts = this.visibleToasts.filter(t => t.key !== key);
170-
this.updateVisibleToasts();
165+
this.updateVisibleToasts({action: 'remove', key});
171166
}
172167

173-
private updateVisibleToasts(oldIndex = -1) {
168+
private updateVisibleToasts(options: {action: 'add' | 'close' | 'remove', key?: string}) {
169+
let {action, key} = options;
174170
let toasts = this.queue.slice(0, this.maxVisibleToasts);
175-
if (this.hasExitAnimation) {
176-
let prevToasts: QueuedToast<T>[] = this.visibleToasts
177-
.filter(t => !toasts.some(t2 => t.key === t2.key))
178-
.map(t => ({...t, animation: 'exiting'}));
179171

180-
if (oldIndex !== -1) {
181-
toasts.splice(oldIndex, 0, prevToasts?.[0]);
182-
}
183-
184-
this.visibleToasts = toasts;
172+
if (action === 'add' && this.hasExitAnimation) {
173+
let prevToasts: QueuedToast<T>[] = this.visibleToasts
174+
.filter(t => !toasts.some(t2 => t.key === t2.key))
175+
.map(t => ({...t, animation: 'exiting'}));
176+
this.visibleToasts = prevToasts.concat(toasts).sort((a, b) => b.priority - a.priority);
177+
} else if (action === 'close' && this.hasExitAnimation) {
178+
// Cause a rerender to happen for exit animation
179+
this.visibleToasts = this.visibleToasts.map(t => {
180+
if (t.key !== key) {
181+
return t;
182+
} else {
183+
return {...t, animation: 'exiting'};
184+
}
185+
});
185186
} else {
186187
this.visibleToasts = toasts;
187188
}

packages/@react-stately/toast/test/useToastState.test.js

Lines changed: 213 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,14 @@ describe('useToastState', () => {
1919
props: {timeout: 0}
2020
}];
2121

22+
beforeEach(() => {
23+
jest.useFakeTimers();
24+
});
25+
26+
afterEach(() => {
27+
act(() => jest.runAllTimers());
28+
});
29+
2230
it('should add a new toast via add', () => {
2331
let {result} = renderHook(() => useToastState());
2432
expect(result.current.visibleToasts).toStrictEqual([]);
@@ -58,8 +66,209 @@ describe('useToastState', () => {
5866

5967
act(() => {result.current.add(secondToast.content, secondToast.props);});
6068
expect(result.current.visibleToasts.length).toBe(2);
61-
expect(result.current.visibleToasts[0].content).toBe(secondToast.content);
62-
expect(result.current.visibleToasts[1].content).toBe(newValue[0].content);
69+
expect(result.current.visibleToasts[0].content).toBe(newValue[0].content);
70+
expect(result.current.visibleToasts[1].content).toBe(secondToast.content);
71+
});
72+
73+
it('should be able to display three toasts and remove the middle toast via timeout then the visible toast', () => {
74+
let {result} = renderHook(() => useToastState({maxVisibleToasts: 3}));
75+
76+
// Add the first toast
77+
act(() => {
78+
result.current.add('First Toast', {timeout: 0});
79+
});
80+
expect(result.current.visibleToasts).toHaveLength(1);
81+
expect(result.current.visibleToasts[0].content).toBe('First Toast');
82+
83+
// Add the second toast
84+
act(() => {
85+
result.current.add('Second Toast', {timeout: 1000});
86+
});
87+
expect(result.current.visibleToasts).toHaveLength(2);
88+
expect(result.current.visibleToasts[0].content).toBe('First Toast');
89+
90+
result.current.resumeAll();
91+
92+
// Add the third toast
93+
act(() => {
94+
result.current.add('Third Toast', {timeout: 0});
95+
});
96+
expect(result.current.visibleToasts).toHaveLength(3);
97+
expect(result.current.visibleToasts[0].content).toBe('First Toast');
98+
expect(result.current.visibleToasts[1].content).toBe('Second Toast');
99+
expect(result.current.visibleToasts[2].content).toBe('Third Toast');
100+
101+
act(() => jest.advanceTimersByTime(500));
102+
expect(result.current.visibleToasts).toHaveLength(3);
103+
104+
act(() => jest.advanceTimersByTime(1000));
105+
expect(result.current.visibleToasts).toHaveLength(2);
106+
expect(result.current.visibleToasts[0].content).toBe('First Toast');
107+
expect(result.current.visibleToasts[1].content).toBe('Third Toast');
108+
109+
act(() => {result.current.close(result.current.visibleToasts[0].key);});
110+
expect(result.current.visibleToasts.length).toBe(1);
111+
expect(result.current.visibleToasts[0].content).toBe('Third Toast');
112+
});
113+
114+
it('should be able to display one toast without exitAnimation, add multiple toasts, and remove the middle not visible one programmatically', () => {
115+
let {result} = renderHook(() => useToastState());
116+
117+
// Add the first toast
118+
act(() => {
119+
result.current.add('First Toast', {timeout: 0});
120+
});
121+
expect(result.current.visibleToasts).toHaveLength(1);
122+
expect(result.current.visibleToasts[0].content).toBe('First Toast');
123+
124+
let secondToastKey = null;
125+
// Add the second toast
126+
act(() => {
127+
secondToastKey = result.current.add('Second Toast', {timeout: 0});
128+
});
129+
expect(result.current.visibleToasts).toHaveLength(1);
130+
expect(result.current.visibleToasts[0].content).toBe('First Toast');
131+
132+
// Add the third toast
133+
act(() => {
134+
result.current.add('Third Toast', {timeout: 0});
135+
});
136+
expect(result.current.visibleToasts).toHaveLength(1);
137+
expect(result.current.visibleToasts[0].content).toBe('First Toast');
138+
139+
// Remove a toast that isn't visible
140+
act(() => {result.current.close(secondToastKey);});
141+
expect(result.current.visibleToasts).toHaveLength(1);
142+
expect(result.current.visibleToasts[0].content).toBe('First Toast');
143+
144+
// Remove the visible toast to confirm the middle toast was removed
145+
act(() => {result.current.close(result.current.visibleToasts[0].key);});
146+
expect(result.current.visibleToasts.length).toBe(1);
147+
expect(result.current.visibleToasts[0].content).toBe('Third Toast');
148+
});
149+
150+
it('should be able to display one toast with exitAnimation, add multiple toasts, and remove the middle not visible one programmatically', () => {
151+
let {result} = renderHook(() => useToastState({hasExitAnimation: true}));
152+
153+
// Add the first toast
154+
act(() => {
155+
result.current.add('First Toast', {timeout: 0});
156+
});
157+
expect(result.current.visibleToasts).toHaveLength(1);
158+
expect(result.current.visibleToasts[0].content).toBe('First Toast');
159+
160+
let secondToastKey = null;
161+
// Add the second toast
162+
act(() => {
163+
secondToastKey = result.current.add('Second Toast', {timeout: 0});
164+
});
165+
expect(result.current.visibleToasts).toHaveLength(1);
166+
expect(result.current.visibleToasts[0].content).toBe('First Toast');
167+
168+
// Add the third toast
169+
act(() => {
170+
result.current.add('Third Toast', {timeout: 0});
171+
});
172+
expect(result.current.visibleToasts).toHaveLength(1);
173+
expect(result.current.visibleToasts[0].content).toBe('First Toast');
174+
175+
// Remove a toast that isn't visible
176+
act(() => {result.current.close(secondToastKey);});
177+
expect(result.current.visibleToasts).toHaveLength(1);
178+
expect(result.current.visibleToasts[0].content).toBe('First Toast');
179+
180+
// Remove the visible toast to confirm the middle toast was removed
181+
act(() => {result.current.close(result.current.visibleToasts[0].key);});
182+
expect(result.current.visibleToasts.length).toBe(1);
183+
expect(result.current.visibleToasts[0].content).toBe('First Toast');
184+
expect(result.current.visibleToasts[0].animation).toBe('exiting');
185+
act(() => {result.current.remove(result.current.visibleToasts[0].key);});
186+
187+
// there should only be one Toast left, the third one
188+
expect(result.current.visibleToasts.length).toBe(1);
189+
expect(result.current.visibleToasts[0].content).toBe('Third Toast');
190+
});
191+
192+
it('should add a exit animation to a toast that is moved out of the visible list by a higher priority toast', () => {
193+
let {result} = renderHook(() => useToastState({hasExitAnimation: true, maxVisibleToasts: 2}));
194+
195+
act(() => {result.current.add('First Toast', {priority: 5});});
196+
expect(result.current.visibleToasts).toHaveLength(1);
197+
expect(result.current.visibleToasts[0].content).toBe('First Toast');
198+
expect(result.current.visibleToasts[0].animation).toBe('entering');
199+
200+
act(() => {result.current.add('Second Toast', {priority: 1});});
201+
expect(result.current.visibleToasts.length).toBe(2);
202+
expect(result.current.visibleToasts[0].content).toBe('First Toast');
203+
expect(result.current.visibleToasts[0].animation).toBe('entering');
204+
expect(result.current.visibleToasts[1].content).toBe('Second Toast');
205+
expect(result.current.visibleToasts[1].animation).toBe('entering');
206+
207+
act(() => {result.current.add('Third Toast', {priority: 10});});
208+
expect(result.current.visibleToasts.length).toBe(3);
209+
expect(result.current.visibleToasts[0].content).toBe('Third Toast');
210+
expect(result.current.visibleToasts[0].animation).toBe('entering');
211+
expect(result.current.visibleToasts[1].content).toBe('First Toast');
212+
expect(result.current.visibleToasts[1].animation).toBe('entering');
213+
expect(result.current.visibleToasts[2].content).toBe('Second Toast');
214+
expect(result.current.visibleToasts[2].animation).toBe('exiting');
215+
216+
// Remove shouldn't get rid of the lower priority toast from the queue so that it may return when there is
217+
// enough room. The below mimics a remove call that might be called in onAnimationEnd
218+
act(() => {result.current.remove(result.current.visibleToasts[2].key);});
219+
expect(result.current.visibleToasts.length).toBe(2);
220+
expect(result.current.visibleToasts[0].content).toBe('Third Toast');
221+
expect(result.current.visibleToasts[1].content).toBe('First Toast');
222+
223+
act(() => {result.current.close(result.current.visibleToasts[0].key);});
224+
act(() => {result.current.remove(result.current.visibleToasts[0].key);});
225+
expect(result.current.visibleToasts.length).toBe(2);
226+
227+
expect(result.current.visibleToasts[0].content).toBe('First Toast');
228+
expect(result.current.visibleToasts[0].animation).toBe('entering');
229+
expect(result.current.visibleToasts[1].content).toBe('Second Toast');
230+
expect(result.current.visibleToasts[1].animation).toBe('queued');
231+
});
232+
233+
it('should maintain the toast queue order on close and apply exiting to the closing toast', () => {
234+
let {result} = renderHook(() => useToastState({hasExitAnimation: true, maxVisibleToasts: 3}));
235+
236+
act(() => {result.current.add('First Toast');});
237+
expect(result.current.visibleToasts).toHaveLength(1);
238+
expect(result.current.visibleToasts[0].content).toBe('First Toast');
239+
expect(result.current.visibleToasts[0].animation).toBe('entering');
240+
241+
act(() => {result.current.add('Second Toast');});
242+
expect(result.current.visibleToasts).toHaveLength(2);
243+
expect(result.current.visibleToasts[0].content).toBe('First Toast');
244+
expect(result.current.visibleToasts[0].animation).toBe('entering');
245+
expect(result.current.visibleToasts[1].content).toBe('Second Toast');
246+
expect(result.current.visibleToasts[1].animation).toBe('entering');
247+
248+
act(() => {result.current.add('Third Toast');});
249+
expect(result.current.visibleToasts).toHaveLength(3);
250+
expect(result.current.visibleToasts[0].content).toBe('First Toast');
251+
expect(result.current.visibleToasts[0].animation).toBe('entering');
252+
expect(result.current.visibleToasts[1].content).toBe('Second Toast');
253+
expect(result.current.visibleToasts[1].animation).toBe('entering');
254+
expect(result.current.visibleToasts[2].content).toBe('Third Toast');
255+
expect(result.current.visibleToasts[2].animation).toBe('entering');
256+
257+
act(() => {result.current.close(result.current.visibleToasts[1].key);});
258+
expect(result.current.visibleToasts).toHaveLength(3);
259+
expect(result.current.visibleToasts[0].content).toBe('First Toast');
260+
expect(result.current.visibleToasts[0].animation).toBe('entering');
261+
expect(result.current.visibleToasts[1].content).toBe('Second Toast');
262+
expect(result.current.visibleToasts[1].animation).toBe('exiting');
263+
expect(result.current.visibleToasts[2].content).toBe('Third Toast');
264+
expect(result.current.visibleToasts[2].animation).toBe('entering');
265+
266+
act(() => {result.current.remove(result.current.visibleToasts[1].key);});
267+
expect(result.current.visibleToasts).toHaveLength(2);
268+
expect(result.current.visibleToasts[0].content).toBe('First Toast');
269+
expect(result.current.visibleToasts[0].animation).toBe('entering');
270+
expect(result.current.visibleToasts[1].content).toBe('Third Toast');
271+
expect(result.current.visibleToasts[1].animation).toBe('entering');
63272
});
64273

65274
it('should close a toast', () => {
@@ -91,11 +300,11 @@ describe('useToastState', () => {
91300

92301
act(() => {result.current.add('Second Toast');});
93302
expect(result.current.visibleToasts.length).toBe(1);
94-
expect(result.current.visibleToasts[0].content).toBe('Second Toast');
303+
expect(result.current.visibleToasts[0].content).toBe(newValue[0].content);
95304

96305
act(() => {result.current.close(result.current.visibleToasts[0].key);});
97306
expect(result.current.visibleToasts.length).toBe(1);
98-
expect(result.current.visibleToasts[0].content).toBe(newValue[0].content);
307+
expect(result.current.visibleToasts[0].content).toBe('Second Toast');
99308
expect(result.current.visibleToasts[0].animation).toBe('queued');
100309
});
101310

0 commit comments

Comments
 (0)