Skip to content

fix(Menu): handle triggers composition#21225

Merged
layershifter merged 19 commits into
microsoft:masterfrom
layershifter:fix/trigger-composition
Jan 19, 2022
Merged

fix(Menu): handle triggers composition#21225
layershifter merged 19 commits into
microsoft:masterfrom
layershifter:fix/trigger-composition

Conversation

@layershifter

@layershifter layershifter commented Jan 10, 2022

Copy link
Copy Markdown
Member

Current behaviour

Currently the is no way to compose components that are using trigger pattern:

<Menu>
  {/* One of these will be broken and will not handle events properly 👇  */}
  <MenuTrigger>
    <Tooltip content="opens a menu">
      <Button>Menu only</Button>
    </Tooltip>
  </MenuTrigger>
</Menu>

Related Issues

Fixes #21115, based on #21128.

Description of changes

This PR proposes a solution to the nested trigger composition problem with ref and props forwarding.

Pros

  • Triggers could be nested
  • Event handlers are still stable by reference
  • Adds generic useTriggerElement hook that can be reused

Cons

  • "jQuery-like" event dispatching callback to have stable event handlers
  • Triggers can no longer read props (i.e. disabled) easily
    • refactored menu to read disabled andaria-disabled from the event targets

How it works?

We need to have stable callbacks that we are passing to slots or when we are cloning elements (trigger's case) to not break memoization that could be used by customers:

function App() {
  return (
    <MenuTrigger>
      <MemoButton />
    </MenuTrigger>
  );
}

It's easy to when we know them in advance:

const handleClick = React.useCallback(
  e => {
    // some custom logic
    props.onClick(e);
  },
  [props.onClick]
);

But it becomes really problematic with triggers (mainly because we can't have random amount of hooks):

const childProps = props.children.props; // may and may not contain "onClick" 🤯
const overrideProps = {};

// How to merge these and keep them **stable**? 👆

The solution is simple: React.SyntheticEvent already contains a name of callback that should be called (see getReactCallbackName() function), this was tested on all minor React versions including 18-rc:

With that we can have a single handler (actually two as before React 17 we don't know in which phase a callback is called) to handle all callbacks 🎉

This PR implements this solution for MenuTrigger component.

@codesandbox-ci

codesandbox-ci Bot commented Jan 10, 2022

Copy link
Copy Markdown

This pull request is automatically built and testable in CodeSandbox.

To see build info of the built libraries, click here or the icon next to each commit SHA.

Latest deployment of this branch, based on commit e95bf61:

Sandbox Source
@fluentui/react 8 starter Configuration
@fluentui/react-components 9 starter Configuration
getCallbackName: React 16.8 PR
getCallbackName: React 16.14 PR
getCallbackName: React 17 PR
getCallbackName: React 18 PR
sad-leaf-hek80 Issue #21115

@fabricteam

fabricteam commented Jan 10, 2022

Copy link
Copy Markdown
Collaborator

📊 Bundle size report

Package & Exports Baseline (minified/GZIP) PR Change
react-components
react-components: Accordion, Button, FluentProvider, Image, Menu, Popover
165.49 kB
46.788 kB
165.776 kB
46.976 kB
286 B
188 B
react-menu
Menu (including children components)
102.537 kB
31.565 kB
102.823 kB
31.77 kB
286 B
205 B
react-menu
Menu (including selectable components)
104.788 kB
31.921 kB
105.074 kB
32.126 kB
286 B
205 B
Unchanged fixtures
Package & Exports Size (minified/GZIP)
react-accordion
Accordion (including children components)
54.598 kB
17.484 kB
react-avatar
Avatar
43.754 kB
12.46 kB
react-badge
Badge
24.154 kB
7.1 kB
react-badge
CounterBadge
25.022 kB
7.401 kB
react-badge
PresenceBadge
22.21 kB
6.521 kB
react-button
Button
28.161 kB
8.085 kB
react-button
CompoundButton
33.458 kB
9.047 kB
react-button
MenuButton
29.816 kB
8.662 kB
react-button
SplitButton
35.902 kB
9.732 kB
react-button
ToggleButton
37.398 kB
8.681 kB
react-card
Card - All
48.379 kB
14.326 kB
react-card
Card
43.888 kB
13.172 kB
react-card
CardFooter
7.582 kB
3.224 kB
react-card
CardHeader
8.849 kB
3.664 kB
react-card
CardPreview
7.775 kB
3.352 kB
react-components
react-components: FluentProvider & webLightTheme
32.193 kB
10.56 kB
react-divider
Divider
14.967 kB
5.411 kB
react-image
Image
10.039 kB
3.939 kB
react-input
Input
21.537 kB
7.125 kB
react-label
Label
8.287 kB
3.472 kB
react-link
Link
11.036 kB
4.488 kB
react-make-styles
makeStaticStyles (runtime)
7.602 kB
3.325 kB
react-make-styles
makeStyles + mergeClasses (runtime)
18.643 kB
7.103 kB
react-make-styles
makeStyles + mergeClasses (build time)
2.689 kB
1.23 kB
react-popover
Popover
95.363 kB
29.026 kB
react-portal
Portal
6.249 kB
2.163 kB
react-positioning
usePopper
22.808 kB
7.935 kB
react-provider
FluentProvider
13.696 kB
5.158 kB
react-slider
Slider
21.907 kB
7.492 kB
react-switch
Switch
25.279 kB
8.176 kB
react-text
Text - Default
10.733 kB
4.219 kB
react-text
Text - Wrappers
14.035 kB
4.553 kB
react-tooltip
Tooltip
45.083 kB
15.646 kB
react-utilities
SSRProvider
189 B
161 B
🤖 This report was generated against dd3b0a121181411de974c62a2797b9291cfbc134

@size-auditor

size-auditor Bot commented Jan 10, 2022

Copy link
Copy Markdown

Asset size changes

Size Auditor did not detect a change in bundle size for any component!

Baseline commit: dd3b0a121181411de974c62a2797b9291cfbc134 (build)

@fabricteam

fabricteam commented Jan 10, 2022

Copy link
Copy Markdown
Collaborator

Perf Analysis (@fluentui/react)

Scenario Render type Master Ticks PR Ticks Iterations Status
ContextualMenu mount 7268 14728 1000 Possible regression
All results

Scenario Render type Master Ticks PR Ticks Iterations Status
Avatar mount 817 781 5000
BaseButton mount 789 815 5000
Breadcrumb mount 2343 2355 1000
ButtonNext mount 453 461 5000
Checkbox mount 1321 1342 5000
CheckboxBase mount 1139 1133 5000
ChoiceGroup mount 4126 4164 5000
ComboBox mount 844 870 1000
CommandBar mount 8811 8801 1000
ContextualMenu mount 7268 14728 1000 Possible regression
DefaultButton mount 1005 974 5000
DetailsRow mount 3268 3301 5000
DetailsRowFast mount 3275 3275 5000
DetailsRowNoStyles mount 3103 3085 5000
Dialog mount 2250 2179 1000
DocumentCardTitle mount 174 170 1000
Dropdown mount 2764 2756 5000
FluentProviderNext mount 1689 1621 5000
FluentProviderWithTheme mount 167 126 10
FluentProviderWithTheme virtual-rerender 91 102 10
FluentProviderWithTheme virtual-rerender-with-unmount 167 165 10
FocusTrapZone mount 1641 1579 5000
FocusZone mount 1575 1565 5000
IconButton mount 1516 1523 5000
Label mount 319 328 5000
Layer mount 2484 2527 5000
Link mount 433 427 5000
MakeStyles mount 1582 1603 50000
MenuButton mount 1288 1292 5000
MessageBar mount 1753 1785 5000
Nav mount 2872 2855 1000
OverflowSet mount 989 979 5000
Panel mount 2114 2154 1000
Persona mount 736 730 1000
Pivot mount 1242 1265 1000
PrimaryButton mount 1133 1140 5000
Rating mount 6695 6599 5000
SearchBox mount 1180 1181 5000
Shimmer mount 2207 2231 5000
Slider mount 1725 1733 5000
SpinButton mount 4288 4578 5000
Spinner mount 410 400 5000
SplitButton mount 2738 2715 5000
Stack mount 465 477 5000
StackWithIntrinsicChildren mount 1981 2015 5000
StackWithTextChildren mount 4434 4448 5000
SwatchColorPicker mount 9644 9897 5000
TagPicker mount 2298 2291 5000
TeachingBubble mount 11187 11106 5000
Text mount 386 377 5000
TextField mount 1202 1216 5000
ThemeProvider mount 1031 1050 5000
ThemeProvider virtual-rerender 548 538 5000
ThemeProvider virtual-rerender-with-unmount 1608 1606 5000
Toggle mount 706 693 5000
buttonNative mount 127 138 5000

Perf Analysis (@fluentui/react-northstar)

Perf tests with no regressions
Scenario Current PR Ticks Baseline Ticks Ratio
TreeWith60ListItems.default 170 153 1.11:1
AttachmentMinimalPerf.default 139 127 1.09:1
RefMinimalPerf.default 218 203 1.07:1
AvatarMinimalPerf.default 178 170 1.05:1
ChatDuplicateMessagesPerf.default 272 261 1.04:1
InputMinimalPerf.default 1136 1091 1.04:1
ListWith60ListItems.default 569 549 1.04:1
PortalMinimalPerf.default 156 150 1.04:1
ReactionMinimalPerf.default 340 326 1.04:1
ButtonMinimalPerf.default 149 145 1.03:1
FormMinimalPerf.default 360 349 1.03:1
ItemLayoutMinimalPerf.default 1031 997 1.03:1
LabelMinimalPerf.default 343 334 1.03:1
LoaderMinimalPerf.default 591 575 1.03:1
SegmentMinimalPerf.default 300 292 1.03:1
TextAreaMinimalPerf.default 434 422 1.03:1
TreeMinimalPerf.default 689 670 1.03:1
AlertMinimalPerf.default 234 229 1.02:1
AnimationMinimalPerf.default 464 457 1.02:1
BoxMinimalPerf.default 302 296 1.02:1
ButtonSlotsPerf.default 467 460 1.02:1
ChatMinimalPerf.default 634 619 1.02:1
DialogMinimalPerf.default 656 643 1.02:1
FlexMinimalPerf.default 252 248 1.02:1
HeaderSlotsPerf.default 662 652 1.02:1
ListNestedPerf.default 480 471 1.02:1
StatusMinimalPerf.default 585 572 1.02:1
AttachmentSlotsPerf.default 914 904 1.01:1
CardMinimalPerf.default 477 473 1.01:1
CarouselMinimalPerf.default 403 399 1.01:1
MenuMinimalPerf.default 720 716 1.01:1
MenuButtonMinimalPerf.default 1413 1402 1.01:1
PopupMinimalPerf.default 524 519 1.01:1
SplitButtonMinimalPerf.default 3681 3643 1.01:1
TextMinimalPerf.default 301 299 1.01:1
CustomToolbarPrototype.default 3509 3489 1.01:1
ButtonOverridesMissPerf.default 1440 1438 1:1
CheckboxMinimalPerf.default 2275 2272 1:1
DropdownManyItemsPerf.default 572 570 1:1
DropdownMinimalPerf.default 2563 2565 1:1
EmbedMinimalPerf.default 3517 3521 1:1
GridMinimalPerf.default 288 287 1:1
ImageMinimalPerf.default 324 323 1:1
ListCommonPerf.default 543 542 1:1
ListMinimalPerf.default 440 438 1:1
RadioGroupMinimalPerf.default 385 385 1:1
SkeletonMinimalPerf.default 305 305 1:1
TooltipMinimalPerf.default 878 877 1:1
DatepickerMinimalPerf.default 4745 4772 0.99:1
ProviderMergeThemesPerf.default 1458 1472 0.99:1
SliderMinimalPerf.default 1411 1430 0.99:1
TableManyItemsPerf.default 1605 1619 0.99:1
ToolbarMinimalPerf.default 792 801 0.99:1
VideoMinimalPerf.default 535 540 0.99:1
DividerMinimalPerf.default 305 312 0.98:1
HeaderMinimalPerf.default 306 312 0.98:1
ProviderMinimalPerf.default 964 979 0.98:1
TableMinimalPerf.default 339 346 0.98:1
ChatWithPopoverPerf.default 320 329 0.97:1
LayoutMinimalPerf.default 300 308 0.97:1
IconMinimalPerf.default 526 546 0.96:1
RosterPerf.default 976 1033 0.94:1
AccordionMinimalPerf.default 123 141 0.87:1

@layershifter

Copy link
Copy Markdown
Member Author

/azp run

@azure-pipelines

Copy link
Copy Markdown
Azure Pipelines successfully started running 3 pipeline(s).

Comment thread change/@fluentui-react-menu-2a0fa934-987f-478e-87c2-10601be44ebd.json Outdated

/** Custom props including callbacks. */
overrideProps: TriggerProps;
};

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

I am not sure that I like the interface there, so opened for ideas/suggestions.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

IMO the interface is fine, but there should be extra documentation that deals with the priority of the prop overriding children > overrideProps > outerProps

outerProps[callbackName]?.(e as any);
/* eslint-enable @typescript-eslint/no-explicit-any */
}
});

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Decided that it's clearer to duplicate (almost duplicate) rather than create a utility hook for this.

