Skip to content

Commit

Permalink
Refactor logic to prevent breaking changes in useToastState (#6270)
Browse files Browse the repository at this point in the history
* 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>
  • Loading branch information
4 people committed Apr 30, 2024
1 parent 04df93f commit 1bd538f
Show file tree
Hide file tree
Showing 3 changed files with 235 additions and 25 deletions.
4 changes: 2 additions & 2 deletions packages/@react-spectrum/toast/src/toastContainer.css
Expand Up @@ -29,14 +29,14 @@

&[data-position=top] {
top: 0;
flex-direction: column;
flex-direction: column-reverse;
--slide-from: translateY(-100%);
--slide-to: translateY(0);
}

&[data-position=bottom] {
bottom: 0;
flex-direction: column-reverse;
flex-direction: column;
--slide-from: translateY(100%);
--slide-to: translateY(0);
}
Expand Down
39 changes: 20 additions & 19 deletions packages/@react-stately/toast/src/useToastState.ts
Expand Up @@ -134,19 +134,15 @@ export class ToastQueue<T> {
}
}

if (toast.priority) {
this.queue.splice(low, 0, toast);
} else {
this.queue.unshift(toast);
}
this.queue.splice(low, 0, toast);

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

this.updateVisibleToasts();
this.updateVisibleToasts({action: 'add'});
return toastKey;
}

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

this.updateVisibleToasts(index);
this.updateVisibleToasts({action: 'close', key});
}

/** Removes a toast from the visible toasts after an exiting animation. */
remove(key: string) {
this.visibleToasts = this.visibleToasts.filter(t => t.key !== key);
this.updateVisibleToasts();
this.updateVisibleToasts({action: 'remove', key});
}

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

if (oldIndex !== -1) {
toasts.splice(oldIndex, 0, prevToasts?.[0]);
}

this.visibleToasts = toasts;
if (action === 'add' && this.hasExitAnimation) {
let prevToasts: QueuedToast<T>[] = this.visibleToasts
.filter(t => !toasts.some(t2 => t.key === t2.key))
.map(t => ({...t, animation: 'exiting'}));
this.visibleToasts = prevToasts.concat(toasts).sort((a, b) => b.priority - a.priority);
} else if (action === 'close' && this.hasExitAnimation) {
// Cause a rerender to happen for exit animation
this.visibleToasts = this.visibleToasts.map(t => {
if (t.key !== key) {
return t;
} else {
return {...t, animation: 'exiting'};
}
});
} else {
this.visibleToasts = toasts;
}
Expand Down
217 changes: 213 additions & 4 deletions packages/@react-stately/toast/test/useToastState.test.js
Expand Up @@ -19,6 +19,14 @@ describe('useToastState', () => {
props: {timeout: 0}
}];

beforeEach(() => {
jest.useFakeTimers();
});

afterEach(() => {
act(() => jest.runAllTimers());
});

