-
Notifications
You must be signed in to change notification settings - Fork 155
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
Conversation
Codecov ReportAll modified and coverable lines are covered by tests ✅
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
☔ View full report in Codecov by Sentry. |
ad5c878
to
df0b4f6
Compare
2d4df9c
to
c0a9765
Compare
8b64eb7
to
c85eb92
Compare
@@ -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); |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
- 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) => { - I think, this code does not need this value at all, because the hook already depends on
isFlashbarStackExpanded
, so ifisFlashbarStackExpanded === false
it automatically means it wastrue
before. If you want to skip initial render, there isuseEffectOnUpdate
which is more idiomatic approach to solve this:export function useEffectOnUpdate(callback: EffectCallback, deps: DependencyList) {
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Fixed the import
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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
for (const withAnimations of [false, true]) { | ||
describe(withAnimations ? 'with animations' : 'without animations', () => { |
There was a problem hiding this comment.
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')
There was a problem hiding this comment.
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); |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Changed
src/flashbar/__tests__/common.tsx
Outdated
); | ||
}; | ||
const appWrapper = createWrapper(render(<App />).container); | ||
expect(appWrapper.findFlashbar()?.findItems()).toHaveLength(0); |
There was a problem hiding this comment.
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 !
@@ -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); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
- 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) => { - I think, this code does not need this value at all, because the hook already depends on
isFlashbarStackExpanded
, so ifisFlashbarStackExpanded === false
it automatically means it wastrue
before. If you want to skip initial render, there isuseEffectOnUpdate
which is more idiomatic approach to solve this:export function useEffectOnUpdate(callback: EffectCallback, deps: DependencyList) {
} | ||
> | ||
<TransitionGroup component={null}> | ||
const renderListWithoutAnimations = () => { |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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]); |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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
This reverts commit de6a191.
testFlashDismissal({ stackItems: true }); | ||
}); | ||
|
||
test('produces the same visual HMTL output with motion enabled and disabled', () => { |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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}</>; |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Fine by me
There was a problem hiding this 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', () => { |
There was a problem hiding this comment.
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', () => { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
describe.only('Flashbar component', () => { | |
describe('Flashbar component', () => { |
There was a problem hiding this comment.
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}</>; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Fine by me
…motion" This reverts commit 55b65a7.
Removed it |
Description
This fixes two issues:
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.How has this been tested?
Review checklist
The following items are to be evaluated by the author(s) and the reviewer(s).
Correctness
CONTRIBUTING.md
.CONTRIBUTING.md
.Security
checkSafeUrl
function.Testing
By submitting this pull request, I confirm that you can use, modify, copy, and redistribute this contribution, under the terms of your choice.