From be9a884575d3ad319a93114f25d2ec36f5b4727b Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Mon, 26 Oct 2026 08:51:13 +0000 Subject: [PATCH 1/2] fix: stabilize composer focus and list scroll notifications Resolve the current composer after delayed focus, reserve notification-banner space on initial layout, and keep inverted-list scroll events scoped to their existing callers. Expose local and realtime notification sources with a backward-compatible callback signature. Co-authored-by: Codex --- src/components/FlashList/index.tsx | 2 +- .../FlatList/FlatList/index.ios.tsx | 2 +- src/components/FlatList/FlatList/index.tsx | 2 +- .../KeyboardDismissibleFlatList/index.tsx | 4 +- .../useEmitComposerScrollEvents/index.ts | 11 +- .../notificationPermission/index.native.ts | 1 + .../notificationPermission/index.ts | 13 +- .../notificationPermission/types.ts | 1 + src/libs/actions/Report/index.ts | 2 +- .../actions/Report/reportActionSubscribers.ts | 8 +- src/libs/focusComposerWithDelay/index.ts | 30 ++-- .../ComposerWithSuggestions.tsx | 2 +- .../useShouldShowEnableNotificationsBanner.ts | 3 +- .../LinkedActionNotFoundGuardTest.tsx | 33 +++- tests/unit/ComposerScrollEventsTest.tsx | 156 ++++++++++++++++++ tests/unit/focusComposerWithDelayTest.ts | 44 +++++ ...houldShowEnableNotificationsBanner.test.ts | 12 ++ .../unit/libs/reportActionSubscribersTest.ts | 15 +- 18 files changed, 301 insertions(+), 40 deletions(-) create mode 100644 tests/unit/ComposerScrollEventsTest.tsx diff --git a/src/components/FlashList/index.tsx b/src/components/FlashList/index.tsx index e98507c9a3bd..f9a7b9528735 100644 --- a/src/components/FlashList/index.tsx +++ b/src/components/FlashList/index.tsx @@ -7,7 +7,7 @@ import {FlashList as ShopifyFlashList} from '@shopify/flash-list'; import React from 'react'; function FlashList({onScroll: onScrollProp, inverted, ...restProps}: FlashListProps) { - const emitComposerScrollEvents = useEmitComposerScrollEvents({enabled: true, inverted}); + const emitComposerScrollEvents = useEmitComposerScrollEvents({enabled: !!inverted}); const handleScroll = (e: NativeSyntheticEvent) => { onScrollProp?.(e); diff --git a/src/components/FlatList/FlatList/index.ios.tsx b/src/components/FlatList/FlatList/index.ios.tsx index 5fc8e9b546a7..7892a044825c 100644 --- a/src/components/FlatList/FlatList/index.ios.tsx +++ b/src/components/FlatList/FlatList/index.ios.tsx @@ -42,7 +42,7 @@ function CustomFlatList({ [onMomentumScrollEnd], ); - const emitComposerScrollEvents = useEmitComposerScrollEvents({enabled: !enableAnimatedKeyboardDismissal, inverted: restProps.inverted}); + const emitComposerScrollEvents = useEmitComposerScrollEvents({enabled: !enableAnimatedKeyboardDismissal && !!restProps.inverted}); const handleScroll = useCallback( (e: NativeSyntheticEvent) => { onScrollProp?.(e); diff --git a/src/components/FlatList/FlatList/index.tsx b/src/components/FlatList/FlatList/index.tsx index fc35d965553e..c50c521eee01 100644 --- a/src/components/FlatList/FlatList/index.tsx +++ b/src/components/FlatList/FlatList/index.tsx @@ -223,7 +223,7 @@ function MVCPFlatList({ }; }, []); - const emitComposerScrollEvents = useEmitComposerScrollEvents({enabled: true, inverted: restProps.inverted}); + const emitComposerScrollEvents = useEmitComposerScrollEvents({enabled: !!restProps.inverted}); const handleScroll = useCallback( (e: NativeSyntheticEvent) => { onScrollProp?.(e); diff --git a/src/components/KeyboardDismissibleFlatList/index.tsx b/src/components/KeyboardDismissibleFlatList/index.tsx index 043dba4d39db..effaefefceaa 100644 --- a/src/components/KeyboardDismissibleFlatList/index.tsx +++ b/src/components/KeyboardDismissibleFlatList/index.tsx @@ -8,10 +8,10 @@ import {useAnimatedScrollHandler, useComposedEventHandler} from 'react-native-re import {useKeyboardDismissibleFlatListActions} from './KeyboardDismissibleFlatListContext'; -function KeyboardDismissibleFlatList({onScroll: onScrollProp, inverted, ref, ...restProps}: AnimatedFlatListWithCellRendererProps) { +function KeyboardDismissibleFlatList({onScroll: onScrollProp, ref, ...restProps}: AnimatedFlatListWithCellRendererProps) { const {onScroll: onScrollHandleKeyboard} = useKeyboardDismissibleFlatListActions(); - const emitComposerScrollEvents = useEmitComposerScrollEvents({enabled: true, inverted}); + const emitComposerScrollEvents = useEmitComposerScrollEvents({enabled: !!restProps.inverted}); const additionalOnScroll = useAnimatedScrollHandler({ onScroll: emitComposerScrollEvents, diff --git a/src/hooks/useEmitComposerScrollEvents/index.ts b/src/hooks/useEmitComposerScrollEvents/index.ts index ba65bfcdcf5e..cfd42ee98773 100644 --- a/src/hooks/useEmitComposerScrollEvents/index.ts +++ b/src/hooks/useEmitComposerScrollEvents/index.ts @@ -5,19 +5,16 @@ import {DeviceEventEmitter} from 'react-native'; type UseEmitComposerScrollEventsOptions = { enabled?: boolean; - inverted: boolean | null | undefined; }; /** * This is used to trigger scroll behavior in the composer on web. On native, this is a no-op. - * The scroll events are only emitted when the list is inverted, since it is only used in the report screen in combination with the composer. * Since our custom FlatList implementation can either be a `KeyboardDismissibleFlatList` or a regular `FlatList`, * we need to emit the scroll events inside the scroll handler of the specific implementation. - * @param inverted - Whether the list is inverted. * @returns A function that can be used to emit the scroll events. */ function useEmitComposerScrollEvents(options?: UseEmitComposerScrollEventsOptions) { - const {enabled = true, inverted} = options ?? {}; + const {enabled = true} = options ?? {}; const lastScrollEvent = useRef(null); const scrollEndTimeout = useRef(null); @@ -28,7 +25,7 @@ function useEmitComposerScrollEvents(options?: UseEmitComposerScrollEventsOption * invokes the onScroll callback function from props. */ const onScroll = () => { - if (!enabled || !inverted) { + if (!enabled) { return; } @@ -44,7 +41,7 @@ function useEmitComposerScrollEvents(options?: UseEmitComposerScrollEventsOption * Emits when the scrolling has ended. */ const onScrollEnd = () => { - if (!enabled || !inverted) { + if (!enabled) { return; } @@ -67,7 +64,7 @@ function useEmitComposerScrollEvents(options?: UseEmitComposerScrollEventsOption * */ const emitComposerScrollEvents = () => { - if (!enabled || !inverted) { + if (!enabled) { return; } diff --git a/src/libs/Notification/notificationPermission/index.native.ts b/src/libs/Notification/notificationPermission/index.native.ts index 29123ad37089..bd0fe625309e 100644 --- a/src/libs/Notification/notificationPermission/index.native.ts +++ b/src/libs/Notification/notificationPermission/index.native.ts @@ -1,6 +1,7 @@ import type NotificationPermissionModule from './types'; const NotificationPermissionNative: NotificationPermissionModule = { + getStatusSync: () => 'denied', getStatus: () => Promise.resolve('denied'), request: () => Promise.resolve('denied'), }; diff --git a/src/libs/Notification/notificationPermission/index.ts b/src/libs/Notification/notificationPermission/index.ts index 62f333a376f4..333fecf4bf54 100644 --- a/src/libs/Notification/notificationPermission/index.ts +++ b/src/libs/Notification/notificationPermission/index.ts @@ -10,12 +10,17 @@ function toStatus(permission: NotificationPermission): NotificationPermissionSta return 'default'; } +function getStatusSync(): NotificationPermissionStatus { + if (typeof window === 'undefined' || !window.Notification) { + return 'denied'; + } + return toStatus(Notification.permission); +} + const NotificationPermission: NotificationPermissionModule = { + getStatusSync, getStatus(): Promise { - if (typeof window === 'undefined' || !window.Notification) { - return Promise.resolve('denied'); - } - return Promise.resolve(toStatus(Notification.permission)); + return Promise.resolve(getStatusSync()); }, request(): Promise { diff --git a/src/libs/Notification/notificationPermission/types.ts b/src/libs/Notification/notificationPermission/types.ts index 9ffca38417ae..b6c834f2ed36 100644 --- a/src/libs/Notification/notificationPermission/types.ts +++ b/src/libs/Notification/notificationPermission/types.ts @@ -1,6 +1,7 @@ type NotificationPermissionStatus = 'granted' | 'denied' | 'default'; type NotificationPermissionModule = { + getStatusSync: () => NotificationPermissionStatus; getStatus: () => Promise; request: () => Promise; }; diff --git a/src/libs/actions/Report/index.ts b/src/libs/actions/Report/index.ts index 0958bee7ac74..0e797c874321 100644 --- a/src/libs/actions/Report/index.ts +++ b/src/libs/actions/Report/index.ts @@ -5593,7 +5593,7 @@ function showReportActionNotification( LocalNotification.showCommentNotification(report, reportAction, onClick, derivedReportName, formatPhoneNumber); } - notifyNewAction(reportID, undefined, reportAction.actorAccountID === currentUserAccountID); + notifyNewAction(reportID, undefined, reportAction.actorAccountID === currentUserAccountID, 'realtime'); } /** Clear the errors associated with the IOUs of a given report. */ diff --git a/src/libs/actions/Report/reportActionSubscribers.ts b/src/libs/actions/Report/reportActionSubscribers.ts index 8fc4644ac0eb..c6e277b83859 100644 --- a/src/libs/actions/Report/reportActionSubscribers.ts +++ b/src/libs/actions/Report/reportActionSubscribers.ts @@ -3,7 +3,8 @@ */ import type ReportAction from '@src/types/onyx/ReportAction'; -type SubscriberCallback = (isFromCurrentUser: boolean, reportAction: ReportAction | undefined) => void; +type ActionEventSource = 'local' | 'realtime'; +type SubscriberCallback = (isFromCurrentUser: boolean, reportAction: ReportAction | undefined, source?: ActionEventSource) => void; type ActionSubscriber = { reportID: string; @@ -26,7 +27,7 @@ function subscribeToNewActionEvent(reportID: string, callback: SubscriberCallbac } /** Notify the ReportActionsList that a new comment has arrived */ -function notifyNewAction(reportID: string | string[] | undefined, reportAction: ReportAction | undefined, isFromCurrentUser: boolean) { +function notifyNewAction(reportID: string | string[] | undefined, reportAction: ReportAction | undefined, isFromCurrentUser: boolean, source: ActionEventSource = 'local') { if (!reportID) { return; } @@ -34,9 +35,10 @@ function notifyNewAction(reportID: string | string[] | undefined, reportAction: for (const id of ids) { const actionSubscriber = newActionSubscribers.find((subscriber) => subscriber.reportID === id); if (actionSubscriber) { - actionSubscriber.callback(isFromCurrentUser, reportAction); + actionSubscriber.callback(isFromCurrentUser, reportAction, source); } } } export {subscribeToNewActionEvent, notifyNewAction}; +export type {ActionEventSource}; diff --git a/src/libs/focusComposerWithDelay/index.ts b/src/libs/focusComposerWithDelay/index.ts index bcefb4bdc200..08e9b6ca7566 100644 --- a/src/libs/focusComposerWithDelay/index.ts +++ b/src/libs/focusComposerWithDelay/index.ts @@ -12,16 +12,14 @@ import setTextInputSelection from './setTextInputSelection'; /** * Create a function that focuses the composer. + * A getter resolves the current input after the delay, since editing can replace it during layout. * @param [canFocusAfterDelay] Re-checked when the delay is up, so a composer that lost its focus claim meanwhile does not take focus back */ -function focusComposerWithDelay(textInput: InputType | null, delay: number = CONST.COMPOSER_FOCUS_DELAY, canFocusAfterDelay?: () => boolean): FocusComposerWithDelay { - function getIsFocused() { - if (textInput && 'isFocused' in textInput) { - return textInput.isFocused(); - } - return false; - } - +function focusComposerWithDelay( + textInput: InputType | null | (() => InputType | null), + delay: number = CONST.COMPOSER_FOCUS_DELAY, + canFocusAfterDelay?: () => boolean, +): FocusComposerWithDelay { /** * Focus the text input * @param [shouldDelay] Impose delay before focusing the text input @@ -35,8 +33,12 @@ function focusComposerWithDelay(textInput: InputType | null, delay: number = CON return; } - function focusAndUpdateSelection(input: InputType) { - if (getIsFocused()) { + function focusAndUpdateSelection() { + const input = typeof textInput === 'function' ? textInput() : textInput; + if (!input) { + return; + } + if ('isFocused' in input && input.isFocused()) { if (forceKeyboardIfAlreadyFocused) { requestKeyboardForFocusedComposer(input, forcedSelectionRange); } @@ -50,23 +52,19 @@ function focusComposerWithDelay(textInput: InputType | null, delay: number = CON } if (!shouldDelay) { - focusAndUpdateSelection(textInput); + focusAndUpdateSelection(); return; } await Promise.all([ComposerFocusManager.isReadyToFocus(), isWindowReadyToFocus()]); - if (!textInput) { - return; - } - // When the closing modal has a focused text input focus() needs a delay to properly work. // Setting 150ms here is a temporary workaround for the Android HybridApp. It should be reverted once we identify the real root cause of this issue: https://github.com/Expensify/App/issues/56311. setTimeout(() => { if (canFocusAfterDelay && !canFocusAfterDelay()) { return; } - focusAndUpdateSelection(textInput); + focusAndUpdateSelection(); }, delay); }; } diff --git a/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions.tsx b/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions.tsx index c274290c2ff1..7e2936df2ce1 100644 --- a/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions.tsx +++ b/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions.tsx @@ -317,7 +317,7 @@ function ComposerWithSuggestions({ const delay = shouldDelayAutoFocusRef.current ? CONST.ANIMATED_TRANSITION : CONST.COMPOSER_FOCUS_DELAY; // Another composer taking focus releases the claim, so it has to be read when the focus lands rather than now. const canFocusAfterDelay = isInSidePanelRef.current ? () => !!ReportActionComposeFocusManager.sidePanelComposerRef.current : undefined; - focusComposerWithDelay(composerRef.current, delay, canFocusAfterDelay)(shouldDelay, forcedSelectionRange, forceKeyboardIfAlreadyFocused).catch(() => {}); + focusComposerWithDelay(() => composerRef.current, delay, canFocusAfterDelay)(shouldDelay, forcedSelectionRange, forceKeyboardIfAlreadyFocused).catch(() => {}); }, []); const shouldIgnoreEditSelectionResetRef = useRef(false); diff --git a/src/pages/inbox/report/useShouldShowEnableNotificationsBanner.ts b/src/pages/inbox/report/useShouldShowEnableNotificationsBanner.ts index 82a875706485..c60c604a7497 100644 --- a/src/pages/inbox/report/useShouldShowEnableNotificationsBanner.ts +++ b/src/pages/inbox/report/useShouldShowEnableNotificationsBanner.ts @@ -14,7 +14,8 @@ import {useEffect, useState} from 'react'; function useShouldShowEnableNotificationsBanner(report: OnyxEntry): boolean { const [conciergeReportID] = useOnyx(ONYXKEYS.CONCIERGE_REPORT_ID); const [hasDismissed] = useOnyx(ONYXKEYS.RAM_ONLY_HAS_DISMISSED_CONCIERGE_NOTIFICATION_BANNER); - const [permissionStatus, setPermissionStatus] = useState(); + // Reserve the banner's space before the report list makes its initial scroll-to-end measurement. + const [permissionStatus, setPermissionStatus] = useState(() => NotificationPermission.getStatusSync()); const isConcierge = isConciergeChatReport(report, conciergeReportID); diff --git a/tests/navigation/LinkedActionNotFoundGuardTest.tsx b/tests/navigation/LinkedActionNotFoundGuardTest.tsx index 653773865d14..bb0bffd09b8c 100644 --- a/tests/navigation/LinkedActionNotFoundGuardTest.tsx +++ b/tests/navigation/LinkedActionNotFoundGuardTest.tsx @@ -16,6 +16,8 @@ const NAVIGATOR_KEY = 'test-navigator-key'; const mockSetParams = jest.fn(); const mockCleanStaleBackToParam = jest.fn(); const mockIsNavigationReady = jest.fn(() => Promise.resolve()); +const mockChildrenMount = jest.fn(); +const mockChildrenUnmount = jest.fn(); jest.mock('@libs/Navigation/Navigation', () => ({ __esModule: true, @@ -43,7 +45,7 @@ jest.mock('@react-navigation/native', () => { useRoute: () => ({ key: ROUTE_KEY, name: 'Report', - params: mockRouteParams, + params: {...mockRouteParams}, }), useNavigation: () => ({ getState: () => ({key: NAVIGATOR_KEY}), @@ -145,6 +147,10 @@ function createReportAction(overrides: Partial = {}): ReportAction } function TestChildren() { + React.useEffect(() => { + mockChildrenMount(); + return () => mockChildrenUnmount(); + }, []); return ; } @@ -170,6 +176,8 @@ describe('LinkedActionNotFoundGuard', () => { mockSetParams.mockReset(); mockCleanStaleBackToParam.mockClear(); mockIsNavigationReady.mockClear(); + mockChildrenMount.mockClear(); + mockChildrenUnmount.mockClear(); mockRouteParams.reportID = REPORT_ID; mockRouteParams.reportActionID = REPORT_ACTION_ID; mockLinkedAction = createReportAction(); @@ -177,6 +185,29 @@ describe('LinkedActionNotFoundGuard', () => { mockIsReportActionVisible = true; }); + it('preserves the report content when a link is cleared or a different link is opened', () => { + // Given a linked report has mounted its list and composer. + const view = render( + + + , + ); + + // When sending clears the link, and the reader subsequently opens another link. + for (const reportActionID of ['', 'another-action', undefined, REPORT_ACTION_ID]) { + mockRouteParams.reportActionID = reportActionID; + view.rerender( + + + , + ); + + // Then the report keeps its mounted list, editor, and keyboard state. + expect(mockChildrenMount).toHaveBeenCalledTimes(1); + expect(mockChildrenUnmount).not.toHaveBeenCalled(); + } + }); + it('renders children when linked action exists', () => { render( diff --git a/tests/unit/ComposerScrollEventsTest.tsx b/tests/unit/ComposerScrollEventsTest.tsx new file mode 100644 index 000000000000..cced2c8752dc --- /dev/null +++ b/tests/unit/ComposerScrollEventsTest.tsx @@ -0,0 +1,156 @@ +import {act, fireEvent, render, screen} from '@testing-library/react-native'; + +import FlashList from '@components/FlashList'; +import MVCPFlatList from '@components/FlatList/FlatList'; +import type * as MVCPList from '@components/FlatList/FlatList'; +import ActiveHoverable from '@components/Hoverable/ActiveHoverable'; +import type * as KeyboardList from '@components/KeyboardDismissibleFlatList'; +import KeyboardDismissibleFlatList from '@components/KeyboardDismissibleFlatList/index'; + +import type * as ComposerScrollEvents from '@hooks/useEmitComposerScrollEvents'; + +import CONST from '@src/CONST'; + +import type {NativeScrollEvent, NativeSyntheticEvent, ScrollViewProps, ViewProps} from 'react-native'; +import type * as Reanimated from 'react-native-reanimated'; + +import React from 'react'; +import {DeviceEventEmitter, View} from 'react-native'; + +jest.mock('@hooks/useEmitComposerScrollEvents', () => jest.requireActual('@hooks/useEmitComposerScrollEvents/index.ts')); +jest.mock('@components/KeyboardDismissibleFlatList/index', () => jest.requireActual('@components/KeyboardDismissibleFlatList/index.tsx')); +jest.mock('@components/FlatList/FlatList', () => jest.requireActual('@components/FlatList/FlatList/index.tsx')); +jest.mock('@hooks/useThemeStyles', () => () => ({})); +jest.mock('@shopify/flash-list', () => ({ + FlashList: (props: ScrollViewProps) => { + const {ScrollView} = jest.requireActual<{ScrollView: React.ComponentType}>('react-native'); + return ; + }, +})); +jest.mock('@components/AnimatedFlatListWithCellRenderer', () => (props: ScrollViewProps) => { + const {ScrollView} = jest.requireActual<{ScrollView: React.ComponentType}>('react-native'); + return ; +}); +jest.mock('react-native-reanimated', () => ({ + ...jest.requireActual('react-native-reanimated/mock'), + useAnimatedScrollHandler: ({onScroll}: {onScroll: (event: NativeSyntheticEvent) => void}) => onScroll, + useComposedEventHandler: (handlers: Array<((event: NativeSyntheticEvent) => void) | null>) => (event: NativeSyntheticEvent) => { + for (const handler of handlers) { + handler?.(event); + } + }, +})); + +const scrollEvent = { + nativeEvent: { + contentOffset: {x: 0, y: 100}, + contentSize: {width: 300, height: 1000}, + layoutMeasurement: {width: 300, height: 500}, + }, +}; + +function HoverTarget({ref, ...props}: ViewProps & {ref?: React.Ref}) { + // Supply the DOM ref used by web hover handling while rendering through the native test renderer. + React.useImperativeHandle(ref, () => document.createElement('div'), []); + return ; +} + +describe('Composer scroll events on shared lists', () => { + beforeEach(() => jest.useFakeTimers()); + afterEach(() => { + jest.clearAllTimers(); + jest.useRealTimers(); + jest.restoreAllMocks(); + }); + + it.each([ + ['FlashList', FlashList], + ['MVCP FlatList', MVCPFlatList], + ['keyboard dismissible FlatList', KeyboardDismissibleFlatList], + ] as const)('%s suppresses hover throughout scrolling and forwards the scroll event', (_name, List) => { + // Given an inverted list that already emits composer scroll events. + const emit = jest.spyOn(DeviceEventEmitter, 'emit'); + const onScroll = jest.fn(); + render( + <> + } + inverted + onScroll={onScroll} + /> + + {(isHovered) => ( + + )} + + , + ); + + // When scrolling continues, hover must remain suppressed until the last event settles. + fireEvent(screen.getByTestId('hoverable-message'), 'mouseEnter'); + expect(screen.getByTestId('hoverable-message').props.accessibilityLabel).toBe('hovered'); + fireEvent.scroll(screen.getByTestId('chronological-list'), scrollEvent); + expect(onScroll).toHaveBeenCalledWith(scrollEvent); + expect(emit).toHaveBeenCalledWith(CONST.EVENTS.SCROLLING, true); + expect(screen.getByTestId('hoverable-message').props.accessibilityLabel).toBe('idle'); + act(() => jest.advanceTimersByTime(200)); + fireEvent.scroll(screen.getByTestId('chronological-list'), scrollEvent); + fireEvent(screen.getByTestId('hoverable-message'), 'mouseEnter'); + expect(screen.getByTestId('hoverable-message').props.accessibilityLabel).toBe('idle'); + act(() => jest.advanceTimersByTime(200)); + expect(emit).not.toHaveBeenCalledWith(CONST.EVENTS.SCROLLING, false); + + // Then hover resumes once, after scrolling ends. + act(() => jest.advanceTimersByTime(50)); + expect(emit.mock.calls.filter(([event]) => event === CONST.EVENTS.SCROLLING)).toEqual([ + [CONST.EVENTS.SCROLLING, true], + [CONST.EVENTS.SCROLLING, false], + ]); + fireEvent(screen.getByTestId('hoverable-message'), 'mouseEnter'); + expect(screen.getByTestId('hoverable-message').props.accessibilityLabel).toBe('hovered'); + }); + + it.each([ + ['FlashList', FlashList], + ['MVCP FlatList', MVCPFlatList], + ['keyboard dismissible FlatList', KeyboardDismissibleFlatList], + ] as const)('%s forwards non-inverted scrolling without changing global hover state', (_name, List) => { + // Given a non-inverted shared list and an unrelated hovered message. + const emit = jest.spyOn(DeviceEventEmitter, 'emit'); + const onScroll = jest.fn(); + render( + <> + } + inverted={false} + onScroll={onScroll} + /> + + {(isHovered) => ( + + )} + + , + ); + fireEvent(screen.getByTestId('hoverable-message'), 'mouseEnter'); + + // When that list scrolls and its scroll-end timer would have settled. + fireEvent.scroll(screen.getByTestId('chronological-list'), scrollEvent); + act(() => jest.advanceTimersByTime(250)); + + // Then its callback still runs without broadcasting a report scroll or clearing hover. + expect(onScroll).toHaveBeenCalledWith(scrollEvent); + expect(emit).not.toHaveBeenCalledWith(CONST.EVENTS.SCROLLING, expect.anything()); + expect(screen.getByTestId('hoverable-message').props.accessibilityLabel).toBe('hovered'); + }); +}); diff --git a/tests/unit/focusComposerWithDelayTest.ts b/tests/unit/focusComposerWithDelayTest.ts index 9be650067e15..f720c489fc2f 100644 --- a/tests/unit/focusComposerWithDelayTest.ts +++ b/tests/unit/focusComposerWithDelayTest.ts @@ -52,6 +52,50 @@ describe('focusComposerWithDelay', () => { expect(focus).toHaveBeenCalled(); }); + + it('focuses an editor that mounts after the delayed focus request', async () => { + // Given an edit transition that requests focus before the input ref is attached. + const {input, focus} = createInput(); + let currentInput: HTMLTextAreaElement | null = null; + + // When the editor mounts before the focus delay ends. + await focusComposerWithDelay(() => currentInput)(true); + currentInput = input; + jest.runAllTimers(); + + // Then the mounted editor receives focus instead of losing the request. + expect(focus).toHaveBeenCalledTimes(1); + }); + + it('does not focus an editor that unmounts before the delay ends', async () => { + // Given a focus request for an editor that is still mounted. + const {input, focus} = createInput(); + let currentInput: HTMLTextAreaElement | null = input; + await focusComposerWithDelay(() => currentInput)(true); + + // When navigation removes the editor before the delayed request runs. + currentInput = null; + jest.runAllTimers(); + + // Then the stale input does not take focus back. + expect(focus).not.toHaveBeenCalled(); + }); + + it('focuses the replacement editor instead of the old input', async () => { + // Given a pending focus request while the composer is changing its layout. + const original = createInput(); + const replacement = createInput(); + let currentInput = original.input; + await focusComposerWithDelay(() => currentInput)(true); + + // When the input ref changes before the focus delay ends. + currentInput = replacement.input; + jest.runAllTimers(); + + // Then only the current editor receives focus. + expect(original.focus).not.toHaveBeenCalled(); + expect(replacement.focus).toHaveBeenCalledTimes(1); + }); }); describe('focusComposerWithDelay command categories', () => { diff --git a/tests/unit/hooks/useShouldShowEnableNotificationsBanner.test.ts b/tests/unit/hooks/useShouldShowEnableNotificationsBanner.test.ts index 38e5c20713bb..4263e110fcf5 100644 --- a/tests/unit/hooks/useShouldShowEnableNotificationsBanner.test.ts +++ b/tests/unit/hooks/useShouldShowEnableNotificationsBanner.test.ts @@ -20,6 +20,7 @@ let mockCurrentPermission: NotificationPermissionStatus = 'default'; jest.mock('@libs/Notification/notificationPermission', () => ({ __esModule: true, default: { + getStatusSync: () => mockCurrentPermission, getStatus: () => Promise.resolve(mockCurrentPermission), request: () => Promise.resolve(mockCurrentPermission), }, @@ -69,4 +70,15 @@ describe('useShouldShowEnableNotificationsBanner', () => { const {result} = renderHook(() => useShouldShowEnableNotificationsBanner(conciergeReport)); await waitFor(() => expect(result.current).toBe(true)); }); + + it('shows the Concierge banner on the initial render so the chat opens with its final viewport height', () => { + // Given notification permission has not been decided and the Concierge report is already known. + mockCurrentPermission = 'default'; + + // When Concierge opens, the list and footer measure their initial layout together. + const {result} = renderHook(() => useShouldShowEnableNotificationsBanner(conciergeReport)); + + // Then the banner must occupy space before the list positions itself at the latest message. + expect(result.current).toBe(true); + }); }); diff --git a/tests/unit/libs/reportActionSubscribersTest.ts b/tests/unit/libs/reportActionSubscribersTest.ts index 262557d19a03..da2401146bda 100644 --- a/tests/unit/libs/reportActionSubscribersTest.ts +++ b/tests/unit/libs/reportActionSubscribersTest.ts @@ -7,7 +7,7 @@ describe('subscribeToNewActionEvent', () => { notifyNewAction('1', undefined, true); - expect(callback).toHaveBeenCalledWith(true, undefined); + expect(callback).toHaveBeenCalledWith(true, undefined, 'local'); unsubscribe(); }); @@ -56,4 +56,17 @@ describe('subscribeToNewActionEvent', () => { expect(callback).not.toHaveBeenCalled(); }); + + test('identifies realtime notifications separately from local actions', () => { + // Given a subscriber listening to the current report + const callback = jest.fn(); + const unsubscribe = subscribeToNewActionEvent('1', callback); + + // When a realtime echo arrives without an action payload + notifyNewAction('1', undefined, true, 'realtime'); + + // Then the subscriber can distinguish it from a local money request + expect(callback).toHaveBeenCalledWith(true, undefined, 'realtime'); + unsubscribe(); + }); }); From 945622d1eb83913efb0f45a1506e7a1ecb7e70aa Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Mon, 26 Oct 2026 09:34:41 +0000 Subject: [PATCH 2/2] fix: keep action event source type internal Co-authored-by: Codex --- src/libs/actions/Report/reportActionSubscribers.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/libs/actions/Report/reportActionSubscribers.ts b/src/libs/actions/Report/reportActionSubscribers.ts index c6e277b83859..8f462b90cd09 100644 --- a/src/libs/actions/Report/reportActionSubscribers.ts +++ b/src/libs/actions/Report/reportActionSubscribers.ts @@ -41,4 +41,3 @@ function notifyNewAction(reportID: string | string[] | undefined, reportAction: } export {subscribeToNewActionEvent, notifyNewAction}; -export type {ActionEventSource};