diff --git a/apps/xi.web/src/pages/(app)/_layout/classrooms/$classroomId/index.tsx b/apps/xi.web/src/pages/(app)/_layout/classrooms/$classroomId/index.tsx index bcd40c2a..158d1fc7 100644 --- a/apps/xi.web/src/pages/(app)/_layout/classrooms/$classroomId/index.tsx +++ b/apps/xi.web/src/pages/(app)/_layout/classrooms/$classroomId/index.tsx @@ -1,5 +1,6 @@ /* eslint-disable @typescript-eslint/ban-ts-comment */ import { createFileRoute } from '@tanstack/react-router'; +import { BoardRouteWarmup } from 'modules.board/warmup'; import { lazy } from 'react'; import { z } from 'zod'; @@ -38,6 +39,7 @@ export const Route = createFileRoute('/(app)/_layout/classrooms/$classroomId/')( function ClassroomPageComponent() { return ( <> + ); diff --git a/apps/xi.web/src/pages/(app)/_layout/materials/index.tsx b/apps/xi.web/src/pages/(app)/_layout/materials/index.tsx index 40966332..1095e934 100644 --- a/apps/xi.web/src/pages/(app)/_layout/materials/index.tsx +++ b/apps/xi.web/src/pages/(app)/_layout/materials/index.tsx @@ -1,5 +1,6 @@ /* eslint-disable @typescript-eslint/ban-ts-comment */ import { createFileRoute } from '@tanstack/react-router'; +import { BoardRouteWarmup } from 'modules.board/warmup'; import { MaterialsPage } from 'pages.materials'; import { z } from 'zod'; @@ -8,7 +9,12 @@ const searchSchema = z.object({ }); const Materials = () => { - return ; + return ( + <> + + + + ); }; // @ts-ignore diff --git a/packages/modules.board/package.json b/packages/modules.board/package.json index 3c42248a..2963898b 100644 --- a/packages/modules.board/package.json +++ b/packages/modules.board/package.json @@ -3,7 +3,8 @@ "version": "0.0.0", "type": "module", "exports": { - ".": "./index.ts" + ".": "./index.ts", + "./warmup": "./src/warmup/index.ts" }, "license": "MIT", "scripts": { diff --git a/packages/modules.board/src/warmup/BoardRouteWarmup.tsx b/packages/modules.board/src/warmup/BoardRouteWarmup.tsx new file mode 100644 index 00000000..f1fa3b04 --- /dev/null +++ b/packages/modules.board/src/warmup/BoardRouteWarmup.tsx @@ -0,0 +1,32 @@ +import { env } from 'common.env'; +import { useEffect, useRef } from 'react'; +import { prefetchBoardModule } from './prefetchBoardModule'; +import { + ensureResourceHint, + scheduleIdleTask, + shouldSkipBackgroundPrefetch, + toHttpOrigin, +} from './utils'; + +/** Фоновый прогрев доски на страницах, откуда чаще всего открывают доску (кабинет, материалы). */ +export const BoardRouteWarmup = () => { + const startedRef = useRef(false); + + useEffect(() => { + if (startedRef.current || shouldSkipBackgroundPrefetch()) return; + + ensureResourceHint('preconnect', toHttpOrigin(env.VITE_SERVER_URL_HOCUS), true); + ensureResourceHint('dns-prefetch', env.VITE_SERVER_URL_BACKEND); + + const cancel = scheduleIdleTask(() => { + startedRef.current = true; + void prefetchBoardModule().catch(() => { + startedRef.current = false; + }); + }, 3000); + + return cancel; + }, []); + + return null; +}; diff --git a/packages/modules.board/src/warmup/index.ts b/packages/modules.board/src/warmup/index.ts new file mode 100644 index 00000000..7c841ab6 --- /dev/null +++ b/packages/modules.board/src/warmup/index.ts @@ -0,0 +1,3 @@ +export { BoardRouteWarmup } from './BoardRouteWarmup'; +export { prefetchBoardModule } from './prefetchBoardModule'; +export { prefetchBoardStorageItem } from './prefetchBoardStorageItem'; diff --git a/packages/modules.board/src/warmup/prefetchBoardModule.ts b/packages/modules.board/src/warmup/prefetchBoardModule.ts new file mode 100644 index 00000000..52615393 --- /dev/null +++ b/packages/modules.board/src/warmup/prefetchBoardModule.ts @@ -0,0 +1,13 @@ +let boardModulePrefetch: Promise | null = null; + +/** Подгружает DrawBoard и зависимости — тот же граф, что при открытии доски. */ +export function prefetchBoardModule(): Promise { + if (!boardModulePrefetch) { + boardModulePrefetch = import('../ui/DrawBoard').catch((error) => { + boardModulePrefetch = null; + throw error; + }); + } + + return boardModulePrefetch; +} diff --git a/packages/modules.board/src/warmup/prefetchBoardStorageItem.ts b/packages/modules.board/src/warmup/prefetchBoardStorageItem.ts new file mode 100644 index 00000000..3a294c1a --- /dev/null +++ b/packages/modules.board/src/warmup/prefetchBoardStorageItem.ts @@ -0,0 +1,43 @@ +import type { QueryClient } from '@tanstack/react-query'; +import { classroomMaterialsApiConfig, ClassroomMaterialsQueryKey } from 'common.api'; +import { getAxiosInstance } from 'common.config'; + +async function fetchClassroomStorageItem( + classroomId: string, + boardId: string, + isTutor: boolean, +): Promise { + const queryKey = isTutor + ? ClassroomMaterialsQueryKey.ClassroomStorageItem + : ClassroomMaterialsQueryKey.ClassroomStorageItemStudent; + const apiConfig = classroomMaterialsApiConfig[queryKey]; + const axiosInstance = await getAxiosInstance(); + + const response = await axiosInstance({ + method: apiConfig.method, + url: apiConfig.getUrl(classroomId, boardId), + headers: { + 'Content-Type': 'application/json', + }, + }); + + return response.data; +} + +export function prefetchBoardStorageItem( + queryClient: QueryClient, + classroomId: string, + boardId: string, + isTutor: boolean, +): Promise { + const queryKey = isTutor + ? [ClassroomMaterialsQueryKey.ClassroomStorageItem, classroomId, boardId] + : [ClassroomMaterialsQueryKey.ClassroomStorageItemStudent, classroomId, boardId]; + + return queryClient + .prefetchQuery({ + queryKey, + queryFn: () => fetchClassroomStorageItem(classroomId, boardId, isTutor), + }) + .then(() => undefined); +} diff --git a/packages/modules.board/src/warmup/utils.ts b/packages/modules.board/src/warmup/utils.ts new file mode 100644 index 00000000..b495dd6e --- /dev/null +++ b/packages/modules.board/src/warmup/utils.ts @@ -0,0 +1,42 @@ +type NetworkInformationLike = { + saveData?: boolean; + effectiveType?: string; +}; + +export function shouldSkipBackgroundPrefetch(): boolean { + const conn = (navigator as Navigator & { connection?: NetworkInformationLike }).connection; + if (!conn) return false; + if (conn.saveData) return true; + return conn.effectiveType === '2g' || conn.effectiveType === 'slow-2g'; +} + +export function scheduleIdleTask(task: () => void, timeoutMs = 3000): () => void { + if (typeof requestIdleCallback !== 'undefined') { + const id = requestIdleCallback(() => task(), { timeout: timeoutMs }); + return () => cancelIdleCallback(id); + } + + const id = window.setTimeout(task, 1000); + return () => clearTimeout(id); +} + +export function toHttpOrigin(url: string): string { + return url.replace(/^wss:\/\//, 'https://').replace(/^ws:\/\//, 'http://'); +} + +export function ensureResourceHint( + rel: 'preconnect' | 'dns-prefetch', + href: string, + crossOrigin = false, +): void { + const selector = `link[rel="${rel}"][href="${href}"]`; + if (document.head.querySelector(selector)) return; + + const link = document.createElement('link'); + link.rel = rel; + link.href = href; + if (crossOrigin) { + link.crossOrigin = 'anonymous'; + } + document.head.appendChild(link); +} diff --git a/packages/modules.calls/package.json b/packages/modules.calls/package.json index fe06330e..cbc764f1 100644 --- a/packages/modules.calls/package.json +++ b/packages/modules.calls/package.json @@ -21,6 +21,7 @@ "common.env": "workspace:*", "common.services": "workspace:*", "common.utils": "workspace:*", + "modules.board": "workspace:*", "@xipkg/calls": "^0.1.4", "@xipkg/calls-chat": "^0.1.4", "@xipkg/calls-compactview": "^0.1.4", diff --git a/packages/modules.calls/src/CallsShell.tsx b/packages/modules.calls/src/CallsShell.tsx index 738c5f9b..e78dd953 100644 --- a/packages/modules.calls/src/CallsShell.tsx +++ b/packages/modules.calls/src/CallsShell.tsx @@ -15,6 +15,7 @@ import { callsSessionPort } from './callsSession'; import { createCallsRuntimeConfig } from './createCallsRuntimeConfig'; import { useCallsDeps } from './useCallsDeps'; import { ProductCallAnalyticsTracker } from './productAnalytics/ProductCallAnalyticsTracker'; +import { BoardCallStorageWarmup } from './boardWarmup/BoardCallStorageWarmup'; import '@xipkg/calls-ui/video-security.css'; import '@xipkg/calls-ui/driver.css'; @@ -62,6 +63,7 @@ const CallsShellProviders = ({ children }: CallsShellPropsT) => { + {children} diff --git a/packages/modules.calls/src/boardWarmup/BoardCallStorageWarmup.tsx b/packages/modules.calls/src/boardWarmup/BoardCallStorageWarmup.tsx new file mode 100644 index 00000000..f43f775c --- /dev/null +++ b/packages/modules.calls/src/boardWarmup/BoardCallStorageWarmup.tsx @@ -0,0 +1,25 @@ +import { useQueryClient } from '@tanstack/react-query'; +import { useCallStore } from '@xipkg/calls-store'; +import { useCurrentUser } from 'common.services'; +import { prefetchBoardStorageItem } from 'modules.board/warmup'; +import { useEffect } from 'react'; + +/** Prefetch storage-item при смене activeBoardId в звонке (до редиректа на доску). */ +export const BoardCallStorageWarmup = () => { + const queryClient = useQueryClient(); + const { data: user } = useCurrentUser(); + const activeBoardId = useCallStore((state) => state.activeBoardId); + const activeClassroom = useCallStore((state) => state.activeClassroom); + + const isTutor = user?.default_layout === 'tutor'; + + useEffect(() => { + if (!activeBoardId || !activeClassroom) return; + + void prefetchBoardStorageItem(queryClient, activeClassroom, activeBoardId, isTutor).catch( + () => undefined, + ); + }, [activeBoardId, activeClassroom, isTutor, queryClient]); + + return null; +}; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9ae1b9d6..c65e3ccf 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -3305,6 +3305,9 @@ importers: common.utils: specifier: workspace:* version: link:../common.utils + modules.board: + specifier: workspace:* + version: link:../modules.board react: specifier: '19' version: 19.2.7