feat: завершить миграцию фронтенда на React (этапы 1-5)

Админка, настройки, логин, кабинеты преподавателя и студента
полностью переведены на React 19 (модель strangler, docs/REACT_MIGRATION.md).

- 15 вкладок админки: lazy-загрузка, общий error boundary, сервисный слой
- Удалены legacy-views и переходный слой LegacyTabView (45 файлов)
- Сборка esbuild (frontend/scripts/build-react.mjs) с бюджетами gzip на чанки
- CSP-хэши, кэширование: chunk'и immutable, CSS/HTML no-cache (react-cache.conf)
- Фиксы: lazy-импорт именованных экспортов (краш после авторизации),
  растяжение #admin-root/#settings-root на всю ширину,
  возврат subjects/lessonTypes из справочников (краш просмотра расписаний),
  отказ от заведомо широкого поиска расписания без выбранной цели,
  центрирование карточки логина
- Тесты: 237/237 (npm run check), регрессионные на lazy-вкладки и ширину
- Документация: REACT_MIGRATION.md, REACT_MIGRATION_RESULT.md, FRONTEND.md
This commit is contained in:
dipatrik10
2026-09-28 23:15:04 +03:00
parent c6083b35e0
commit 97bf3cc50a
167 changed files with 31283 additions and 19033 deletions

View File

@@ -0,0 +1,83 @@
import { build } from 'esbuild';
await Promise.all([
build({
entryPoints: ['react/shared/testing/render-shared.jsx'],
bundle: true,
// Пакеты остаются external: react-dom/server в Node использует
// require встроенных модулей, который невозможен внутри ESM-бандла.
packages: 'external',
define: {
'process.env.NODE_ENV': '"test"'
},
format: 'esm',
jsx: 'automatic',
legalComments: 'none',
minify: false,
outfile: 'dist/tests/react-shared-renderer.mjs',
platform: 'node',
target: ['node18']
}),
build({
entryPoints: ['react/student/testing/render-student.jsx'],
bundle: true,
packages: 'external',
define: {
'process.env.NODE_ENV': '"test"'
},
format: 'esm',
jsx: 'automatic',
legalComments: 'none',
minify: false,
outfile: 'dist/tests/react-student-renderer.mjs',
platform: 'node',
target: ['node18']
}),
build({
entryPoints: ['react/teacher/testing/render-teacher.jsx'],
bundle: true,
packages: 'external',
define: {
'process.env.NODE_ENV': '"test"'
},
format: 'esm',
jsx: 'automatic',
legalComments: 'none',
minify: false,
outfile: 'dist/tests/react-teacher-renderer.mjs',
platform: 'node',
target: ['node18']
}),
build({
entryPoints: ['react/settings/testing/render-settings.jsx'],
bundle: true,
packages: 'external',
define: {
'process.env.NODE_ENV': '"test"'
},
format: 'esm',
jsx: 'automatic',
legalComments: 'none',
minify: false,
outfile: 'dist/tests/react-settings-renderer.mjs',
platform: 'node',
target: ['node18']
}),
build({
entryPoints: ['react/admin/testing/render-admin.jsx'],
bundle: true,
packages: 'external',
define: {
'process.env.NODE_ENV': '"test"'
},
format: 'esm',
jsx: 'automatic',
legalComments: 'none',
minify: false,
outfile: 'dist/tests/react-admin-renderer.mjs',
platform: 'node',
target: ['node18']
})
]);
console.log('Компонентные React-проверки собраны');

View File

