Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[HybridApp] Fix transitions #44903

Merged
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/CONST.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,7 @@ const CONST = {
ANIMATION_GYROSCOPE_VALUE: 0.4,
BACKGROUND_IMAGE_TRANSITION_DURATION: 1000,
SCREEN_TRANSITION_END_TIMEOUT: 1000,
HYBRID_APP_MAX_TRANSITION_TIMEOUT: 5000,
ARROW_HIDE_DELAY: 3000,

API_ATTACHMENT_VALIDATIONS: {
Expand Down
139 changes: 92 additions & 47 deletions src/components/HybridAppMiddleware.tsx
Original file line number Diff line number Diff line change
@@ -1,104 +1,149 @@
import {useNavigation} from '@react-navigation/native';
import type {StackNavigationProp} from '@react-navigation/stack';
import React, {useCallback, useEffect, useMemo, useState} from 'react';
import React, {useCallback, useContext, useEffect, useMemo, useRef, useState} from 'react';
import {NativeModules} from 'react-native';
import {useOnyx} from 'react-native-onyx';
import useSplashScreen from '@hooks/useSplashScreen';
import useTransitionRouteParams from '@hooks/useTransitionRouteParams';
import BootSplash from '@libs/BootSplash';
import Log from '@libs/Log';
import Navigation from '@libs/Navigation/Navigation';
import type {RootStackParamList} from '@libs/Navigation/types';
import * as SessionUtils from '@libs/SessionUtils';
import * as Welcome from '@userActions/Welcome';
import CONST from '@src/CONST';
import type {Route} from '@src/ROUTES';
import ONYXKEYS from '@src/ONYXKEYS';
import type {HybridAppRoute, Route} from '@src/ROUTES';
import ROUTES from '@src/ROUTES';
import {InitialURLContext} from './InitialURLContextProvider';

type HybridAppMiddlewareProps = {
authenticated: boolean;
children: React.ReactNode;
};

type HybridAppMiddlewareContextType = {
navigateToExitUrl: (exitUrl: Route) => void;
showSplashScreenOnNextStart: () => void;
};
const HybridAppMiddlewareContext = React.createContext<HybridAppMiddlewareContextType>({
navigateToExitUrl: () => {},
showSplashScreenOnNextStart: () => {},
});

/*
* HybridAppMiddleware is responsible for handling BootSplash visibility correctly.
* It is crucial to make transitions between OldDot and NewDot look smooth.
* The middleware assumes that the entry point for HybridApp is the /transition route.
*/
function HybridAppMiddleware(props: HybridAppMiddlewareProps) {
function HybridAppMiddleware({children, authenticated}: HybridAppMiddlewareProps) {
const {isSplashHidden, setIsSplashHidden} = useSplashScreen();
const [startedTransition, setStartedTransition] = useState(false);
const [finishedTransition, setFinishedTransition] = useState(false);
const navigation = useNavigation<StackNavigationProp<RootStackParamList>>();
const [forcedTransition, setForcedTransition] = useState(false);

/*
* Handles navigation during transition from OldDot. For ordinary NewDot app it is just pure navigation.
*/
const navigateToExitUrl = useCallback((exitUrl: Route) => {
if (NativeModules.HybridAppModule) {
setStartedTransition(true);
Log.info(`[HybridApp] Started transition to ${exitUrl}`, true);
const initialURL = useContext(InitialURLContext);
const routeParams = useTransitionRouteParams();
const [exitTo, setExitTo] = useState<Route | HybridAppRoute | undefined>();

const [isAccountLoading] = useOnyx(ONYXKEYS.ACCOUNT, {selector: (account) => account?.isLoading ?? false});
const [sessionEmail] = useOnyx(ONYXKEYS.SESSION, {selector: (session) => session?.email});

const maxTimeoutRef = useRef<NodeJS.Timeout | null>(null);

// We need to ensure that the BootSplash is always hidden after a certain period.
useEffect(() => {
if (!NativeModules.HybridAppModule) {
return;
}

Navigation.navigate(exitUrl);
maxTimeoutRef.current = setTimeout(() => {
Log.info('[HybridApp] Forcing transition due to unknown problem', true);
setStartedTransition(true);
setForcedTransition(true);
setExitTo(ROUTES.HOME);
}, CONST.HYBRID_APP_MAX_TRANSITION_TIMEOUT);
}, []);

/**
* This function only affects iOS. If during a single app lifecycle we frequently transition from OldDot to NewDot,
* we need to artificially show the bootsplash because the app is only booted once.
*/
// Save `exitTo` when we reach /transition route.
// `exitTo` should always exist during OldDot -> NewDot transitions.
useEffect(() => {
if (!NativeModules.HybridAppModule || !routeParams?.exitTo || exitTo) {
return;
mateuuszzzzz marked this conversation as resolved.
Show resolved Hide resolved
}

Log.info('[HybridApp] Saving `exitTo` for later', true, {exitTo: routeParams?.exitTo});
setExitTo(routeParams?.exitTo);

Log.info(`[HybridApp] Started transition`, true);
setStartedTransition(true);
}, [exitTo, routeParams?.email, routeParams?.exitTo]);

// This function only affects iOS. If during a single app lifecycle we frequently transition from OldDot to NewDot,
// we need to artificially show the bootsplash because the app is only booted once.
const showSplashScreenOnNextStart = useCallback(() => {
Log.info('[HybridApp] Resetting the state of HybridAppMiddleware to show the BootSplash on the next transition', true);
setIsSplashHidden(false);
setStartedTransition(false);
setFinishedTransition(false);
setForcedTransition(false);
setExitTo(undefined);
}, [setIsSplashHidden]);

useEffect(() => {
if (!finishedTransition || isSplashHidden) {
if (!startedTransition || finishedTransition) {
return;
}

Log.info('[HybridApp] Finished transtion', true);
BootSplash.hide().then(() => {
setIsSplashHidden(true);
Log.info('[HybridApp] Handling onboarding flow', true);
Welcome.handleHybridAppOnboarding();
});
}, [finishedTransition, isSplashHidden, setIsSplashHidden]);
const transitionURL = NativeModules.HybridAppModule ? `${CONST.DEEPLINK_BASE_URL}${initialURL ?? ''}` : initialURL;
const isLoggingInAsNewUser = SessionUtils.isLoggingInAsNewUser(transitionURL ?? undefined, sessionEmail);

useEffect(() => {
if (!startedTransition) {
// We need to wait with navigating to exitTo until all login-related actions are complete.
if ((!authenticated || isLoggingInAsNewUser || isAccountLoading) && !forcedTransition) {
return;
}

// On iOS, the transitionEnd event doesn't trigger some times. As such, we need to set a timeout.
const timeout = setTimeout(() => {
setFinishedTransition(true);
}, CONST.SCREEN_TRANSITION_END_TIMEOUT);
if (exitTo) {
Navigation.isNavigationReady().then(() => {
if (maxTimeoutRef.current) {
clearTimeout(maxTimeoutRef.current);
}

// We need to remove /transition from route history.
// `useTransitionRouteParams` returns undefined for routes other than /transition.
if (routeParams) {
Log.info('[HybridApp] Removing /transition route from history', true);
mateuuszzzzz marked this conversation as resolved.
Show resolved Hide resolved
Navigation.goBack();
}

Log.info('[HybridApp] Navigating to `exitTo` route', true, {exitTo});
Navigation.navigate(Navigation.parseHybridAppUrl(exitTo));
setTimeout(() => {
Log.info('[HybridApp] Setting `finishedTransition` to true', true);
setFinishedTransition(true);
}, CONST.SCREEN_TRANSITION_END_TIMEOUT);
});
}
}, [authenticated, exitTo, finishedTransition, forcedTransition, initialURL, isAccountLoading, routeParams, sessionEmail, startedTransition]);

const unsubscribeTransitionEnd = navigation.addListener('transitionEnd', () => {
clearTimeout(timeout);
setFinishedTransition(true);
});
useEffect(() => {
if (!finishedTransition || isSplashHidden) {
return;
}

return () => {
clearTimeout(timeout);
unsubscribeTransitionEnd();
};
}, [navigation, startedTransition]);
Log.info('[HybridApp] Finished transition, hiding BootSplash', true);
BootSplash.hide().then(() => {
setIsSplashHidden(true);
if (authenticated) {
Log.info('[HybridApp] Handling onboarding flow', true);
Welcome.handleHybridAppOnboarding();
}
});
}, [authenticated, finishedTransition, isSplashHidden, setIsSplashHidden]);

const contextValue = useMemo(
() => ({
navigateToExitUrl,
showSplashScreenOnNextStart,
}),
[navigateToExitUrl, showSplashScreenOnNextStart],
[showSplashScreenOnNextStart],
);

return <HybridAppMiddlewareContext.Provider value={contextValue}>{props.children}</HybridAppMiddlewareContext.Provider>;
return <HybridAppMiddlewareContext.Provider value={contextValue}>{children}</HybridAppMiddlewareContext.Provider>;
}

HybridAppMiddleware.displayName = 'HybridAppMiddleware';
Expand Down
4 changes: 2 additions & 2 deletions src/hooks/useHybridAppMiddleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@ import {HybridAppMiddlewareContext} from '@components/HybridAppMiddleware';
type SplashScreenHiddenContextType = {isSplashHidden: boolean};

export default function useHybridAppMiddleware() {
const {navigateToExitUrl, showSplashScreenOnNextStart} = useContext(HybridAppMiddlewareContext);
return {navigateToExitUrl, showSplashScreenOnNextStart};
const {showSplashScreenOnNextStart} = useContext(HybridAppMiddlewareContext);
return showSplashScreenOnNextStart;
}

export type {SplashScreenHiddenContextType};
17 changes: 17 additions & 0 deletions src/hooks/useTransitionRouteParams.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import {findFocusedRoute, useNavigationState} from '@react-navigation/native';
import type {PublicScreensParamList, RootStackParamList} from '@libs/Navigation/types';
import SCREENS from '@src/SCREENS';

export default function useTransitionRouteParams() {
mateuuszzzzz marked this conversation as resolved.
Show resolved Hide resolved
const activeRouteParams = useNavigationState<RootStackParamList, PublicScreensParamList[typeof SCREENS.TRANSITION_BETWEEN_APPS] | undefined>((state) => {
const focusedRoute = findFocusedRoute(state);

if (focusedRoute?.name !== SCREENS.TRANSITION_BETWEEN_APPS) {
return undefined;
}

return focusedRoute?.params as PublicScreensParamList[typeof SCREENS.TRANSITION_BETWEEN_APPS];
});

return activeRouteParams;
}
2 changes: 1 addition & 1 deletion src/libs/Navigation/NavigationRoot.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -154,7 +154,7 @@ function NavigationRoot({authenticated, lastVisitedPath, initialUrl, onReady}: N
}}
>
{/* HybridAppMiddleware needs to have access to navigation ref and SplashScreenHidden context */}
<HybridAppMiddleware>
<HybridAppMiddleware authenticated={authenticated}>
<AppNavigator authenticated={authenticated} />
</HybridAppMiddleware>
</NavigationContainer>
Expand Down
4 changes: 2 additions & 2 deletions src/libs/actions/Session/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -198,12 +198,12 @@ function hasAuthToken(): boolean {
return !!session.authToken;
}

function signOutAndRedirectToSignIn(shouldResetToHome?: boolean, shouldStashSession?: boolean) {
function signOutAndRedirectToSignIn(shouldResetToHome?: boolean, shouldStashSession?: boolean, killHybridApp = true) {
Log.info('Redirecting to Sign In because signOut() was called');
hideContextMenu(false);
if (!isAnonymousUser()) {
// In the HybridApp, we want the Old Dot to handle the sign out process
if (NativeModules.HybridAppModule) {
if (NativeModules.HybridAppModule && killHybridApp) {
NativeModules.HybridAppModule.closeReactNativeApp();
return;
}
Expand Down
8 changes: 3 additions & 5 deletions src/pages/LogInWithShortLivedAuthTokenPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@ import * as Expensicons from '@components/Icon/Expensicons';
import * as Illustrations from '@components/Icon/Illustrations';
import Text from '@components/Text';
import TextLink from '@components/TextLink';
import useHybridAppMiddleware from '@hooks/useHybridAppMiddleware';
import useLocalize from '@hooks/useLocalize';
import useTheme from '@hooks/useTheme';
import useThemeStyles from '@hooks/useThemeStyles';
Expand All @@ -35,7 +34,6 @@ function LogInWithShortLivedAuthTokenPage({route, account}: LogInWithShortLivedA
const theme = useTheme();
const styles = useThemeStyles();
const {translate} = useLocalize();
const {navigateToExitUrl} = useHybridAppMiddleware();
const {email = '', shortLivedAuthToken = '', shortLivedToken = '', authTokenType, exitTo, error} = route?.params ?? {};

useEffect(() => {
Expand Down Expand Up @@ -64,10 +62,10 @@ function LogInWithShortLivedAuthTokenPage({route, account}: LogInWithShortLivedA
Session.setAccountError(error);
}

if (exitTo) {
// For HybridApp we have separate logic to handle transitions.
if (!NativeModules.HybridAppModule && exitTo) {
Navigation.isNavigationReady().then(() => {
const url = NativeModules.HybridAppModule ? Navigation.parseHybridAppUrl(exitTo) : (exitTo as Route);
navigateToExitUrl(url);
Navigation.navigate(exitTo as Route);
});
}
// The only dependencies of the effect are based on props.route
Expand Down
8 changes: 3 additions & 5 deletions src/pages/LogOutPreviousUserPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ import {withOnyx} from 'react-native-onyx';
import type {OnyxEntry} from 'react-native-onyx';
import FullScreenLoadingIndicator from '@components/FullscreenLoadingIndicator';
import {InitialURLContext} from '@components/InitialURLContextProvider';
import useHybridAppMiddleware from '@hooks/useHybridAppMiddleware';
import * as SessionUtils from '@libs/SessionUtils';
import Navigation from '@navigation/Navigation';
import type {AuthScreensParamList} from '@navigation/types';
Expand Down Expand Up @@ -33,7 +32,6 @@ type LogOutPreviousUserPageProps = LogOutPreviousUserPageOnyxProps & StackScreen
// This component should not do any other navigation as that handled in App.setUpPoliciesAndNavigate
function LogOutPreviousUserPage({session, route, isAccountLoading}: LogOutPreviousUserPageProps) {
const initialURL = useContext(InitialURLContext);
const {navigateToExitUrl} = useHybridAppMiddleware();

useEffect(() => {
const sessionEmail = session?.email;
Expand Down Expand Up @@ -78,12 +76,12 @@ function LogOutPreviousUserPage({session, route, isAccountLoading}: LogOutPrevio
// We don't want to navigate to the exitTo route when creating a new workspace from a deep link,
// because we already handle creating the optimistic policy and navigating to it in App.setUpPoliciesAndNavigate,
// which is already called when AuthScreens mounts.
if (exitTo && exitTo !== ROUTES.WORKSPACE_NEW && !isAccountLoading && !isLoggingInAsNewUser) {
// For HybridApp we have separate logic to handle transitions.
if (!NativeModules.HybridAppModule && exitTo && exitTo !== ROUTES.WORKSPACE_NEW && !isAccountLoading && !isLoggingInAsNewUser) {
Navigation.isNavigationReady().then(() => {
// remove this screen and navigate to exit route
const exitUrl = NativeModules.HybridAppModule ? Navigation.parseHybridAppUrl(exitTo) : exitTo;
Navigation.goBack();
navigateToExitUrl(exitUrl);
Navigation.navigate(exitTo);
});
}
// eslint-disable-next-line react-hooks/exhaustive-deps
Expand Down
4 changes: 2 additions & 2 deletions src/pages/settings/ExitSurvey/ExitSurveyConfirmPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ type ExitSurveyConfirmPageProps = ExitSurveyConfirmPageOnyxProps & StackScreenPr

function ExitSurveyConfirmPage({exitReason, isLoading, route, navigation}: ExitSurveyConfirmPageProps) {
const {translate} = useLocalize();
const {showSplashScreenOnNextStart} = useHybridAppMiddleware();
const showSplashScreenOnNextStart = useHybridAppMiddleware();
const {isOffline} = useNetwork();
const styles = useThemeStyles();

Expand Down Expand Up @@ -88,8 +88,8 @@ function ExitSurveyConfirmPage({exitReason, isLoading, route, navigation}: ExitS
onPress={() => {
ExitSurvey.switchToOldDot().then(() => {
if (NativeModules.HybridAppModule) {
Navigation.resetToHome();
showSplashScreenOnNextStart();
Navigation.resetToHome();
NativeModules.HybridAppModule.closeReactNativeApp();
return;
}
Expand Down
Loading