From 4d0c989d86074762eaf33e934ea37b7ed1002edf Mon Sep 17 00:00:00 2001 From: Stephanie Freitag <stephanie.freitag@holi.team> Date: Tue, 18 Feb 2025 14:59:56 +0100 Subject: [PATCH] NOISSUE: make sync event propagation explicit and add tests --- .../components/HoliLink/index.web.tsx | 6 +- .../components/__tests__/HoliLink.test.tsx | 10 -- .../__tests__/HoliTextLink.test.tsx | 10 -- .../hooks/__tests__/useLink.test.ts | 104 ++++++++++++++++++ core/navigation/hooks/useLink.ts | 18 +-- 5 files changed, 119 insertions(+), 29 deletions(-) create mode 100644 core/navigation/hooks/__tests__/useLink.test.ts diff --git a/core/navigation/components/HoliLink/index.web.tsx b/core/navigation/components/HoliLink/index.web.tsx index 1559c1aa2a..517cee01e7 100644 --- a/core/navigation/components/HoliLink/index.web.tsx +++ b/core/navigation/components/HoliLink/index.web.tsx @@ -15,12 +15,14 @@ export const HoliLink = ({ }: PropsWithChildren<HoliLinkProps>) => { const onLinkPress = useCallback( async (event: SyntheticEvent<HTMLAnchorElement, Event>) => { - const stopPropagation = await onPress?.(event) - if (stopPropagation === false) { + // Async onPress callbacks won't be awaited before event propagation + const propagateEventOrPromise = onPress?.(event) + if (propagateEventOrPromise === false) { event?.preventDefault() event?.stopPropagation() return false } + await propagateEventOrPromise }, [onPress] ) diff --git a/core/navigation/components/__tests__/HoliLink.test.tsx b/core/navigation/components/__tests__/HoliLink.test.tsx index bd042e8c0f..4dd504a907 100644 --- a/core/navigation/components/__tests__/HoliLink.test.tsx +++ b/core/navigation/components/__tests__/HoliLink.test.tsx @@ -31,14 +31,4 @@ describe('HoliLink', () => { expect(mockNavigate).toHaveBeenCalledWith('//spaces') }) - - it('should navigate on press if event is not propagated', () => { - const onPress = jest.fn().mockReturnValue(false) - render(<HoliLink label="test" href="/" onPress={onPress} />) - - fireEvent.press(screen.getByTestId('link')) - - expect(onPress).toHaveBeenCalled() - expect(mockNavigate).not.toHaveBeenCalled() - }) }) diff --git a/core/navigation/components/__tests__/HoliTextLink.test.tsx b/core/navigation/components/__tests__/HoliTextLink.test.tsx index dbd82b7b43..f3df698301 100644 --- a/core/navigation/components/__tests__/HoliTextLink.test.tsx +++ b/core/navigation/components/__tests__/HoliTextLink.test.tsx @@ -30,14 +30,4 @@ describe('HoliTextLink', () => { expect(mockNavigate).toHaveBeenCalledWith('//spaces') }) - - it('should navigate on press if event is not propagated', () => { - const onPress = jest.fn().mockReturnValue(false) - render(<HoliTextLink label="test" href="/" onPress={onPress} size="md" />) - - fireEvent.press(screen.getByRole('link')) - - expect(onPress).toHaveBeenCalled() - expect(mockNavigate).not.toHaveBeenCalled() - }) }) diff --git a/core/navigation/hooks/__tests__/useLink.test.ts b/core/navigation/hooks/__tests__/useLink.test.ts new file mode 100644 index 0000000000..73ac82e950 --- /dev/null +++ b/core/navigation/hooks/__tests__/useLink.test.ts @@ -0,0 +1,104 @@ +import useLink from '@holi/core/navigation/hooks/useLink' +import useRouting from '@holi/core/navigation/hooks/useRouting' +import { renderHook, waitFor } from '@testing-library/react-native' +import { Linking, Platform } from 'react-native' + +const mockUseRouting = jest.mocked(useRouting) +jest.mock('@holi/core/navigation/hooks/useRouting', () => jest.fn()) + +describe('useLink', () => { + const testEvent = {} as React.SyntheticEvent + const mockNavigate = jest.fn() + const mockNavigateBack = jest.fn() + + beforeEach(() => { + const routingMocks: ReturnType<typeof useRouting> = { + navigate: mockNavigate, + navigateBack: mockNavigateBack, + replaceRoute: jest.fn(), + replaceHistory: jest.fn(), + setParams: jest.fn(), + } + mockUseRouting.mockReturnValue(routingMocks) + }) + + it('should call onPress if given', () => { + const onPress = jest.fn() + const { result } = renderHook(() => useLink({ href: '/', onPress })) + + result.current(testEvent) + + expect(onPress).toHaveBeenCalled() + }) + + it('should stop event propagation', () => { + const onPress = jest.fn().mockReturnValue(false) + const { result } = renderHook(() => useLink({ href: '/', onPress })) + + result.current(testEvent) + + expect(onPress).toHaveBeenCalled() + expect(mockNavigate).not.toHaveBeenCalled() + }) + + it('should not await async onPress callback before deciding on event propagation', async () => { + const onPress = jest.fn().mockResolvedValue(false) + const { result } = renderHook(() => useLink({ href: '/', onPress })) + + result.current(testEvent) + + expect(onPress).toHaveBeenCalled() + await waitFor(() => expect(mockNavigate).toHaveBeenCalled()) + }) + + it('should propagate event if not stopped', async () => { + const onPress = jest.fn() + const { result } = renderHook(() => useLink({ href: '/', onPress })) + + result.current(testEvent) + + expect(onPress).toHaveBeenCalled() + await waitFor(() => expect(mockNavigate).toHaveBeenCalledWith('//')) + }) + + it('should navigate to href', () => { + const { result } = renderHook(() => useLink({ href: '/' })) + + result.current(testEvent) + + expect(mockNavigate).toHaveBeenCalledWith('//') + }) + + it('should open external url', () => { + const mockOpenURL = jest.spyOn(Linking, 'openURL') + const { result } = renderHook(() => useLink({ href: '/', external: true })) + + result.current(testEvent) + + expect(mockNavigate).not.toHaveBeenCalled() + expect(mockOpenURL).toHaveBeenCalledWith('/') + }) + + it('should navigate back first on mobile', async () => { + const { result } = renderHook(() => useLink({ href: '/spaces', mobileNavigateBackFirst: true })) + + result.current(testEvent) + + expect(mockNavigateBack).toHaveBeenCalled() + await waitFor(() => expect(mockNavigate).toHaveBeenCalledWith('//spaces')) + }) + + it('should ignore domain and locale path param on mobile', () => { + const originalPlatform = Platform.OS + Platform.OS = 'android' + + const { result } = renderHook(() => useLink({ href: 'https://app.holi.social/en/spaces' })) + + result.current(testEvent) + + expect(mockNavigate).toHaveBeenCalledWith('/spaces') + + // Clean up + Platform.OS = originalPlatform + }) +}) diff --git a/core/navigation/hooks/useLink.ts b/core/navigation/hooks/useLink.ts index 2a964935eb..c5ccbc14f0 100644 --- a/core/navigation/hooks/useLink.ts +++ b/core/navigation/hooks/useLink.ts @@ -6,7 +6,7 @@ import useRouting from '@holi/core/navigation/hooks/useRouting' export interface UseLinkProps { href: string - /** if given, `onPress` will be executed before opening href (if given). if `onPress` returns `false` event propagation will be stopped */ + /** if given, `onPress` will be executed before opening href (if given). if `onPress` returns `false` (synchronously, required for web) event propagation will be stopped */ onPress?: (event: React.SyntheticEvent | GestureResponderEvent) => unknown | Promise<unknown> external?: boolean mobileNavigateBackFirst?: boolean @@ -18,8 +18,10 @@ const useLink = ({ onPress, external, href, mobileNavigateBackFirst }: UseLinkPr return useCallback( async (event: React.SyntheticEvent | GestureResponderEvent) => { if (onPress) { - const stopPropagation = await onPress(event) - if (stopPropagation === false) return + // Not awaiting promise before event propagation to mimick web behaviour + const propagateEventOrPromise = onPress(event) + if (propagateEventOrPromise === false) return + await propagateEventOrPromise } if (external) { Linking.openURL(href) @@ -29,10 +31,12 @@ const useLink = ({ onPress, external, href, mobileNavigateBackFirst }: UseLinkPr if (Platform.OS === 'ios' || Platform.OS === 'android') { // If the platform is mobile and the link domain matches our app's domain, navigate to the route instead of opening the link const route = '/' + href.replace(/https:\/\/(app\.holi\.social|staging\.dev\.holi\.social)(\/(en|de))?\/?/, '') - navigate(route) - } else if (mobileNavigateBackFirst) { - navigateBack() - setTimeout(() => navigate(href), 300) + if (mobileNavigateBackFirst) { + navigateBack() + setTimeout(() => navigate(route), 300) + } else { + navigate(route) + } } else { navigate(href) } -- GitLab