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