diff options
| author | Oskar <oskar@mullvad.net> | 2025-01-30 14:32:07 +0100 |
|---|---|---|
| committer | Tobias Järvelöv <tobias.jarvelov@mullvad.net> | 2025-04-28 12:53:03 +0200 |
| commit | 49aef713718f0401a726eeb3043dbd942a5030a3 (patch) | |
| tree | 673e0b01714ffdfb51220092ec0ff11eab7aa879 | |
| parent | b80bdbe342186856ee43a92ca844a2f085a228e2 (diff) | |
| download | mullvadvpn-49aef713718f0401a726eeb3043dbd942a5030a3.tar.xz mullvadvpn-49aef713718f0401a726eeb3043dbd942a5030a3.zip | |
Replace TransitionContainer with ViewTransition API
| -rw-r--r-- | desktop/packages/mullvad-vpn/src/renderer/components/AppRouter.tsx | 105 | ||||
| -rw-r--r-- | desktop/packages/mullvad-vpn/src/renderer/components/TransitionContainer.tsx | 381 |
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, - ); - } -} |
