[Glitch] Replace react-router-scroll-4 with inlined implementation

Port d801cf8e59 to glitch-soc

Signed-off-by: Claire <claire.github-309c@sitedethib.com>
This commit is contained in:
diondiondion
2025-09-25 14:26:50 +02:00
committed by Claire
parent 8fad4926c5
commit 3cd021d4d9
10 changed files with 286 additions and 31 deletions

View File

@@ -1,6 +1,7 @@
import type { PropsWithChildren } from 'react'; import type { PropsWithChildren } from 'react';
import type React from 'react'; import type React from 'react';
import type { useLocation } from 'react-router';
import { Router as OriginalRouter, useHistory } from 'react-router'; import { Router as OriginalRouter, useHistory } from 'react-router';
import type { import type {
@@ -18,7 +19,9 @@ interface MastodonLocationState {
mastodonModalKey?: string; mastodonModalKey?: string;
} }
type LocationState = MastodonLocationState | null | undefined; export type LocationState = MastodonLocationState | null | undefined;
export type MastodonLocation = ReturnType<typeof useLocation<LocationState>>;
type HistoryPath = Path | LocationDescriptor<LocationState>; type HistoryPath = Path | LocationDescriptor<LocationState>;

View File

@@ -10,7 +10,7 @@ import { connect } from 'react-redux';
import { supportsPassiveEvents } from 'detect-passive-events'; import { supportsPassiveEvents } from 'detect-passive-events';
import { throttle } from 'lodash'; import { throttle } from 'lodash';
import ScrollContainer from 'flavours/glitch/containers/scroll_container'; import { ScrollContainer } from 'flavours/glitch/containers/scroll_container';
import IntersectionObserverArticleContainer from '../containers/intersection_observer_article_container'; import IntersectionObserverArticleContainer from '../containers/intersection_observer_article_container';
import { attachFullscreenListener, detachFullscreenListener, isFullscreen } from '../features/ui/util/fullscreen'; import { attachFullscreenListener, detachFullscreenListener, isFullscreen } from '../features/ui/util/fullscreen';

View File

@@ -5,7 +5,6 @@ import { Route } from 'react-router-dom';
import { Provider as ReduxProvider } from 'react-redux'; import { Provider as ReduxProvider } from 'react-redux';
import { ScrollContext } from 'react-router-scroll-4';
import { fetchCustomEmojis } from 'flavours/glitch/actions/custom_emojis'; import { fetchCustomEmojis } from 'flavours/glitch/actions/custom_emojis';
import { checkDeprecatedLocalSettings } from 'flavours/glitch/actions/local_settings'; import { checkDeprecatedLocalSettings } from 'flavours/glitch/actions/local_settings';
@@ -21,6 +20,8 @@ import { store } from 'flavours/glitch/store';
import { isProduction } from 'flavours/glitch/utils/environment'; import { isProduction } from 'flavours/glitch/utils/environment';
import { BodyScrollLock } from 'flavours/glitch/features/ui/components/body_scroll_lock'; import { BodyScrollLock } from 'flavours/glitch/features/ui/components/body_scroll_lock';
import { ScrollContext } from './scroll_container/scroll_context';
const title = isProduction() ? siteTitle : `${siteTitle} (Dev)`; const title = isProduction() ? siteTitle : `${siteTitle} (Dev)`;
const hydrateAction = hydrateStore(initialState); const hydrateAction = hydrateStore(initialState);
@@ -50,10 +51,6 @@ export default class Mastodon extends PureComponent {
} }
} }
shouldUpdateScroll (prevRouterProps, { location }) {
return !(location.state?.mastodonModalKey && location.state?.mastodonModalKey !== prevRouterProps?.location?.state?.mastodonModalKey);
}
render () { render () {
return ( return (
<IdentityContext.Provider value={this.identity}> <IdentityContext.Provider value={this.identity}>
@@ -61,7 +58,7 @@ export default class Mastodon extends PureComponent {
<ReduxProvider store={store}> <ReduxProvider store={store}>
<ErrorBoundary> <ErrorBoundary>
<Router> <Router>
<ScrollContext shouldUpdateScroll={this.shouldUpdateScroll}> <ScrollContext>
<Route path='/' component={UI} /> <Route path='/' component={UI} />
</ScrollContext> </ScrollContext>
<BodyScrollLock /> <BodyScrollLock />

View File

@@ -1,18 +0,0 @@
import { ScrollContainer as OriginalScrollContainer } from 'react-router-scroll-4';
// ScrollContainer is used to automatically scroll to the top when pushing a
// new history state and remembering the scroll position when going back.
// There are a few things we need to do differently, though.
const defaultShouldUpdateScroll = (prevRouterProps, { location }) => {
// If the change is caused by opening a modal, do not scroll to top
return !(location.state?.mastodonModalKey && location.state?.mastodonModalKey !== prevRouterProps?.location?.state?.mastodonModalKey);
};
export default
class ScrollContainer extends OriginalScrollContainer {
static defaultProps = {
shouldUpdateScroll: defaultShouldUpdateScroll,
};
}

View File

@@ -0,0 +1,25 @@
import type { MastodonLocation } from 'flavours/glitch/components/router';
export type ShouldUpdateScrollFn = (
prevLocationContext: MastodonLocation | null,
locationContext: MastodonLocation,
) => boolean;
/**
* ScrollBehavior will automatically scroll to the top on navigations
* or restore saved scroll positions, but on some location changes we
* need to prevent this.
*/
export const defaultShouldUpdateScroll: ShouldUpdateScrollFn = (
prevLocation,
location,
) => {
// If the change is caused by opening a modal, do not scroll to top
const shouldUpdateScroll = !(
location.state?.mastodonModalKey &&
location.state.mastodonModalKey !== prevLocation?.state?.mastodonModalKey
);
return shouldUpdateScroll;
};

View File

@@ -0,0 +1,62 @@
import React, { useContext, useEffect, useRef } from 'react';
import { defaultShouldUpdateScroll } from './default_should_update_scroll';
import type { ShouldUpdateScrollFn } from './default_should_update_scroll';
import { ScrollBehaviorContext } from './scroll_context';
interface ScrollContainerProps {
/**
* This key must be static for the element & not change
* while the component is mounted.
*/
scrollKey: string;
shouldUpdateScroll?: ShouldUpdateScrollFn;
children: React.ReactElement;
}
/**
* `ScrollContainer` is used to manage the scroll position of elements on the page
* that can be scrolled independently of the page body.
* This component is a port of the unmaintained https://github.com/ytase/react-router-scroll/
*/
export const ScrollContainer: React.FC<ScrollContainerProps> = ({
children,
scrollKey,
shouldUpdateScroll = defaultShouldUpdateScroll,
}) => {
const scrollBehaviorContext = useContext(ScrollBehaviorContext);
const containerRef = useRef<HTMLElement>();
/**
* Register/unregister scrollable element with ScrollBehavior
*/
useEffect(() => {
if (!scrollBehaviorContext || !containerRef.current) {
return;
}
scrollBehaviorContext.registerElement(
scrollKey,
containerRef.current,
(prevLocation, location) => {
// Hack to allow accessing scrollBehavior._stateStorage
return shouldUpdateScroll.call(
scrollBehaviorContext.scrollBehavior,
prevLocation,
location,
);
},
);
return () => {
scrollBehaviorContext.unregisterElement(scrollKey);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return React.Children.only(
React.cloneElement(children, { ref: containerRef }),
);
};

View File

@@ -0,0 +1,141 @@
import React, { useEffect, useMemo, useRef, useState } from 'react';
import { useLocation, useHistory } from 'react-router-dom';
import type { LocationBase } from 'scroll-behavior';
import ScrollBehavior from 'scroll-behavior';
import type {
LocationState,
MastodonLocation,
} from 'flavours/glitch/components/router';
import { usePrevious } from 'flavours/glitch/hooks/usePrevious';
import { defaultShouldUpdateScroll } from './default_should_update_scroll';
import type { ShouldUpdateScrollFn } from './default_should_update_scroll';
import { SessionStorage } from './state_storage';
type ScrollBehaviorInstance = InstanceType<
typeof ScrollBehavior<LocationBase, MastodonLocation>
>;
export interface ScrollBehaviorContextType {
registerElement: (
key: string,
element: HTMLElement,
shouldUpdateScroll: (
prevLocationContext: MastodonLocation | null,
locationContext: MastodonLocation,
) => boolean,
) => void;
unregisterElement: (key: string) => void;
scrollBehavior?: ScrollBehaviorInstance;
}
export const ScrollBehaviorContext =
React.createContext<ScrollBehaviorContextType | null>(null);
interface ScrollContextProps {
shouldUpdateScroll?: ShouldUpdateScrollFn;
children: React.ReactElement;
}
/**
* A top-level wrapper that provides the app with an instance of the
* ScrollBehavior object. scroll-behavior is a library for managing the
* scroll position of a single-page app in the same way the browser would
* normally do for a multi-page app. This means it'll scroll back to top
* when navigating to a new page, and will restore the scroll position
* when navigating e.g. using `history.back`.
* The library keeps a record of scroll positions in session storage.
*
* This component is a port of the unmaintained https://github.com/ytase/react-router-scroll/
*/
export const ScrollContext: React.FC<ScrollContextProps> = ({
children,
shouldUpdateScroll = defaultShouldUpdateScroll,
}) => {
const location = useLocation<LocationState>();
const history = useHistory<LocationState>();
/**
* Keep the current location in a mutable ref so that ScrollBehavior's
* `getCurrentLocation` can access it without having to recreate the
* whole ScrollBehavior object
*/
const currentLocationRef = useRef(location);
useEffect(() => {
currentLocationRef.current = location;
}, [location]);
/**
* Initialise ScrollBehavior object once using state rather
* than a ref to simplify the types and ensure it's defined immediately.
*/
const [scrollBehavior] = useState(
(): ScrollBehaviorInstance =>
new ScrollBehavior({
addNavigationListener: history.listen.bind(history),
stateStorage: new SessionStorage(),
getCurrentLocation: () =>
currentLocationRef.current as unknown as LocationBase,
shouldUpdateScroll: (
prevLocationContext: MastodonLocation | null,
locationContext: MastodonLocation,
) =>
// Hack to allow accessing scrollBehavior._stateStorage
shouldUpdateScroll.call(
scrollBehavior,
prevLocationContext,
locationContext,
),
}),
);
/**
* Handle scroll update when location changes
*/
const prevLocation = usePrevious(location) ?? null;
useEffect(() => {
scrollBehavior.updateScroll(prevLocation, location);
}, [location, prevLocation, scrollBehavior]);
/**
* Stop Scrollbehavior on unmount
*/
useEffect(() => {
return () => {
scrollBehavior.stop();
};
}, [scrollBehavior]);
/**
* Provide the app with a way to register separately scrollable
* elements to also be tracked by ScrollBehavior. (By default
* ScrollBehavior only handles scrolling on the main document body.)
*/
const contextValue = useMemo<ScrollBehaviorContextType>(
() => ({
registerElement: (key, element, shouldUpdateScroll) => {
scrollBehavior.registerElement(
key,
element,
shouldUpdateScroll,
location,
);
},
unregisterElement: (key) => {
scrollBehavior.unregisterElement(key);
},
scrollBehavior,
}),
[location, scrollBehavior],
);
return (
<ScrollBehaviorContext.Provider value={contextValue}>
{React.Children.only(children)}
</ScrollBehaviorContext.Provider>
);
};

View File

@@ -0,0 +1,46 @@
import type { LocationBase, ScrollPosition } from 'scroll-behavior';
const STATE_KEY_PREFIX = '@@scroll|';
interface LocationBaseWithKey extends LocationBase {
key?: string;
}
/**
* This module is part of our port of https://github.com/ytase/react-router-scroll/
* and handles storing scroll positions in SessionStorage.
* Stored positions (`[x, y]`) are keyed by the location key and an optional
* `scrollKey` that's used for to track separately scrollable elements other
* than the document body.
*/
export class SessionStorage {
read(
location: LocationBaseWithKey,
key: string | null,
): ScrollPosition | null {
const stateKey = this.getStateKey(location, key);
try {
const value = sessionStorage.getItem(stateKey);
return value ? (JSON.parse(value) as ScrollPosition) : null;
} catch {
return null;
}
}
save(location: LocationBaseWithKey, key: string | null, value: unknown) {
const stateKey = this.getStateKey(location, key);
const storedValue = JSON.stringify(value);
try {
sessionStorage.setItem(stateKey, storedValue);
} catch {}
}
getStateKey(location: LocationBaseWithKey, key: string | null) {
const locationKey = location.key;
const stateKeyBase = `${STATE_KEY_PREFIX}${locationKey}`;
return key == null ? stateKeyBase : `${stateKeyBase}|${key}`;
}
}

View File

@@ -24,7 +24,7 @@ import { ColumnHeader } from 'flavours/glitch/components/column_header';
import { LoadMore } from 'flavours/glitch/components/load_more'; import { LoadMore } from 'flavours/glitch/components/load_more';
import { LoadingIndicator } from 'flavours/glitch/components/loading_indicator'; import { LoadingIndicator } from 'flavours/glitch/components/loading_indicator';
import { RadioButton } from 'flavours/glitch/components/radio_button'; import { RadioButton } from 'flavours/glitch/components/radio_button';
import ScrollContainer from 'flavours/glitch/containers/scroll_container'; import { ScrollContainer } from 'flavours/glitch/containers/scroll_container';
import { useSearchParam } from 'flavours/glitch/hooks/useSearchParam'; import { useSearchParam } from 'flavours/glitch/hooks/useSearchParam';
import { useAppDispatch, useAppSelector } from 'flavours/glitch/store'; import { useAppDispatch, useAppSelector } from 'flavours/glitch/store';
@@ -209,7 +209,6 @@ export const Directory: React.FC<{
/> />
{multiColumn && !pinned ? ( {multiColumn && !pinned ? (
// @ts-expect-error ScrollContainer is not properly typed yet
<ScrollContainer scrollKey='directory'> <ScrollContainer scrollKey='directory'>
{scrollableArea} {scrollableArea}
</ScrollContainer> </ScrollContainer>

View File

@@ -17,7 +17,7 @@ import VisibilityOffIcon from '@/material-icons/400-24px/visibility_off.svg?reac
import { Hotkeys } from 'flavours/glitch/components/hotkeys'; import { Hotkeys } from 'flavours/glitch/components/hotkeys';
import { Icon } from 'flavours/glitch/components/icon'; import { Icon } from 'flavours/glitch/components/icon';
import { LoadingIndicator } from 'flavours/glitch/components/loading_indicator'; import { LoadingIndicator } from 'flavours/glitch/components/loading_indicator';
import ScrollContainer from 'flavours/glitch/containers/scroll_container'; import { ScrollContainer } from 'flavours/glitch/containers/scroll_container';
import BundleColumnError from 'flavours/glitch/features/ui/components/bundle_column_error'; import BundleColumnError from 'flavours/glitch/features/ui/components/bundle_column_error';
import { identityContextPropShape, withIdentity } from 'flavours/glitch/identity_context'; import { identityContextPropShape, withIdentity } from 'flavours/glitch/identity_context';
import { autoUnfoldCW } from 'flavours/glitch/utils/content_warning'; import { autoUnfoldCW } from 'flavours/glitch/utils/content_warning';
@@ -551,9 +551,9 @@ class Status extends ImmutablePureComponent {
this.setState({ fullscreen: isFullscreen() }); this.setState({ fullscreen: isFullscreen() });
}; };
shouldUpdateScroll = (prevRouterProps, { location }) => { shouldUpdateScroll = (prevLocation, location) => {
// Do not change scroll when opening a modal // Do not change scroll when opening a modal
if (location.state?.mastodonModalKey !== prevRouterProps?.location?.state?.mastodonModalKey) { if (location.state?.mastodonModalKey !== prevLocation?.state?.mastodonModalKey) {
return false; return false;
} }