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

fix: Prevent Flashbar from delaying render when not using animations #1656

Merged
merged 22 commits into from
Oct 23, 2023

Conversation

jperals
Copy link
Member

@jperals jperals commented Oct 16, 2023

Description

This fixes two issues:

  • Ticket AWSUI-22525: The collapsible flashbar was making use of TransitionGroup even when motion is disabled, although without defining any animation in that case, so there would not be any perceivable issue for users, but since TransitionGroup delays the removal of items by one frame, this would break tests that remove items. This is fixed by adopting the same approach as the non-collapsible flashbar, with separate list rendering for motion enabled and motion disabled, where TransitionGroup is only used when motion is enabled. replacing the wrapping TransitionGroup with a React fragment when motion is disabled.
  • An additional issue was found and fixed by working on this: the logic for scrolling the expand/collapse button when collapsing the flashbar was only called when motion is enabled.

How has this been tested?

  • Added unit tests to cover flash dismissal for collapsible and non-collapsible flashbar (with motion disabled)
  • Reorganized unit tests so that as much as possible of the collapsible flashbar functionality is tested with motion enabled and disabled (this uncovered the second issue mentioned above)
  • Manually tested on local dev pages, with motion enabled and disabled, and compared with mainline deployment
Review checklist

The following items are to be evaluated by the author(s) and the reviewer(s).

Correctness

  • Changes include appropriate documentation updates.
  • Changes are backward-compatible if not indicated, see CONTRIBUTING.md.
  • Changes do not include unsupported browser features, see CONTRIBUTING.md.
  • Changes were manually tested for accessibility, see accessibility guidelines.

Security

Testing

  • Changes are covered with new/existing unit tests?
  • Changes are covered with new/existing integration tests?

By submitting this pull request, I confirm that you can use, modify, copy, and redistribute this contribution, under the terms of your choice.

@codecov
Copy link

codecov bot commented Oct 16, 2023

Codecov Report

All modified and coverable lines are covered by tests ✅

Comparison is base (7fa1db8) 94.19% compared to head (44c41ba) 94.20%.
Report is 5 commits behind head on main.

Additional details and impacted files
@@           Coverage Diff           @@
##             main    #1656   +/-   ##
=======================================
  Coverage   94.19%   94.20%           
=======================================
  Files         650      650           
  Lines       17530    17539    +9     
  Branches     5771     5776    +5     
=======================================
+ Hits        16513    16522    +9     
  Misses        948      948           
  Partials       69       69           
Files Coverage Δ
src/flashbar/collapsible-flashbar.tsx 98.05% <100.00%> (+0.02%) ⬆️
src/flashbar/non-collapsible-flashbar.tsx 100.00% <100.00%> (ø)

... and 2 files with indirect coverage changes

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

