From 8c35aed8f2640ce3b8cdac9ff7de717b0af07299 Mon Sep 17 00:00:00 2001 From: Vassiliy Yegorov Date: Sat, 4 Jul 2026 16:04:17 +0700 Subject: [PATCH] perf(web): route-level code-splitting; harden channel config rendering Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01BwxdSt4reTm7Dj1oxRvpP3 --- web/src/App.tsx | 40 +++++++++++++++++------------ web/src/pages/ChannelsPage.test.tsx | 10 ++++++++ web/src/pages/ChannelsPage.tsx | 18 +++++++------ 3 files changed, 43 insertions(+), 25 deletions(-) diff --git a/web/src/App.tsx b/web/src/App.tsx index 8f81bb1..a83f54c 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -1,15 +1,19 @@ import type { ReactNode } from "react" +import { lazy, Suspense } from "react" import { Routes, Route, Navigate } from "react-router-dom" import { ProtectedRoute } from "@/auth/ProtectedRoute" import { Layout } from "@/components/Layout" -import { AccountsPage } from "@/pages/AccountsPage" -import { ChannelsPage } from "@/pages/ChannelsPage" -import { DomainDiffPage } from "@/pages/DomainDiffPage" -import { DomainsPage } from "@/pages/DomainsPage" import { LoginPage } from "@/pages/LoginPage" import { RegisterPage } from "@/pages/RegisterPage" -import { SchedulePage } from "@/pages/SchedulePage" -import { TemplatesPage } from "@/pages/TemplatesPage" + +// Маршрутные страницы грузятся лениво — Vite бьёт бандл на per-route чанки, +// первый экран (login/register) остаётся статичным, т.к. нужен сразу. +const DomainsPage = lazy(() => import("@/pages/DomainsPage").then((m) => ({ default: m.DomainsPage }))) +const DomainDiffPage = lazy(() => import("@/pages/DomainDiffPage").then((m) => ({ default: m.DomainDiffPage }))) +const AccountsPage = lazy(() => import("@/pages/AccountsPage").then((m) => ({ default: m.AccountsPage }))) +const TemplatesPage = lazy(() => import("@/pages/TemplatesPage").then((m) => ({ default: m.TemplatesPage }))) +const SchedulePage = lazy(() => import("@/pages/SchedulePage").then((m) => ({ default: m.SchedulePage }))) +const ChannelsPage = lazy(() => import("@/pages/ChannelsPage").then((m) => ({ default: m.ChannelsPage }))) // Every non-auth route shares the same guard + chrome; wrapping here keeps // each below a one-liner instead of repeating both on every page. @@ -23,16 +27,18 @@ function Protected({ children }: { children: ReactNode }) { export function App() { return ( - - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - + Загрузка…}> + + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + ) } diff --git a/web/src/pages/ChannelsPage.test.tsx b/web/src/pages/ChannelsPage.test.tsx index 162041f..de9a8e4 100644 --- a/web/src/pages/ChannelsPage.test.tsx +++ b/web/src/pages/ChannelsPage.test.tsx @@ -48,6 +48,16 @@ test("отрисовывает список каналов без секрета expect(screen.queryByDisplayValue(/123456/)).not.toBeInTheDocument() }) +test("formatConfig — вайтлист по типу канала не печатает незнакомые поля config", async () => { + vi.spyOn(api, "listChannels").mockResolvedValue([ + { id: "c5", type: "webhook", config: { url: "https://x", token: "LEAK" }, enabled: true }, + ]) + renderPage() + + expect(await screen.findByText(/url: https:\/\/x/)).toBeInTheDocument() + expect(document.body.textContent).not.toMatch(/LEAK/) +}) + test("создание telegram-канала собирает config.chat_id + secret=bot_token", async () => { const createSpy = vi.spyOn(api, "createChannel").mockResolvedValue({ id: "c3", type: "telegram", config: { chat_id: "999" }, enabled: true, diff --git a/web/src/pages/ChannelsPage.tsx b/web/src/pages/ChannelsPage.tsx index ad51923..f224640 100644 --- a/web/src/pages/ChannelsPage.tsx +++ b/web/src/pages/ChannelsPage.tsx @@ -69,13 +69,15 @@ type ChannelForm = z.infer const EMPTY_FORM: ChannelForm = { type: "telegram", chatId: "", botToken: "", url: "" } -// Channel.config is a generic `object` end-to-end (T5/T7) — it never carries -// the secret (bot_token/signing key), only the public half (chat_id/url), so -// rendering every key is always safe to show in the list. -function formatConfig(config: object): string { - const entries = Object.entries(config as Record) - if (entries.length === 0) return "—" - return entries.map(([k, v]) => `${k}=${String(v)}`).join(" · ") +// Печатаем ТОЛЬКО известные несекретные поля по типу канала — чтобы новый +// тип канала с чувствительным полем в config не «протёк» в DOM автоматически +// (Object.entries по всему config печатал бы любое поле, включая случайно +// сохранённый секрет). +function formatConfig(type: string, config: object): string { + const c = config as Record + if (type === "telegram") return c.chat_id ? `chat_id: ${String(c.chat_id)}` : "" + if (type === "webhook") return c.url ? `url: ${String(c.url)}` : "" + return "" } function ChannelForm({ onCreated }: { onCreated: () => void }) { @@ -294,7 +296,7 @@ export function ChannelsPage() { {c.type} - {formatConfig(c.config)} + {formatConfig(c.type, c.config)}