Skip to content

Commit

Permalink
native: sync performance improvements (#3563)
Browse files Browse the repository at this point in the history
* Db/sync optimization (#3561)

* cache auth cookie in AsyncStorage

* don't fetch settings on api native urbit module init

* syncing fixes

* sync stale channels

* skip refetch on chat list screen focus

* test disabling ChatList queries when offscreen

* fix post event handling failure

* add more devtools

* cleanup
  • Loading branch information
dnbrwstr authored Jun 3, 2024
1 parent a2be37d commit 83717b1
Show file tree
Hide file tree
Showing 19 changed files with 412 additions and 217 deletions.
3 changes: 3 additions & 0 deletions apps/tlon-mobile/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,9 @@
"@10play/tentap-editor": "^0.4.55",
"@aws-sdk/client-s3": "^3.190.0",
"@aws-sdk/s3-request-presigner": "^3.190.0",
"@dev-plugins/async-storage": "^0.0.3",
"@dev-plugins/react-navigation": "^0.0.6",
"@dev-plugins/react-query": "^0.0.6",
"@google-cloud/recaptcha-enterprise-react-native": "^18.3.0",
"@gorhom/bottom-sheet": "^4.5.1",
"@op-engineering/op-sqlite": "5.0.5",
Expand Down
35 changes: 33 additions & 2 deletions apps/tlon-mobile/src/App.main.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,16 @@
import { useAsyncStorageDevTools } from '@dev-plugins/async-storage';
import { useReactNavigationDevTools } from '@dev-plugins/react-navigation';
import { useReactQueryDevTools } from '@dev-plugins/react-query';
import NetInfo from '@react-native-community/netinfo';
import {
DarkTheme,
DefaultTheme,
NavigationContainer,
NavigationContainerRefWithCurrent,
useNavigationContainerRef,
} from '@react-navigation/native';
import { createNativeStackNavigator } from '@react-navigation/native-stack';
import { QueryClientProvider, queryClient } from '@tloncorp/shared/dist/api';
import { TamaguiProvider } from '@tloncorp/ui';
import { PostHogProvider } from 'posthog-react-native';
import type { PropsWithChildren } from 'react';
Expand Down Expand Up @@ -197,20 +203,32 @@ function MigrationCheck({ children }: PropsWithChildren) {
export default function ConnectedApp(props: Props) {
const isDarkMode = useIsDarkMode();
const tailwind = useTailwind();
const navigationContainerRef = useNavigationContainerRef();

return (
<TamaguiProvider
defaultTheme={isDarkMode ? 'dark' : 'light'}
config={tamaguiConfig}
>
<ShipProvider>
<NavigationContainer theme={isDarkMode ? DarkTheme : DefaultTheme}>
<NavigationContainer
theme={isDarkMode ? DarkTheme : DefaultTheme}
ref={navigationContainerRef}
>
<BranchProvider>
<PostHogProvider client={posthogAsync} autocapture>
<GestureHandlerRootView style={tailwind('flex-1')}>
<SafeAreaProvider>
<MigrationCheck>
<App {...props} />
<QueryClientProvider client={queryClient}>
<App {...props} />

{__DEV__ && (
<DevTools
navigationContainerRef={navigationContainerRef}
/>
)}
</QueryClientProvider>
</MigrationCheck>
</SafeAreaProvider>
</GestureHandlerRootView>
Expand All @@ -221,3 +239,16 @@ export default function ConnectedApp(props: Props) {
</TamaguiProvider>
);
}

// This is rendered as a component because I didn't have any better ideas
// on calling these hooks conditionally.
const DevTools = ({
navigationContainerRef,
}: {
navigationContainerRef: NavigationContainerRefWithCurrent<any>;
}) => {
useAsyncStorageDevTools();
useReactQueryDevTools(queryClient);
useReactNavigationDevTools(navigationContainerRef);
return null;
};
10 changes: 1 addition & 9 deletions apps/tlon-mobile/src/components/AuthenticatedApp.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,19 +25,11 @@ function AuthenticatedApp({
useDeepLinkListener();

useEffect(() => {
const start = () => {
sync.start().catch((e) => {
console.warn('Sync failed', e);
});
};

configureClient({
shipName: ship ?? '',
shipUrl: shipUrl ?? '',
onReset: () => start(),
onReset: () => sync.setupSubscriptions(),
});

start();
}, [currentUserId, ship, shipUrl]);

return (
Expand Down
78 changes: 51 additions & 27 deletions apps/tlon-mobile/src/contexts/ship.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,13 @@ import crashlytics from '@react-native-firebase/crashlytics';
import { configureApi } from '@tloncorp/shared/dist/api';
import { preSig } from '@urbit/aura';
import type { ReactNode } from 'react';
import { useContext, useEffect, useState } from 'react';
import { createContext } from 'react';
import {
createContext,
useCallback,
useContext,
useEffect,
useState,
} from 'react';
import { NativeModules } from 'react-native';

import storage from '../lib/storage';
Expand All @@ -14,6 +19,7 @@ const { UrbitModule } = NativeModules;
export type ShipInfo = {
ship: string | undefined;
shipUrl: string | undefined;
authCookie: string | undefined;
};

type State = ShipInfo & {
Expand All @@ -39,21 +45,24 @@ export const useShip = () => {
return context;
};

const emptyShip: ShipInfo = {
ship: undefined,
shipUrl: undefined,
authCookie: undefined,
};

export const ShipProvider = ({ children }: { children: ReactNode }) => {
const [isLoading, setIsLoading] = useState(true);
const [{ ship, shipUrl }, setShipInfo] = useState<ShipInfo>({
ship: undefined,
shipUrl: undefined,
});
const [shipInfo, setShipInfo] = useState(emptyShip);

const setShip = ({ ship, shipUrl }: ShipInfo, authCookie?: string) => {
const setShip = useCallback(({ ship, shipUrl, authCookie }: ShipInfo) => {
// Clear all saved ship info if either required field is empty
if (!ship || !shipUrl) {
// Remove from React Native storage
storage.remove({ key: 'store' });
clearShipInfo();

// Clear context state
setShipInfo({ ship: undefined, shipUrl: undefined });
setShipInfo(emptyShip);

// Clear native storage
UrbitModule.clearUrbit();
Expand All @@ -62,12 +71,13 @@ export const ShipProvider = ({ children }: { children: ReactNode }) => {

// The passed shipUrl should already be normalized, but defensively ensure it is
const normalizedShipUrl = transformShipURL(shipUrl);
const nextShipInfo = { ship, shipUrl: normalizedShipUrl, authCookie };

// Save to React Native stoage
storage.save({ key: 'store', data: { ship, shipUrl: normalizedShipUrl } });
saveShipInfo(nextShipInfo);

// Save context state
setShipInfo({ ship, shipUrl: normalizedShipUrl });
setShipInfo(nextShipInfo);

// Configure API
configureApi(ship, normalizedShipUrl);
Expand All @@ -79,6 +89,8 @@ export const ShipProvider = ({ children }: { children: ReactNode }) => {
);

// If cookie was passed in, use it, otherwise fetch from ship
// TODO: This may not be necessary, as I *believe* auth cookie will always
// be stored on successful login.
if (authCookie) {
// Save to native storage
UrbitModule.setUrbit(ship, normalizedShipUrl, authCookie);
Expand All @@ -91,55 +103,67 @@ export const ShipProvider = ({ children }: { children: ReactNode }) => {
});
const fetchedAuthCookie = response.headers.get('set-cookie');
if (fetchedAuthCookie) {
setShipInfo({ ...nextShipInfo, authCookie: fetchedAuthCookie });
saveShipInfo({ ...nextShipInfo, authCookie: fetchedAuthCookie });
// Save to native storage
UrbitModule.setUrbit(ship, normalizedShipUrl, fetchedAuthCookie);
}
})();
}

setIsLoading(false);
};
}, []);

useEffect(() => {
const loadConnection = async () => {
try {
const shipInfo = (await storage.load({ key: 'store' })) as
| ShipInfo
| undefined;
if (shipInfo) {
setShip(shipInfo);
const storedShipInfo = await loadShipInfo();
if (storedShipInfo) {
setShip(storedShipInfo);
} else {
setIsLoading(false);
}
} catch (err) {
if (err instanceof Error && err.name !== 'NotFoundError') {
console.error('Error reading ship connection from storage', err);
}

setIsLoading(false);
}
};

loadConnection();
}, [setShip]);

const clearShip = useCallback(() => {
setShipInfo(emptyShip);
}, []);

return (
<Context.Provider
value={{
isLoading,
isAuthenticated: !!ship && !!shipUrl,
ship,
shipUrl,
contactId: ship ? preSig(ship) : undefined,
isAuthenticated: !!shipInfo.ship && !!shipInfo.shipUrl,
contactId: shipInfo.ship ? preSig(shipInfo.ship) : undefined,
setShip,
clearShip: () =>
setShip({
ship: undefined,
shipUrl: undefined,
}),
clearShip,
...shipInfo,
}}
>
{children}
</Context.Provider>
);
};

const shipInfoKey = 'store';

export const saveShipInfo = (shipInfo: ShipInfo) => {
return storage.save({ key: shipInfoKey, data: shipInfo });
};

export const loadShipInfo = () => {
return storage.load<ShipInfo | undefined>({ key: shipInfoKey });
};

export const clearShipInfo = () => {
return storage.remove({ key: shipInfoKey });
};
58 changes: 58 additions & 0 deletions apps/tlon-mobile/src/hooks/useFocusNotifyOnChangeProps.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { useFocusEffect } from '@react-navigation/native';
import { NotifyOnChangeProps } from '@tanstack/query-core';
import React from 'react';

/**
* Wires `react-query` up to `react-navigation` to prevent offscreen components
* from re-rendering unnecessarily.
*
* To use, use the function returned by this hook as the `notifyOnChangeProps` param
* of a react-query query.
*
* `notifyOnChangeProps` controls which properties of the react-query query
* object will trigger re-renders when modified. This function disables all
* properties when the view is not focused, preventing re-render of offscreen
* content.
*
* Not that this doesn't prevent the query/db work from happening, which we may
* want at some point.
*
* Based on
* https://tanstack.com/query/latest/docs/framework/react/react-native#disable-re-renders-on-out-of-focus-screens
*/
export function useFocusNotifyOnChangeProps(
notifyOnChangeProps?: NotifyOnChangeProps
): NotifyOnChangeProps {
const focusedRef = React.useRef(true);

useFocusEffect(
React.useCallback(() => {
focusedRef.current = true;

return () => {
focusedRef.current = false;
};
}, [])
);

// @ts-expect-error NotifyOnChangeProps type is incorrect in react query, see
// https://github.com/TanStack/query/issues/7426
return () => {
// If the screen is blurred, no properties should trigger a rerender on
// change.
if (!focusedRef.current) {
return [];
}

// If this screen is focused, and changeProps are defined with a function, execute
// it to get the final props
if (typeof notifyOnChangeProps === 'function') {
return notifyOnChangeProps();
}

// Otherwise just return whatever the default we passed in way. If it's
// undefined, react-query will default to using its tracking of property
// accesses to determine when to rerender.
return notifyOnChangeProps;
};
}
4 changes: 2 additions & 2 deletions apps/tlon-mobile/src/navigation/NavBarView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ const NavBarView = (props: { navigation: any }) => {
const isRouteActive = (routeName: string) => {
return state.routes[state.index].name === routeName;
};
const { data: unreadCount } = store.useAllUnreadsCounts();
const { data: unreadCount } = store.useUnreadsCount();
const currentUserId = useCurrentUserId();
const { data: contact, isLoading } = store.useContact({ id: currentUserId });

Expand All @@ -19,7 +19,7 @@ const NavBarView = (props: { navigation: any }) => {
type="Home"
activeType="HomeFilled"
isActive={isRouteActive('ChatList')}
hasUnreads={(unreadCount?.channels ?? 0) > 0}
hasUnreads={(unreadCount ?? 0) > 0}
onPress={() => props.navigation.navigate('ChatList')}
/>
<NavIcon
Expand Down
2 changes: 1 addition & 1 deletion apps/tlon-mobile/src/screens/ChannelScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ export default function ChannelScreen(props: ChannelScreenProps) {
}
: {
mode: 'newest',
firstPageCount: 10,
firstPageCount: 50,
}),
});

Expand Down
12 changes: 7 additions & 5 deletions apps/tlon-mobile/src/screens/ChatListScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,11 @@ import {
StartDmSheet,
View,
} from '@tloncorp/ui';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useCallback, useMemo, useState } from 'react';

import AddGroupSheet from '../components/AddGroupSheet';
import { TLON_EMPLOYEE_GROUP } from '../constants';
import { useRefetchQueryOnFocus } from '../hooks/useRefetchQueryOnFocus';
import { useFocusNotifyOnChangeProps } from '../hooks/useFocusNotifyOnChangeProps';
import NavBar from '../navigation/NavBarView';
import type { HomeStackParamList } from '../types';
import { identifyTlonEmployee } from '../utils/posthog';
Expand All @@ -36,7 +36,10 @@ export default function ChatListScreen(
const [selectedGroup, setSelectedGroup] = useState<db.Group | null>(null);
const [startDmOpen, setStartDmOpen] = useState(false);
const [addGroupOpen, setAddGroupOpen] = useState(false);
const { data: chats } = store.useCurrentChats();
const notifyOnChangeProps = useFocusNotifyOnChangeProps();
const { data: chats } = store.useCurrentChats({
notifyOnChangeProps,
});
const { data: contacts } = store.useContacts();
const resolvedChats = useMemo(() => {
return {
Expand All @@ -46,8 +49,7 @@ export default function ChatListScreen(
};
}, [chats]);

const { isFetching: isFetchingInitData, refetch } = store.useInitialSync();
useRefetchQueryOnFocus(refetch);
const { isFetching: isFetchingInitData } = store.useInitialSync();

const goToDm = useCallback(
async (participants: string[]) => {
Expand Down
Loading

0 comments on commit 83717b1

Please sign in to comment.