Skip to content
Closed
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"type": "minor",
"comment": "fix: update Menu open change event type to include `Event` (as `useOnScrollOutside` adds scroll event listener)",
"packageName": "@fluentui/react-menu",
"email": "yuanboxue@microsoft.com",
"dependentChangeType": "patch"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"type": "minor",
"comment": "fix: update type `OpenPopoverEvents` to include `Event` (as `useOnScrollOutside` adds scroll event listener)",
"packageName": "@fluentui/react-popover",
"email": "yuanboxue@microsoft.com",
"dependentChangeType": "patch"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"type": "patch",
"comment": "fix: `useOnScrollOutside` should invoke callback on dragging scrollbar",
"packageName": "@fluentui/react-utilities",
"email": "yuanboxue@microsoft.com",
"dependentChangeType": "patch"
}
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,10 @@ describe('AvatarGroupPopover', () => {
getPortalElement: getPopoverSurfaceElement,
},
],
'consistent-callback-args': {
// Popover onOpenChange uses Event type due to scroll event in useOnScrollOutside
ignoreProps: ['onOpenChange'],
},
},
requiredProps: {
children: (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -281,7 +281,7 @@ export type MenuOpenChangeData = {
event: MouseEvent | TouchEvent;
} | {
type: 'scrollOutside';
event: MouseEvent | TouchEvent;
event: MouseEvent | TouchEvent | Event;
} | {
type: 'menuMouseEnter';
event: MouseEvent | TouchEvent;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -239,7 +239,7 @@ export type MenuOpenChangeData = {
}
| {
type: 'scrollOutside';
event: MouseEvent | TouchEvent;
event: MouseEvent | TouchEvent | Event;
}
| {
type: 'menuMouseEnter';
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import * as React from 'react';
import { useEventCallback } from '@fluentui/react-utilities';
import { elementContains } from '@fluentui/react-portal';
// eslint-disable-next-line deprecation/deprecation
import type { UseOnClickOrScrollOutsideOptions } from '@fluentui/react-utilities';

/**
Expand All @@ -19,6 +20,7 @@ export const MENU_ENTER_EVENT = 'fuimenuenter';
* Instead, dispatch custom DOM event from the menu so that it can bubble
* Each nested menu can use the listener to check if the event is from a child or parent menu
*/
// eslint-disable-next-line deprecation/deprecation
export const useOnMenuMouseEnter = (options: UseOnClickOrScrollOutsideOptions) => {
const { refs, callback, element, disabled } = options;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ export type OnOpenChangeData = {
};

// @public
export type OpenPopoverEvents = MouseEvent | TouchEvent | React_2.FocusEvent<HTMLElement> | React_2.KeyboardEvent<HTMLElement> | React_2.MouseEvent<HTMLElement>;
export type OpenPopoverEvents = Event | MouseEvent | TouchEvent | React_2.FocusEvent<HTMLElement> | React_2.KeyboardEvent<HTMLElement> | React_2.MouseEvent<HTMLElement>;

// @public
export const Popover: React_2.FC<PopoverProps>;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,12 @@ describe('Popover', () => {
// Popover does not have own styles
'make-styles-overrides-win',
],
testOptions: {
'consistent-callback-args': {
// Popover onOpenChange uses Event type due to scroll event in useOnScrollOutside
ignoreProps: ['onOpenChange'],
},
},
});

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,7 @@ export type OnOpenChangeData = { open: boolean };
* The supported events that will trigger open/close of the menu
*/
export type OpenPopoverEvents =
| Event
| MouseEvent
| TouchEvent
| React.FocusEvent<HTMLElement>
Comment on lines 211 to 215
Copy link
Contributor

Choose a reason for hiding this comment

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

Unfortunately I think this is a breaking change. Existing onOpenChange handlers aren't prepared to handle events other than MouseEvent, TouchEvent, or FocusEvent. The only way I can think of to make this not breaking is to create an onOpenChange2 event with the new type for the event arg.

Copy link
Contributor

@ling1726 ling1726 Aug 31, 2023

Choose a reason for hiding this comment

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

Yeah good catch 👍, I tried playing with this a bit more in TS playground and it can indeed break typescript build depending on how users define their onOpenChange handlers https://www.typescriptlang.org/play?#code/KYDwDg9gTgLgBDAnmYcDyKB2AFCkBuwUAooZjAM5wC8AUHHAD5ynDn1NwCyEArhcFbsGzACp8AxgAshMWvKQo42KHirU4Abw4MImDGwDCUgIaYA5sABccABTX0WXASKyKAShoA+OPggBLABMAbloAX3kJPQp4PQNMYzNLGjsHHn5BMngxSRksz2ofbXDI6PgwVTAKGxU1FM04OKxEi1Qw0NogA

This is actually quite worrying because we will never be able to add new events to our callback types without breaking users 😱 - this looks like a good candidate to discuss in tech sync with the wider team

Copy link
Contributor

@ling1726 ling1726 Aug 31, 2023

Choose a reason for hiding this comment

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

In this case we might want to consider casting internally in the Popover and Menu components to one of the existing event types, to unblock the fixing of the bugs in the description. It will change the runtime behaviour, but I think that is acceptable compared to the risk of build breaks for users.

However actually adding new events to types in useOnScrollOutside is also technically a breaking change in that case, even if the utility is not exported by our suite package 😱😱 - if we want full non-break coverage we should probably revert to @YuanboXue-Amber's original implementation and even cast the new event type within the utility.

All options are pretty bad for me, WDYT @behowell?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

The original implementation is at this commit: b73b2bd

Copy link
Contributor

@behowell behowell Aug 31, 2023

Choose a reason for hiding this comment

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

Casting to avoid a breaking change in types is still a breaking change; and possibly even worse: it results in extra-confusing runtime bugs when the object isn't the type that was promised. In this case, the event object would be missing any of the properties that are in MouseEvents.

I think in practice, it's pretty unlikely that it will cause an actual problem in this case, especially since the existing type includes React.FocusEvent<HTMLElement> and already requires you to do some runtime inspection of the object to see what type it is.

It might be worth discussing this with the rest of the team to come up with the right fix here. This isn't the first time this has caused a problem. In my recent PR #28951, I had to come up with a "hacky" workaround to this problem, by including the new event on the data argument instead. Unfortunately, that won't work here because it requires passing undefined for the event argument, which would be a breaking change here (the OpenPopoverEvents type doesn't include undefined as a possibility).

All of the possible options I can think of are broken in some way, so we'd have to decide what the least-bad option is:

  1. Add | Event to the OpenPopoverEvents type (this PR).
    • Pros: Gets the type correct. Any potential breaks are caught at build time.
    • Cons: It's a breaking change for any code that explicitly types its event argument (instead of using our exported OpenPopoverEvents type).
  2. Cast the event type to MouseEvent.
    • Pros: No type changes, and no changes to existing code to listen to the new case where onOpenChange can be called.
    • Cons: Still a breaking change, but the break happens at runtime instead of build time. The type is a lie.
  3. Create onOpenChange2 with the new types, and deprecate onOpenChange.
    • Pros: No type breaks to existing code.
    • Cons: Breaks the contract of the old onOpenChange: it won't be called when the popover hides in response to a scrollbar. Code will need to be updated to onOpenChange2. Also, this adds a bunch of code to keep both the old event and new event working.

Given all of that, I think option 1 might actually be the least-bad option.

Copy link
Contributor

@ling1726 ling1726 Sep 1, 2023

Choose a reason for hiding this comment

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

Yeah I think that 1. might be the least bad option, but it would require discussion with the wider team to make sure we know about it and the consequences. I would propose that we:

  1. Implement option 2 (casting the event) to fix the standing bug
  2. create an issue to track whether we stick to option 2 or switch to option 1
  3. discuss the new issue in tech sync

In the interim option 2 is the best case IMO, since build breaks generally block our users from upgrading to fix other issues. I can't as easily think of cases where users rely on knowing exactly what the event type is. However for option 1 it's quite common from what I see in codebases to type the event

// 👍 no need to do anything
const onOpenChange: PopoverProps['onOpenChange'] = (e, data) => {/**/}

// 👎 breaking change - I've generally seen this more often since developers generally don't pick from PopoverProps as much
const onOpenChange = (e: React.MouseEvent | React.FocusEvent, data: PopoverOpenData) => {/**/}

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I created a new PR #29062 using the cast option (option 2) to fix the original issue. Let's discuss how to extend event type this wednesday

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -328,8 +328,14 @@ export function useIsSSR(): boolean;
// @public
export function useMergedRefs<T>(...refs: (React_2.Ref<T> | undefined)[]): RefObjectFunction<T>;

// @internal (undocumented)
export type UseOnClickOrScrollOutsideOptions = {
// @public @deprecated (undocumented)
export type UseOnClickOrScrollOutsideOptions = UseOnClickOutsideOptions;

// @internal
export const useOnClickOutside: (options: UseOnClickOutsideOptions) => void;

// @public (undocumented)
export type UseOnClickOutsideOptions = {
element: Document | undefined;
refs: React_2.MutableRefObject<HTMLElement | undefined | null>[];
contains?(parent: HTMLElement | null, child: HTMLElement): boolean;
Expand All @@ -339,10 +345,12 @@ export type UseOnClickOrScrollOutsideOptions = {
};

// @internal
export const useOnClickOutside: (options: UseOnClickOrScrollOutsideOptions) => void;
export const useOnScrollOutside: (options: UseOnScrollOutsideOptions) => void;

// @internal
export const useOnScrollOutside: (options: UseOnClickOrScrollOutsideOptions) => void;
// @public (undocumented)
export type UseOnScrollOutsideOptions = Pick<UseOnClickOutsideOptions, 'element' | 'refs' | 'contains' | 'disabled'> & {
callback: (ev: Event | MouseEvent | TouchEvent) => void;
};

// @internal (undocumented)
export const usePrevious: <ValueType = unknown>(value: ValueType) => ValueType | null;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,7 @@
import * as React from 'react';
import { useEventCallback } from './useEventCallback';

/**
* @internal
*/
export type UseOnClickOrScrollOutsideOptions = {
export type UseOnClickOutsideOptions = {
/**
* The element to listen for the click event
*/
Expand Down Expand Up @@ -38,13 +35,18 @@ export type UseOnClickOrScrollOutsideOptions = {
callback: (ev: MouseEvent | TouchEvent) => void;
};

const DEFAULT_CONTAINS: UseOnClickOrScrollOutsideOptions['contains'] = (parent, child) => !!parent?.contains(child);
/**
* @deprecated use UseOnClickOutsideOptions instead
*/
export type UseOnClickOrScrollOutsideOptions = UseOnClickOutsideOptions;

const DEFAULT_CONTAINS: UseOnClickOutsideOptions['contains'] = (parent, child) => !!parent?.contains(child);

/**
* @internal
* Utility to perform checks where a click/touch event was made outside a component
*/
export const useOnClickOutside = (options: UseOnClickOrScrollOutsideOptions) => {
export const useOnClickOutside = (options: UseOnClickOutsideOptions) => {
const { refs, callback, element, disabled, disabledFocusOnIframe, contains = DEFAULT_CONTAINS } = options;
const timeoutId = React.useRef<number | undefined>(undefined);

Expand Down Expand Up @@ -130,7 +132,7 @@ const getWindowEvent = (target: Node | Window): Event | undefined => {
const FUI_FRAME_EVENT = 'fuiframefocus';

interface UseIFrameFocusOptions
extends Pick<UseOnClickOrScrollOutsideOptions, 'disabled' | 'element' | 'callback' | 'contains' | 'refs'> {
extends Pick<UseOnClickOutsideOptions, 'disabled' | 'element' | 'callback' | 'contains' | 'refs'> {
/**
* Millisecond duration to poll
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,21 +2,23 @@ import { renderHook } from '@testing-library/react-hooks';
import { useOnScrollOutside } from './useOnScrollOutside';

describe('useOnScrollOutside', () => {
const supportedEvents = ['wheel', 'touchmove'];
const supportedEvents = [{ event: 'wheel' }, { event: 'touchmove' }, { event: 'scroll', capture: true }];

it.each(supportedEvents)('should add %s listener', event => {
it.each(supportedEvents)('should add %s listener', ({ event, capture }) => {
// Arrange
const element = { addEventListener: jest.fn(), removeEventListener: jest.fn() } as unknown as Document;

// Act
renderHook(() => useOnScrollOutside({ element, callback: jest.fn(), refs: [] }));

// Assert
expect(element.addEventListener).toHaveBeenCalledTimes(2);
expect(element.addEventListener).toHaveBeenCalledWith(event, expect.anything());
expect(element.addEventListener).toHaveBeenCalledTimes(supportedEvents.length);
expect(element.addEventListener).toHaveBeenCalledWith(
...(capture ? [event, expect.anything(), true] : [event, expect.anything()]),
);
});

it.each(supportedEvents)('should cleanup %s listener', event => {
it.each(supportedEvents)('should cleanup %s listener', ({ event, capture }) => {
// Arrange
const element = { addEventListener: jest.fn(), removeEventListener: jest.fn() } as unknown as Document;

Expand All @@ -25,8 +27,10 @@ describe('useOnScrollOutside', () => {
unmount();

// Assert
expect(element.removeEventListener).toHaveBeenCalledTimes(2);
expect(element.removeEventListener).toHaveBeenCalledWith(event, expect.anything());
expect(element.removeEventListener).toHaveBeenCalledTimes(supportedEvents.length);
expect(element.removeEventListener).toHaveBeenCalledWith(
...(capture ? [event, expect.anything(), true] : [event, expect.anything()]),
);
});

it('should not add or remove event listeners when disabled', () => {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,16 +1,23 @@
import * as React from 'react';
import { useEventCallback } from './useEventCallback';
import type { UseOnClickOrScrollOutsideOptions } from './useOnClickOutside';
import { UseOnClickOutsideOptions } from './useOnClickOutside';

export type UseOnScrollOutsideOptions = Pick<UseOnClickOutsideOptions, 'element' | 'refs' | 'contains' | 'disabled'> & {
Copy link
Contributor

Choose a reason for hiding this comment

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

As far as I can tell, the goal here is to use UseOnClickOutsideOptions, except replace the callback field? If so, it'd be better to use Omit instead of Pick:

Suggested change
export type UseOnScrollOutsideOptions = Pick<UseOnClickOutsideOptions, 'element' | 'refs' | 'contains' | 'disabled'> & {
export type UseOnScrollOutsideOptions = Omit<UseOnClickOutsideOptions, 'callback'> & {

Copy link
Contributor

@ling1726 ling1726 Aug 31, 2023

Choose a reason for hiding this comment

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

I'd personally prefer Pick since there's less risk of picking up any new properties unintentionally in the future. Since it's only types, there should be no consequences the extra code to pick explicit properties.

Pick will also break if any properties are removed - which Omit will not do https://www.typescriptlang.org/play?#code/C4TwDgpgBAYg9nAvFA3gKCpqAjAhgJwC4oA7AVwFtsJ8BuDLPAL2OwQBsJcT6BfNNKEhQAQgSjIA8hQCWwADzw4AGigByZmoB89QeGhimEqAAUZAYwDWihKo0E1UAD7qAZgm31MQA

/**
* Called if the scroll is outside the element refs
*/
callback: (ev: Event | MouseEvent | TouchEvent) => void;
};
Comment on lines +5 to +10
Copy link
Contributor

Choose a reason for hiding this comment

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

Should this have /** @internal */ since it appears to only be used by an internal utility?

Copy link
Contributor

@ling1726 ling1726 Aug 31, 2023

Choose a reason for hiding this comment

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

We could add it, but I'm not sure what the use of @internal is anymore, even if these utilties are internal we are still bound by the requirement to never break them until the next major, now that our API surface includes our entire set of packages 🥲


/**
* @internal
* Utility to perform checks where a click/touch event was made outside a component
* Utility to perform checks where a scroll/touch event was made outside a component
*/
export const useOnScrollOutside = (options: UseOnClickOrScrollOutsideOptions) => {
export const useOnScrollOutside = (options: UseOnScrollOutsideOptions) => {
const { refs, callback, element, disabled, contains: containsProp } = options;

const listener = useEventCallback((ev: MouseEvent | TouchEvent) => {
const contains: UseOnClickOrScrollOutsideOptions['contains'] =
const listener = useEventCallback((ev: Event | MouseEvent | TouchEvent) => {
const contains: UseOnScrollOutsideOptions['contains'] =
containsProp || ((parent, child) => !!parent?.contains(child));

const target = ev.composedPath()[0] as HTMLElement;
Expand All @@ -28,10 +35,13 @@ export const useOnScrollOutside = (options: UseOnClickOrScrollOutsideOptions) =>

element?.addEventListener('wheel', listener);
element?.addEventListener('touchmove', listener);
// use capture phase because scroll does not bubble
element?.addEventListener('scroll', listener, true);

return () => {
element?.removeEventListener('wheel', listener);
element?.removeEventListener('touchmove', listener);
element?.removeEventListener('scroll', listener, true);
};
}, [listener, element, disabled]);
};
9 changes: 8 additions & 1 deletion packages/react-components/react-utilities/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,14 @@ export {
useScrollbarWidth,
useTimeout,
} from './hooks/index';
export type { RefObjectFunction, UseControllableStateOptions, UseOnClickOrScrollOutsideOptions } from './hooks/index';
export type {
RefObjectFunction,
UseControllableStateOptions,
// eslint-disable-next-line deprecation/deprecation
UseOnClickOrScrollOutsideOptions,
UseOnClickOutsideOptions,
UseOnScrollOutsideOptions,
} from './hooks/index';

export { canUseDOM, useIsSSR, SSRProvider } from './ssr/index';

Expand Down