@@ -93,6 +93,10 @@ export default function CollapsibleFlashbar({ items, ...restProps }: FlashbarPro
focusFlashById(ref.current, mostRecentItem.id);
}
}
// When collapsing, scroll up if necessary to avoid losing track of the focused button
else if (wasFlashbarStackExpanded && !isFlashbarStackExpanded && notificationBarRef.current) {
scrollElementIntoView(notificationBarRef.current);
Copy link
Member Author

Choose a reason for hiding this comment

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

By moving this here, this will be called regardless of motion being enabled or not. Before, it was under a condition if (initialAnimationState), and initialAnimationState is only initialized if motion is enabled.

Copy link
Member

Choose a reason for hiding this comment

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

  1. Let's not use dnd-kit code in unrelated use-cases. We have our own version of it by the way:
    export const usePrevious = <T>(value: T) => {
  2. I think, this code does not need this value at all, because the hook already depends on isFlashbarStackExpanded, so if isFlashbarStackExpanded === false it automatically means it was true before. If you want to skip initial render, there is useEffectOnUpdate which is more idiomatic approach to solve this:
    export function useEffectOnUpdate(callback: EffectCallback, deps: DependencyList) {

Copy link
Member Author

Choose a reason for hiding this comment

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

Oh, imported from the wrong place, thanks for spotting this.

Ideally I'd like to keep using a layout effect instead of a "normal" effect to prevent flickering, i.e, a frame where the flashbar collapsed but the scrolling hasn't happened yet.

Copy link
Member Author

Choose a reason for hiding this comment

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

Fixed the import

Copy link
Member

Choose a reason for hiding this comment

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

Why not to simplify it like this?

useEffectOnUpdate(() => {
   if(!isFlashbarStackExpanded && notificationBarRef.current) {
     scrollElementIntoView(notificationBarRef.current);
   }
}, [isFlashbarStackExpanded])

I see no need to track previous value

Copy link
Member Author

Choose a reason for hiding this comment

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

Because useEffectOnUpdate uses useEffect and not useLayoutEffect, the scroll is delayed to the next render. See test failure: https://github.com/cloudscape-design/components/actions/runs/6576775598/job/17866925600

Copy link
Member

Choose a reason for hiding this comment

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

findNotificationBar().click() uses vanilla HTMLElement.click function instead of operating via our test utils.

Use our test utils and I expect it will work fine

@jperals jperals marked this pull request as ready for review October 17, 2023 08:00
@jperals jperals requested a review from a team as a code owner October 17, 2023 08:00
@jperals jperals requested review from just-boris and removed request for a team October 17, 2023 08:00
Comment on lines 49 to 50
for (const withAnimations of [false, true]) {
describe(withAnimations ? 'with animations' : 'without animations', () => {
Copy link
Member

@just-boris just-boris Oct 17, 2023

Choose a reason for hiding this comment

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

Optional: Could save one indentation level by using describe.each([true, false])('withAnimations=%s')

Copy link
Member Author

Choose a reason for hiding this comment

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

Changed

// Test this feature only without animations because TransitionGroup delays item removals by one frame.
// Customers should disable animations in their tests too:
// https://cloudscape.design/foundation/visual-foundation/motion/#implementation
disableMotion(true);
Copy link
Member

Choose a reason for hiding this comment

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

If you manipulate with motion inside a single test, should the beforeAll hook convert to beforeEach to ensure the clean state between tests?

Copy link
Member

Choose a reason for hiding this comment

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

Do you have any response on this comment?

Copy link
Member Author

Choose a reason for hiding this comment

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

Sorry I missed this comment. This test is outside the describe block that contains the beforeAll hook, so this call should not affect the tests in that block.

Copy link
Member

Choose a reason for hiding this comment

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

This code contains a hidden danger, why not to resolve it by converting beforeAll to beforeEach

Copy link
Member Author

Choose a reason for hiding this comment

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

Changed

);
};
const appWrapper = createWrapper(render(<App />).container);
expect(appWrapper.findFlashbar()?.findItems()).toHaveLength(0);
Copy link
Member

Choose a reason for hiding this comment

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

Using ? makes tests less solid. All test code should use !

src/flashbar/__tests__/flashbar.test.tsx Show resolved Hide resolved
@@ -93,6 +93,10 @@ export default function CollapsibleFlashbar({ items, ...restProps }: FlashbarPro
focusFlashById(ref.current, mostRecentItem.id);
}
}
// When collapsing, scroll up if necessary to avoid losing track of the focused button
else if (wasFlashbarStackExpanded && !isFlashbarStackExpanded && notificationBarRef.current) {
scrollElementIntoView(notificationBarRef.current);
Copy link
Member

Choose a reason for hiding this comment

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

  1. Let's not use dnd-kit code in unrelated use-cases. We have our own version of it by the way:
    export const usePrevious = <T>(value: T) => {
  2. I think, this code does not need this value at all, because the hook already depends on isFlashbarStackExpanded, so if isFlashbarStackExpanded === false it automatically means it was true before. If you want to skip initial render, there is useEffectOnUpdate which is more idiomatic approach to solve this:
    export function useEffectOnUpdate(callback: EffectCallback, deps: DependencyList) {

}
>
<TransitionGroup component={null}>
const renderListWithoutAnimations = () => {
Copy link
Member

Choose a reason for hiding this comment

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

This is too much code copy paste isn't it? It can get our of sync very easily

Any options how reduce this duplication?

Copy link
Member Author

Choose a reason for hiding this comment

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

The tests, which are now run with motion enabled and disabled, should prevent breakage of any of the two, but yes, there is some stuff that can be done, for example at the very least reusing some static attributes like the ARIA ones.

Copy link
Member Author

Choose a reason for hiding this comment

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

Grouped the common props together

Copy link
Member

Choose a reason for hiding this comment

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

Even if the props are reused, the HTML markup is different. Reminder that in our screenshot tests we use no-motion mode, so the animated markup does not go through screenshot testing. This is a big risk for our coverage.

Would you like to make one more pass, making sure the actual HTML is the same (except transition wrapper) or do you want me to look into it and come with the code?

Copy link
Member Author

Choose a reason for hiding this comment

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

I added a test which compares the HTML output. Note:

  • For this I needed the test to remove some a11y-related attributes that are not relevant to the visual result, because they have dynamic values
  • Only for the collapsed state. For the expanded state, it gets trickier since we do need to add classes for the motion case, for the animations to work

If we want to be even more precise here, my suggestion would be to consider actually making screenshot tests with motion enabled too for this component.

@@ -13,7 +13,8 @@ export default function InteractiveFlashbar() {
};

const add = (type: FlashbarProps.Type, hasHeader = false) => {
setItems(items => [generateItem({ type, id: nextId.current.toString(), dismiss, hasHeader }), ...items]);
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 makes sure that the new item has the correct id and not the next one. Since React batches state updates, the old logic could lead to two items having the same id.

Copy link
Member

Choose a reason for hiding this comment

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

Or you could just embed this logic inside generateItem, so it always produces items with unique ids

testFlashDismissal({ stackItems: true });
});

test('produces the same visual HMTL output with motion enabled and disabled', () => {
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 test is less relevant after last commit, but it helped me catch that if using <TransitionGroup> without component={null}, it adds a wrapping div. This could actually break styles, eventually.

Copy link
Member

Choose a reason for hiding this comment

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

When using with removeInvisibleAttributes the test is not very trust worthy.

Feel free to keep it, but I do not find it very useful

@@ -369,3 +368,6 @@ const NotificationTypeCount = ({
</span>
);
};

const ListWrapper = ({ children, withMotion }: { children: ReactNode; withMotion: boolean }) =>
withMotion ? <TransitionGroup component={null}>{children}</TransitionGroup> : <>{children}</>;
Copy link
Member Author

Choose a reason for hiding this comment

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

Added this small component because TransitionGroup adds an extra div by default unless component is null, but using this same prop in React.Fragment triggers runtime (not build time) errors.

An alternative would be to have this inline inside the main component:

const ListWrapper = isReducedMotion ? TransitionGroup : React.Fragment;
(...)
<ListWrapper {...isReducedMotion && { component: null } }>
  (...)
</ListWrapper>

Equally valid, just a bit uglier IMO.

Copy link
Member Author

Choose a reason for hiding this comment

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

Not just uglier but less type safe too, actually.

Copy link
Member

Choose a reason for hiding this comment

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

Fine by me

Copy link
Member

@just-boris just-boris left a comment

Choose a reason for hiding this comment

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

describe.only is important to fix, I will approve after this

testFlashDismissal({ stackItems: true });
});

test('produces the same visual HMTL output with motion enabled and disabled', () => {
Copy link
Member

Choose a reason for hiding this comment

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

When using with removeInvisibleAttributes the test is not very trust worthy.

Feel free to keep it, but I do not find it very useful

beforeEach(() => {
useAnimations = withAnimations;
});
describe.only('Flashbar component', () => {
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
describe.only('Flashbar component', () => {
describe('Flashbar component', () => {

Copy link
Member Author

Choose a reason for hiding this comment

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

Thanks for spotting, removed

@@ -369,3 +368,6 @@ const NotificationTypeCount = ({
</span>
);
};

const ListWrapper = ({ children, withMotion }: { children: ReactNode; withMotion: boolean }) =>
withMotion ? <TransitionGroup component={null}>{children}</TransitionGroup> : <>{children}</>;
Copy link
Member

Choose a reason for hiding this comment

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

Fine by me

@jperals
Copy link
Member Author

jperals commented Oct 23, 2023

When using with removeInvisibleAttributes the test is not very trust worthy.
Feel free to keep it, but I do not find it very useful

Removed it

@jperals jperals merged commit a86eadc into main Oct 23, 2023
31 checks passed
@jperals jperals deleted the fix/flashbar-delayed-render branch October 23, 2023 11:44
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

2 participants