@@ -0,0 +1,173 @@
import { build } from 'esbuild';
import { readFile, rm } from 'node:fs/promises';
import { gzipSync } from 'node:zlib';
const LOGIN_ENTRY_GZIP_LIMIT = 10_000;
const STUDENT_ENTRY_GZIP_LIMIT = 20_000;
const TEACHER_ENTRY_GZIP_LIMIT = 20_000;
const SETTINGS_ENTRY_GZIP_LIMIT = 20_000;
const ADMIN_ENTRY_GZIP_LIMIT = 20_000;
// Лимит общих chunk'ов поднят с 75 000 до 85 000 на этапе 5 (волна 1):
// компоненты дизайн-системы (ConfirmDialog, AsyncCombobox) стали общими
// chunk'ами — их теперь используют и React-вкладки админ-панели, поэтому
// они переехали из entrypoint'ов студента/настроек в общий кэшируемый пул.
const SHARED_CHUNKS_GZIP_LIMIT = 85_000;
// Лимит холодной загрузки входа поднят с 80 000 до 85 000 на этапе 5 (волна 1):
// общие компоненты дизайн-системы (ConfirmDialog, AsyncCombobox) стали общими
// chunk'ами — их теперь используют и React-вкладки админ-панели. Сам вход
// login не изменился, вырос только объём общих кэшируемых chunk'ов.
const COLD_LOAD_GZIP_LIMIT = 85_000;
const STUDENT_COLD_LOAD_GZIP_LIMIT = 90_000;
const TEACHER_COLD_LOAD_GZIP_LIMIT = 90_000;
const SETTINGS_COLD_LOAD_GZIP_LIMIT = 90_000;
const ADMIN_COLD_LOAD_GZIP_LIMIT = 90_000;
// Lazy-chunkи вкладок админ-панели: каждая вкладка загружается
// отдельным chunk'ом при открытии, поэтому бюджет отдельный от
// холодной загрузки оболочки.
const ADMIN_TAB_CHUNKS_GZIP_LIMIT = 150_000;
await rm('dist/react', { force: true, recursive: true });
const result = await build({
entryPoints: {
login: 'react/login/main.jsx',
student: 'react/student/main.jsx',
teacher: 'react/teacher/main.jsx',
settings: 'react/settings/main.jsx',
admin: 'react/admin/main.jsx',
'shared/runtime': 'react/shared/runtime-entry.jsx'
},
bundle: true,
chunkNames: 'shared/chunks/[name]-[hash]',
define: {
'process.env.NODE_ENV': '"production"'
},
format: 'esm',
jsx: 'automatic',
legalComments: 'none',
metafile: true,
minify: true,
outdir: 'dist/react',
platform: 'browser',
splitting: true,
target: ['es2022']
});
const outputs = result.metafile.outputs;
function outputBytes(path) {
return outputs[path]?.bytes ?? 0;
}
async function gzipSize(path) {
return gzipSync(await readFile(path)).byteLength;
}
const sharedChunkPaths = Object.keys(outputs)
.filter(path => path.startsWith('dist/react/shared/chunks/') && path.endsWith('.js'));
// Chunkи, достижимые только из entrypoint'а админ-панели (lazy-загрузчики
// React-вкладок react/admin/**), — в общий runtime-бюджет и холодную загрузку
// других страниц они не входят. Классифицируем по достижимости от entrypoint'ов,
// потому что модуль react/shared, используемый только одной вкладкой, попадает
// в её chunk и не является общим.
const ENTRY_OUTPUT_PATHS = Object.freeze({
login: 'dist/react/login.js',
student: 'dist/react/student.js',
teacher: 'dist/react/teacher.js',
settings: 'dist/react/settings.js',
admin: 'dist/react/admin.js',
runtime: 'dist/react/shared/runtime.js'
});
function chunkReachability() {
const reach = new Map();
for (const [entryName, entryPath] of Object.entries(ENTRY_OUTPUT_PATHS)) {
const stack = [entryPath];
const seen = new Set();
while (stack.length > 0) {
const path = stack.pop();
if (seen.has(path) || !outputs[path]) continue;
seen.add(path);
for (const imported of outputs[path].imports || []) {
stack.push(imported.path);
}
}
for (const path of seen) {
if (!reach.has(path)) reach.set(path, new Set());
reach.get(path).add(entryName);
}
}
return reach;
}
const reach = chunkReachability();
const adminLazyChunkPaths = sharedChunkPaths.filter(path => {
const entries = reach.get(path);
return entries !== undefined && entries.size > 0 && [...entries].every(name => name === 'admin');
});
const runtimeChunkPaths = sharedChunkPaths.filter(path => !adminLazyChunkPaths.includes(path));
const sharedChunkBytes = runtimeChunkPaths.reduce((total, path) => total + outputBytes(path), 0);
const sharedChunkGzipBytes = (
await Promise.all(runtimeChunkPaths.map(gzipSize))
).reduce((total, bytes) => total + bytes, 0);
const adminLazyChunkBytes = adminLazyChunkPaths.reduce((total, path) => total + outputBytes(path), 0);
const adminLazyChunkGzipBytes = (
await Promise.all(adminLazyChunkPaths.map(gzipSize))
).reduce((total, bytes) => total + bytes, 0);
const entryReports = [];
for (const [name, path, limit, coldLimit] of [
['входа', 'dist/react/login.js', LOGIN_ENTRY_GZIP_LIMIT, COLD_LOAD_GZIP_LIMIT],
['студента', 'dist/react/student.js', STUDENT_ENTRY_GZIP_LIMIT, STUDENT_COLD_LOAD_GZIP_LIMIT],
['преподавателя', 'dist/react/teacher.js', TEACHER_ENTRY_GZIP_LIMIT, TEACHER_COLD_LOAD_GZIP_LIMIT],
['настроек', 'dist/react/settings.js', SETTINGS_ENTRY_GZIP_LIMIT, SETTINGS_COLD_LOAD_GZIP_LIMIT],
['админ-панели', 'dist/react/admin.js', ADMIN_ENTRY_GZIP_LIMIT, ADMIN_COLD_LOAD_GZIP_LIMIT]
]) {
const rawBytes = outputBytes(path);
const gzipBytes = await gzipSize(path);
const coldLoadGzipBytes = gzipBytes + sharedChunkGzipBytes;
entryReports.push({ name, path, rawBytes, gzipBytes, coldLoadGzipBytes, limit, coldLimit });
if (gzipBytes > limit) {
throw new Error(
`React entrypoint ${name} превышает бюджет: ${gzipBytes} байт gzip при лимите ${limit}`
);
}
if (coldLoadGzipBytes > coldLimit) {
throw new Error(
`Холодная загрузка ${name} превышает бюджет: ${coldLoadGzipBytes} байт gzip при лимите ${coldLimit}`
);
}
}
if (sharedChunkGzipBytes > SHARED_CHUNKS_GZIP_LIMIT) {
throw new Error(
`Общие React-chunk превышают бюджет: ${sharedChunkGzipBytes} байт gzip при лимите ${SHARED_CHUNKS_GZIP_LIMIT}`
);
}
if (adminLazyChunkGzipBytes > ADMIN_TAB_CHUNKS_GZIP_LIMIT) {
throw new Error(
`Lazy-chunkи вкладок админки превышают бюджет: ${adminLazyChunkGzipBytes} байт gzip`
+ ` при лимите ${ADMIN_TAB_CHUNKS_GZIP_LIMIT}`
);
}
if (runtimeChunkPaths.length === 0) {
throw new Error('Сборка не создала общий кэшируемый React-chunk');
}
for (const report of entryReports) {
console.log(
`React entrypoint ${report.name}: ${report.rawBytes} байт, ${report.gzipBytes} байт gzip, `
+ `холодная загрузка ${report.coldLoadGzipBytes} байт gzip`
);
}
console.log(`Общие React-chunk: ${sharedChunkBytes} байт, ${sharedChunkGzipBytes} байт gzip`);
console.log(
`Lazy-chunkи вкладок админки: ${adminLazyChunkPaths.length} chunk, `
+ `${adminLazyChunkBytes} байт, ${adminLazyChunkGzipBytes} байт gzip`
);