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

Implement <ScrollRestoration /> #5086

Merged
merged 12 commits into from
May 17, 2024
99 changes: 83 additions & 16 deletions packages/twenty-front/src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,39 @@
import { Route, Routes, useLocation } from 'react-router-dom';
import { StrictMode } from 'react';
import {
createBrowserRouter,
createRoutesFromElements,
Outlet,
redirect,
Route,
RouterProvider,
Routes,
useLocation,
} from 'react-router-dom';
import { useRecoilValue } from 'recoil';

import { ApolloProvider } from '@/apollo/components/ApolloProvider';
import { VerifyEffect } from '@/auth/components/VerifyEffect';
import { ClientConfigProvider } from '@/client-config/components/ClientConfigProvider';
import { ClientConfigProviderEffect } from '@/client-config/components/ClientConfigProviderEffect';
import { billingState } from '@/client-config/states/billingState';
import { PromiseRejectionEffect } from '@/error-handler/components/PromiseRejectionEffect';
import { ApolloMetadataClientProvider } from '@/object-metadata/components/ApolloMetadataClientProvider';
import { ObjectMetadataItemsProvider } from '@/object-metadata/components/ObjectMetadataItemsProvider';
import { PrefetchDataProvider } from '@/prefetch/components/PrefetchDataProvider';
import { AppPath } from '@/types/AppPath';
import { SettingsPath } from '@/types/SettingsPath';
import { DialogManager } from '@/ui/feedback/dialog-manager/components/DialogManager';
import { DialogManagerScope } from '@/ui/feedback/dialog-manager/scopes/DialogManagerScope';
import { SnackBarProvider } from '@/ui/feedback/snack-bar-manager/components/SnackBarProvider';
import { BlankLayout } from '@/ui/layout/page/BlankLayout';
import { DefaultLayout } from '@/ui/layout/page/DefaultLayout';
import { AppThemeProvider } from '@/ui/theme/components/AppThemeProvider';
import { PageTitle } from '@/ui/utilities/page-title/PageTitle';
import { UserProvider } from '@/users/components/UserProvider';
import { UserProviderEffect } from '@/users/components/UserProviderEffect';
import { CommandMenuEffect } from '~/effect-components/CommandMenuEffect';
import { GotoHotkeysEffect } from '~/effect-components/GotoHotkeysEffect';
import { PageChangeEffect } from '~/effect-components/PageChangeEffect';
import { Authorize } from '~/pages/auth/Authorize';
import { ChooseYourPlan } from '~/pages/auth/ChooseYourPlan';
import { CreateProfile } from '~/pages/auth/CreateProfile';
Expand Down Expand Up @@ -54,17 +78,53 @@ import { SettingsWorkspaceMembers } from '~/pages/settings/SettingsWorkspaceMemb
import { Tasks } from '~/pages/tasks/Tasks';
import { getPageTitleFromPath } from '~/utils/title-utils';