it('should add a new toast via add', () => {
let {result} = renderHook(() => useToastState());
expect(result.current.visibleToasts).toStrictEqual([]);
Expand Down Expand Up @@ -58,8 +66,209 @@ describe('useToastState', () => {

act(() => {result.current.add(secondToast.content, secondToast.props);});
expect(result.current.visibleToasts.length).toBe(2);
expect(result.current.visibleToasts[0].content).toBe(secondToast.content);
expect(result.current.visibleToasts[1].content).toBe(newValue[0].content);
expect(result.current.visibleToasts[0].content).toBe(newValue[0].content);
expect(result.current.visibleToasts[1].content).toBe(secondToast.content);
});

it('should be able to display three toasts and remove the middle toast via timeout then the visible toast', () => {
let {result} = renderHook(() => useToastState({maxVisibleToasts: 3}));

// Add the first toast
act(() => {
result.current.add('First Toast', {timeout: 0});
});
expect(result.current.visibleToasts).toHaveLength(1);
expect(result.current.visibleToasts[0].content).toBe('First Toast');

// Add the second toast
act(() => {
result.current.add('Second Toast', {timeout: 1000});
});
expect(result.current.visibleToasts).toHaveLength(2);
expect(result.current.visibleToasts[0].content).toBe('First Toast');

result.current.resumeAll();

// Add the third toast
act(() => {
result.current.add('Third Toast', {timeout: 0});
});
expect(result.current.visibleToasts).toHaveLength(3);
expect(result.current.visibleToasts[0].content).toBe('First Toast');
expect(result.current.visibleToasts[1].content).toBe('Second Toast');
expect(result.current.visibleToasts[2].content).toBe('Third Toast');

act(() => jest.advanceTimersByTime(500));
expect(result.current.visibleToasts).toHaveLength(3);

act(() => jest.advanceTimersByTime(1000));
expect(result.current.visibleToasts).toHaveLength(2);
expect(result.current.visibleToasts[0].content).toBe('First Toast');
expect(result.current.visibleToasts[1].content).toBe('Third Toast');

act(() => {result.current.close(result.current.visibleToasts[0].key);});
expect(result.current.visibleToasts.length).toBe(1);
expect(result.current.visibleToasts[0].content).toBe('Third Toast');
});

it('should be able to display one toast without exitAnimation, add multiple toasts, and remove the middle not visible one programmatically', () => {
let {result} = renderHook(() => useToastState());

// Add the first toast
act(() => {
result.current.add('First Toast', {timeout: 0});
});
expect(result.current.visibleToasts).toHaveLength(1);
expect(result.current.visibleToasts[0].content).toBe('First Toast');

let secondToastKey = null;
// Add the second toast
act(() => {
secondToastKey = result.current.add('Second Toast', {timeout: 0});
});
expect(result.current.visibleToasts).toHaveLength(1);
expect(result.current.visibleToasts[0].content).toBe('First Toast');

// Add the third toast
act(() => {
result.current.add('Third Toast', {timeout: 0});
});
expect(result.current.visibleToasts).toHaveLength(1);
expect(result.current.visibleToasts[0].content).toBe('First Toast');

// Remove a toast that isn't visible
act(() => {result.current.close(secondToastKey);});
expect(result.current.visibleToasts).toHaveLength(1);
expect(result.current.visibleToasts[0].content).toBe('First Toast');

// Remove the visible toast to confirm the middle toast was removed
act(() => {result.current.close(result.current.visibleToasts[0].key);});
expect(result.current.visibleToasts.length).toBe(1);
expect(result.current.visibleToasts[0].content).toBe('Third Toast');
});

it('should be able to display one toast with exitAnimation, add multiple toasts, and remove the middle not visible one programmatically', () => {
let {result} = renderHook(() => useToastState({hasExitAnimation: true}));

// Add the first toast
act(() => {
result.current.add('First Toast', {timeout: 0});
});
expect(result.current.visibleToasts).toHaveLength(1);
expect(result.current.visibleToasts[0].content).toBe('First Toast');

let secondToastKey = null;
// Add the second toast
act(() => {
secondToastKey = result.current.add('Second Toast', {timeout: 0});
});
expect(result.current.visibleToasts).toHaveLength(1);
expect(result.current.visibleToasts[0].content).toBe('First Toast');

// Add the third toast
act(() => {
result.current.add('Third Toast', {timeout: 0});
});
expect(result.current.visibleToasts).toHaveLength(1);
expect(result.current.visibleToasts[0].content).toBe('First Toast');

// Remove a toast that isn't visible
act(() => {result.current.close(secondToastKey);});
expect(result.current.visibleToasts).toHaveLength(1);
expect(result.current.visibleToasts[0].content).toBe('First Toast');

// Remove the visible toast to confirm the middle toast was removed
act(() => {result.current.close(result.current.visibleToasts[0].key);});
expect(result.current.visibleToasts.length).toBe(1);
expect(result.current.visibleToasts[0].content).toBe('First Toast');
expect(result.current.visibleToasts[0].animation).toBe('exiting');
act(() => {result.current.remove(result.current.visibleToasts[0].key);});

// there should only be one Toast left, the third one
expect(result.current.visibleToasts.length).toBe(1);
expect(result.current.visibleToasts[0].content).toBe('Third Toast');
});

it('should add a exit animation to a toast that is moved out of the visible list by a higher priority toast', () => {
let {result} = renderHook(() => useToastState({hasExitAnimation: true, maxVisibleToasts: 2}));

act(() => {result.current.add('First Toast', {priority: 5});});
expect(result.current.visibleToasts).toHaveLength(1);
expect(result.current.visibleToasts[0].content).toBe('First Toast');
expect(result.current.visibleToasts[0].animation).toBe('entering');

act(() => {result.current.add('Second Toast', {priority: 1});});
expect(result.current.visibleToasts.length).toBe(2);
expect(result.current.visibleToasts[0].content).toBe('First Toast');
expect(result.current.visibleToasts[0].animation).toBe('entering');
expect(result.current.visibleToasts[1].content).toBe('Second Toast');
expect(result.current.visibleToasts[1].animation).toBe('entering');

act(() => {result.current.add('Third Toast', {priority: 10});});
expect(result.current.visibleToasts.length).toBe(3);
expect(result.current.visibleToasts[0].content).toBe('Third Toast');
expect(result.current.visibleToasts[0].animation).toBe('entering');
expect(result.current.visibleToasts[1].content).toBe('First Toast');
expect(result.current.visibleToasts[1].animation).toBe('entering');
expect(result.current.visibleToasts[2].content).toBe('Second Toast');
expect(result.current.visibleToasts[2].animation).toBe('exiting');

// Remove shouldn't get rid of the lower priority toast from the queue so that it may return when there is
// enough room. The below mimics a remove call that might be called in onAnimationEnd
act(() => {result.current.remove(result.current.visibleToasts[2].key);});
expect(result.current.visibleToasts.length).toBe(2);
expect(result.current.visibleToasts[0].content).toBe('Third Toast');
expect(result.current.visibleToasts[1].content).toBe('First Toast');

act(() => {result.current.close(result.current.visibleToasts[0].key);});
act(() => {result.current.remove(result.current.visibleToasts[0].key);});
expect(result.current.visibleToasts.length).toBe(2);

expect(result.current.visibleToasts[0].content).toBe('First Toast');
expect(result.current.visibleToasts[0].animation).toBe('entering');
expect(result.current.visibleToasts[1].content).toBe('Second Toast');
expect(result.current.visibleToasts[1].animation).toBe('queued');
});

it('should maintain the toast queue order on close and apply exiting to the closing toast', () => {
let {result} = renderHook(() => useToastState({hasExitAnimation: true, maxVisibleToasts: 3}));

act(() => {result.current.add('First Toast');});
expect(result.current.visibleToasts).toHaveLength(1);
expect(result.current.visibleToasts[0].content).toBe('First Toast');
expect(result.current.visibleToasts[0].animation).toBe('entering');

act(() => {result.current.add('Second Toast');});
expect(result.current.visibleToasts).toHaveLength(2);
expect(result.current.visibleToasts[0].content).toBe('First Toast');
expect(result.current.visibleToasts[0].animation).toBe('entering');
expect(result.current.visibleToasts[1].content).toBe('Second Toast');
expect(result.current.visibleToasts[1].animation).toBe('entering');

act(() => {result.current.add('Third Toast');});
expect(result.current.visibleToasts).toHaveLength(3);
expect(result.current.visibleToasts[0].content).toBe('First Toast');
expect(result.current.visibleToasts[0].animation).toBe('entering');
expect(result.current.visibleToasts[1].content).toBe('Second Toast');
expect(result.current.visibleToasts[1].animation).toBe('entering');
expect(result.current.visibleToasts[2].content).toBe('Third Toast');
expect(result.current.visibleToasts[2].animation).toBe('entering');

act(() => {result.current.close(result.current.visibleToasts[1].key);});
expect(result.current.visibleToasts).toHaveLength(3);
expect(result.current.visibleToasts[0].content).toBe('First Toast');
expect(result.current.visibleToasts[0].animation).toBe('entering');
expect(result.current.visibleToasts[1].content).toBe('Second Toast');
expect(result.current.visibleToasts[1].animation).toBe('exiting');
expect(result.current.visibleToasts[2].content).toBe('Third Toast');
expect(result.current.visibleToasts[2].animation).toBe('entering');

act(() => {result.current.remove(result.current.visibleToasts[1].key);});
expect(result.current.visibleToasts).toHaveLength(2);
expect(result.current.visibleToasts[0].content).toBe('First Toast');
expect(result.current.visibleToasts[0].animation).toBe('entering');
expect(result.current.visibleToasts[1].content).toBe('Third Toast');
expect(result.current.visibleToasts[1].animation).toBe('entering');
});

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

act(() => {result.current.add('Second Toast');});
expect(result.current.visibleToasts.length).toBe(1);
expect(result.current.visibleToasts[0].content).toBe('Second Toast');
expect(result.current.visibleToasts[0].content).toBe(newValue[0].content);

act(() => {result.current.close(result.current.visibleToasts[0].key);});
expect(result.current.visibleToasts.length).toBe(1);
expect(result.current.visibleToasts[0].content).toBe(newValue[0].content);
expect(result.current.visibleToasts[0].content).toBe('Second Toast');
expect(result.current.visibleToasts[0].animation).toBe('queued');
});

Expand Down

1 comment on commit 1bd538f

@rspbot
Copy link

@rspbot rspbot commented on 1bd538f May 1, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please sign in to comment.