F: Improve lazy loading and hydration

This commit is contained in:
Ivan 2025-01-29 23:18:20 +03:00
parent bb8824d2c3
commit 276b6a6be6
30 changed files with 118 additions and 103 deletions

View File

@ -1,30 +1,27 @@
import React from 'react';
import { createBrowserRouter } from 'react-router';
import { prefetchAuth } from '@/backend/auth/useAuth';
import { prefetchLibrary } from '@/backend/library/useLibrary';
import { prefetchOSS } from '@/backend/oss/useOSS';
import { prefetchRSForm } from '@/backend/rsform/useRSForm';
import { prefetchProfile } from '@/backend/users/useProfile';
import { prefetchUsers } from '@/backend/users/useUsers';
import Loader from '@/components/ui/Loader';
import CreateItemPage from '@/pages/CreateItemPage';
import HomePage from '@/pages/HomePage';
import LibraryPage from '@/pages/LibraryPage';
import LoginPage from '@/pages/LoginPage';
import NotFoundPage from '@/pages/NotFoundPage';
import OssPage from '@/pages/OssPage';
import RSFormPage from '@/pages/RSFormPage';
import ApplicationLayout from './ApplicationLayout';
import { routes } from './urls';
const UserProfilePage = React.lazy(() => import('@/pages/UserProfilePage'));
const RestorePasswordPage = React.lazy(() => import('@/pages/RestorePasswordPage'));
const PasswordChangePage = React.lazy(() => import('@/pages/PasswordChangePage'));
const RegisterPage = React.lazy(() => import('@/pages/RegisterPage'));
const ManualsPage = React.lazy(() => import('@/pages/ManualsPage'));
const IconsPage = React.lazy(() => import('@/pages/IconsPage'));
const DatabaseSchemaPage = React.lazy(() => import('@/pages/DatabaseSchemaPage'));
export const Router = createBrowserRouter([
{
path: '/',
element: <ApplicationLayout />,
errorElement: <NotFoundPage />,
loader: prefetchAuth,
hydrateFallbackElement: <Loader />,
children: [
{
path: '',
@ -40,23 +37,25 @@ export const Router = createBrowserRouter([
},
{
path: routes.signup,
element: <RegisterPage />
lazy: () => import('@/pages/RegisterPage')
},
{
path: routes.profile,
element: <UserProfilePage />
loader: prefetchProfile,
lazy: () => import('@/pages/UserProfilePage')
},
{
path: routes.restore_password,
element: <RestorePasswordPage />
lazy: () => import('@/pages/RestorePasswordPage')
},
{
path: routes.password_change,
element: <PasswordChangePage />
lazy: () => import('@/pages/PasswordChangePage')
},
{
path: routes.library,
element: <LibraryPage />
loader: () => Promise.allSettled([prefetchLibrary(), prefetchUsers()]),
lazy: () => import('@/pages/LibraryPage')
},
{
path: routes.create_schema,
@ -64,24 +63,37 @@ export const Router = createBrowserRouter([
},
{
path: `${routes.rsforms}/:id`,
element: <RSFormPage />
loader: data => prefetchRSForm(parseRSFormURL(data.params.id, data.request.url)),
lazy: () => import('@/pages/RSFormPage')
},
{
path: `${routes.oss}/:id`,
element: <OssPage />
loader: data => prefetchOSS(parseOssURL(data.params.id)),
lazy: () => import('@/pages/OssPage')
},
{
path: routes.manuals,
element: <ManualsPage />
lazy: () => import('@/pages/ManualsPage')
},
{
path: `${routes.icons}`,
element: <IconsPage />
lazy: () => import('@/pages/IconsPage')
},
{
path: `${routes.database_schema}`,
element: <DatabaseSchemaPage />
lazy: () => import('@/pages/DatabaseSchemaPage')
}
]
}
]);
// ======= Internals =========
function parseRSFormURL(id: string | undefined, url: string) {
const params = new URLSearchParams(url.split('?')[1]);
const version = params.get('v');
return { itemID: id ? Number(id) : undefined, version: version ? Number(version) : undefined };
}
function parseOssURL(id: string | undefined) {
return { itemID: id ? Number(id) : undefined };
}

View File

@ -1,5 +1,6 @@
import { useQuery, useSuspenseQuery } from '@tanstack/react-query';
import { queryClient } from '../queryClient';
import { authApi } from './api';
export function useAuth() {
@ -19,3 +20,7 @@ export function useAuthSuspense() {
});
return { user, isAnonymous: user.id === null };
}
export function prefetchAuth() {
return queryClient.prefetchQuery(authApi.getAuthQueryOptions());
}

View File

@ -74,7 +74,7 @@ export const libraryApi = {
},
getLibraryQueryOptions: ({ isAdmin }: { isAdmin: boolean }) =>
queryOptions({
queryKey: libraryApi.libraryListKey,
queryKey: [...libraryApi.libraryListKey, isAdmin ? 'admin' : 'user'],
staleTime: DELAYS.staleMedium,
queryFn: meta =>
axiosGet<ILibraryItem[]>({

View File

@ -1,22 +1,29 @@
import { useQuery, useSuspenseQuery } from '@tanstack/react-query';
import { useAuthSuspense } from '@/backend/auth/useAuth';
import { queryClient } from '@/backend/queryClient';
import { usePreferencesStore } from '@/stores/preferences';
import { libraryApi } from './api';
export function useLibrarySuspense() {
const adminMode = usePreferencesStore(state => state.adminMode);
const { user } = useAuthSuspense();
const { data: items } = useSuspenseQuery({
...libraryApi.getLibraryQueryOptions({ isAdmin: user?.is_staff ?? false })
...libraryApi.getLibraryQueryOptions({ isAdmin: user.is_staff && adminMode })
});
return { items };
}
export function useLibrary() {
// NOTE: Using suspense here to avoid duplicated library data requests
const adminMode = usePreferencesStore(state => state.adminMode);
const { user } = useAuthSuspense();
const { data: items, isLoading } = useQuery({
...libraryApi.getLibraryQueryOptions({ isAdmin: user.is_staff })
...libraryApi.getLibraryQueryOptions({ isAdmin: user.is_staff && adminMode })
});
return { items: items ?? [], isLoading };
}
export function prefetchLibrary() {
return queryClient.prefetchQuery(libraryApi.getLibraryQueryOptions({ isAdmin: false }));
}

View File

@ -1,5 +1,7 @@
import { useQuery, useSuspenseQuery } from '@tanstack/react-query';
import { queryClient } from '@/backend/queryClient';
import { libraryApi } from './api';
export function useTemplatesSuspense() {
@ -15,3 +17,7 @@ export function useTemplates() {
});
return { templates: templates ?? [] };
}
export function prefetchTemplates() {
return queryClient.prefetchQuery(libraryApi.getTemplatesQueryOptions());
}

View File

@ -4,6 +4,7 @@ import { useLibrary, useLibrarySuspense } from '@/backend/library/useLibrary';
import { LibraryItemID } from '@/models/library';
import { OssLoader } from '@/models/OssLoader';
import { queryClient } from '../queryClient';
import { ossApi } from './api';
export function useOss({ itemID }: { itemID?: LibraryItemID }) {
@ -24,3 +25,10 @@ export function useOssSuspense({ itemID }: { itemID: LibraryItemID }) {
const schema = new OssLoader(data!, libraryItems).produceOSS();
return { schema };
}
export function prefetchOSS({ itemID }: { itemID?: LibraryItemID }) {
if (!itemID) {
return null;
}
return queryClient.prefetchQuery(ossApi.getOssQueryOptions({ itemID }));
}

View File

@ -3,6 +3,7 @@ import { useQuery, useSuspenseQuery } from '@tanstack/react-query';
import { LibraryItemID, VersionID } from '@/models/library';
import { RSFormLoader } from '@/models/RSFormLoader';
import { queryClient } from '../queryClient';
import { rsformsApi } from './api';
export function useRSForm({ itemID, version }: { itemID?: LibraryItemID; version?: VersionID }) {
@ -21,3 +22,10 @@ export function useRSFormSuspense({ itemID, version }: { itemID: LibraryItemID;
const schema = new RSFormLoader(data!).produceRSForm();
return { schema };
}
export function prefetchRSForm({ itemID, version }: { itemID?: LibraryItemID; version?: VersionID }) {
if (!itemID) {
return null;
}
return queryClient.prefetchQuery(rsformsApi.getRSFormQueryOptions({ itemID, version }));
}

View File

@ -1,5 +1,7 @@
import { useQuery, useSuspenseQuery } from '@tanstack/react-query';
import { queryClient } from '@/backend/queryClient';
import { usersApi } from './api';
export function useProfile() {
@ -19,3 +21,7 @@ export function useProfileSuspense() {
});
return { profile };
}
export function prefetchProfile() {
return queryClient.prefetchQuery(usersApi.getProfileQueryOptions());
}

View File

@ -1,5 +1,7 @@
import { useQuery, useSuspenseQuery } from '@tanstack/react-query';
import { queryClient } from '@/backend/queryClient';
import { usersApi } from './api';
export function useUsersSuspense() {
@ -15,3 +17,7 @@ export function useUsers() {
});
return { users: users ?? [] };
}
export function prefetchUsers() {
return queryClient.prefetchQuery(usersApi.getUsersQueryOptions());
}

View File

@ -4,11 +4,6 @@ import { createRoot } from 'react-dom/client';
import App from './app';
import GlobalProviders from './app/GlobalProviders';
import { authApi } from './backend/auth/api';
import { queryClient } from './backend/queryClient';
// Prefetch auth data
queryClient.prefetchQuery(authApi.getAuthQueryOptions()).catch(console.error);
createRoot(document.getElementById('root')!).render(
<GlobalProviders>

View File

@ -6,9 +6,8 @@ import { TransformComponent, TransformWrapper } from 'react-zoom-pan-pinch';
import { useAppLayoutStore, useFitHeight } from '@/stores/appLayout';
import { resources } from '@/utils/constants';
function DatabaseSchemaPage() {
export function Component() {
const hideFooter = useAppLayoutStore(state => state.hideFooter);
const panelHeight = useFitHeight('0px');
useEffect(() => {
@ -26,5 +25,3 @@ function DatabaseSchemaPage() {
</div>
);
}
export default DatabaseSchemaPage;

View File

@ -3,7 +3,7 @@
// @ts-nocheck
import * as icons from '@/components/Icons';
export function IconsPage() {
export function Component() {
const iconsList = Object.keys(icons).filter(key => key.startsWith('Icon'));
return (
<div className='flex flex-col items-center px-6 py-3'>
@ -19,5 +19,3 @@ export function IconsPage() {
</div>
);
}
export default IconsPage;

View File

@ -19,7 +19,7 @@ import TableLibraryItems from './TableLibraryItems';
import ToolbarSearch from './ToolbarSearch';
import ViewSideLocation from './ViewSideLocation';
function LibraryPage() {
export function LibraryPage() {
const { items: libraryItems } = useLibrarySuspense();
const { renameLocation } = useRenameLocation();
@ -83,5 +83,3 @@ function LibraryPage() {
</>
);
}
export default LibraryPage;

View File

@ -1 +1 @@
export { default } from './LibraryPage';
export { LibraryPage as Component } from './LibraryPage';

View File

@ -9,7 +9,7 @@ import { urls } from '@/app/urls';
import { useAuthSuspense } from '@/backend/auth/useAuth';
import { useLogin } from '@/backend/auth/useLogin';
import ExpectedAnonymous from '@/components/ExpectedAnonymous';
import InfoError, { ErrorData } from '@/components/info/InfoError';
import { ErrorData } from '@/components/info/InfoError';
import SubmitButton from '@/components/ui/SubmitButton';
import TextInput from '@/components/ui/TextInput';
import TextURL from '@/components/ui/TextURL';
@ -97,7 +97,6 @@ function ProcessError({ error }: { error: ErrorData }): React.ReactElement {
На Портале отсутствует такое сочетание имени пользователя и пароля
</div>
);
} else {
return <InfoError error={error} />;
}
throw error as Error;
}

View File

@ -1,7 +1,5 @@
'use client';
import { useCallback } from 'react';
import { useConceptNavigation } from '@/app/Navigation/NavigationContext';
import { urls } from '@/app/urls';
import useQueryStrings from '@/hooks/useQueryStrings';
@ -12,19 +10,16 @@ import { PARAMETER } from '@/utils/constants';
import TopicsList from './TopicsList';
import ViewTopic from './ViewTopic';
function ManualsPage() {
export function ManualsPage() {
const router = useConceptNavigation();
const query = useQueryStrings();
const activeTopic = (query.get('topic') || HelpTopic.MAIN) as HelpTopic;
const mainHeight = useMainHeight();
const onSelectTopic = useCallback(
(newTopic: HelpTopic) => {
router.push(urls.help_topic(newTopic));
},
[router]
);
function onSelectTopic(newTopic: HelpTopic) {
router.push(urls.help_topic(newTopic));
}
if (!Object.values(HelpTopic).includes(activeTopic)) {
setTimeout(() => {
@ -40,5 +35,3 @@ function ManualsPage() {
</div>
);
}
export default ManualsPage;

View File

@ -1 +1 @@
export { default } from './ManualsPage';
export { ManualsPage as Component } from './ManualsPage';

View File

@ -6,14 +6,14 @@ import { useParams } from 'react-router';
import { useBlockNavigation, useConceptNavigation } from '@/app/Navigation/NavigationContext';
import { urls } from '@/app/urls';
import InfoError, { ErrorData } from '@/components/info/InfoError';
import { ErrorData } from '@/components/info/InfoError';
import TextURL from '@/components/ui/TextURL';
import { useModificationStore } from '@/stores/modification';
import { OssEditState } from './OssEditContext';
import OssTabs from './OssTabs';
function OssPage() {
export function OssPage() {
const router = useConceptNavigation();
const params = useParams();
const itemID = params.id ? Number(params.id) : undefined;
@ -35,8 +35,6 @@ function OssPage() {
);
}
export default OssPage;
// ====== Internals =========
function ProcessError({ error }: { error: ErrorData }): React.ReactElement {
if (axios.isAxiosError(error) && error.response) {
@ -58,5 +56,5 @@ function ProcessError({ error }: { error: ErrorData }): React.ReactElement {
);
}
}
return <InfoError error={error} />;
throw error as Error;
}

View File

@ -1 +1 @@
export { default } from './OssPage';
export { OssPage as Component } from './OssPage';

View File

@ -8,13 +8,13 @@ import { useConceptNavigation } from '@/app/Navigation/NavigationContext';
import { urls } from '@/app/urls';
import { IResetPasswordDTO } from '@/backend/auth/api';
import { useResetPassword } from '@/backend/auth/useResetPassword';
import InfoError, { ErrorData } from '@/components/info/InfoError';
import { ErrorData } from '@/components/info/InfoError';
import Loader from '@/components/ui/Loader';
import SubmitButton from '@/components/ui/SubmitButton';
import TextInput from '@/components/ui/TextInput';
import useQueryStrings from '@/hooks/useQueryStrings';
function PasswordChangePage() {
export function Component() {
const router = useConceptNavigation();
const token = useQueryStrings().get('token');
@ -96,13 +96,10 @@ function PasswordChangePage() {
);
}
export default PasswordChangePage;
// ====== Internals =========
function ProcessError({ error }: { error: ErrorData }): React.ReactElement {
if (axios.isAxiosError(error) && error.response && error.response.status === 404) {
return <div className='mx-auto mt-6 text-sm select-text text-warn-600'>Данная ссылка не действительна</div>;
} else {
return <InfoError error={error} />;
}
throw error as Error;
}

View File

@ -16,7 +16,7 @@ import { useModificationStore } from '@/stores/modification';
import { RSEditState, RSTabID } from './RSEditContext';
import RSTabs from './RSTabs';
function RSFormPage() {
export function RSFormPage() {
const router = useConceptNavigation();
const query = useQueryStrings();
const params = useParams();
@ -33,7 +33,6 @@ function RSFormPage() {
}
return (
<ErrorBoundary
onError={filterErrors}
FallbackComponent={({ error }) => (
<ProcessError error={error as ErrorData} isArchive={!!version} itemID={itemID} />
)}
@ -45,16 +44,7 @@ function RSFormPage() {
);
}
export default RSFormPage;
// ====== Internals =========
const filterErrors = (error: Error) => {
if (axios.isAxiosError(error) && error.response && (error.response.status === 404 || error.response.status === 403)) {
return;
}
throw error;
};
function ProcessError({
error,
isArchive,
@ -85,5 +75,5 @@ function ProcessError({
);
}
}
return null;
throw error as Error;
}

View File

@ -1 +1 @@
export { default } from './RSFormPage';
export { RSFormPage as Component } from './RSFormPage';

View File

@ -8,7 +8,7 @@ import { useConceptNavigation } from '@/app/Navigation/NavigationContext';
import { urls } from '@/app/urls';
import { useSignup } from '@/backend/users/useSignup';
import { IconHelp } from '@/components/Icons';
import InfoError, { ErrorData } from '@/components/info/InfoError';
import { ErrorData } from '@/components/info/InfoError';
import Button from '@/components/ui/Button';
import Checkbox from '@/components/ui/Checkbox';
import FlexColumn from '@/components/ui/FlexColumn';
@ -188,5 +188,5 @@ function ProcessError({ error }: { error: ErrorData }): React.ReactElement {
);
}
}
return <InfoError error={error} />;
throw error as Error;
}

View File

@ -3,7 +3,7 @@ import ExpectedAnonymous from '@/components/ExpectedAnonymous';
import FormSignup from './FormSignup';
function RegisterPage() {
export function RegisterPage() {
const { user } = useAuthSuspense();
if (user) {
@ -12,5 +12,3 @@ function RegisterPage() {
return <FormSignup />;
}
}
export default RegisterPage;

View File

@ -1 +1 @@
export { default } from './RegisterPage';
export { RegisterPage as Component } from './RegisterPage';

View File

@ -5,12 +5,12 @@ import clsx from 'clsx';
import { useEffect, useState } from 'react';
import { useRequestPasswordReset } from '@/backend/auth/useRequestPasswordReset';
import InfoError, { ErrorData } from '@/components/info/InfoError';
import { ErrorData } from '@/components/info/InfoError';
import SubmitButton from '@/components/ui/SubmitButton';
import TextInput from '@/components/ui/TextInput';
import TextURL from '@/components/ui/TextURL';
function RestorePasswordPage() {
export function Component() {
const { requestPasswordReset, isPending, error, reset } = useRequestPasswordReset();
const [isCompleted, setIsCompleted] = useState(false);
@ -60,15 +60,12 @@ function RestorePasswordPage() {
}
}
export default RestorePasswordPage;
// ====== Internals =========
function ProcessError({ error }: { error: ErrorData }): React.ReactElement {
if (axios.isAxiosError(error) && error.response && error.response.status === 400) {
return (
<div className='mx-auto mt-6 text-sm select-text text-warn-600'>Данный email не используется на Портале.</div>
);
} else {
return <InfoError error={error} />;
}
throw error as Error;
}

View File

@ -9,7 +9,7 @@ import { useConceptNavigation } from '@/app/Navigation/NavigationContext';
import { urls } from '@/app/urls';
import { IChangePasswordDTO } from '@/backend/auth/api';
import { useChangePassword } from '@/backend/auth/useChangePassword';
import InfoError, { ErrorData } from '@/components/info/InfoError';
import { ErrorData } from '@/components/info/InfoError';
import FlexColumn from '@/components/ui/FlexColumn';
import SubmitButton from '@/components/ui/SubmitButton';
import TextInput from '@/components/ui/TextInput';
@ -97,7 +97,6 @@ export default EditorPassword;
function ProcessError({ error }: { error: ErrorData }): React.ReactElement {
if (axios.isAxiosError(error) && error.response && error.response.status === 400) {
return <div className='text-sm select-text text-warn-600'>Неверно введен старый пароль</div>;
} else {
return <InfoError error={error} />;
}
throw error as Error;
}

View File

@ -7,7 +7,7 @@ import { useBlockNavigation } from '@/app/Navigation/NavigationContext';
import { IUpdateProfileDTO } from '@/backend/users/api';
import { useProfileSuspense } from '@/backend/users/useProfile';
import { useUpdateProfile } from '@/backend/users/useUpdateProfile';
import InfoError, { ErrorData } from '@/components/info/InfoError';
import { ErrorData } from '@/components/info/InfoError';
import SubmitButton from '@/components/ui/SubmitButton';
import TextInput from '@/components/ui/TextInput';
@ -99,5 +99,5 @@ function ProcessError({ error }: { error: ErrorData }): React.ReactElement {
);
}
}
return <InfoError error={error} />;
throw error as Error;
}

View File

@ -3,7 +3,7 @@ import RequireAuth from '@/components/RequireAuth';
import EditorPassword from './EditorPassword';
import EditorProfile from './EditorProfile';
function UserProfilePage() {
export function UserProfilePage() {
return (
<RequireAuth>
<div className='cc-fade-in flex flex-col py-2 mx-auto w-fit'>
@ -16,5 +16,3 @@ function UserProfilePage() {
</RequireAuth>
);
}
export default UserProfilePage;

View File

@ -1 +1 @@
export { default } from './UserProfilePage';
export { UserProfilePage as Component } from './UserProfilePage';