Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Refactor logic to prevent breaking changes in useToastState #6270

Merged
merged 14 commits into from
Apr 30, 2024
Merged
Show file tree
Hide file tree
Changes from 10 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
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);
Copy link
Member

Choose a reason for hiding this comment

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

Is this a breaking change or was this.queue.unshift(toast); a bug we hadn't found?

Copy link
Member Author

Choose a reason for hiding this comment

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

This reverts the logic to what is currently released

Copy link
Member

Choose a reason for hiding this comment

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

Right, I just remembered that and was coming back to delete it, but you responded.


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);
LFDanLu marked this conversation as resolved.
Show resolved Hide resolved
} 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
176 changes: 172 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,168 @@ 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 close a toast', () => {
Expand Down Expand Up @@ -91,11 +259,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