Comment thread packages/react-utilities/src/hooks/useTriggerElement.ts Outdated
@layershifter layershifter requested a review from a team as a code owner January 11, 2022 19:10
@miroslavstastny miroslavstastny requested review from a team and TristanWatanabe and removed request for a team January 13, 2022 17:13
@varholak-peter varholak-peter self-requested a review January 14, 2022 13:12
Comment thread packages/react-utilities/src/hooks/useTriggerElement.test.tsx
Comment thread packages/react-utilities/src/utils/getReactCallbackName.test.tsx Outdated
}),
});

ReactDOM.render(<Component />, element);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

looking at this test, I can't see why testing-library can't be used, it will also deal with cleanup

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

I want to use bare minimum in these tests to ensure that nothing that Enzyme/Testing Library inject can affect the result.


if (callbackName) {
/* eslint-disable @typescript-eslint/no-explicit-any */
childProps[callbackName]?.(e as any);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

why did you need the casts to any ?

Copy link
Copy Markdown
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 comment to code to clarify:

TS2345: Argument of type 'SyntheticEvent<unknown, Event>' is not assignable to parameter of type 'ClipboardEvent<unknown> & CompositionEvent<unknown> & FocusEvent<unknown> & FormEvent<unknown> & SyntheticEvent<unknown, Event> & ... 8 more ... & TransitionEvent<...>'.
Property 'clipboardData' is missing in type 'SyntheticEvent<unknown, Event>' but required in type 'ClipboardEvent<unknown>'.

event is always SyntheticEvent but callbacks have better typings and expect more specific types

ref: React.Ref<unknown> | undefined;

/** Props that are passed to a parent component and should be forwarded down. */
outerProps: React.HTMLProps<unknown>;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

nit: it is not clear what outerProps mean without reading the docs, passedProps would be more understandable for me, but to be fair, it seems other libraries use outer as well.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

#21225 (comment)
Will be happy to iterate on interface there.

@varholak-peter varholak-peter left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

LGTM 🇹🇲

@khmakoto

Copy link
Copy Markdown
Member

@layershifter @ling1726 seems like this change broke up SplitButton scenarios.

@ling1726

Copy link
Copy Markdown
Contributor

@layershifter @ling1726 seems like this change broke up SplitButton scenarios.

@khmakoto thanks for reporting I'll take a look at it

@layershifter

Copy link
Copy Markdown
Member Author

@layershifter @ling1726 seems like this change broke up SplitButton scenarios.

I checked the problem, it's related to newly introduced isTargetDisabled(), it checks for aria-disabled, but does not check its value. As a button there has aria-disabled=false it explodes 💥

image

@ling1726

Copy link
Copy Markdown
Contributor

@layershifter @ling1726 seems like this change broke up SplitButton scenarios.

I checked the problem, it's related to newly introduced isTargetDisabled(), it checks for aria-disabled, but does not check its value. As a button there has aria-disabled=false it explodes 💥

image

@khmakoto @layershifter recorded in issue #21350 and a fix #21349

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Triggers cannot be nested

8 participants