export const App = () => {
const billing = useRecoilValue(billingState);
const ProvidersThatNeedRouterContext = () => {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Need react-router context, put under pathless route with Outlet

const { pathname } = useLocation();
const pageTitle = getPageTitleFromPath(pathname);
Comment on lines 82 to 83
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Needs react-router context, put under pathless route


return (
<>
<PageTitle title={pageTitle} />
<GotoHotkeysEffect />
<CommandMenuEffect />
<Routes>
<ApolloProvider>
<ClientConfigProviderEffect />
<ClientConfigProvider>
<UserProviderEffect />
<UserProvider>
<ApolloMetadataClientProvider>
<ObjectMetadataItemsProvider>
<PrefetchDataProvider>
<AppThemeProvider>
<SnackBarProvider>
<DialogManagerScope dialogManagerScopeId="dialog-manager">
<DialogManager>
<StrictMode>
<PromiseRejectionEffect />
<CommandMenuEffect />
<GotoHotkeysEffect />
<PageTitle title={pageTitle} />
<Outlet />
</StrictMode>
</DialogManager>
</DialogManagerScope>
</SnackBarProvider>
</AppThemeProvider>
</PrefetchDataProvider>
<PageChangeEffect />
</ObjectMetadataItemsProvider>
</ApolloMetadataClientProvider>
</UserProvider>
</ClientConfigProvider>
</ApolloProvider>
);
};

const createRouter = (isBillingEnabled?: boolean) =>
createBrowserRouter(
createRoutesFromElements(
<Route
element={<ProvidersThatNeedRouterContext />}
// To switch state to `loading` temporarily to enable us
// to set scroll position before the page is rendered
loader={async () => Promise.resolve(null)}
>
<Route element={<DefaultLayout />}>
<Route path={AppPath.Verify} element={<VerifyEffect />} />
<Route path={AppPath.SignInUp} element={<SignInUp />} />
Expand Down Expand Up @@ -119,12 +179,14 @@ export const App = () => {
path={SettingsPath.AccountsEmailsInboxSettings}
element={<SettingsAccountsEmailsInboxSettings />}
/>
{billing?.isBillingEnabled && (
<Route
path={SettingsPath.Billing}
element={<SettingsBilling />}
/>
)}
<Route
path={SettingsPath.Billing}
element={<SettingsBilling />}
loader={() => {
if (!isBillingEnabled) return redirect(AppPath.Index);
return null;
}}
/>
<Route
path={SettingsPath.WorkspaceMembersPage}
element={<SettingsWorkspaceMembers />}
Expand Down Expand Up @@ -217,7 +279,12 @@ export const App = () => {
<Route element={<BlankLayout />}>
<Route path={AppPath.Authorize} element={<Authorize />} />
</Route>
</Routes>
</>
</Route>,
),
);

export const App = () => {
const billing = useRecoilValue(billingState);

return <RouterProvider router={createRouter(billing?.isBillingEnabled)} />;
};
34 changes: 34 additions & 0 deletions packages/twenty-front/src/hooks/useScrollRestoration.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { useEffect } from 'react';
import { useLocation, useNavigation } from 'react-router-dom';
import { useRecoilState, useRecoilValue } from 'recoil';

import { overlayScrollbarsState } from '@/ui/utilities/scroll/states/overlayScrollbarsState';
import { scrollPositionState } from '@/ui/utilities/scroll/states/scrollPositionState';
import { isDefined } from '~/utils/isDefined';

/**
* Note that `location.key` is used in the cache key, not `location.pathname`,
* so the same path navigated to at different points in the history stack will
* not share the same scroll position.
*/
export const useScrollRestoration = (viewportHeight?: number) => {
const key = `scroll-position-${useLocation().key}`;
const { state } = useNavigation();

const [scrollPosition, setScrollPosition] = useRecoilState(
scrollPositionState(key),
);

const overlayScrollbars = useRecoilValue(overlayScrollbarsState);

const scrollWrapper = overlayScrollbars?.elements().viewport;
const skip = isDefined(viewportHeight) && scrollPosition > viewportHeight;

useEffect(() => {
if (state === 'loading') {
setScrollPosition(scrollWrapper?.scrollTop ?? 0);
} else if (state === 'idle' && isDefined(scrollWrapper) && !skip) {
scrollWrapper.scrollTo({ top: scrollPosition });
}
}, [key, state, scrollWrapper, skip, scrollPosition, setScrollPosition]);
};
62 changes: 9 additions & 53 deletions packages/twenty-front/src/index.tsx
Original file line number Diff line number Diff line change
@@ -1,30 +1,14 @@
import { StrictMode } from 'react';
import ReactDOM from 'react-dom/client';
import { HelmetProvider } from 'react-helmet-async';
import { BrowserRouter } from 'react-router-dom';
import { RecoilRoot } from 'recoil';
import { IconsProvider } from 'twenty-ui';

import { ApolloProvider } from '@/apollo/components/ApolloProvider';
import { CaptchaProvider } from '@/captcha/components/CaptchaProvider';
import { ClientConfigProvider } from '@/client-config/components/ClientConfigProvider';
import { ClientConfigProviderEffect } from '@/client-config/components/ClientConfigProviderEffect';
import { ApolloDevLogEffect } from '@/debug/components/ApolloDevLogEffect';
import { RecoilDebugObserverEffect } from '@/debug/components/RecoilDebugObserver';
import { AppErrorBoundary } from '@/error-handler/components/AppErrorBoundary';
import { ExceptionHandlerProvider } from '@/error-handler/components/ExceptionHandlerProvider';
import { PromiseRejectionEffect } from '@/error-handler/components/PromiseRejectionEffect';
import { ApolloMetadataClientProvider } from '@/object-metadata/components/ApolloMetadataClientProvider';
import { ObjectMetadataItemsProvider } from '@/object-metadata/components/ObjectMetadataItemsProvider';
import { PrefetchDataProvider } from '@/prefetch/components/PrefetchDataProvider';
import { DialogManager } from '@/ui/feedback/dialog-manager/components/DialogManager';
import { DialogManagerScope } from '@/ui/feedback/dialog-manager/scopes/DialogManagerScope';
import { SnackBarProvider } from '@/ui/feedback/snack-bar-manager/components/SnackBarProvider';
import { SnackBarProviderScope } from '@/ui/feedback/snack-bar-manager/scopes/SnackBarProviderScope';
import { AppThemeProvider } from '@/ui/theme/components/AppThemeProvider';
import { UserProvider } from '@/users/components/UserProvider';
import { UserProviderEffect } from '@/users/components/UserProviderEffect';
import { PageChangeEffect } from '~/effect-components/PageChangeEffect';

import '@emotion/react';

Expand All @@ -43,43 +27,15 @@ root.render(
<CaptchaProvider>
<RecoilDebugObserverEffect />
<ApolloDevLogEffect />
<BrowserRouter>
<SnackBarProviderScope snackBarManagerScopeId="snack-bar-manager">
<IconsProvider>
<ExceptionHandlerProvider>
<ApolloProvider>
<HelmetProvider>
<ClientConfigProviderEffect />
<ClientConfigProvider>
<UserProviderEffect />
<UserProvider>
<ApolloMetadataClientProvider>
<ObjectMetadataItemsProvider>
<PrefetchDataProvider>
<AppThemeProvider>
<SnackBarProvider>
<DialogManagerScope dialogManagerScopeId="dialog-manager">
<DialogManager>
<StrictMode>
<PromiseRejectionEffect />
<App />
</StrictMode>
</DialogManager>
</DialogManagerScope>
</SnackBarProvider>
</AppThemeProvider>
</PrefetchDataProvider>
<PageChangeEffect />
</ObjectMetadataItemsProvider>
</ApolloMetadataClientProvider>
</UserProvider>
</ClientConfigProvider>
</HelmetProvider>
</ApolloProvider>
</ExceptionHandlerProvider>
</IconsProvider>
</SnackBarProviderScope>
</BrowserRouter>
<SnackBarProviderScope snackBarManagerScopeId="snack-bar-manager">
<IconsProvider>
<ExceptionHandlerProvider>
<HelmetProvider>
<App />
</HelmetProvider>
</ExceptionHandlerProvider>
</IconsProvider>
</SnackBarProviderScope>
</CaptchaProvider>
</AppErrorBoundary>
</RecoilRoot>,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys';
import { useListenClickOutsideByClassName } from '@/ui/utilities/pointer-event/hooks/useListenClickOutside';
import { getScopeIdFromComponentId } from '@/ui/utilities/recoil-scope/utils/getScopeIdFromComponentId';
import { ScrollWrapper } from '@/ui/utilities/scroll/components/ScrollWrapper';
import { useScrollRestoration } from '~/hooks/useScrollRestoration';

export type RecordBoardProps = {
recordBoardId: string;
Expand All @@ -42,6 +43,11 @@ const StyledBoardHeader = styled.div`
z-index: 1;
`;

const RecordBoardScrollRestoreEffect = () => {
useScrollRestoration();
return null;
};

export const RecordBoard = ({ recordBoardId }: RecordBoardProps) => {
const { updateOneRecord, selectFieldMetadataItem } =
useContext(RecordBoardContext);
Expand Down Expand Up @@ -152,6 +158,7 @@ export const RecordBoard = ({ recordBoardId }: RecordBoardProps) => {
))}
</DragDropContext>
</StyledContainer>
<RecordBoardScrollRestoreEffect />
</ScrollWrapper>
<DragSelect
dragSelectable={boardRef}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { useRecoilState, useRecoilValue } from 'recoil';
import { useLoadRecordIndexTable } from '@/object-record/record-index/hooks/useLoadRecordIndexTable';
import { useRecordTableStates } from '@/object-record/record-table/hooks/internal/useRecordTableStates';
import { isFetchingMoreRecordsFamilyState } from '@/object-record/states/isFetchingMoreRecordsFamilyState';
import { useScrollRestoration } from '~/hooks/useScrollRestoration';

type RecordTableBodyEffectProps = {
objectNameSingular: string;
Expand Down Expand Up @@ -31,6 +32,11 @@ export const RecordTableBodyEffect = ({
isFetchingMoreRecordsFamilyState(queryStateIdentifier),
);

const rowHeight = 32;
const viewportHeight = records.length * rowHeight;

useScrollRestoration(viewportHeight);

useEffect(() => {
if (!loading) {
setRecordTableData(records, totalCount);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,9 @@ import { createContext, RefObject, useEffect, useRef } from 'react';
import styled from '@emotion/styled';
import { OverlayScrollbars } from 'overlayscrollbars';
import { useOverlayScrollbars } from 'overlayscrollbars-react';
import { useRecoilCallback } from 'recoil';
import { useRecoilCallback, useSetRecoilState } from 'recoil';

import { overlayScrollbarsState } from '@/ui/utilities/scroll/states/overlayScrollbarsState';
import { scrollLeftState } from '@/ui/utilities/scroll/states/scrollLeftState';
import { scrollTopState } from '@/ui/utilities/scroll/states/scrollTopState';

Expand Down Expand Up @@ -48,7 +49,9 @@ export const ScrollWrapper = ({
[],
);

const [initialize] = useOverlayScrollbars({
const setOverlayScrollbars = useSetRecoilState(overlayScrollbarsState);

const [initialize, instance] = useOverlayScrollbars({
options: {
scrollbars: { autoHide: 'scroll' },
overflow: {
Expand All @@ -67,6 +70,10 @@ export const ScrollWrapper = ({
}
}, [initialize, scrollableRef]);

useEffect(() => {
setOverlayScrollbars(instance());
}, [instance, setOverlayScrollbars]);

return (
<ScrollWrapperContext.Provider value={scrollableRef}>
<StyledScrollWrapper ref={scrollableRef} className={className}>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { OverlayScrollbars } from 'overlayscrollbars';
import { createState } from 'twenty-ui';

export const overlayScrollbarsState = createState<OverlayScrollbars | null>({
key: 'scroll/overlayScrollbarsState',
defaultValue: null,
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { createFamilyState } from '@/ui/utilities/state/utils/createFamilyState';

export const scrollPositionState = createFamilyState({
key: 'scroll/scrollPositionState',
defaultValue: 0,
});