From 21e4ca0eb32b84e78e48b4cb57979a92efe774fd Mon Sep 17 00:00:00 2001 From: Gregor MacLennan Date: Thu, 5 Nov 2026 03:04:46 +0000 Subject: [PATCH 1/5] test(exchange): keep the server connection when leaving mid-sync --- .../Exchange/ExchangeScreenContent.test.tsx | 120 ++++++++++++++++++ 1 file changed, 120 insertions(+) create mode 100644 src/frontend/screens/Exchange/ExchangeScreenContent.test.tsx diff --git a/src/frontend/screens/Exchange/ExchangeScreenContent.test.tsx b/src/frontend/screens/Exchange/ExchangeScreenContent.test.tsx new file mode 100644 index 000000000..e8772e6e5 --- /dev/null +++ b/src/frontend/screens/Exchange/ExchangeScreenContent.test.tsx @@ -0,0 +1,120 @@ +import * as React from 'react'; +import type {SyncState} from '@comapeo/core-react'; +import {NavigationContainer} from '@react-navigation/native'; +import {createNativeStackNavigator} from '@react-navigation/native-stack'; +import {QueryClient, QueryClientProvider} from '@tanstack/react-query'; +import {render, screen} from '@testing-library/react-native'; +import {IntlProvider} from 'react-intl'; + +import {ExchangeScreenContent} from './ExchangeScreenContent'; + +let mockDataSyncProgress: number | null = null; + +const mockSync = { + connectServers: jest.fn(), + disconnectServers: jest.fn(), + setAutostopDataSyncTimeout: jest.fn(), + stop: jest.fn(), +}; + +jest.mock('@comapeo/core-react', () => ({ + ...jest.requireActual('@comapeo/core-react'), + useDataSyncProgress: () => mockDataSyncProgress, + useIsArchiveDevice: () => ({data: false}), + useStartSync: () => ({mutate: jest.fn()}), +})); + +jest.mock('../../contexts/ActiveProjectContext', () => ({ + useActiveProject: () => ({ + projectId: 'project-id', + projectApi: {$sync: mockSync}, + }), +})); + +jest.mock('../../hooks/server/projects', () => ({ + useActiveArchiveServer: () => undefined, +})); + +jest.mock('../../hooks/useLocalDiscoveryState', () => ({ + useLocalDiscoveryState: () => 'CoMapeo Test Wi-Fi', +})); + +const serverPeerSyncState = { + initial: {isSyncEnabled: true, want: 0, wanted: 0}, + data: {isSyncEnabled: true, want: 5, wanted: 0}, +}; + +const syncingWithServer = { + initial: {isSyncEnabled: true}, + data: {isSyncEnabled: true}, + remoteDeviceSyncState: {server: serverPeerSyncState}, +} satisfies SyncState; + +const Stack = createNativeStackNavigator<{Sync: undefined}>(); + +const queryClient = new QueryClient(); + +const Wrapper = ({children}: {children: React.ReactNode}) => ( + + {children} + +); + +async function renderExchangeScreen(syncState: SyncState) { + const {unmount} = await render( + + + + + {() => } + + + + , + {wrapper: Wrapper}, + ); + return unmount; +} + +describe('ExchangeScreenContent server connection on leaving the screen', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + test('keeps the server connected while data sync is still in progress', async () => { + mockDataSyncProgress = 0.4; + const unmount = await renderExchangeScreen(syncingWithServer); + + await expect(screen.findByText('Exchanging...')).resolves.toBeVisible(); + expect(mockSync.connectServers).toHaveBeenCalled(); + + await unmount(); + + expect(mockSync.disconnectServers).not.toHaveBeenCalled(); + }); + + test('disconnects the server once data sync is complete', async () => { + mockDataSyncProgress = 1; + const unmount = await renderExchangeScreen(syncingWithServer); + + await expect(screen.findByText('Complete!')).resolves.toBeVisible(); + + await unmount(); + + expect(mockSync.disconnectServers).toHaveBeenCalledTimes(1); + }); + + test('disconnects the server when data sync is not enabled', async () => { + mockDataSyncProgress = null; + const unmount = await renderExchangeScreen({ + ...syncingWithServer, + data: {isSyncEnabled: false}, + }); + + await expect(screen.findByText('Devices found.')).resolves.toBeVisible(); + + await unmount(); + + expect(mockSync.disconnectServers).toHaveBeenCalledTimes(1); + }); +}); From dc22913d16cdf23e38ed7f15e54a241d4ec3b4e3 Mon Sep 17 00:00:00 2001 From: Gregor MacLennan Date: Thu, 5 Nov 2026 03:04:47 +0000 Subject: [PATCH 2/5] fix(exchange): keep server connection when leaving screen mid-sync --- .../screens/Exchange/ExchangeScreenContent.tsx | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/src/frontend/screens/Exchange/ExchangeScreenContent.tsx b/src/frontend/screens/Exchange/ExchangeScreenContent.tsx index a172bb095..c9884fc82 100644 --- a/src/frontend/screens/Exchange/ExchangeScreenContent.tsx +++ b/src/frontend/screens/Exchange/ExchangeScreenContent.tsx @@ -154,6 +154,19 @@ export const ExchangeScreenContent = ({syncState}: {syncState: SyncState}) => { dataSyncEnabled: syncState.data.isSyncEnabled, }); + // Cutting the server websocket mid-sync leaves it with partial data. + const shouldDisconnectServersWhenLeavingScreen = + !syncState.data.isSyncEnabled || syncStage.name === 'complete-full'; + + // Ref, not a dependency: re-running the focus effect would reconnect servers. + const shouldDisconnectServersRef = React.useRef( + shouldDisconnectServersWhenLeavingScreen, + ); + React.useEffect(() => { + shouldDisconnectServersRef.current = + shouldDisconnectServersWhenLeavingScreen; + }, [shouldDisconnectServersWhenLeavingScreen]); + useFocusEffect( React.useCallback(() => { // Unset sync autostop when screen mounts @@ -161,7 +174,9 @@ export const ExchangeScreenContent = ({syncState}: {syncState: SyncState}) => { // Connects to Servers projectApi.$sync.connectServers(); return () => { - projectApi.$sync.disconnectServers(); + if (shouldDisconnectServersRef.current) { + projectApi.$sync.disconnectServers(); + } }; }, [projectApi]), ); From afac571bb9d90c898bd56fed40f4bf8ead855cdd Mon Sep 17 00:00:00 2001 From: Gregor MacLennan Date: Thu, 5 Nov 2026 13:49:20 +0000 Subject: [PATCH 3/5] refactor(exchange): drop the intermediate variable for the disconnect decision --- .../screens/Exchange/ExchangeScreenContent.tsx | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/src/frontend/screens/Exchange/ExchangeScreenContent.tsx b/src/frontend/screens/Exchange/ExchangeScreenContent.tsx index c9884fc82..d58243bc9 100644 --- a/src/frontend/screens/Exchange/ExchangeScreenContent.tsx +++ b/src/frontend/screens/Exchange/ExchangeScreenContent.tsx @@ -155,17 +155,12 @@ export const ExchangeScreenContent = ({syncState}: {syncState: SyncState}) => { }); // Cutting the server websocket mid-sync leaves it with partial data. - const shouldDisconnectServersWhenLeavingScreen = - !syncState.data.isSyncEnabled || syncStage.name === 'complete-full'; - // Ref, not a dependency: re-running the focus effect would reconnect servers. - const shouldDisconnectServersRef = React.useRef( - shouldDisconnectServersWhenLeavingScreen, - ); + const shouldDisconnectServersRef = React.useRef(true); React.useEffect(() => { shouldDisconnectServersRef.current = - shouldDisconnectServersWhenLeavingScreen; - }, [shouldDisconnectServersWhenLeavingScreen]); + !syncState.data.isSyncEnabled || syncStage.name === 'complete-full'; + }); useFocusEffect( React.useCallback(() => { From ceac969b505b92f06713eacf381a281d19954647 Mon Sep 17 00:00:00 2001 From: Gregor MacLennan Date: Fri, 6 Nov 2026 11:50:08 +0000 Subject: [PATCH 4/5] refactor(exchange): use core-react sync hooks for the server connection Replace the direct projectApi.$sync calls in ExchangeScreenContent with the mutation hooks from @comapeo/core-react, and drop the ref that kept the server connection open on blur while sync was in progress. Disconnecting gracefully is moving into core's disconnectServers(), so the screen goes back to an unconditional disconnect on blur. The component no longer needs projectApi from useActiveProject. --- .../Exchange/ExchangeScreenContent.tsx | 42 +++++++++---------- 1 file changed, 21 insertions(+), 21 deletions(-) diff --git a/src/frontend/screens/Exchange/ExchangeScreenContent.tsx b/src/frontend/screens/Exchange/ExchangeScreenContent.tsx index d58243bc9..ec89974a2 100644 --- a/src/frontend/screens/Exchange/ExchangeScreenContent.tsx +++ b/src/frontend/screens/Exchange/ExchangeScreenContent.tsx @@ -1,8 +1,12 @@ import * as React from 'react'; import { + useConnectSyncServers, useDataSyncProgress, + useDisconnectSyncServers, useIsArchiveDevice, + useSetAutostopDataSyncTimeout, useStartSync, + useStopSync, type SyncState, } from '@comapeo/core-react'; import {useFocusEffect} from '@react-navigation/native'; @@ -128,9 +132,15 @@ export const ExchangeScreenContent = ({syncState}: {syncState: SyncState}) => { const {formatMessage: t} = useIntl(); const queryClient = useQueryClient(); const navigation = useNavigationFromRoot(); - const {projectApi, projectId} = useActiveProject(); + const {projectId} = useActiveProject(); const progress = useDataSyncProgress({projectId}); const startSync = useStartSync({projectId}); + const stopSync = useStopSync({projectId}); + const {mutate: connectServers} = useConnectSyncServers({projectId}); + const {mutate: disconnectServers} = useDisconnectSyncServers({projectId}); + const {mutate: setAutostopDataSyncTimeout} = useSetAutostopDataSyncTimeout({ + projectId, + }); const {data: isArchive} = useIsArchiveDevice(); const currentMediaSetting = isArchive ? 'everything' : 'previews'; @@ -154,26 +164,16 @@ export const ExchangeScreenContent = ({syncState}: {syncState: SyncState}) => { dataSyncEnabled: syncState.data.isSyncEnabled, }); - // Cutting the server websocket mid-sync leaves it with partial data. - // Ref, not a dependency: re-running the focus effect would reconnect servers. - const shouldDisconnectServersRef = React.useRef(true); - React.useEffect(() => { - shouldDisconnectServersRef.current = - !syncState.data.isSyncEnabled || syncStage.name === 'complete-full'; - }); - useFocusEffect( React.useCallback(() => { // Unset sync autostop when screen mounts - projectApi.$sync.setAutostopDataSyncTimeout(null); + setAutostopDataSyncTimeout({after: null}); // Connects to Servers - projectApi.$sync.connectServers(); + connectServers(); return () => { - if (shouldDisconnectServersRef.current) { - projectApi.$sync.disconnectServers(); - } + disconnectServers(); }; - }, [projectApi]), + }, [connectServers, disconnectServers, setAutostopDataSyncTimeout]), ); const shouldAutostopSyncWhenLeavingScreen = @@ -184,7 +184,7 @@ export const ExchangeScreenContent = ({syncState}: {syncState: SyncState}) => { React.useCallback(() => { const unsubscribe = navigation.addListener('beforeRemove', () => { if (shouldAutostopSyncWhenLeavingScreen) { - projectApi.$sync.setAutostopDataSyncTimeout(30_000); + setAutostopDataSyncTimeout({after: 30_000}); } queryClient.invalidateQueries({ queryKey: [ROOT_QUERY_KEY, 'projects', projectId], @@ -197,7 +197,7 @@ export const ExchangeScreenContent = ({syncState}: {syncState: SyncState}) => { }; }, [ navigation, - projectApi, + setAutostopDataSyncTimeout, queryClient, projectId, shouldAutostopSyncWhenLeavingScreen, @@ -255,7 +255,7 @@ export const ExchangeScreenContent = ({syncState}: {syncState: SyncState}) => { fullSize={true} onPress={() => { // TODO: Catch/surface error - projectApi.$sync.stop(); + stopSync.mutate(undefined); }} text={t(m.stop)} renderIcon={({size, color}) => ( @@ -281,7 +281,7 @@ export const ExchangeScreenContent = ({syncState}: {syncState: SyncState}) => { fullSize={true} onPress={() => { // TODO: Catch/surface error - projectApi.$sync.stop(); + stopSync.mutate(undefined); }} text={t(m.stop)} renderIcon={({size, color}) => ( @@ -309,7 +309,7 @@ export const ExchangeScreenContent = ({syncState}: {syncState: SyncState}) => { fullSize={true} onPress={() => { // TODO: Catch/surface error - projectApi.$sync.stop(); + stopSync.mutate(undefined); }} text={t(m.stop)} renderIcon={({size, color}) => ( @@ -335,7 +335,7 @@ export const ExchangeScreenContent = ({syncState}: {syncState: SyncState}) => { fullSize={true} onPress={() => { // TODO: Catch/surface error - projectApi.$sync.stop(); + stopSync.mutate(undefined); }} text={t(m.stop)} renderIcon={({size, color}) => ( From 6d0fa77ff3e3b112a35f483edbcf22ce298b92be Mon Sep 17 00:00:00 2001 From: Gregor MacLennan Date: Fri, 6 Nov 2026 11:50:08 +0000 Subject: [PATCH 5/5] test(exchange): cover connecting servers on focus and disconnecting on blur The previous tests asserted the conditional disconnect that has been removed. The remaining test mocks the core-react hooks and checks that the screen clears the autostop and connects servers once on focus, and disconnects once when another screen is pushed. --- .../Exchange/ExchangeScreenContent.test.tsx | 104 +++++++----------- 1 file changed, 39 insertions(+), 65 deletions(-) diff --git a/src/frontend/screens/Exchange/ExchangeScreenContent.test.tsx b/src/frontend/screens/Exchange/ExchangeScreenContent.test.tsx index e8772e6e5..fbafff82b 100644 --- a/src/frontend/screens/Exchange/ExchangeScreenContent.test.tsx +++ b/src/frontend/screens/Exchange/ExchangeScreenContent.test.tsx @@ -1,34 +1,36 @@ import * as React from 'react'; import type {SyncState} from '@comapeo/core-react'; -import {NavigationContainer} from '@react-navigation/native'; +import { + createNavigationContainerRef, + NavigationContainer, +} from '@react-navigation/native'; import {createNativeStackNavigator} from '@react-navigation/native-stack'; import {QueryClient, QueryClientProvider} from '@tanstack/react-query'; -import {render, screen} from '@testing-library/react-native'; +import {act, render, screen} from '@testing-library/react-native'; import {IntlProvider} from 'react-intl'; +import {Text} from 'react-native'; import {ExchangeScreenContent} from './ExchangeScreenContent'; -let mockDataSyncProgress: number | null = null; - -const mockSync = { - connectServers: jest.fn(), - disconnectServers: jest.fn(), - setAutostopDataSyncTimeout: jest.fn(), - stop: jest.fn(), -}; +const mockConnectServers = jest.fn(); +const mockDisconnectServers = jest.fn(); +const mockSetAutostopDataSyncTimeout = jest.fn(); jest.mock('@comapeo/core-react', () => ({ ...jest.requireActual('@comapeo/core-react'), - useDataSyncProgress: () => mockDataSyncProgress, + useDataSyncProgress: () => null, useIsArchiveDevice: () => ({data: false}), useStartSync: () => ({mutate: jest.fn()}), + useStopSync: () => ({mutate: jest.fn()}), + useConnectSyncServers: () => ({mutate: mockConnectServers}), + useDisconnectSyncServers: () => ({mutate: mockDisconnectServers}), + useSetAutostopDataSyncTimeout: () => ({ + mutate: mockSetAutostopDataSyncTimeout, + }), })); jest.mock('../../contexts/ActiveProjectContext', () => ({ - useActiveProject: () => ({ - projectId: 'project-id', - projectApi: {$sync: mockSync}, - }), + useActiveProject: () => ({projectId: 'project-id'}), })); jest.mock('../../hooks/server/projects', () => ({ @@ -39,18 +41,15 @@ jest.mock('../../hooks/useLocalDiscoveryState', () => ({ useLocalDiscoveryState: () => 'CoMapeo Test Wi-Fi', })); -const serverPeerSyncState = { - initial: {isSyncEnabled: true, want: 0, wanted: 0}, - data: {isSyncEnabled: true, want: 5, wanted: 0}, -}; - -const syncingWithServer = { +const syncState = { initial: {isSyncEnabled: true}, - data: {isSyncEnabled: true}, - remoteDeviceSyncState: {server: serverPeerSyncState}, + data: {isSyncEnabled: false}, + remoteDeviceSyncState: {}, } satisfies SyncState; -const Stack = createNativeStackNavigator<{Sync: undefined}>(); +type StackParams = {Sync: undefined; Other: undefined}; + +const Stack = createNativeStackNavigator(); const queryClient = new QueryClient(); @@ -60,61 +59,36 @@ const Wrapper = ({children}: {children: React.ReactNode}) => ( ); -async function renderExchangeScreen(syncState: SyncState) { - const {unmount} = await render( +test('connects servers on focus and disconnects them on blur', async () => { + const navigationRef = createNavigationContainerRef(); + + await render( - + {() => } + {() => Other} , {wrapper: Wrapper}, ); - return unmount; -} - -describe('ExchangeScreenContent server connection on leaving the screen', () => { - beforeEach(() => { - jest.clearAllMocks(); - }); - - test('keeps the server connected while data sync is still in progress', async () => { - mockDataSyncProgress = 0.4; - const unmount = await renderExchangeScreen(syncingWithServer); - await expect(screen.findByText('Exchanging...')).resolves.toBeVisible(); - expect(mockSync.connectServers).toHaveBeenCalled(); + await expect(screen.findByText('No devices found.')).resolves.toBeVisible(); - await unmount(); + expect(mockSetAutostopDataSyncTimeout).toHaveBeenCalledTimes(1); + expect(mockSetAutostopDataSyncTimeout).toHaveBeenCalledWith({after: null}); + expect(mockConnectServers).toHaveBeenCalledTimes(1); + expect(mockDisconnectServers).not.toHaveBeenCalled(); - expect(mockSync.disconnectServers).not.toHaveBeenCalled(); + await act(async () => { + navigationRef.navigate('Other'); }); - test('disconnects the server once data sync is complete', async () => { - mockDataSyncProgress = 1; - const unmount = await renderExchangeScreen(syncingWithServer); + await expect(screen.findByText('Other')).resolves.toBeVisible(); - await expect(screen.findByText('Complete!')).resolves.toBeVisible(); - - await unmount(); - - expect(mockSync.disconnectServers).toHaveBeenCalledTimes(1); - }); - - test('disconnects the server when data sync is not enabled', async () => { - mockDataSyncProgress = null; - const unmount = await renderExchangeScreen({ - ...syncingWithServer, - data: {isSyncEnabled: false}, - }); - - await expect(screen.findByText('Devices found.')).resolves.toBeVisible(); - - await unmount(); - - expect(mockSync.disconnectServers).toHaveBeenCalledTimes(1); - }); + expect(mockDisconnectServers).toHaveBeenCalledTimes(1); + expect(mockConnectServers).toHaveBeenCalledTimes(1); });