summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorOskar <oskar@mullvad.net>2025-01-30 14:32:07 +0100
committerTobias Järvelöv <tobias.jarvelov@mullvad.net>2025-04-28 12:53:03 +0200
commit49aef713718f0401a726eeb3043dbd942a5030a3 (patch)
tree673e0b01714ffdfb51220092ec0ff11eab7aa879
parentb80bdbe342186856ee43a92ca844a2f085a228e2 (diff)
downloadmullvadvpn-49aef713718f0401a726eeb3043dbd942a5030a3.tar.xz
mullvadvpn-49aef713718f0401a726eeb3043dbd942a5030a3.zip
Replace TransitionContainer with ViewTransition API
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/AppRouter.tsx105
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/TransitionContainer.tsx381
2 files changed, 41 insertions, 445 deletions
diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/AppRouter.tsx b/desktop/packages/mullvad-vpn/src/renderer/components/AppRouter.tsx
index 6ad0a9e199..fc027738d1 100644
--- a/desktop/packages/mullvad-vpn/src/renderer/components/AppRouter.tsx
+++ b/desktop/packages/mullvad-vpn/src/renderer/components/AppRouter.tsx
@@ -1,11 +1,10 @@
-import { createRef, useCallback, useEffect, useState } from 'react';
+import { useCallback, useRef } from 'react';
import { Route, Switch } from 'react-router';
import LoginPage from '../components/Login';
import SelectLocation from '../components/select-location/SelectLocationContainer';
-import { useAppContext } from '../context';
-import { ITransitionSpecification, transitions, useHistory } from '../lib/history';
import { RoutePath } from '../lib/routes';
+import { useViewTransitions } from '../lib/transition-hooks';
import Account from './Account';
import ApiAccessMethods from './ApiAccessMethods';
import DaitaSettings from './DaitaSettings';
@@ -35,7 +34,6 @@ import Shadowsocks from './Shadowsocks';
import SplitTunnelingSettings from './SplitTunnelingSettings';
import Support from './Support';
import TooManyDevices from './TooManyDevices';
-import TransitionContainer, { TransitionView } from './TransitionContainer';
import UdpOverTcp from './UdpOverTcp';
import UserInterfaceSettings from './UserInterfaceSettings';
import { AppInfoView, ChangelogView } from './views';
@@ -43,72 +41,51 @@ import VpnSettings from './VpnSettings';
import WireguardSettings from './WireguardSettings';
export default function AppRouter() {
- const history = useHistory();
- const [currentLocation, setCurrentLocation] = useState(history.location);
- const [transition, setTransition] = useState<ITransitionSpecification>(transitions.none);
- const { setNavigationHistory } = useAppContext();
- const focusRef = createRef<IFocusHandle>();
-
- useEffect(() => {
- // React throttles updates, so it's impossible to capture the intermediate navigation without
- // listening to the history directly.
- const unobserveHistory = history.listen((location, _, transition) => {
- setNavigationHistory(history.asObject);
- setCurrentLocation(location);
- setTransition(transition);
- });
-
- return () => {
- unobserveHistory?.();
- };
- }, [history, setNavigationHistory]);
-
+ const focusRef = useRef<IFocusHandle>(null);
const onNavigation = useCallback(() => {
focusRef.current?.resetFocus();
}, [focusRef]);
+ const currentLocation = useViewTransitions(onNavigation);
+
return (
<Focus ref={focusRef}>
- <TransitionContainer onTransitionEnd={onNavigation} {...transition}>
- <TransitionView routePath={history.location.pathname}>
- <Switch key={currentLocation.key} location={currentLocation}>
- <Route exact path={RoutePath.launch} component={Launch} />
- <Route exact path={RoutePath.login} component={LoginPage} />
- <Route exact path={RoutePath.tooManyDevices} component={TooManyDevices} />
- <Route exact path={RoutePath.deviceRevoked} component={DeviceRevokedView} />
- <Route exact path={RoutePath.main} component={MainView} />
- <Route exact path={RoutePath.expired} component={ExpiredAccountErrorView} />
- <Route exact path={RoutePath.redeemVoucher} component={VoucherInput} />
- <Route exact path={RoutePath.voucherSuccess} component={VoucherVerificationSuccess} />
- <Route exact path={RoutePath.timeAdded} component={TimeAdded} />
- <Route exact path={RoutePath.setupFinished} component={SetupFinished} />
- <Route exact path={RoutePath.account} component={Account} />
- <Route exact path={RoutePath.settings} component={Settings} />
- <Route exact path={RoutePath.selectLanguage} component={SelectLanguage} />
- <Route exact path={RoutePath.userInterfaceSettings} component={UserInterfaceSettings} />
- <Route exact path={RoutePath.multihopSettings} component={MultihopSettings} />
- <Route exact path={RoutePath.vpnSettings} component={VpnSettings} />
- <Route exact path={RoutePath.wireguardSettings} component={WireguardSettings} />
- <Route exact path={RoutePath.daitaSettings} component={DaitaSettings} />
- <Route exact path={RoutePath.udpOverTcp} component={UdpOverTcp} />
- <Route exact path={RoutePath.shadowsocks} component={Shadowsocks} />
- <Route exact path={RoutePath.openVpnSettings} component={OpenVpnSettings} />
- <Route exact path={RoutePath.splitTunneling} component={SplitTunnelingSettings} />
- <Route exact path={RoutePath.apiAccessMethods} component={ApiAccessMethods} />
- <Route exact path={RoutePath.settingsImport} component={SettingsImport} />
- <Route exact path={RoutePath.settingsTextImport} component={SettingsTextImport} />
- <Route exact path={RoutePath.editApiAccessMethods} component={EditApiAccessMethod} />
- <Route exact path={RoutePath.support} component={Support} />
- <Route exact path={RoutePath.problemReport} component={ProblemReport} />
- <Route exact path={RoutePath.debug} component={Debug} />
- <Route exact path={RoutePath.selectLocation} component={SelectLocation} />
- <Route exact path={RoutePath.editCustomBridge} component={EditCustomBridge} />
- <Route exact path={RoutePath.filter} component={Filter} />
- <Route exact path={RoutePath.appInfo} component={AppInfoView} />
- <Route exact path={RoutePath.changelog} component={ChangelogView} />
- </Switch>
- </TransitionView>
- </TransitionContainer>
+ <Switch key={currentLocation.key} location={currentLocation}>
+ <Route exact path={RoutePath.launch} component={Launch} />
+ <Route exact path={RoutePath.login} component={LoginPage} />
+ <Route exact path={RoutePath.tooManyDevices} component={TooManyDevices} />
+ <Route exact path={RoutePath.deviceRevoked} component={DeviceRevokedView} />
+ <Route exact path={RoutePath.main} component={MainView} />
+ <Route exact path={RoutePath.expired} component={ExpiredAccountErrorView} />
+ <Route exact path={RoutePath.redeemVoucher} component={VoucherInput} />
+ <Route exact path={RoutePath.voucherSuccess} component={VoucherVerificationSuccess} />
+ <Route exact path={RoutePath.timeAdded} component={TimeAdded} />
+ <Route exact path={RoutePath.setupFinished} component={SetupFinished} />
+ <Route exact path={RoutePath.account} component={Account} />
+ <Route exact path={RoutePath.settings} component={Settings} />
+ <Route exact path={RoutePath.selectLanguage} component={SelectLanguage} />
+ <Route exact path={RoutePath.userInterfaceSettings} component={UserInterfaceSettings} />
+ <Route exact path={RoutePath.multihopSettings} component={MultihopSettings} />
+ <Route exact path={RoutePath.vpnSettings} component={VpnSettings} />
+ <Route exact path={RoutePath.wireguardSettings} component={WireguardSettings} />
+ <Route exact path={RoutePath.daitaSettings} component={DaitaSettings} />
+ <Route exact path={RoutePath.udpOverTcp} component={UdpOverTcp} />
+ <Route exact path={RoutePath.shadowsocks} component={Shadowsocks} />
+ <Route exact path={RoutePath.openVpnSettings} component={OpenVpnSettings} />
+ <Route exact path={RoutePath.splitTunneling} component={SplitTunnelingSettings} />
+ <Route exact path={RoutePath.apiAccessMethods} component={ApiAccessMethods} />
+ <Route exact path={RoutePath.settingsImport} component={SettingsImport} />
+ <Route exact path={RoutePath.settingsTextImport} component={SettingsTextImport} />
+ <Route exact path={RoutePath.editApiAccessMethods} component={EditApiAccessMethod} />
+ <Route exact path={RoutePath.support} component={Support} />
+ <Route exact path={RoutePath.problemReport} component={ProblemReport} />
+ <Route exact path={RoutePath.debug} component={Debug} />
+ <Route exact path={RoutePath.selectLocation} component={SelectLocation} />
+ <Route exact path={RoutePath.editCustomBridge} component={EditCustomBridge} />
+ <Route exact path={RoutePath.filter} component={Filter} />
+ <Route exact path={RoutePath.appInfo} component={AppInfoView} />
+ <Route exact path={RoutePath.changelog} component={ChangelogView} />
+ </Switch>
</Focus>
);
}
diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/TransitionContainer.tsx b/desktop/packages/mullvad-vpn/src/renderer/components/TransitionContainer.tsx
deleted file mode 100644
index 1df9b097c5..0000000000
--- a/desktop/packages/mullvad-vpn/src/renderer/components/TransitionContainer.tsx
+++ /dev/null
@@ -1,381 +0,0 @@
-import * as React from 'react';
-import styled from 'styled-components';
-
-import { ITransitionSpecification } from '../lib/history';
-import { WillExit } from '../lib/will-exit';
-
-interface ITransitioningViewProps {
- routePath: string;
- children?: React.ReactNode;
-}
-
-type TransitioningView = React.ReactElement<ITransitioningViewProps>;
-
-interface ITransitionQueueItem {
- view: TransitioningView;
- transition: ITransitionSpecification;
-}
-
-interface IProps extends ITransitionSpecification {
- children: TransitioningView;
- onTransitionEnd: () => void;
-}
-
-interface IItemStyle {
- // x and y are percentages
- x: number;
- y: number;
- inFront: boolean;
- duration?: number;
-}
-
-interface IState {
- currentItem?: ITransitionQueueItem;
- nextItem?: ITransitionQueueItem;
- queuedItem?: ITransitionQueueItem;
- currentItemStyle?: IItemStyle;
- nextItemStyle?: IItemStyle;
- currentItemTransition?: Partial<IItemStyle>;
- nextItemTransition?: Partial<IItemStyle>;
-}
-
-export const StyledTransitionContainer = styled.div({ flex: 1 });
-
-interface StyledTransitionContentProps {
- $transition?: IItemStyle;
- $disableUserInteraction?: boolean;
-}
-
-export const StyledTransitionContent = styled.div.attrs<
- StyledTransitionContentProps,
- { 'data-testid': string }
->({
- 'data-testid': 'transition-content',
-})((props) => {
- const x = `${props.$transition?.x ?? 0}%`;
- const y = `${props.$transition?.y ?? 0}%`;
- const duration = props.$transition?.duration ?? 450;
-
- return {
- display: 'flex',
- flexDirection: 'column',
- position: 'absolute',
- left: 0,
- right: 0,
- top: 0,
- bottom: 0,
- zIndex: props.$transition?.inFront ? 1 : 0,
- willChange: 'transform',
- transform: `translate3d(${x}, ${y}, 0)`,
- transition: `transform ${duration}ms ease-in-out`,
- pointerEvents: props.$disableUserInteraction ? 'none' : undefined,
- };
-});
-
-export const StyledTransitionView = styled.div({
- display: 'flex',
- flex: 1,
- flexDirection: 'column',
- height: '100%',
- width: '100%',
-});
-
-export class TransitionView extends React.Component<ITransitioningViewProps> {
- public render() {
- return (
- <StyledTransitionView data-testid={this.props.routePath}>
- {this.props.children}
- </StyledTransitionView>
- );
- }
-}
-
-export default class TransitionContainer extends React.Component<IProps, IState> {
- public state: IState = {
- currentItem: TransitionContainer.makeItem(this.props),
- };
-
- private isCycling = false;
- private isTransitioning = false;
-
- private currentContentRef: React.MutableRefObject<HTMLDivElement | null> =
- React.createRef<HTMLDivElement>();
- private nextContentRef: React.MutableRefObject<HTMLDivElement | null> =
- React.createRef<HTMLDivElement>();
- // The item that should trigger the cycle to finish in onTransitionEnd
- private transitioningItemRef?: React.RefObject<HTMLDivElement>;
-
- public componentDidUpdate(prevProps: IProps) {
- if (this.props.children !== prevProps.children) {
- this.updateStateFromProps();
- }
-
- if (
- this.state.currentItemStyle &&
- this.state.currentItemTransition &&
- this.state.nextItemStyle &&
- this.state.nextItemTransition
- ) {
- // Force browser reflow before starting transition. Without this animations won't run since
- // the next view content hasn't been painted yet. It will just appear without a transition.
- void this.nextContentRef.current?.offsetHeight;
-
- // Start transition
- this.setState((state) => ({
- currentItemStyle: Object.assign({}, state.currentItemStyle, state.currentItemTransition),
- nextItemStyle: Object.assign({}, state.nextItemStyle, state.nextItemTransition),
- currentItemTransition: undefined,
- nextItemTransition: undefined,
- }));
- } else {
- this.cycle();
- }
- }
-
- public render() {
- const willExit = this.state.queuedItem !== undefined || this.state.nextItem !== undefined;
-
- return (
- <StyledTransitionContainer>
- {this.state.currentItem && (
- <WillExit key={this.state.currentItem.view.props.routePath} value={willExit}>
- <StyledTransitionContent
- ref={this.setCurrentContentRef}
- $transition={this.state.currentItemStyle}
- onTransitionEnd={this.onTransitionEnd}
- $disableUserInteraction={willExit}>
- {this.state.currentItem.view}
- </StyledTransitionContent>
- </WillExit>
- )}
-
- {this.state.nextItem && (
- <WillExit key={this.state.nextItem.view.props.routePath} value={false}>
- <StyledTransitionContent
- ref={this.setNextContentRef}
- $transition={this.state.nextItemStyle}
- onTransitionEnd={this.onTransitionEnd}>
- {this.state.nextItem.view}
- </StyledTransitionContent>
- </WillExit>
- )}
- </StyledTransitionContainer>
- );
- }
-
- private setCurrentContentRef = (element: HTMLDivElement) => {
- this.currentContentRef.current?.removeEventListener('transitionstart', this.onTransitionStart);
- this.currentContentRef.current = element;
- this.currentContentRef.current?.addEventListener('transitionstart', this.onTransitionStart);
- };
-
- private setNextContentRef = (element: HTMLDivElement) => {
- this.nextContentRef.current?.removeEventListener('transitionstart', this.onTransitionStart);
- this.nextContentRef.current = element;
- this.nextContentRef.current?.addEventListener('transitionstart', this.onTransitionStart);
- };
-
- private updateStateFromProps() {
- const candidate = this.props.children;
-
- if (candidate && this.state.currentItem) {
- // Update currentItem, nextItem, queuedItem depending on which the candidate matches.
- if (
- !this.isCycling &&
- this.state.currentItem.view.props.routePath === candidate.props.routePath
- ) {
- // There's no transition in progress and the newest candidate has the same path as the
- // current. In this situation the app should just remain in the same view.
- this.setState(
- {
- currentItem: TransitionContainer.makeItem(this.props),
- nextItem: undefined,
- queuedItem: undefined,
- currentItemStyle: undefined,
- nextItemStyle: undefined,
- currentItemTransition: undefined,
- nextItemTransition: undefined,
- },
- () => (this.isCycling = false),
- );
- } else if (!this.isCycling && this.state.nextItem) {
- // There's no transition in progress but there is a next item. Abort the transition and add
- // the candidate to the queue. The app shouldn't start a transition if there is another view
- // to queue.
- this.setState(
- {
- nextItem: undefined,
- queuedItem: TransitionContainer.makeItem(this.props),
- currentItemStyle: undefined,
- nextItemStyle: undefined,
- currentItemTransition: undefined,
- nextItemTransition: undefined,
- },
- () => (this.isCycling = false),
- );
- } else if (this.state.nextItem?.view.props.routePath === candidate.props.routePath) {
- // There's an update to the item that is currently being transitioned to. Update that item
- // and continue the transition.
- this.setState({
- nextItem: TransitionContainer.makeItem(this.props),
- queuedItem: undefined,
- });
- } else {
- // If none of the above, initiate a transition to the new item.
- this.setState({ queuedItem: TransitionContainer.makeItem(this.props) });
- }
- } else if (candidate) {
- // Child is set as current item if there's no item already.
- this.setState({ currentItem: TransitionContainer.makeItem(this.props) });
- }
- }
-
- private onTransitionStart = (event: TransitionEvent) => {
- if (
- this.isCycling &&
- !this.isTransitioning &&
- event.target === this.transitioningItemRef?.current
- ) {
- this.isTransitioning = true;
- }
- };
-
- private onTransitionEnd = (event: React.TransitionEvent<HTMLDivElement>) => {
- if (this.isCycling && event.target === this.transitioningItemRef?.current) {
- this.isTransitioning = false;
- this.transitioningItemRef = undefined;
- this.makeNextItemCurrent(() => {
- this.onFinishCycle();
- });
- }
- };
-
- private cycle() {
- if (!this.isCycling) {
- this.isCycling = true;
- this.cycleUnguarded();
- }
- }
-
- private onFinishCycle() {
- this.props.onTransitionEnd();
- this.cycleUnguarded();
- }
-
- private cycleUnguarded = () => {
- if (this.state.queuedItem) {
- const transition = this.state.queuedItem.transition;
-
- switch (transition.name) {
- case 'slide-up':
- this.slideUp(transition.duration);
- break;
-
- case 'slide-down':
- this.slideDown(transition.duration);
- break;
-
- case 'push':
- this.push(transition.duration);
- break;
-
- case 'pop':
- this.pop(transition.duration);
- break;
-
- default:
- this.replace(() => this.onFinishCycle);
- break;
- }
- } else {
- this.isCycling = false;
- }
- };
-
- private static makeItem(props: IProps): ITransitionQueueItem {
- return {
- transition: {
- name: props.name,
- duration: props.duration,
- },
- view: React.cloneElement(props.children),
- };
- }
-
- private makeNextItemCurrent(completion: () => void) {
- this.setState(
- (state) => ({
- currentItem: state.nextItem,
- nextItem: undefined,
- currentItemStyle: undefined,
- nextItemStyle: undefined,
- currentItemTransition: undefined,
- nextItemTransition: undefined,
- }),
- completion,
- );
- }
-
- private slideUp(duration: number) {
- this.transitioningItemRef = this.nextContentRef;
- this.setState((state) => ({
- nextItem: state.queuedItem,
- queuedItem: undefined,
- currentItemStyle: { x: 0, y: 0, inFront: false },
- nextItemStyle: { x: 0, y: 100, inFront: true },
- currentItemTransition: { duration },
- nextItemTransition: { y: 0, duration },
- }));
- }
-
- private slideDown(duration: number) {
- this.transitioningItemRef = this.currentContentRef;
- this.setState((state) => ({
- nextItem: state.queuedItem,
- queuedItem: undefined,
- currentItemStyle: { x: 0, y: 0, inFront: true },
- nextItemStyle: { x: 0, y: 0, inFront: false },
- currentItemTransition: { y: 100, duration },
- nextItemTransition: { duration },
- }));
- }
-
- private push(duration: number) {
- this.transitioningItemRef = this.nextContentRef;
- this.setState((state) => ({
- nextItem: state.queuedItem,
- queuedItem: undefined,
- currentItemStyle: { x: 0, y: 0, inFront: false },
- nextItemStyle: { x: 100, y: 0, inFront: true },
- currentItemTransition: { x: -50, duration },
- nextItemTransition: { x: 0, duration },
- }));
- }
-
- private pop(duration: number) {
- this.transitioningItemRef = this.currentContentRef;
- this.setState((state) => ({
- nextItem: state.queuedItem,
- queuedItem: undefined,
- currentItemStyle: { x: 0, y: 0, inFront: true },
- nextItemStyle: { x: -50, y: 0, inFront: false },
- currentItemTransition: { x: 100, duration },
- nextItemTransition: { x: 0, duration },
- }));
- }
-
- private replace(completion: () => void) {
- this.setState(
- (state) => ({
- currentItem: state.queuedItem,
- nextItem: undefined,
- queuedItem: undefined,
- currentItemStyle: { x: 0, y: 0, inFront: false, duration: 0 },
- nextItemStyle: { x: 0, y: 0, inFront: true, duration: 0 },
- currentItemTransition: undefined,
- nextItemTransition: undefined,
- }),
- completion,
- );
- }
-}