Skip to content

Commit

Permalink
fix: resolve blip of UI before notices redirect, refactor NProgress l…
Browse files Browse the repository at this point in the history
…oader (#993)
  • Loading branch information
adamstankiewicz authored Mar 6, 2024
1 parent 0c905a5 commit bc2a7a0
Show file tree
Hide file tree
Showing 13 changed files with 374 additions and 96 deletions.
6 changes: 5 additions & 1 deletion src/components/app/App.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
// import extractNamedExport from '../../utils/extract-named-export';

import createAppRouter from './data/createAppRouter';
import { RouterFallback } from './routes';

/* eslint-disable max-len */
// const EnterpriseAppPageRoutes = lazy(() => import(/* webpackChunkName: "enterprise-app-routes" */ './EnterpriseAppPageRoutes'));
Expand Down Expand Up @@ -44,7 +45,10 @@ const App = () => (
<QueryClientProvider client={queryClient}>
<ReactQueryDevtools initialIsOpen={false} />
<AppProvider wrapWithRouter={false}>
<RouterProvider router={router} />
<RouterProvider
router={router}
fallbackElement={<RouterFallback />}
/>
{/* page routes for the app
<Suspense fallback={(
<DelayedFallbackContainer className="py-5 d-flex justify-content-center align-items-center" />
Expand Down
33 changes: 9 additions & 24 deletions src/components/app/Root.jsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,7 @@
import {
Outlet, ScrollRestoration, useFetchers, useNavigation, useParams,
Outlet, ScrollRestoration, useParams,
} from 'react-router-dom';
import {
Suspense, useContext, useEffect,
} from 'react';
import NProgress from 'accessible-nprogress';
import { Suspense, useContext } from 'react';
import { AppContext } from '@edx/frontend-platform/react';
import { getConfig } from '@edx/frontend-platform';
import { getLoginRedirectUrl } from '@edx/frontend-platform/auth';
Expand All @@ -13,31 +10,15 @@ import { Hyperlink } from '@openedx/paragon';
import DelayedFallbackContainer from '../DelayedFallback/DelayedFallbackContainer';
import { Toasts, ToastsProvider } from '../Toasts';
import { ErrorPage } from '../error-page';

// Determines amount of time that must elapse before the
// NProgress loader is shown in the UI. No need to show it
// for quick route transitions.
export const NPROGRESS_DELAY_MS = 300;
import { useNProgressLoader } from './data';

const Root = () => {
const { authenticatedUser } = useContext(AppContext);
const navigation = useNavigation();
const fetchers = useFetchers();
const { enterpriseSlug } = useParams();

useEffect(() => {
const timeoutId = setTimeout(() => {
const fetchersIdle = fetchers.every((f) => f.state === 'idle');
if (navigation.state === 'idle' && fetchersIdle) {
NProgress.done();
} else {
NProgress.start();
}
}, NPROGRESS_DELAY_MS);
return () => clearTimeout(timeoutId);
}, [navigation, fetchers]);
const isAppDataHydrated = useNProgressLoader();

// in the special case where there is not authenticated user and we are being told it's the logout
// In the special case where there is not authenticated user and we are being told it's the logout
// flow, we can show the logout message safely.
// not rendering the SiteFooter here since it looks like it requires additional setup
// not available in the logged out state (errors with InjectIntl errors)
Expand All @@ -56,6 +37,10 @@ const Root = () => {
);
}

if (!isAppDataHydrated) {
return null;
}

// User is authenticated, so render the child routes (rest of the app).
return (
<>
Expand Down
41 changes: 24 additions & 17 deletions src/components/app/Root.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,12 @@ import '@testing-library/jest-dom/extend-expect';
import { QueryClientProvider } from '@tanstack/react-query';
import Root from './Root';
import { queryClient, renderWithRouterProvider } from '../../utils/tests';
import { useNProgressLoader } from './data';

jest.mock('./data', () => ({
...jest.requireActual('./data'),
useNProgressLoader: jest.fn().mockReturnValue(true),
}));

jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
Expand All @@ -32,15 +38,12 @@ const unauthenticatedAppContextValue = {
};

const RootWrapper = ({
children,
appContextValue = defaultAppContextValue,
}) => (
<IntlProvider locale="en">
<QueryClientProvider client={queryClient()}>
<AppContext.Provider value={appContextValue}>
<Root>
{children}
</Root>
<Root />
</AppContext.Provider>
</QueryClientProvider>
</IntlProvider>
Expand All @@ -55,9 +58,7 @@ describe('Root tests', () => {
renderWithRouterProvider({
path: '/:enterpriseSlug',
element: (
<RootWrapper appContextValue={unauthenticatedAppContextValue}>
<div data-testid="hidden-children" />
</RootWrapper>
<RootWrapper appContextValue={unauthenticatedAppContextValue} />
),
}, {
initialEntries: ['/test-enterprise?logout=true'],
Expand All @@ -66,20 +67,26 @@ describe('Root tests', () => {
expect(screen.queryByTestId('hidden-children')).not.toBeInTheDocument();
});

test('page renders child routes', () => {
test.each([
{ isAppDataHydrated: true },
{ isAppDataHydrated: false },
])('page renders child routes when app is ready', ({ isAppDataHydrated }) => {
useNProgressLoader.mockReturnValue(isAppDataHydrated);
renderWithRouterProvider({
path: '/:enterpriseSlug',
element: (
<RootWrapper>
<div data-testid="hidden-children" />
</RootWrapper>
),
element: <RootWrapper />,
}, {
initialEntries: ['/test-enterprise?logout=true'],
initialEntries: ['/test-enterprise'],
});

expect(screen.queryByText('You are now logged out.')).not.toBeInTheDocument();
expect(screen.queryByTestId('hidden-children')).not.toBeInTheDocument(); // Root does not render children; it renders child routes via Outlet
expect(screen.getByTestId('scroll-restoration')).toBeInTheDocument();
expect(screen.getByTestId('outlet')).toBeInTheDocument();

if (isAppDataHydrated) {
expect(screen.getByTestId('scroll-restoration')).toBeInTheDocument();
expect(screen.getByTestId('outlet')).toBeInTheDocument();
} else {
expect(screen.queryByTestId('scroll-restoration')).not.toBeInTheDocument();
expect(screen.queryByTestId('outlet')).not.toBeInTheDocument();
}
});
});
2 changes: 2 additions & 0 deletions src/components/app/data/hooks/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,5 @@ export { default as useUserEntitlements } from './useUserEntitlements';
export { default as useRecommendCoursesForMe } from './useRecommendCoursesForMe';
export { default as useBrowseAndRequestConfiguration } from './useBrowseAndRequestConfiguration';
export { default as useIsAssignmentsOnlyLearner } from './useIsAssignmentsOnlyLearner';
export { default as useNProgressLoader } from './useNProgressLoader';
export { default as useNotices } from './useNotices';
40 changes: 40 additions & 0 deletions src/components/app/data/hooks/useNProgressLoader.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { useContext, useEffect } from 'react';
import { useFetchers, useNavigation } from 'react-router-dom';
import nprogress from 'accessible-nprogress';
import { AppContext } from '@edx/frontend-platform/react';
import useNotices from './useNotices';

// Determines amount of time that must elapse before the
// NProgress loader is shown in the UI. No need to show it
// for quick route transitions.
export const NPROGRESS_DELAY_MS = 300;

function useNProgressLoader() {
const { authenticatedUser } = useContext(AppContext);
const isAuthenticatedUserHydrated = !!authenticatedUser?.profileImage;
const navigation = useNavigation();
const fetchers = useFetchers();
const {
data: noticeRedirectUrl,
isLoading: isLoadingNotices,
} = useNotices();

const hasNoticeRedirectUrl = !isLoadingNotices && !!noticeRedirectUrl;
const isAppDataHydrated = isAuthenticatedUserHydrated && !hasNoticeRedirectUrl;

useEffect(() => {
const timeoutId = setTimeout(() => {
const fetchersIdle = fetchers.every((f) => f.state === 'idle');
if (navigation.state === 'idle' && fetchersIdle && isAppDataHydrated) {
nprogress.done();
} else {
nprogress.start();
}
}, NPROGRESS_DELAY_MS);
return () => clearTimeout(timeoutId);
}, [navigation, fetchers, isAppDataHydrated]);

return isAppDataHydrated;
}

export default useNProgressLoader;
139 changes: 139 additions & 0 deletions src/components/app/data/hooks/useNProgressLoader.test.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
import { renderHook } from '@testing-library/react-hooks';
import { AppContext } from '@edx/frontend-platform/react';
import nprogress from 'accessible-nprogress';
import { useFetchers, useNavigation } from 'react-router-dom';
import { waitFor } from '@testing-library/react';

import useNProgressLoader from './useNProgressLoader';
import useNotices from './useNotices';

jest.mock('./useNotices');

jest.mock('accessible-nprogress', () => ({
start: jest.fn(),
done: jest.fn(),
}));

jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useNavigation: jest.fn(),
useFetchers: jest.fn(),
}));

const defaultAppContextValue = {
authenticatedUser: {
userId: 3,
},
};

const appContextValueWithHydratedUser = {
authenticatedUser: {
userId: 3,
profileImage: 'profileImage',
},
};

describe('useNProgressLoader', () => {
beforeEach(() => {
jest.clearAllMocks();
useNotices.mockReturnValue({
data: null,
isLoading: false,
});
useNavigation.mockReturnValue({ state: 'idle' });
useFetchers.mockReturnValue([]);
});

it('should start nprogress, but not call done with unhydrated authenticated user', async () => {
const Wrapper = ({ children }) => (
<AppContext.Provider value={defaultAppContextValue}>
{children}
</AppContext.Provider>
);
const { result } = renderHook(() => useNProgressLoader(), { wrapper: Wrapper });

await waitFor(() => {
expect(nprogress.start).toHaveBeenCalledTimes(1);
});
await waitFor(() => {
expect(nprogress.done).not.toHaveBeenCalled();
});

expect(result.current).toBe(false);
});

it('should start nprogress, but not call done with notice redirect url', async () => {
useNotices.mockReturnValue({ data: 'http://example.com', isLoading: false });
const Wrapper = ({ children }) => (
<AppContext.Provider value={defaultAppContextValue}>
{children}
</AppContext.Provider>
);
const { result } = renderHook(() => useNProgressLoader(), { wrapper: Wrapper });

await waitFor(() => {
expect(nprogress.start).toHaveBeenCalledTimes(1);
});
await waitFor(() => {
expect(nprogress.done).not.toHaveBeenCalled();
});

expect(result.current).toBe(false);
});

it('should start nprogress, but not call done with loading navigation state', async () => {
useNavigation.mockReturnValue({ state: 'loading' });
const Wrapper = ({ children }) => (
<AppContext.Provider value={appContextValueWithHydratedUser}>
{children}
</AppContext.Provider>
);
const { result } = renderHook(() => useNProgressLoader(), { wrapper: Wrapper });

await waitFor(() => {
expect(nprogress.start).toHaveBeenCalledTimes(1);
});
await waitFor(() => {
expect(nprogress.done).not.toHaveBeenCalled();
});

expect(result.current).toBe(true);
});

it('should start nprogress, but not call done with non-idle fetchers', async () => {
useFetchers.mockReturnValue([{ state: 'loading' }]);
const Wrapper = ({ children }) => (
<AppContext.Provider value={appContextValueWithHydratedUser}>
{children}
</AppContext.Provider>
);
const { result } = renderHook(() => useNProgressLoader(), { wrapper: Wrapper });

await waitFor(() => {
expect(nprogress.start).toHaveBeenCalledTimes(1);
});
await waitFor(() => {
expect(nprogress.done).not.toHaveBeenCalled();
});

expect(result.current).toBe(true);
});

it('should call nprogress done with hydrated user and no notices', async () => {
const Wrapper = ({ children }) => (
<AppContext.Provider value={appContextValueWithHydratedUser}>
{children}
</AppContext.Provider>
);
const { result } = renderHook(() => useNProgressLoader(), { wrapper: Wrapper });

await waitFor(() => {
expect(nprogress.done).toHaveBeenCalledTimes(1);
});
await waitFor(() => {
expect(nprogress.start).not.toHaveBeenCalled();
});

expect(result.current).toBe(true);
});
});
23 changes: 23 additions & 0 deletions src/components/app/data/hooks/useNotices.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { useEffect } from 'react';
import { useQuery } from '@tanstack/react-query';

import { queryNotices } from '../../routes/data';

/**
* Responsible for returning the redirect URL for any notice(s) present
* for the authenticated user.
*/
function useNotices() {
const queryResults = useQuery(queryNotices());

useEffect(() => {
if (!queryResults.data) {
return;
}
window.location.assign(queryResults.data);
}, [queryResults.data]);

return queryResults;
}

export default useNotices;
Loading

0 comments on commit bc2a7a0

Please sign in to comment.